v0.2.0: Rich interactive map, automation gallery, auth & UX improvements
Some checks failed
Release / release (push) Has been cancelled
Some checks failed
Release / release (push) Has been cancelled
Map overhaul: - Replace colored boxes with actual game tile images (skin textures from CDN) - Overlay content icons (monsters, resources, NPCs) on tiles - Add layer switching (Overworld/Underground/Interior) - Fix API schema to parse interactions.content and layer fields - Add hover tooltips, tile search with coordinate parsing, keyboard shortcuts - Add minimap with viewport rectangle, zoom-toward-cursor, loading progress - Show tile/content images in side panel, coordinate labels at high zoom Automation gallery: - 27+ pre-built automation templates (combat, gathering, crafting, trading, utility) - Multi-character selection for batch automation creation - Gallery component with activate dialog Auth & settings: - API key gate with auth provider for token management - Enhanced settings page with token configuration UI improvements: - Game icon component for item/monster/resource images - Character automations panel on character detail page - Equipment grid and inventory grid enhancements - Automations page layout refresh - Bank, exchange page minor fixes - README update with live demo link
This commit is contained in:
parent
f845647934
commit
10781c7987
37 changed files with 4243 additions and 208 deletions
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
> Dashboard & automation platform for [Artifacts MMO](https://artifactsmmo.com) — control, automate, and analyze your characters through a beautiful web interface.
|
> Dashboard & automation platform for [Artifacts MMO](https://artifactsmmo.com) — control, automate, and analyze your characters through a beautiful web interface.
|
||||||
|
|
||||||
|
**[Live Demo — artifacts.gtf.ovh](https://artifacts.gtf.ovh)** | **[GitHub](https://github.com/pawelorzech/artifacts-dashboard)**
|
||||||
|
|
||||||
|
Free to use — just log in with your Artifacts MMO token and start automating.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
@ -41,7 +45,7 @@
|
||||||
|
|
||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/yourusername/artifacts-dashboard.git
|
git clone https://github.com/pawelorzech/artifacts-dashboard.git
|
||||||
cd artifacts-dashboard
|
cd artifacts-dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
114
backend/app/api/auth.py
Normal file
114
backend/app/api/auth.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""Auth endpoints for runtime API token management.
|
||||||
|
|
||||||
|
When no ARTIFACTS_TOKEN is set in the environment, users can provide
|
||||||
|
their own token through the UI. The token is stored in memory only
|
||||||
|
and must be re-sent if the backend restarts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.services.artifacts_client import ArtifactsClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class AuthStatus(BaseModel):
|
||||||
|
has_token: bool
|
||||||
|
source: str # "env", "user", or "none"
|
||||||
|
|
||||||
|
|
||||||
|
class SetTokenRequest(BaseModel):
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
class SetTokenResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
source: str
|
||||||
|
account: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=AuthStatus)
|
||||||
|
async def auth_status(request: Request) -> AuthStatus:
|
||||||
|
client: ArtifactsClient = request.app.state.artifacts_client
|
||||||
|
return AuthStatus(
|
||||||
|
has_token=client.has_token,
|
||||||
|
source=client.token_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/token", response_model=SetTokenResponse)
|
||||||
|
async def set_token(body: SetTokenRequest, request: Request) -> SetTokenResponse:
|
||||||
|
token = body.token.strip()
|
||||||
|
if not token:
|
||||||
|
return SetTokenResponse(success=False, source="none", error="Token cannot be empty")
|
||||||
|
|
||||||
|
# Validate the token by making a test call
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=settings.artifacts_api_url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
timeout=httpx.Timeout(10.0),
|
||||||
|
) as test_client:
|
||||||
|
resp = await test_client.get("/my/characters")
|
||||||
|
if resp.status_code == 401:
|
||||||
|
return SetTokenResponse(success=False, source="none", error="Invalid token")
|
||||||
|
resp.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
return SetTokenResponse(
|
||||||
|
success=False,
|
||||||
|
source="none",
|
||||||
|
error=f"API error: {exc.response.status_code}",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Token validation failed: %s", exc)
|
||||||
|
return SetTokenResponse(
|
||||||
|
success=False,
|
||||||
|
source="none",
|
||||||
|
error="Could not validate token. Check your network connection.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Token is valid — apply it
|
||||||
|
client: ArtifactsClient = request.app.state.artifacts_client
|
||||||
|
client.set_token(token)
|
||||||
|
|
||||||
|
# Reconnect WebSocket with new token
|
||||||
|
game_ws_client = getattr(request.app.state, "game_ws_client", None)
|
||||||
|
if game_ws_client is not None:
|
||||||
|
try:
|
||||||
|
await game_ws_client.reconnect_with_token(token)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to reconnect WebSocket with new token")
|
||||||
|
|
||||||
|
logger.info("API token updated via UI (source: user)")
|
||||||
|
return SetTokenResponse(success=True, source="user")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/token")
|
||||||
|
async def clear_token(request: Request) -> AuthStatus:
|
||||||
|
client: ArtifactsClient = request.app.state.artifacts_client
|
||||||
|
client.clear_token()
|
||||||
|
|
||||||
|
# Reconnect WebSocket with env token (or empty)
|
||||||
|
game_ws_client = getattr(request.app.state, "game_ws_client", None)
|
||||||
|
if game_ws_client is not None and settings.artifacts_token:
|
||||||
|
try:
|
||||||
|
await game_ws_client.reconnect_with_token(settings.artifacts_token)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to reconnect WebSocket after token clear")
|
||||||
|
|
||||||
|
logger.info("API token cleared, reverted to env")
|
||||||
|
return AuthStatus(
|
||||||
|
has_token=client.has_token,
|
||||||
|
source=client.token_source,
|
||||||
|
)
|
||||||
|
|
@ -78,24 +78,55 @@ async def get_character_logs(
|
||||||
@router.get("/analytics")
|
@router.get("/analytics")
|
||||||
async def get_analytics(
|
async def get_analytics(
|
||||||
request: Request,
|
request: Request,
|
||||||
character: str = Query(..., description="Character name"),
|
character: str = Query(default="", description="Character name (empty for all)"),
|
||||||
hours: int = Query(default=24, ge=1, le=168, description="Hours of history"),
|
hours: int = Query(default=24, ge=1, le=168, description="Hours of history"),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Get analytics aggregations for a character.
|
"""Get analytics aggregations for a character.
|
||||||
|
|
||||||
Returns XP history, gold history, and estimated actions per hour.
|
Returns XP history, gold history, and estimated actions per hour.
|
||||||
|
If no character is specified, aggregates across all characters with snapshots.
|
||||||
"""
|
"""
|
||||||
analytics = AnalyticsService()
|
analytics = AnalyticsService()
|
||||||
|
|
||||||
async with async_session_factory() as db:
|
async with async_session_factory() as db:
|
||||||
xp_history = await analytics.get_xp_history(db, character, hours)
|
if character:
|
||||||
gold_history = await analytics.get_gold_history(db, character, hours)
|
characters = [character]
|
||||||
actions_rate = await analytics.get_actions_per_hour(db, character)
|
else:
|
||||||
|
characters = await analytics.get_tracked_characters(db)
|
||||||
|
|
||||||
|
all_xp: list[dict[str, Any]] = []
|
||||||
|
all_gold: list[dict[str, Any]] = []
|
||||||
|
total_actions_per_hour = 0.0
|
||||||
|
|
||||||
|
for char_name in characters:
|
||||||
|
xp_history = await analytics.get_xp_history(db, char_name, hours)
|
||||||
|
gold_history = await analytics.get_gold_history(db, char_name, hours)
|
||||||
|
actions_rate = await analytics.get_actions_per_hour(db, char_name)
|
||||||
|
|
||||||
|
# Transform xp_history to TimeSeriesPoint format
|
||||||
|
for point in xp_history:
|
||||||
|
all_xp.append({
|
||||||
|
"timestamp": point["timestamp"],
|
||||||
|
"value": point["xp"],
|
||||||
|
"label": f"{char_name} XP" if not character else "XP",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Transform gold_history to TimeSeriesPoint format
|
||||||
|
for point in gold_history:
|
||||||
|
all_gold.append({
|
||||||
|
"timestamp": point["timestamp"],
|
||||||
|
"value": point["gold"],
|
||||||
|
"label": char_name if not character else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
total_actions_per_hour += actions_rate.get("estimated_actions_per_hour", 0)
|
||||||
|
|
||||||
|
# Sort by timestamp
|
||||||
|
all_xp.sort(key=lambda p: p["timestamp"] or "")
|
||||||
|
all_gold.sort(key=lambda p: p["timestamp"] or "")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"character": character,
|
"xp_history": all_xp,
|
||||||
"hours": hours,
|
"gold_history": all_gold,
|
||||||
"xp_history": xp_history,
|
"actions_per_hour": round(total_actions_per_hour, 1),
|
||||||
"gold_history": gold_history,
|
|
||||||
"actions_rate": actions_rate,
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ from app.api.ws import router as ws_router
|
||||||
from app.api.exchange import router as exchange_router
|
from app.api.exchange import router as exchange_router
|
||||||
from app.api.events import router as events_router
|
from app.api.events import router as events_router
|
||||||
from app.api.logs import router as logs_router
|
from app.api.logs import router as logs_router
|
||||||
|
from app.api.auth import router as auth_router
|
||||||
|
|
||||||
# Automation engine
|
# Automation engine
|
||||||
from app.engine.pathfinder import Pathfinder
|
from app.engine.pathfinder import Pathfinder
|
||||||
|
|
@ -235,6 +236,7 @@ app.include_router(ws_router)
|
||||||
app.include_router(exchange_router)
|
app.include_router(exchange_router)
|
||||||
app.include_router(events_router)
|
app.include_router(events_router)
|
||||||
app.include_router(logs_router)
|
app.include_router(logs_router)
|
||||||
|
app.include_router(auth_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
# --- Inventory ---
|
# --- Inventory ---
|
||||||
|
|
@ -101,10 +102,22 @@ class ContentSchema(BaseModel):
|
||||||
|
|
||||||
class MapSchema(BaseModel):
|
class MapSchema(BaseModel):
|
||||||
name: str = ""
|
name: str = ""
|
||||||
|
skin: str = ""
|
||||||
x: int
|
x: int
|
||||||
y: int
|
y: int
|
||||||
|
layer: str = "overworld"
|
||||||
content: ContentSchema | None = None
|
content: ContentSchema | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _extract_interactions(cls, data: Any) -> Any:
|
||||||
|
"""The Artifacts API nests content under interactions.content."""
|
||||||
|
if isinstance(data, dict) and "interactions" in data:
|
||||||
|
interactions = data.get("interactions") or {}
|
||||||
|
if "content" not in data or data["content"] is None:
|
||||||
|
data["content"] = interactions.get("content")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
# --- Characters ---
|
# --- Characters ---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,15 @@ logger = logging.getLogger(__name__)
|
||||||
class AnalyticsService:
|
class AnalyticsService:
|
||||||
"""Provides analytics derived from character snapshot time-series data."""
|
"""Provides analytics derived from character snapshot time-series data."""
|
||||||
|
|
||||||
|
async def get_tracked_characters(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return distinct character names that have snapshots."""
|
||||||
|
stmt = select(CharacterSnapshot.name).distinct()
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return [row[0] for row in result.all()]
|
||||||
|
|
||||||
async def get_xp_history(
|
async def get_xp_history(
|
||||||
self,
|
self,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
|
|
|
||||||
|
|
@ -59,10 +59,11 @@ class ArtifactsClient:
|
||||||
RETRY_BASE_DELAY: float = 1.0
|
RETRY_BASE_DELAY: float = 1.0
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
self._token = settings.artifacts_token
|
||||||
self._client = httpx.AsyncClient(
|
self._client = httpx.AsyncClient(
|
||||||
base_url=settings.artifacts_api_url,
|
base_url=settings.artifacts_api_url,
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {settings.artifacts_token}",
|
"Authorization": f"Bearer {self._token}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
},
|
},
|
||||||
|
|
@ -77,6 +78,28 @@ class ArtifactsClient:
|
||||||
window_seconds=settings.data_rate_window,
|
window_seconds=settings.data_rate_window,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_token(self) -> bool:
|
||||||
|
return bool(self._token)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def token_source(self) -> str:
|
||||||
|
if not self._token:
|
||||||
|
return "none"
|
||||||
|
if settings.artifacts_token and self._token == settings.artifacts_token:
|
||||||
|
return "env"
|
||||||
|
return "user"
|
||||||
|
|
||||||
|
def set_token(self, token: str) -> None:
|
||||||
|
"""Update the API token at runtime."""
|
||||||
|
self._token = token
|
||||||
|
self._client.headers["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
|
def clear_token(self) -> None:
|
||||||
|
"""Revert to the env token (or empty if none)."""
|
||||||
|
self._token = settings.artifacts_token
|
||||||
|
self._client.headers["Authorization"] = f"Bearer {self._token}"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Low-level request helpers
|
# Low-level request helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,16 @@ class GameWebSocketClient:
|
||||||
pass
|
pass
|
||||||
logger.info("Game WebSocket client stopped")
|
logger.info("Game WebSocket client stopped")
|
||||||
|
|
||||||
|
async def reconnect_with_token(self, token: str) -> None:
|
||||||
|
"""Update the token and reconnect."""
|
||||||
|
self._token = token
|
||||||
|
# Close current connection to trigger reconnect with new token
|
||||||
|
if self._ws is not None:
|
||||||
|
try:
|
||||||
|
await self._ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Connection loop
|
# Connection loop
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
1034
backend/uv.lock
Normal file
1034
backend/uv.lock
Normal file
File diff suppressed because it is too large
Load diff
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
36
frontend/README.md
Normal file
36
frontend/README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
|
|
@ -1,7 +1,18 @@
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
turbopack: {
|
||||||
|
root: __dirname,
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "artifactsmmo.com",
|
||||||
|
pathname: "/images/**",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
3
frontend/pnpm-workspace.yaml
Normal file
3
frontend/pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
ignoredBuiltDependencies:
|
||||||
|
- sharp
|
||||||
|
- unrs-resolver
|
||||||
1
frontend/public/file.svg
Normal file
1
frontend/public/file.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
frontend/public/globe.svg
Normal file
1
frontend/public/globe.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1 KiB |
1
frontend/public/next.svg
Normal file
1
frontend/public/next.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/vercel.svg
Normal file
1
frontend/public/vercel.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
frontend/public/window.svg
Normal file
1
frontend/public/window.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
|
|
@ -9,6 +9,8 @@ import {
|
||||||
Pickaxe,
|
Pickaxe,
|
||||||
Bot,
|
Bot,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
LayoutGrid,
|
||||||
|
List,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
@ -29,13 +31,14 @@ import {
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
useAutomations,
|
useAutomations,
|
||||||
useAutomationStatuses,
|
useAutomationStatuses,
|
||||||
useDeleteAutomation,
|
useDeleteAutomation,
|
||||||
useControlAutomation,
|
|
||||||
} from "@/hooks/use-automations";
|
} from "@/hooks/use-automations";
|
||||||
import { RunControls } from "@/components/automation/run-controls";
|
import { RunControls } from "@/components/automation/run-controls";
|
||||||
|
import { AutomationGallery } from "@/components/automation/automation-gallery";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -101,109 +104,134 @@ export default function AutomationsPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
<Tabs defaultValue="gallery">
|
||||||
<Card className="border-destructive bg-destructive/10 p-4">
|
<TabsList>
|
||||||
<p className="text-sm text-destructive">
|
<TabsTrigger value="gallery" className="gap-1.5">
|
||||||
Failed to load automations. Make sure the backend is running.
|
<LayoutGrid className="size-3.5" />
|
||||||
</p>
|
Gallery
|
||||||
</Card>
|
</TabsTrigger>
|
||||||
)}
|
<TabsTrigger value="active" className="gap-1.5">
|
||||||
|
<List className="size-3.5" />
|
||||||
|
My Automations
|
||||||
|
{automations && automations.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-1 text-xs px-1.5 py-0">
|
||||||
|
{automations.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
{isLoading && (
|
<TabsContent value="gallery" className="mt-6">
|
||||||
<div className="flex items-center justify-center py-12">
|
<AutomationGallery />
|
||||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
</TabsContent>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{automations && automations.length === 0 && !isLoading && (
|
<TabsContent value="active" className="mt-6">
|
||||||
<Card className="p-8 text-center">
|
{error && (
|
||||||
<Bot className="size-12 text-muted-foreground mx-auto mb-4" />
|
<Card className="border-destructive bg-destructive/10 p-4">
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-sm text-destructive">
|
||||||
No automations configured yet. Create one to get started.
|
Failed to load automations. Make sure the backend is running.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => router.push("/automations/new")}>
|
</Card>
|
||||||
<Plus className="size-4" />
|
)}
|
||||||
Create Automation
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{automations && automations.length > 0 && (
|
{isLoading && (
|
||||||
<Card>
|
<div className="flex items-center justify-center py-12">
|
||||||
<Table>
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
<TableHeader>
|
</div>
|
||||||
<TableRow>
|
)}
|
||||||
<TableHead>Name</TableHead>
|
|
||||||
<TableHead>Character</TableHead>
|
|
||||||
<TableHead>Strategy</TableHead>
|
|
||||||
<TableHead>Status / Controls</TableHead>
|
|
||||||
<TableHead className="w-10" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{automations.map((automation) => {
|
|
||||||
const status = statusMap.get(automation.id);
|
|
||||||
const currentStatus = status?.status ?? "stopped";
|
|
||||||
const actionsCount = status?.actions_count ?? 0;
|
|
||||||
|
|
||||||
return (
|
{automations && automations.length === 0 && !isLoading && (
|
||||||
<TableRow
|
<Card className="p-8 text-center">
|
||||||
key={automation.id}
|
<Bot className="size-12 text-muted-foreground mx-auto mb-4" />
|
||||||
className="cursor-pointer"
|
<p className="text-muted-foreground mb-4">
|
||||||
onClick={() =>
|
No automations configured yet. Pick one from the Gallery or
|
||||||
router.push(`/automations/${automation.id}`)
|
create a custom one.
|
||||||
}
|
</p>
|
||||||
>
|
<Button onClick={() => router.push("/automations/new")}>
|
||||||
<TableCell className="font-medium">
|
<Plus className="size-4" />
|
||||||
{automation.name}
|
Create Automation
|
||||||
</TableCell>
|
</Button>
|
||||||
<TableCell className="text-muted-foreground">
|
</Card>
|
||||||
{automation.character_name}
|
)}
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
{automations && automations.length > 0 && (
|
||||||
<div className="flex items-center gap-1.5">
|
<Card>
|
||||||
{STRATEGY_ICONS[automation.strategy_type] ?? (
|
<Table>
|
||||||
<Bot className="size-4" />
|
<TableHeader>
|
||||||
)}
|
<TableRow>
|
||||||
<span
|
<TableHead>Name</TableHead>
|
||||||
className={cn(
|
<TableHead>Character</TableHead>
|
||||||
"capitalize text-sm",
|
<TableHead>Strategy</TableHead>
|
||||||
STRATEGY_COLORS[automation.strategy_type]
|
<TableHead>Status / Controls</TableHead>
|
||||||
)}
|
<TableHead className="w-10" />
|
||||||
>
|
</TableRow>
|
||||||
{automation.strategy_type}
|
</TableHeader>
|
||||||
</span>
|
<TableBody>
|
||||||
</div>
|
{automations.map((automation) => {
|
||||||
</TableCell>
|
const status = statusMap.get(automation.id);
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
const currentStatus = status?.status ?? "stopped";
|
||||||
<RunControls
|
const actionsCount = status?.actions_count ?? 0;
|
||||||
automationId={automation.id}
|
|
||||||
status={currentStatus}
|
return (
|
||||||
actionsCount={actionsCount}
|
<TableRow
|
||||||
/>
|
key={automation.id}
|
||||||
</TableCell>
|
className="cursor-pointer"
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Button
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setDeleteTarget({
|
router.push(`/automations/${automation.id}`)
|
||||||
id: automation.id,
|
|
||||||
name: automation.name,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<TableCell className="font-medium">
|
||||||
</Button>
|
{automation.name}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
<TableCell className="text-muted-foreground">
|
||||||
);
|
{automation.character_name}
|
||||||
})}
|
</TableCell>
|
||||||
</TableBody>
|
<TableCell>
|
||||||
</Table>
|
<div className="flex items-center gap-1.5">
|
||||||
</Card>
|
{STRATEGY_ICONS[automation.strategy_type] ?? (
|
||||||
)}
|
<Bot className="size-4" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"capitalize text-sm",
|
||||||
|
STRATEGY_COLORS[automation.strategy_type]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{automation.strategy_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
|
<RunControls
|
||||||
|
automationId={automation.id}
|
||||||
|
status={currentStatus}
|
||||||
|
actionsCount={actionsCount}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() =>
|
||||||
|
setDeleteTarget({
|
||||||
|
id: automation.id,
|
||||||
|
name: automation.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={deleteTarget !== null}
|
open={deleteTarget !== null}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { GameIcon } from "@/components/ui/game-icon";
|
||||||
import { useBank } from "@/hooks/use-bank";
|
import { useBank } from "@/hooks/use-bank";
|
||||||
|
|
||||||
interface BankItem {
|
interface BankItem {
|
||||||
|
|
@ -172,7 +173,10 @@ export default function BankPage() {
|
||||||
className="py-3 px-3 hover:bg-accent/30 transition-colors"
|
className="py-3 px-3 hover:bg-accent/30 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-sm font-medium text-foreground truncate">
|
<div className="flex justify-center">
|
||||||
|
<GameIcon type="item" code={item.code} size="lg" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-foreground truncate text-center">
|
||||||
{item.code}
|
{item.code}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { StatsPanel } from "@/components/character/stats-panel";
|
||||||
import { EquipmentGrid } from "@/components/character/equipment-grid";
|
import { EquipmentGrid } from "@/components/character/equipment-grid";
|
||||||
import { InventoryGrid } from "@/components/character/inventory-grid";
|
import { InventoryGrid } from "@/components/character/inventory-grid";
|
||||||
import { SkillBars } from "@/components/character/skill-bars";
|
import { SkillBars } from "@/components/character/skill-bars";
|
||||||
|
import { CharacterAutomations } from "@/components/character/character-automations";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -124,6 +125,9 @@ export default function CharacterPage({
|
||||||
<SkillBars character={character} />
|
<SkillBars character={character} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Automations */}
|
||||||
|
<CharacterAutomations characterName={decodedName} character={character} />
|
||||||
|
|
||||||
{/* Manual Actions */}
|
{/* Manual Actions */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import {
|
||||||
usePriceHistory,
|
usePriceHistory,
|
||||||
} from "@/hooks/use-exchange";
|
} from "@/hooks/use-exchange";
|
||||||
import { PriceChart } from "@/components/exchange/price-chart";
|
import { PriceChart } from "@/components/exchange/price-chart";
|
||||||
|
import { GameIcon } from "@/components/ui/game-icon";
|
||||||
import type { GEOrder } from "@/lib/types";
|
import type { GEOrder } from "@/lib/types";
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
|
|
@ -93,7 +94,12 @@ function OrdersTable({
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filtered.map((order) => (
|
{filtered.map((order) => (
|
||||||
<TableRow key={order.id}>
|
<TableRow key={order.id}>
|
||||||
<TableCell className="font-medium">{order.code}</TableCell>
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GameIcon type="item" code={order.code} size="sm" />
|
||||||
|
{order.code}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -233,7 +239,7 @@ export default function ExchangePage() {
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
<TrendingUp className="size-5 text-blue-400" />
|
<GameIcon type="item" code={searchedItem} size="md" showTooltip={false} />
|
||||||
Price History: {searchedItem}
|
Price History: {searchedItem}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,32 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
import { Loader2, Plus, Minus, RotateCcw } from "lucide-react";
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
Layers,
|
||||||
|
} from "lucide-react";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { useMaps } from "@/hooks/use-game-data";
|
import { useMaps } from "@/hooks/use-game-data";
|
||||||
import { useCharacters } from "@/hooks/use-characters";
|
import { useCharacters } from "@/hooks/use-characters";
|
||||||
import type { MapTile, Character } from "@/lib/types";
|
import type { MapTile, Character } from "@/lib/types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const CONTENT_COLORS: Record<string, string> = {
|
const CONTENT_COLORS: Record<string, string> = {
|
||||||
monster: "#ef4444",
|
monster: "#ef4444",
|
||||||
monsters: "#ef4444",
|
monsters: "#ef4444",
|
||||||
|
|
@ -24,6 +42,8 @@ const CONTENT_COLORS: Record<string, string> = {
|
||||||
const EMPTY_COLOR = "#1f2937";
|
const EMPTY_COLOR = "#1f2937";
|
||||||
const CHARACTER_COLOR = "#facc15";
|
const CHARACTER_COLOR = "#facc15";
|
||||||
const GRID_LINE_COLOR = "#374151";
|
const GRID_LINE_COLOR = "#374151";
|
||||||
|
const BASE_CELL_SIZE = 18;
|
||||||
|
const IMAGE_BASE = "https://artifactsmmo.com/images";
|
||||||
|
|
||||||
const LEGEND_ITEMS = [
|
const LEGEND_ITEMS = [
|
||||||
{ label: "Monsters", color: "#ef4444", key: "monster" },
|
{ label: "Monsters", color: "#ef4444", key: "monster" },
|
||||||
|
|
@ -37,28 +57,107 @@ const LEGEND_ITEMS = [
|
||||||
{ label: "Character", color: "#facc15", key: "character" },
|
{ label: "Character", color: "#facc15", key: "character" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const LAYER_OPTIONS = [
|
||||||
|
{ key: "overworld", label: "Overworld" },
|
||||||
|
{ key: "underground", label: "Underground" },
|
||||||
|
{ key: "interior", label: "Interior" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Content types that have images on the CDN
|
||||||
|
const IMAGE_CONTENT_TYPES = new Set([
|
||||||
|
"monster",
|
||||||
|
"monsters",
|
||||||
|
"resource",
|
||||||
|
"resources",
|
||||||
|
"npc",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Module-level image cache (persists across re-renders)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const imageCache = new Map<string, HTMLImageElement>();
|
||||||
|
|
||||||
|
function loadImage(url: string): Promise<HTMLImageElement> {
|
||||||
|
const cached = imageCache.get(url);
|
||||||
|
if (cached) return Promise.resolve(cached);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
img.onload = () => {
|
||||||
|
imageCache.set(url, img);
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function skinUrl(skin: string): string {
|
||||||
|
return `${IMAGE_BASE}/maps/${skin}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentIconUrl(type: string, code: string): string {
|
||||||
|
const normalizedType = type === "monsters" ? "monster" : type === "resources" ? "resource" : type;
|
||||||
|
// CDN path uses plural folder names
|
||||||
|
const folder =
|
||||||
|
normalizedType === "monster"
|
||||||
|
? "monsters"
|
||||||
|
: normalizedType === "resource"
|
||||||
|
? "resources"
|
||||||
|
: "npcs";
|
||||||
|
return `${IMAGE_BASE}/${folder}/${code}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
function getTileColor(tile: MapTile): string {
|
function getTileColor(tile: MapTile): string {
|
||||||
if (!tile.content?.type) return EMPTY_COLOR;
|
if (!tile.content?.type) return EMPTY_COLOR;
|
||||||
return CONTENT_COLORS[tile.content.type] ?? EMPTY_COLOR;
|
return CONTENT_COLORS[tile.content.type] ?? EMPTY_COLOR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeContentType(type: string): string {
|
||||||
|
if (type === "monsters") return "monster";
|
||||||
|
if (type === "resources") return "resource";
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface SelectedTile {
|
interface SelectedTile {
|
||||||
tile: MapTile;
|
tile: MapTile;
|
||||||
characters: Character[];
|
characters: Character[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
tile: MapTile;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function MapPage() {
|
export default function MapPage() {
|
||||||
const { data: tiles, isLoading, error } = useMaps();
|
const { data: tiles, isLoading, error } = useMaps();
|
||||||
const { data: characters } = useCharacters();
|
const { data: characters } = useCharacters();
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const minimapCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const hoveredKeyRef = useRef<string>("");
|
||||||
|
const rafRef = useRef<number>(0);
|
||||||
|
const dragDistRef = useRef(0);
|
||||||
|
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||||
const [selectedTile, setSelectedTile] = useState<SelectedTile | null>(null);
|
const [selectedTile, setSelectedTile] = useState<SelectedTile | null>(null);
|
||||||
|
const [imagesLoaded, setImagesLoaded] = useState(false);
|
||||||
|
const [imageLoadProgress, setImageLoadProgress] = useState({ loaded: 0, total: 0 });
|
||||||
|
const [activeLayer, setActiveLayer] = useState("overworld");
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
const [filters, setFilters] = useState<Record<string, boolean>>({
|
const [filters, setFilters] = useState<Record<string, boolean>>({
|
||||||
monster: true,
|
monster: true,
|
||||||
|
|
@ -72,33 +171,38 @@ export default function MapPage() {
|
||||||
character: true,
|
character: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Compute grid bounds
|
const cellSize = BASE_CELL_SIZE * zoom;
|
||||||
|
|
||||||
|
// ---- Computed data ----
|
||||||
|
|
||||||
|
// Filter tiles by active layer
|
||||||
|
const layerTiles = useMemo(() => {
|
||||||
|
if (!tiles) return [];
|
||||||
|
return tiles.filter((t) => t.layer === activeLayer);
|
||||||
|
}, [tiles, activeLayer]);
|
||||||
|
|
||||||
const bounds = useMemo(() => {
|
const bounds = useMemo(() => {
|
||||||
if (!tiles || tiles.length === 0) return { minX: 0, maxX: 0, minY: 0, maxY: 0 };
|
if (layerTiles.length === 0)
|
||||||
|
return { minX: 0, maxX: 0, minY: 0, maxY: 0 };
|
||||||
let minX = Infinity,
|
let minX = Infinity,
|
||||||
maxX = -Infinity,
|
maxX = -Infinity,
|
||||||
minY = Infinity,
|
minY = Infinity,
|
||||||
maxY = -Infinity;
|
maxY = -Infinity;
|
||||||
for (const tile of tiles) {
|
for (const tile of layerTiles) {
|
||||||
if (tile.x < minX) minX = tile.x;
|
if (tile.x < minX) minX = tile.x;
|
||||||
if (tile.x > maxX) maxX = tile.x;
|
if (tile.x > maxX) maxX = tile.x;
|
||||||
if (tile.y < minY) minY = tile.y;
|
if (tile.y < minY) minY = tile.y;
|
||||||
if (tile.y > maxY) maxY = tile.y;
|
if (tile.y > maxY) maxY = tile.y;
|
||||||
}
|
}
|
||||||
return { minX, maxX, minY, maxY };
|
return { minX, maxX, minY, maxY };
|
||||||
}, [tiles]);
|
}, [layerTiles]);
|
||||||
|
|
||||||
// Build tile lookup
|
|
||||||
const tileMap = useMemo(() => {
|
const tileMap = useMemo(() => {
|
||||||
if (!tiles) return new Map<string, MapTile>();
|
|
||||||
const map = new Map<string, MapTile>();
|
const map = new Map<string, MapTile>();
|
||||||
for (const tile of tiles) {
|
for (const tile of layerTiles) map.set(`${tile.x},${tile.y}`, tile);
|
||||||
map.set(`${tile.x},${tile.y}`, tile);
|
|
||||||
}
|
|
||||||
return map;
|
return map;
|
||||||
}, [tiles]);
|
}, [layerTiles]);
|
||||||
|
|
||||||
// Character positions
|
|
||||||
const charPositions = useMemo(() => {
|
const charPositions = useMemo(() => {
|
||||||
if (!characters) return new Map<string, Character[]>();
|
if (!characters) return new Map<string, Character[]>();
|
||||||
const map = new Map<string, Character[]>();
|
const map = new Map<string, Character[]>();
|
||||||
|
|
@ -110,13 +214,50 @@ export default function MapPage() {
|
||||||
return map;
|
return map;
|
||||||
}, [characters]);
|
}, [characters]);
|
||||||
|
|
||||||
const BASE_CELL_SIZE = 18;
|
// ---- Image preloading ----
|
||||||
const cellSize = BASE_CELL_SIZE * zoom;
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tiles || tiles.length === 0) return;
|
||||||
|
|
||||||
|
const urls = new Set<string>();
|
||||||
|
|
||||||
|
for (const tile of tiles) {
|
||||||
|
if (tile.skin) urls.add(skinUrl(tile.skin));
|
||||||
|
if (tile.content && IMAGE_CONTENT_TYPES.has(tile.content.type)) {
|
||||||
|
urls.add(contentIconUrl(tile.content.type, tile.content.code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urls.size === 0) {
|
||||||
|
setImagesLoaded(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = urls.size;
|
||||||
|
let loaded = 0;
|
||||||
|
setImageLoadProgress({ loaded: 0, total });
|
||||||
|
|
||||||
|
const promises = [...urls].map((url) =>
|
||||||
|
loadImage(url)
|
||||||
|
.then(() => {
|
||||||
|
loaded++;
|
||||||
|
setImageLoadProgress({ loaded, total });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
loaded++;
|
||||||
|
setImageLoadProgress({ loaded, total });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
Promise.allSettled(promises).then(() => setImagesLoaded(true));
|
||||||
|
}, [tiles]);
|
||||||
|
|
||||||
|
// ---- Canvas drawing ----
|
||||||
|
|
||||||
const drawMap = useCallback(() => {
|
const drawMap = useCallback(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!canvas || !container || !tiles || tiles.length === 0) return;
|
if (!canvas || !container || layerTiles.length === 0) return;
|
||||||
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const width = container.clientWidth;
|
const width = container.clientWidth;
|
||||||
|
|
@ -136,15 +277,13 @@ export default function MapPage() {
|
||||||
|
|
||||||
const gridWidth = bounds.maxX - bounds.minX + 1;
|
const gridWidth = bounds.maxX - bounds.minX + 1;
|
||||||
const gridHeight = bounds.maxY - bounds.minY + 1;
|
const gridHeight = bounds.maxY - bounds.minY + 1;
|
||||||
|
|
||||||
const centerOffsetX = (width - gridWidth * cellSize) / 2 + offset.x;
|
const centerOffsetX = (width - gridWidth * cellSize) / 2 + offset.x;
|
||||||
const centerOffsetY = (height - gridHeight * cellSize) / 2 + offset.y;
|
const centerOffsetY = (height - gridHeight * cellSize) / 2 + offset.y;
|
||||||
|
|
||||||
// Draw tiles
|
// Draw tiles
|
||||||
for (const tile of tiles) {
|
for (const tile of layerTiles) {
|
||||||
const contentType = tile.content?.type ?? "empty";
|
const contentType = tile.content?.type ?? "empty";
|
||||||
// Normalize plural to singular for filter check
|
const normalizedType = normalizeContentType(contentType);
|
||||||
const normalizedType = contentType === "monsters" ? "monster" : contentType === "resources" ? "resource" : contentType;
|
|
||||||
|
|
||||||
if (!filters[normalizedType] && normalizedType !== "empty") continue;
|
if (!filters[normalizedType] && normalizedType !== "empty") continue;
|
||||||
if (normalizedType === "empty" && !filters.empty) continue;
|
if (normalizedType === "empty" && !filters.empty) continue;
|
||||||
|
|
@ -152,19 +291,97 @@ export default function MapPage() {
|
||||||
const px = (tile.x - bounds.minX) * cellSize + centerOffsetX;
|
const px = (tile.x - bounds.minX) * cellSize + centerOffsetX;
|
||||||
const py = (tile.y - bounds.minY) * cellSize + centerOffsetY;
|
const py = (tile.y - bounds.minY) * cellSize + centerOffsetY;
|
||||||
|
|
||||||
// Skip tiles outside visible area
|
// Cull off-screen tiles
|
||||||
if (px + cellSize < 0 || py + cellSize < 0 || px > width || py > height)
|
if (px + cellSize < 0 || py + cellSize < 0 || px > width || py > height)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
ctx.fillStyle = getTileColor(tile);
|
// Try to draw skin image
|
||||||
ctx.fillRect(px, py, cellSize - 1, cellSize - 1);
|
const skinImg = tile.skin ? imageCache.get(skinUrl(tile.skin)) : null;
|
||||||
|
|
||||||
// Grid lines when zoomed in
|
if (skinImg) {
|
||||||
|
ctx.drawImage(skinImg, px, py, cellSize, cellSize);
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = getTileColor(tile);
|
||||||
|
ctx.fillRect(px, py, cellSize - 1, cellSize - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content icon overlay
|
||||||
|
if (tile.content) {
|
||||||
|
if (IMAGE_CONTENT_TYPES.has(tile.content.type)) {
|
||||||
|
const iconImg = imageCache.get(
|
||||||
|
contentIconUrl(tile.content.type, tile.content.code)
|
||||||
|
);
|
||||||
|
if (iconImg) {
|
||||||
|
const iconSize = cellSize * 0.6;
|
||||||
|
const iconX = px + (cellSize - iconSize) / 2;
|
||||||
|
const iconY = py + (cellSize - iconSize) / 2;
|
||||||
|
ctx.drawImage(iconImg, iconX, iconY, iconSize, iconSize);
|
||||||
|
}
|
||||||
|
} else if (!skinImg) {
|
||||||
|
// For bank/workshop/etc without skin, draw a colored indicator dot
|
||||||
|
const dotRadius = Math.max(2, cellSize * 0.15);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(
|
||||||
|
px + cellSize / 2,
|
||||||
|
py + cellSize / 2,
|
||||||
|
dotRadius,
|
||||||
|
0,
|
||||||
|
Math.PI * 2
|
||||||
|
);
|
||||||
|
ctx.fillStyle = CONTENT_COLORS[tile.content.type] ?? "#fff";
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid lines (subtler when images are shown)
|
||||||
if (cellSize > 10) {
|
if (cellSize > 10) {
|
||||||
ctx.strokeStyle = GRID_LINE_COLOR;
|
ctx.strokeStyle = skinImg
|
||||||
|
? "rgba(55, 65, 81, 0.3)"
|
||||||
|
: GRID_LINE_COLOR;
|
||||||
ctx.lineWidth = 0.5;
|
ctx.lineWidth = 0.5;
|
||||||
ctx.strokeRect(px, py, cellSize - 1, cellSize - 1);
|
ctx.strokeRect(px, py, cellSize - 1, cellSize - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Selected tile highlight
|
||||||
|
if (
|
||||||
|
selectedTile &&
|
||||||
|
tile.x === selectedTile.tile.x &&
|
||||||
|
tile.y === selectedTile.tile.y
|
||||||
|
) {
|
||||||
|
ctx.strokeStyle = "#3b82f6";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeRect(px + 1, py + 1, cellSize - 3, cellSize - 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coordinate text at high zoom
|
||||||
|
if (cellSize >= 40) {
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.6)";
|
||||||
|
ctx.font = `${Math.max(8, cellSize * 0.2)}px sans-serif`;
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.textBaseline = "top";
|
||||||
|
ctx.fillText(`${tile.x},${tile.y}`, px + 2, py + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tile labels at very high zoom
|
||||||
|
if (cellSize >= 50 && (tile.name || tile.content?.code)) {
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.85)";
|
||||||
|
ctx.font = `bold ${Math.max(9, cellSize * 0.18)}px sans-serif`;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "bottom";
|
||||||
|
if (tile.name) {
|
||||||
|
ctx.fillText(tile.name, px + cellSize / 2, py + cellSize - 2);
|
||||||
|
}
|
||||||
|
if (tile.content?.code) {
|
||||||
|
ctx.font = `${Math.max(8, cellSize * 0.15)}px sans-serif`;
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.65)";
|
||||||
|
ctx.textBaseline = "bottom";
|
||||||
|
ctx.fillText(
|
||||||
|
tile.content.code,
|
||||||
|
px + cellSize / 2,
|
||||||
|
py + cellSize - 2 - Math.max(10, cellSize * 0.2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw character markers
|
// Draw character markers
|
||||||
|
|
@ -205,25 +422,192 @@ export default function MapPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [tiles, characters, bounds, cellSize, offset, filters]);
|
}, [layerTiles, characters, bounds, cellSize, offset, filters, selectedTile, imagesLoaded]);
|
||||||
|
|
||||||
|
// ---- Minimap ----
|
||||||
|
|
||||||
|
const drawMinimap = useCallback(() => {
|
||||||
|
const minimapCanvas = minimapCanvasRef.current;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!minimapCanvas || !container || layerTiles.length === 0) return;
|
||||||
|
|
||||||
|
const size = 150;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
minimapCanvas.width = size * dpr;
|
||||||
|
minimapCanvas.height = size * dpr;
|
||||||
|
minimapCanvas.style.width = `${size}px`;
|
||||||
|
minimapCanvas.style.height = `${size}px`;
|
||||||
|
|
||||||
|
const ctx = minimapCanvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
ctx.fillStyle = "#0f172a";
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
|
||||||
|
const gridWidth = bounds.maxX - bounds.minX + 1;
|
||||||
|
const gridHeight = bounds.maxY - bounds.minY + 1;
|
||||||
|
const miniCellW = size / gridWidth;
|
||||||
|
const miniCellH = size / gridHeight;
|
||||||
|
|
||||||
|
for (const tile of layerTiles) {
|
||||||
|
const mx = (tile.x - bounds.minX) * miniCellW;
|
||||||
|
const my = (tile.y - bounds.minY) * miniCellH;
|
||||||
|
|
||||||
|
// Use skin image color or fallback
|
||||||
|
ctx.fillStyle = getTileColor(tile);
|
||||||
|
if (tile.skin && imageCache.get(skinUrl(tile.skin))) {
|
||||||
|
// Approximate dominant color for minimap – just draw the image tiny
|
||||||
|
ctx.drawImage(
|
||||||
|
imageCache.get(skinUrl(tile.skin))!,
|
||||||
|
mx,
|
||||||
|
my,
|
||||||
|
Math.max(1, miniCellW),
|
||||||
|
Math.max(1, miniCellH)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ctx.fillRect(mx, my, Math.max(1, miniCellW), Math.max(1, miniCellH));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Character dots
|
||||||
|
if (characters) {
|
||||||
|
ctx.fillStyle = CHARACTER_COLOR;
|
||||||
|
for (const char of characters) {
|
||||||
|
const cx = (char.x - bounds.minX) * miniCellW + miniCellW / 2;
|
||||||
|
const cy = (char.y - bounds.minY) * miniCellH + miniCellH / 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Viewport rectangle
|
||||||
|
const containerWidth = container.clientWidth;
|
||||||
|
const containerHeight = container.clientHeight;
|
||||||
|
const mainCenterX = (containerWidth - gridWidth * cellSize) / 2 + offset.x;
|
||||||
|
const mainCenterY = (containerHeight - gridHeight * cellSize) / 2 + offset.y;
|
||||||
|
|
||||||
|
// Convert main canvas viewport to grid coordinates
|
||||||
|
const viewMinX = -mainCenterX / cellSize;
|
||||||
|
const viewMinY = -mainCenterY / cellSize;
|
||||||
|
const viewW = containerWidth / cellSize;
|
||||||
|
const viewH = containerHeight / cellSize;
|
||||||
|
|
||||||
|
ctx.strokeStyle = "#3b82f6";
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.strokeRect(
|
||||||
|
viewMinX * miniCellW,
|
||||||
|
viewMinY * miniCellH,
|
||||||
|
viewW * miniCellW,
|
||||||
|
viewH * miniCellH
|
||||||
|
);
|
||||||
|
}, [layerTiles, characters, bounds, cellSize, offset]);
|
||||||
|
|
||||||
|
// ---- RAF draw scheduling ----
|
||||||
|
|
||||||
|
const scheduleDrawRef = useRef<() => void>(null);
|
||||||
|
scheduleDrawRef.current = () => {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
|
drawMap();
|
||||||
|
drawMinimap();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleDraw = useCallback(() => {
|
||||||
|
scheduleDrawRef.current?.();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
drawMap();
|
scheduleDraw();
|
||||||
}, [drawMap]);
|
}, [drawMap, drawMinimap, scheduleDraw]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
const observer = new ResizeObserver(() => scheduleDraw());
|
||||||
const observer = new ResizeObserver(() => drawMap());
|
|
||||||
observer.observe(container);
|
observer.observe(container);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [drawMap]);
|
}, [scheduleDraw]);
|
||||||
|
|
||||||
|
// ---- Search ----
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchQuery.trim() || layerTiles.length === 0) {
|
||||||
|
setSearchResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Try coordinate parse: "3, -5" or "3 -5"
|
||||||
|
const coordMatch = q.match(/^(-?\d+)[,\s]+(-?\d+)$/);
|
||||||
|
if (coordMatch) {
|
||||||
|
const cx = parseInt(coordMatch[1]);
|
||||||
|
const cy = parseInt(coordMatch[2]);
|
||||||
|
const tile = tileMap.get(`${cx},${cy}`);
|
||||||
|
if (tile) {
|
||||||
|
setSearchResults([
|
||||||
|
{ tile, label: `(${cx}, ${cy}) ${tile.name || "Unknown"}` },
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text search on name and content code
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
for (const tile of layerTiles) {
|
||||||
|
if (results.length >= 20) break;
|
||||||
|
const nameMatch = tile.name?.toLowerCase().includes(q);
|
||||||
|
const codeMatch = tile.content?.code?.toLowerCase().includes(q);
|
||||||
|
if (nameMatch || codeMatch) {
|
||||||
|
results.push({
|
||||||
|
tile,
|
||||||
|
label: `(${tile.x}, ${tile.y}) ${tile.name || tile.content?.code || "Unknown"}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSearchResults(results);
|
||||||
|
}, [searchQuery, layerTiles, tileMap]);
|
||||||
|
|
||||||
|
function jumpToTile(tile: MapTile) {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const gridWidth = bounds.maxX - bounds.minX + 1;
|
||||||
|
const gridHeight = bounds.maxY - bounds.minY + 1;
|
||||||
|
const containerWidth = container.clientWidth;
|
||||||
|
const containerHeight = container.clientHeight;
|
||||||
|
|
||||||
|
// Center the tile in view
|
||||||
|
const tilePxX = (tile.x - bounds.minX) * cellSize + cellSize / 2;
|
||||||
|
const tilePxY = (tile.y - bounds.minY) * cellSize + cellSize / 2;
|
||||||
|
const gridOriginX = (containerWidth - gridWidth * cellSize) / 2;
|
||||||
|
const gridOriginY = (containerHeight - gridHeight * cellSize) / 2;
|
||||||
|
|
||||||
|
setOffset({
|
||||||
|
x: containerWidth / 2 - gridOriginX - tilePxX,
|
||||||
|
y: containerHeight / 2 - gridOriginY - tilePxY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const charsOnTile = charPositions.get(`${tile.x},${tile.y}`) ?? [];
|
||||||
|
setSelectedTile({ tile, characters: charsOnTile });
|
||||||
|
setSearchOpen(false);
|
||||||
|
setSearchQuery("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Mouse events ----
|
||||||
|
|
||||||
function handleCanvasClick(e: React.MouseEvent<HTMLCanvasElement>) {
|
function handleCanvasClick(e: React.MouseEvent<HTMLCanvasElement>) {
|
||||||
|
// Ignore clicks after dragging
|
||||||
|
if (dragDistRef.current > 5) return;
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!canvas || !container || !tiles) return;
|
if (!canvas || !container || layerTiles.length === 0) return;
|
||||||
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const mx = e.clientX - rect.left;
|
const mx = e.clientX - rect.left;
|
||||||
|
|
@ -231,8 +615,10 @@ export default function MapPage() {
|
||||||
|
|
||||||
const gridWidth = bounds.maxX - bounds.minX + 1;
|
const gridWidth = bounds.maxX - bounds.minX + 1;
|
||||||
const gridHeight = bounds.maxY - bounds.minY + 1;
|
const gridHeight = bounds.maxY - bounds.minY + 1;
|
||||||
const centerOffsetX = (container.clientWidth - gridWidth * cellSize) / 2 + offset.x;
|
const centerOffsetX =
|
||||||
const centerOffsetY = (container.clientHeight - gridHeight * cellSize) / 2 + offset.y;
|
(container.clientWidth - gridWidth * cellSize) / 2 + offset.x;
|
||||||
|
const centerOffsetY =
|
||||||
|
(container.clientHeight - gridHeight * cellSize) / 2 + offset.y;
|
||||||
|
|
||||||
const tileX = Math.floor((mx - centerOffsetX) / cellSize) + bounds.minX;
|
const tileX = Math.floor((mx - centerOffsetX) / cellSize) + bounds.minX;
|
||||||
const tileY = Math.floor((my - centerOffsetY) / cellSize) + bounds.minY;
|
const tileY = Math.floor((my - centerOffsetY) / cellSize) + bounds.minY;
|
||||||
|
|
@ -252,10 +638,19 @@ export default function MapPage() {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
setDragging(true);
|
setDragging(true);
|
||||||
setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y });
|
setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y });
|
||||||
|
dragDistRef.current = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMouseMove(e: React.MouseEvent) {
|
function handleMouseMove(e: React.MouseEvent) {
|
||||||
|
// Tooltip handling
|
||||||
|
updateTooltip(e);
|
||||||
|
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
|
|
||||||
|
const dx = e.clientX - dragStart.x - offset.x;
|
||||||
|
const dy = e.clientY - dragStart.y - offset.y;
|
||||||
|
dragDistRef.current += Math.abs(dx) + Math.abs(dy);
|
||||||
|
|
||||||
setOffset({
|
setOffset({
|
||||||
x: e.clientX - dragStart.x,
|
x: e.clientX - dragStart.x,
|
||||||
y: e.clientY - dragStart.y,
|
y: e.clientY - dragStart.y,
|
||||||
|
|
@ -266,10 +661,87 @@ export default function MapPage() {
|
||||||
setDragging(false);
|
setDragging(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
setDragging(false);
|
||||||
|
if (tooltipRef.current) {
|
||||||
|
tooltipRef.current.style.display = "none";
|
||||||
|
}
|
||||||
|
hoveredKeyRef.current = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTooltip(e: React.MouseEvent) {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const container = containerRef.current;
|
||||||
|
const tooltip = tooltipRef.current;
|
||||||
|
if (!canvas || !container || !tooltip || layerTiles.length === 0) return;
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mx = e.clientX - rect.left;
|
||||||
|
const my = e.clientY - rect.top;
|
||||||
|
|
||||||
|
const gridWidth = bounds.maxX - bounds.minX + 1;
|
||||||
|
const gridHeight = bounds.maxY - bounds.minY + 1;
|
||||||
|
const centerOffsetX =
|
||||||
|
(container.clientWidth - gridWidth * cellSize) / 2 + offset.x;
|
||||||
|
const centerOffsetY =
|
||||||
|
(container.clientHeight - gridHeight * cellSize) / 2 + offset.y;
|
||||||
|
|
||||||
|
const tileX = Math.floor((mx - centerOffsetX) / cellSize) + bounds.minX;
|
||||||
|
const tileY = Math.floor((my - centerOffsetY) / cellSize) + bounds.minY;
|
||||||
|
const key = `${tileX},${tileY}`;
|
||||||
|
|
||||||
|
// Skip re-render if same tile
|
||||||
|
if (key === hoveredKeyRef.current) {
|
||||||
|
tooltip.style.left = `${e.clientX - rect.left + 12}px`;
|
||||||
|
tooltip.style.top = `${e.clientY - rect.top + 12}px`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hoveredKeyRef.current = key;
|
||||||
|
const tile = tileMap.get(key);
|
||||||
|
|
||||||
|
if (!tile) {
|
||||||
|
tooltip.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const charsOnTile = charPositions.get(key);
|
||||||
|
let html = `<div class="font-semibold">${tile.name || "Unknown"}</div>`;
|
||||||
|
html += `<div class="text-xs text-gray-400">(${tile.x}, ${tile.y})</div>`;
|
||||||
|
if (tile.content) {
|
||||||
|
html += `<div class="text-xs mt-1"><span class="capitalize">${tile.content.type}</span>: ${tile.content.code}</div>`;
|
||||||
|
}
|
||||||
|
if (charsOnTile && charsOnTile.length > 0) {
|
||||||
|
html += `<div class="text-xs mt-1 text-yellow-400">${charsOnTile.map((c) => c.name).join(", ")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip.innerHTML = html;
|
||||||
|
tooltip.style.display = "block";
|
||||||
|
tooltip.style.left = `${e.clientX - rect.left + 12}px`;
|
||||||
|
tooltip.style.top = `${e.clientY - rect.top + 12}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Zoom toward cursor ----
|
||||||
|
|
||||||
function handleWheel(e: React.WheelEvent) {
|
function handleWheel(e: React.WheelEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
const container = containerRef.current;
|
||||||
setZoom((z) => Math.max(0.3, Math.min(5, z + delta)));
|
if (!container) return;
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const mouseX = e.clientX - rect.left;
|
||||||
|
const mouseY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||||
|
const newZoom = Math.max(0.3, Math.min(5, zoom * factor));
|
||||||
|
const scale = newZoom / zoom;
|
||||||
|
|
||||||
|
// Adjust offset so the point under cursor stays fixed
|
||||||
|
setOffset((prev) => ({
|
||||||
|
x: mouseX - scale * (mouseX - prev.x),
|
||||||
|
y: mouseY - scale * (mouseY - prev.y),
|
||||||
|
}));
|
||||||
|
setZoom(newZoom);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetView() {
|
function resetView() {
|
||||||
|
|
@ -281,16 +753,107 @@ export default function MapPage() {
|
||||||
setFilters((prev) => ({ ...prev, [key]: !prev[key] }));
|
setFilters((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Keyboard shortcuts ----
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
// Don't intercept if typing in an input
|
||||||
|
if (
|
||||||
|
e.target instanceof HTMLInputElement ||
|
||||||
|
e.target instanceof HTMLTextAreaElement
|
||||||
|
) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setSearchOpen(false);
|
||||||
|
setSearchQuery("");
|
||||||
|
(e.target as HTMLElement).blur();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "+":
|
||||||
|
case "=":
|
||||||
|
e.preventDefault();
|
||||||
|
setZoom((z) => Math.min(5, z * 1.15));
|
||||||
|
break;
|
||||||
|
case "-":
|
||||||
|
e.preventDefault();
|
||||||
|
setZoom((z) => Math.max(0.3, z * 0.87));
|
||||||
|
break;
|
||||||
|
case "0":
|
||||||
|
e.preventDefault();
|
||||||
|
resetView();
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
setSelectedTile(null);
|
||||||
|
setSearchOpen(false);
|
||||||
|
setSearchQuery("");
|
||||||
|
break;
|
||||||
|
case "/":
|
||||||
|
e.preventDefault();
|
||||||
|
setSearchOpen(true);
|
||||||
|
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- Render ----
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
<div>
|
||||||
World Map
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
</h1>
|
World Map
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
</h1>
|
||||||
Interactive map of the game world. Click tiles for details, drag to
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
pan, scroll to zoom.
|
Interactive map of the game world. Click tiles for details, drag to
|
||||||
</p>
|
pan, scroll to zoom. Press{" "}
|
||||||
|
<kbd className="px-1 py-0.5 text-xs border rounded bg-muted">
|
||||||
|
/
|
||||||
|
</kbd>{" "}
|
||||||
|
to search.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative w-64">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
|
placeholder='Search tiles or "x, y"...'
|
||||||
|
className="pl-9 h-9"
|
||||||
|
value={searchQuery}
|
||||||
|
onFocus={() => setSearchOpen(true)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setSearchOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{searchOpen && searchResults.length > 0 && (
|
||||||
|
<div className="absolute top-full mt-1 left-0 right-0 z-50 bg-popover border border-border rounded-md shadow-lg max-h-60 overflow-auto">
|
||||||
|
{searchResults.map((r) => (
|
||||||
|
<button
|
||||||
|
key={`${r.tile.x},${r.tile.y}`}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors flex items-center gap-2"
|
||||||
|
onClick={() => jumpToTile(r.tile)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block size-2.5 rounded-sm shrink-0"
|
||||||
|
style={{ backgroundColor: getTileColor(r.tile) }}
|
||||||
|
/>
|
||||||
|
<span className="text-foreground truncate">{r.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -301,8 +864,25 @@ export default function MapPage() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Layer selector + Filters */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1 mr-2">
|
||||||
|
<Layers className="size-4 text-muted-foreground" />
|
||||||
|
{LAYER_OPTIONS.map((layer) => (
|
||||||
|
<button
|
||||||
|
key={layer.key}
|
||||||
|
onClick={() => setActiveLayer(layer.key)}
|
||||||
|
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
||||||
|
activeLayer === layer.key
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "border-border text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{layer.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="h-5 w-px bg-border" />
|
||||||
{LEGEND_ITEMS.map((item) => (
|
{LEGEND_ITEMS.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
|
|
@ -335,6 +915,19 @@ export default function MapPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Image loading progress */}
|
||||||
|
{!imagesLoaded && imageLoadProgress.total > 0 && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-slate-950/70 z-10">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Loading map images... {imageLoadProgress.loaded}/
|
||||||
|
{imageLoadProgress.total}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className={`${dragging ? "cursor-grabbing" : "cursor-grab"}`}
|
className={`${dragging ? "cursor-grabbing" : "cursor-grab"}`}
|
||||||
|
|
@ -342,23 +935,37 @@ export default function MapPage() {
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseLeave={handleMouseUp}
|
onMouseLeave={handleMouseLeave}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Hover tooltip */}
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
className="absolute pointer-events-none z-20 bg-popover/95 border border-border rounded-md px-3 py-2 text-sm text-foreground shadow-lg backdrop-blur-sm"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Minimap */}
|
||||||
|
<canvas
|
||||||
|
ref={minimapCanvasRef}
|
||||||
|
className="absolute top-3 right-3 rounded border border-border/50 bg-slate-950/80"
|
||||||
|
style={{ width: 150, height: 150 }}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Zoom controls */}
|
{/* Zoom controls */}
|
||||||
<div className="absolute right-3 bottom-3 flex flex-col gap-1">
|
<div className="absolute right-3 bottom-3 flex flex-col gap-1">
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => setZoom((z) => Math.min(5, z + 0.2))}
|
onClick={() => setZoom((z) => Math.min(5, z * 1.2))}
|
||||||
>
|
>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => setZoom((z) => Math.max(0.3, z - 0.2))}
|
onClick={() => setZoom((z) => Math.max(0.3, z * 0.83))}
|
||||||
>
|
>
|
||||||
<Minus className="size-4" />
|
<Minus className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -367,7 +974,7 @@ export default function MapPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zoom level indicator */}
|
{/* Coordinate display + zoom level */}
|
||||||
<div className="absolute left-3 bottom-3 text-xs text-muted-foreground bg-background/80 px-2 py-1 rounded">
|
<div className="absolute left-3 bottom-3 text-xs text-muted-foreground bg-background/80 px-2 py-1 rounded">
|
||||||
{Math.round(zoom * 100)}%
|
{Math.round(zoom * 100)}%
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -388,6 +995,18 @@ export default function MapPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tile skin image */}
|
||||||
|
{selectedTile.tile.skin && (
|
||||||
|
<div className="rounded overflow-hidden border border-border">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={skinUrl(selectedTile.tile.skin)}
|
||||||
|
alt={selectedTile.tile.name}
|
||||||
|
className="w-full h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground space-y-1.5">
|
<div className="text-sm text-muted-foreground space-y-1.5">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs uppercase tracking-wider text-muted-foreground/70">
|
<span className="text-xs uppercase tracking-wider text-muted-foreground/70">
|
||||||
|
|
@ -417,6 +1036,21 @@ export default function MapPage() {
|
||||||
<p className="mt-1 text-foreground">
|
<p className="mt-1 text-foreground">
|
||||||
{selectedTile.tile.content.code}
|
{selectedTile.tile.content.code}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Content icon image */}
|
||||||
|
{IMAGE_CONTENT_TYPES.has(selectedTile.tile.content.type) && (
|
||||||
|
<div className="mt-2 flex justify-center">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={contentIconUrl(
|
||||||
|
selectedTile.tile.content.type,
|
||||||
|
selectedTile.tile.content.code
|
||||||
|
)}
|
||||||
|
alt={selectedTile.tile.content.code}
|
||||||
|
className="size-16 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Settings, Save, RotateCcw } from "lucide-react";
|
import {
|
||||||
|
Settings,
|
||||||
|
Save,
|
||||||
|
RotateCcw,
|
||||||
|
KeyRound,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -9,16 +16,15 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
|
||||||
interface AppSettings {
|
interface AppSettings {
|
||||||
apiUrl: string;
|
|
||||||
characterRefreshInterval: number;
|
characterRefreshInterval: number;
|
||||||
automationRefreshInterval: number;
|
automationRefreshInterval: number;
|
||||||
mapAutoRefresh: boolean;
|
mapAutoRefresh: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: AppSettings = {
|
const DEFAULT_SETTINGS: AppSettings = {
|
||||||
apiUrl: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000",
|
|
||||||
characterRefreshInterval: 5,
|
characterRefreshInterval: 5,
|
||||||
automationRefreshInterval: 3,
|
automationRefreshInterval: 3,
|
||||||
mapAutoRefresh: true,
|
mapAutoRefresh: true,
|
||||||
|
|
@ -35,6 +41,9 @@ function loadSettings(): AppSettings {
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
|
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
|
||||||
|
const { status: authStatus, setToken, removeToken } = useAuth();
|
||||||
|
const [newToken, setNewToken] = useState("");
|
||||||
|
const [tokenLoading, setTokenLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSettings(loadSettings());
|
setSettings(loadSettings());
|
||||||
|
|
@ -55,6 +64,29 @@ export default function SettingsPage() {
|
||||||
toast.success("Settings reset to defaults");
|
toast.success("Settings reset to defaults");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSetToken(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newToken.trim()) return;
|
||||||
|
|
||||||
|
setTokenLoading(true);
|
||||||
|
const result = await setToken(newToken.trim());
|
||||||
|
setTokenLoading(false);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setNewToken("");
|
||||||
|
toast.success("API token updated");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Failed to set token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveToken() {
|
||||||
|
setTokenLoading(true);
|
||||||
|
await removeToken();
|
||||||
|
setTokenLoading(false);
|
||||||
|
toast.success("API token removed");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -67,29 +99,83 @@ export default function SettingsPage() {
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
<Settings className="size-4" />
|
<KeyRound className="size-4" />
|
||||||
Connection
|
API Token
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label htmlFor="api-url">Backend API URL</Label>
|
<span className="text-sm text-muted-foreground">Status:</span>
|
||||||
<Input
|
{authStatus?.has_token ? (
|
||||||
id="api-url"
|
<Badge variant="default">
|
||||||
value={settings.apiUrl}
|
Connected (
|
||||||
onChange={(e) =>
|
{authStatus.source === "env"
|
||||||
setSettings((s) => ({ ...s, apiUrl: e.target.value }))
|
? "environment"
|
||||||
}
|
: "user-provided"}
|
||||||
placeholder="http://localhost:8000"
|
)
|
||||||
/>
|
</Badge>
|
||||||
<p className="text-xs text-muted-foreground">
|
) : (
|
||||||
The URL of the backend API server. Requires page reload to take
|
<Badge variant="destructive">Not configured</Badge>
|
||||||
effect.
|
)}
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{authStatus?.source !== "env" && (
|
||||||
|
<form onSubmit={handleSetToken} className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="new-token">
|
||||||
|
{authStatus?.has_token ? "Replace token" : "Set token"}
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="new-token"
|
||||||
|
type="password"
|
||||||
|
value={newToken}
|
||||||
|
onChange={(e) => setNewToken(e.target.value)}
|
||||||
|
placeholder="Paste your Artifacts MMO token..."
|
||||||
|
disabled={tokenLoading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!newToken.trim() || tokenLoading}
|
||||||
|
className="gap-1.5 shrink-0"
|
||||||
|
>
|
||||||
|
{tokenLoading ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<KeyRound className="size-4" />
|
||||||
|
)}
|
||||||
|
{authStatus?.has_token ? "Update" : "Connect"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{authStatus?.has_token && authStatus.source === "user" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRemoveToken}
|
||||||
|
disabled={tokenLoading}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
Remove token
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authStatus?.source === "env" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Token is configured via environment variable. To change it,
|
||||||
|
update the ARTIFACTS_TOKEN in your .env file and restart the
|
||||||
|
backend.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
|
@ -114,7 +200,8 @@ export default function SettingsPage() {
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setSettings((s) => ({
|
setSettings((s) => ({
|
||||||
...s,
|
...s,
|
||||||
characterRefreshInterval: parseInt(e.target.value, 10) || 5,
|
characterRefreshInterval:
|
||||||
|
parseInt(e.target.value, 10) || 5,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -136,7 +223,8 @@ export default function SettingsPage() {
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setSettings((s) => ({
|
setSettings((s) => ({
|
||||||
...s,
|
...s,
|
||||||
automationRefreshInterval: parseInt(e.target.value, 10) || 3,
|
automationRefreshInterval:
|
||||||
|
parseInt(e.target.value, 10) || 3,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
115
frontend/src/components/auth/api-key-gate.tsx
Normal file
115
frontend/src/components/auth/api-key-gate.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { KeyRound, Loader2, ExternalLink } from "lucide-react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useAuth } from "./auth-provider";
|
||||||
|
|
||||||
|
export function ApiKeyGate({ children }: { children: React.ReactNode }) {
|
||||||
|
const { status, loading, setToken } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-background">
|
||||||
|
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status?.has_token) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ApiKeyForm onSubmit={setToken} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApiKeyForm({
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
onSubmit: (token: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
}) {
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!token.trim()) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
const result = await onSubmit(token.trim());
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error || "Failed to set token");
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<KeyRound className="size-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">Artifacts Dashboard</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
Enter your Artifacts MMO API token to get started.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="api-token">API Token</Label>
|
||||||
|
<Input
|
||||||
|
id="api-token"
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
placeholder="Paste your token here..."
|
||||||
|
disabled={submitting}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full gap-2"
|
||||||
|
disabled={!token.trim() || submitting}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<KeyRound className="size-4" />
|
||||||
|
)}
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-xs text-center text-muted-foreground">
|
||||||
|
You can find your token in your{" "}
|
||||||
|
<a
|
||||||
|
href="https://artifactsmmo.com/account"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline inline-flex items-center gap-0.5 hover:text-foreground"
|
||||||
|
>
|
||||||
|
Artifacts MMO account
|
||||||
|
<ExternalLink className="size-3" />
|
||||||
|
</a>
|
||||||
|
. Your token is stored locally in your browser and sent to the
|
||||||
|
backend server only.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
frontend/src/components/auth/auth-provider.tsx
Normal file
113
frontend/src/components/auth/auth-provider.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
getAuthStatus,
|
||||||
|
setAuthToken,
|
||||||
|
clearAuthToken,
|
||||||
|
type AuthStatus,
|
||||||
|
} from "@/lib/api-client";
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
status: AuthStatus | null;
|
||||||
|
loading: boolean;
|
||||||
|
setToken: (token: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
removeToken: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue>({
|
||||||
|
status: null,
|
||||||
|
loading: true,
|
||||||
|
setToken: async () => ({ success: false }),
|
||||||
|
removeToken: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const STORAGE_KEY = "artifacts-api-token";
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [status, setStatus] = useState<AuthStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const checkStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const s = await getAuthStatus();
|
||||||
|
setStatus(s);
|
||||||
|
|
||||||
|
// If backend has no token but we have one in localStorage, auto-restore it
|
||||||
|
if (!s.has_token) {
|
||||||
|
const savedToken = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (savedToken) {
|
||||||
|
const result = await setAuthToken(savedToken);
|
||||||
|
if (result.success) {
|
||||||
|
setStatus({ has_token: true, source: "user" });
|
||||||
|
} else {
|
||||||
|
// Token is stale, remove it
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Backend unreachable — show the gate anyway
|
||||||
|
setStatus({ has_token: false, source: "none" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkStatus();
|
||||||
|
}, [checkStatus]);
|
||||||
|
|
||||||
|
const handleSetToken = useCallback(
|
||||||
|
async (token: string): Promise<{ success: boolean; error?: string }> => {
|
||||||
|
try {
|
||||||
|
const result = await setAuthToken(token);
|
||||||
|
if (result.success) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, token);
|
||||||
|
setStatus({ has_token: true, source: "user" });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false, error: result.error || "Unknown error" };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: e instanceof Error ? e.message : "Network error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemoveToken = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const s = await clearAuthToken();
|
||||||
|
setStatus(s);
|
||||||
|
} catch {
|
||||||
|
setStatus({ has_token: false, source: "none" });
|
||||||
|
}
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
status,
|
||||||
|
loading,
|
||||||
|
setToken: handleSetToken,
|
||||||
|
removeToken: handleRemoveToken,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
249
frontend/src/components/automation/automation-gallery.tsx
Normal file
249
frontend/src/components/automation/automation-gallery.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Swords,
|
||||||
|
Pickaxe,
|
||||||
|
Hammer,
|
||||||
|
TrendingUp,
|
||||||
|
ClipboardList,
|
||||||
|
GraduationCap,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
GALLERY_TEMPLATES,
|
||||||
|
GALLERY_CATEGORIES,
|
||||||
|
type GalleryTemplate,
|
||||||
|
type GalleryCategoryKey,
|
||||||
|
} from "./gallery-templates";
|
||||||
|
import { GalleryActivateDialog } from "./gallery-activate-dialog";
|
||||||
|
|
||||||
|
const STRATEGY_ICONS: Record<string, typeof Swords> = {
|
||||||
|
combat: Swords,
|
||||||
|
gathering: Pickaxe,
|
||||||
|
crafting: Hammer,
|
||||||
|
trading: TrendingUp,
|
||||||
|
task: ClipboardList,
|
||||||
|
leveling: GraduationCap,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STRATEGY_COLORS: Record<
|
||||||
|
string,
|
||||||
|
{ text: string; bg: string; border: string }
|
||||||
|
> = {
|
||||||
|
combat: {
|
||||||
|
text: "text-red-400",
|
||||||
|
bg: "bg-red-500/10",
|
||||||
|
border: "border-red-500/20",
|
||||||
|
},
|
||||||
|
gathering: {
|
||||||
|
text: "text-green-400",
|
||||||
|
bg: "bg-green-500/10",
|
||||||
|
border: "border-green-500/20",
|
||||||
|
},
|
||||||
|
crafting: {
|
||||||
|
text: "text-blue-400",
|
||||||
|
bg: "bg-blue-500/10",
|
||||||
|
border: "border-blue-500/20",
|
||||||
|
},
|
||||||
|
trading: {
|
||||||
|
text: "text-yellow-400",
|
||||||
|
bg: "bg-yellow-500/10",
|
||||||
|
border: "border-yellow-500/20",
|
||||||
|
},
|
||||||
|
task: {
|
||||||
|
text: "text-purple-400",
|
||||||
|
bg: "bg-purple-500/10",
|
||||||
|
border: "border-purple-500/20",
|
||||||
|
},
|
||||||
|
leveling: {
|
||||||
|
text: "text-cyan-400",
|
||||||
|
bg: "bg-cyan-500/10",
|
||||||
|
border: "border-cyan-500/20",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIFFICULTY_COLORS: Record<string, string> = {
|
||||||
|
beginner: "bg-green-500/10 text-green-400 border-green-500/30",
|
||||||
|
intermediate: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
|
||||||
|
advanced: "bg-red-500/10 text-red-400 border-red-500/30",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_ICONS: Record<string, typeof Swords> = {
|
||||||
|
all: Zap,
|
||||||
|
combat: Swords,
|
||||||
|
gathering: Pickaxe,
|
||||||
|
crafting: Hammer,
|
||||||
|
trading: TrendingUp,
|
||||||
|
utility: ClipboardList,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AutomationGallery() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [category, setCategory] = useState<GalleryCategoryKey>("all");
|
||||||
|
const [selectedTemplate, setSelectedTemplate] =
|
||||||
|
useState<GalleryTemplate | null>(null);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let result = GALLERY_TEMPLATES;
|
||||||
|
|
||||||
|
if (category !== "all") {
|
||||||
|
result = result.filter((t) => t.category === category);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search.trim()) {
|
||||||
|
const q = search.toLowerCase().trim();
|
||||||
|
result = result.filter(
|
||||||
|
(t) =>
|
||||||
|
t.name.toLowerCase().includes(q) ||
|
||||||
|
t.description.toLowerCase().includes(q) ||
|
||||||
|
t.tags.some((tag) => tag.includes(q))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [search, category]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Search & Category Filter */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search automations..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Tabs */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{GALLERY_CATEGORIES.map((cat) => {
|
||||||
|
const Icon = CATEGORY_ICONS[cat.key];
|
||||||
|
const isActive = category === cat.key;
|
||||||
|
const count =
|
||||||
|
cat.key === "all"
|
||||||
|
? GALLERY_TEMPLATES.length
|
||||||
|
: GALLERY_TEMPLATES.filter((t) => t.category === cat.key).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cat.key}
|
||||||
|
onClick={() => setCategory(cat.key)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-3.5" />
|
||||||
|
{cat.label}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs",
|
||||||
|
isActive
|
||||||
|
? "text-primary-foreground/70"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template Grid */}
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Search className="size-10 text-muted-foreground mx-auto mb-3" />
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
No automations match your search.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filtered.map((template) => {
|
||||||
|
const Icon = STRATEGY_ICONS[template.strategy_type] ?? Zap;
|
||||||
|
const colors = STRATEGY_COLORS[template.strategy_type] ?? {
|
||||||
|
text: "text-muted-foreground",
|
||||||
|
bg: "bg-muted",
|
||||||
|
border: "border-muted",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={template.id}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer transition-all hover:shadow-md group py-0 overflow-hidden",
|
||||||
|
"hover:border-primary/30"
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedTemplate(template)}
|
||||||
|
>
|
||||||
|
{/* Colored top bar */}
|
||||||
|
<div className={cn("h-1", colors.bg.replace("/10", "/40"))} />
|
||||||
|
<CardContent className="p-4 space-y-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex size-10 shrink-0 items-center justify-center rounded-lg",
|
||||||
|
colors.bg
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn("size-5", colors.text)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-sm text-foreground group-hover:text-primary transition-colors">
|
||||||
|
{template.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||||
|
{template.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] capitalize",
|
||||||
|
DIFFICULTY_COLORS[template.difficulty]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{template.difficulty}
|
||||||
|
</Badge>
|
||||||
|
{template.min_level > 1 && (
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
Lv. {template.min_level}+
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{template.skill_requirement && (
|
||||||
|
<Badge variant="outline" className="text-[10px] capitalize">
|
||||||
|
{template.skill_requirement.skill} {template.skill_requirement.level}+
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Activation Dialog */}
|
||||||
|
<GalleryActivateDialog
|
||||||
|
template={selectedTemplate}
|
||||||
|
onClose={() => setSelectedTemplate(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
445
frontend/src/components/automation/gallery-activate-dialog.tsx
Normal file
445
frontend/src/components/automation/gallery-activate-dialog.tsx
Normal file
|
|
@ -0,0 +1,445 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Swords,
|
||||||
|
Pickaxe,
|
||||||
|
Hammer,
|
||||||
|
TrendingUp,
|
||||||
|
ClipboardList,
|
||||||
|
GraduationCap,
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { ConfigForm } from "@/components/automation/config-form";
|
||||||
|
import { useCharacters } from "@/hooks/use-characters";
|
||||||
|
import { useCreateAutomation, useControlAutomation } from "@/hooks/use-automations";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { GalleryTemplate } from "./gallery-templates";
|
||||||
|
import type { Character } from "@/lib/types";
|
||||||
|
|
||||||
|
const STRATEGY_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
combat: <Swords className="size-4 text-red-400" />,
|
||||||
|
gathering: <Pickaxe className="size-4 text-green-400" />,
|
||||||
|
crafting: <Hammer className="size-4 text-blue-400" />,
|
||||||
|
trading: <TrendingUp className="size-4 text-yellow-400" />,
|
||||||
|
task: <ClipboardList className="size-4 text-purple-400" />,
|
||||||
|
leveling: <GraduationCap className="size-4 text-cyan-400" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIFFICULTY_COLORS: Record<string, string> = {
|
||||||
|
beginner: "bg-green-500/10 text-green-400 border-green-500/30",
|
||||||
|
intermediate: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
|
||||||
|
advanced: "bg-red-500/10 text-red-400 border-red-500/30",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GalleryActivateDialogProps {
|
||||||
|
template: GalleryTemplate | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GalleryActivateDialog({
|
||||||
|
template,
|
||||||
|
onClose,
|
||||||
|
}: GalleryActivateDialogProps) {
|
||||||
|
const { data: characters, isLoading: loadingCharacters } = useCharacters();
|
||||||
|
const createMutation = useCreateAutomation();
|
||||||
|
const controlMutation = useControlAutomation();
|
||||||
|
|
||||||
|
const [selectedCharacters, setSelectedCharacters] = useState<Set<string>>(
|
||||||
|
new Set()
|
||||||
|
);
|
||||||
|
const [config, setConfig] = useState<Record<string, unknown>>({});
|
||||||
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
|
const [autoStart, setAutoStart] = useState(true);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [progress, setProgress] = useState<{
|
||||||
|
total: number;
|
||||||
|
done: number;
|
||||||
|
results: { name: string; success: boolean; error?: string }[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Reset state when template changes
|
||||||
|
function handleOpenChange(open: boolean) {
|
||||||
|
if (!open) {
|
||||||
|
setSelectedCharacters(new Set());
|
||||||
|
setConfig({});
|
||||||
|
setShowConfig(false);
|
||||||
|
setAutoStart(true);
|
||||||
|
setCreating(false);
|
||||||
|
setProgress(null);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize config from template when it opens
|
||||||
|
if (template && Object.keys(config).length === 0) {
|
||||||
|
setConfig({ ...template.config });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCharacter(name: string) {
|
||||||
|
setSelectedCharacters((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(name)) {
|
||||||
|
next.delete(name);
|
||||||
|
} else {
|
||||||
|
next.add(name);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAll() {
|
||||||
|
if (!characters) return;
|
||||||
|
setSelectedCharacters(new Set(characters.map((c) => c.name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselectAll() {
|
||||||
|
setSelectedCharacters(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSkillLevel(char: Character, skill: string): number {
|
||||||
|
const key = `${skill}_level` as keyof Character;
|
||||||
|
const val = char[key];
|
||||||
|
return typeof val === "number" ? val : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleActivate() {
|
||||||
|
if (!template || selectedCharacters.size === 0) return;
|
||||||
|
|
||||||
|
setCreating(true);
|
||||||
|
const names = Array.from(selectedCharacters);
|
||||||
|
const results: { name: string; success: boolean; error?: string }[] = [];
|
||||||
|
setProgress({ total: names.length, done: 0, results });
|
||||||
|
|
||||||
|
for (const charName of names) {
|
||||||
|
const automationName = `${template.name} (${charName})`;
|
||||||
|
try {
|
||||||
|
const created = await createMutation.mutateAsync({
|
||||||
|
name: automationName,
|
||||||
|
character_name: charName,
|
||||||
|
strategy_type: template.strategy_type,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (autoStart && created?.id) {
|
||||||
|
try {
|
||||||
|
await controlMutation.mutateAsync({
|
||||||
|
id: created.id,
|
||||||
|
action: "start",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Created but failed to start - still a partial success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({ name: charName, success: true });
|
||||||
|
} catch (err) {
|
||||||
|
results.push({
|
||||||
|
name: charName,
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress({ total: names.length, done: results.length, results: [...results] });
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(false);
|
||||||
|
|
||||||
|
const successCount = results.filter((r) => r.success).length;
|
||||||
|
const failCount = results.filter((r) => !r.success).length;
|
||||||
|
|
||||||
|
if (failCount === 0) {
|
||||||
|
toast.success(
|
||||||
|
`Created ${successCount} automation${successCount > 1 ? "s" : ""}${autoStart ? " and started" : ""}`
|
||||||
|
);
|
||||||
|
handleOpenChange(false);
|
||||||
|
} else if (successCount > 0) {
|
||||||
|
toast.warning(
|
||||||
|
`Created ${successCount}, failed ${failCount} automation(s)`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to create automations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSelected =
|
||||||
|
characters && characters.length > 0 && selectedCharacters.size === characters.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={template !== null} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||||
|
{template && (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{STRATEGY_ICONS[template.strategy_type]}
|
||||||
|
{template.name}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>{template.description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn("capitalize", DIFFICULTY_COLORS[template.difficulty])}
|
||||||
|
>
|
||||||
|
{template.difficulty}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{template.strategy_type}
|
||||||
|
</Badge>
|
||||||
|
{template.min_level > 1 && (
|
||||||
|
<Badge variant="outline">Lv. {template.min_level}+</Badge>
|
||||||
|
)}
|
||||||
|
{template.skill_requirement && (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{template.skill_requirement.skill} Lv.{" "}
|
||||||
|
{template.skill_requirement.level}+
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Character Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
Select Characters
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
onClick={allSelected ? deselectAll : selectAll}
|
||||||
|
>
|
||||||
|
{allSelected ? "Deselect All" : "Select All"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingCharacters && (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{characters && characters.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||||
|
No characters found. Make sure the backend is connected.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{characters && characters.length > 0 && (
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{characters.map((char) => {
|
||||||
|
const isSelected = selectedCharacters.has(char.name);
|
||||||
|
const meetsLevel = char.level >= template.min_level;
|
||||||
|
const meetsSkill = template.skill_requirement
|
||||||
|
? getSkillLevel(char, template.skill_requirement.skill) >=
|
||||||
|
template.skill_requirement.level
|
||||||
|
: true;
|
||||||
|
const meetsRequirements = meetsLevel && meetsSkill;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={char.name}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer transition-all p-3",
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary/5 ring-1 ring-primary/30"
|
||||||
|
: "hover:bg-accent/30",
|
||||||
|
!meetsRequirements && "opacity-60"
|
||||||
|
)}
|
||||||
|
onClick={() => toggleCharacter(char.name)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex size-8 shrink-0 items-center justify-center rounded-md border transition-colors",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary border-primary text-primary-foreground"
|
||||||
|
: "border-muted-foreground/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSelected && <Check className="size-4" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm truncate">
|
||||||
|
{char.name}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="text-xs shrink-0">
|
||||||
|
Lv. {char.level}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{template.skill_requirement && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
meetsSkill
|
||||||
|
? "text-green-400"
|
||||||
|
: "text-red-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{template.skill_requirement.skill}:{" "}
|
||||||
|
{getSkillLevel(
|
||||||
|
char,
|
||||||
|
template.skill_requirement.skill
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!template.skill_requirement && (
|
||||||
|
<span>HP: {char.hp}/{char.max_hp}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!meetsRequirements && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs text-yellow-400 border-yellow-500/30 shrink-0"
|
||||||
|
>
|
||||||
|
Low level
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Config Customization (Collapsible) */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 text-sm font-semibold text-foreground hover:text-primary transition-colors w-full"
|
||||||
|
onClick={() => setShowConfig(!showConfig)}
|
||||||
|
>
|
||||||
|
{showConfig ? (
|
||||||
|
<ChevronUp className="size-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="size-4" />
|
||||||
|
)}
|
||||||
|
Customize Configuration
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">
|
||||||
|
(optional)
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showConfig && (
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<ConfigForm
|
||||||
|
strategyType={template.strategy_type}
|
||||||
|
config={config}
|
||||||
|
onChange={setConfig}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
{progress && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Progress</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{progress.done} / {progress.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${(progress.done / progress.total) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{progress.results.length > 0 && (
|
||||||
|
<div className="space-y-1 mt-2">
|
||||||
|
{progress.results.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.name}
|
||||||
|
className="flex items-center gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 rounded-full",
|
||||||
|
r.success ? "bg-green-400" : "bg-red-400"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{r.name}
|
||||||
|
</span>
|
||||||
|
{r.error && (
|
||||||
|
<span className="text-red-400 truncate">
|
||||||
|
{r.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="flex-row items-center gap-3 sm:justify-between">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoStart}
|
||||||
|
onChange={(e) => setAutoStart(e.target.checked)}
|
||||||
|
className="rounded border-muted-foreground/30"
|
||||||
|
disabled={creating}
|
||||||
|
/>
|
||||||
|
Auto-start after creating
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleActivate}
|
||||||
|
disabled={
|
||||||
|
selectedCharacters.size === 0 || creating
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{creating && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
{creating
|
||||||
|
? "Creating..."
|
||||||
|
: `Activate for ${selectedCharacters.size} character${selectedCharacters.size !== 1 ? "s" : ""}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
575
frontend/src/components/automation/gallery-templates.ts
Normal file
575
frontend/src/components/automation/gallery-templates.ts
Normal file
|
|
@ -0,0 +1,575 @@
|
||||||
|
import type {
|
||||||
|
CombatConfig,
|
||||||
|
GatheringConfig,
|
||||||
|
CraftingConfig,
|
||||||
|
TradingConfig,
|
||||||
|
TaskConfig,
|
||||||
|
LevelingConfig,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
export type StrategyType =
|
||||||
|
| "combat"
|
||||||
|
| "gathering"
|
||||||
|
| "crafting"
|
||||||
|
| "trading"
|
||||||
|
| "task"
|
||||||
|
| "leveling";
|
||||||
|
|
||||||
|
export type Difficulty = "beginner" | "intermediate" | "advanced";
|
||||||
|
|
||||||
|
export interface GalleryTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
strategy_type: StrategyType;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
category: string;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
tags: string[];
|
||||||
|
/** Suggested minimum character level */
|
||||||
|
min_level: number;
|
||||||
|
/** Relevant skill and its minimum level, if applicable */
|
||||||
|
skill_requirement?: { skill: string; level: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFFICULTY_ORDER: Record<Difficulty, number> = {
|
||||||
|
beginner: 0,
|
||||||
|
intermediate: 1,
|
||||||
|
advanced: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Combat Templates ----------
|
||||||
|
|
||||||
|
const COMBAT_TEMPLATES: GalleryTemplate[] = [
|
||||||
|
{
|
||||||
|
id: "combat-chicken-farm",
|
||||||
|
name: "Chicken Farm",
|
||||||
|
description:
|
||||||
|
"Fight chickens for feathers, raw chicken, and early XP. Great first automation for new characters.",
|
||||||
|
strategy_type: "combat",
|
||||||
|
config: {
|
||||||
|
monster_code: "chicken",
|
||||||
|
auto_heal_threshold: 40,
|
||||||
|
heal_method: "rest",
|
||||||
|
min_inventory_slots: 3,
|
||||||
|
deposit_loot: true,
|
||||||
|
} satisfies CombatConfig,
|
||||||
|
category: "combat",
|
||||||
|
difficulty: "beginner",
|
||||||
|
tags: ["xp", "gold", "food", "starter"],
|
||||||
|
min_level: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "combat-yellow-slime",
|
||||||
|
name: "Yellow Slime Grinder",
|
||||||
|
description:
|
||||||
|
"Farm yellow slimes for slimeballs and steady XP. Low risk with auto-healing.",
|
||||||
|
strategy_type: "combat",
|
||||||
|
config: {
|
||||||
|
monster_code: "yellow_slime",
|
||||||
|
auto_heal_threshold: 50,
|
||||||
|
heal_method: "rest",
|
||||||
|
min_inventory_slots: 3,
|
||||||
|
deposit_loot: true,
|
||||||
|
} satisfies CombatConfig,
|
||||||
|
category: "combat",
|
||||||
|
difficulty: "beginner",
|
||||||
|
tags: ["xp", "drops", "slime"],
|
||||||
|
min_level: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "combat-green-slime",
|
||||||
|
name: "Green Slime Grinder",
|
||||||
|
description:
|
||||||
|
"Farm green slimes for slimeballs and consistent XP. Slight step up from chickens.",
|
||||||
|
strategy_type: "combat",
|
||||||
|
config: {
|
||||||
|
monster_code: "green_slime",
|
||||||
|
auto_heal_threshold: 50,
|
||||||
|
heal_method: "rest",
|
||||||
|
min_inventory_slots: 3,
|
||||||
|
deposit_loot: true,
|
||||||
|
} satisfies CombatConfig,
|
||||||
|
category: "combat",
|
||||||
|
difficulty: "beginner",
|
||||||
|
tags: ["xp", "drops", "slime"],
|
||||||
|
min_level: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "combat-blue-slime",
|
||||||
|
name: "Blue Slime Farmer",
|
||||||
|
description:
|
||||||
|
"Farm blue slimes for water-element drops and good mid-level XP.",
|
||||||
|
strategy_type: "combat",
|
||||||
|
config: {
|
||||||
|
monster_code: "blue_slime",
|
||||||
|
auto_heal_threshold: 50,
|
||||||
|
heal_method: "rest",
|
||||||
|
min_inventory_slots: 3,
|
||||||
|
deposit_loot: true,
|
||||||
|
} satisfies CombatConfig,
|
||||||
|
category: "combat",
|
||||||
|
difficulty: "beginner",
|
||||||
|
tags: ["xp", "drops", "slime", "water"],
|
||||||
|
min_level: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "combat-red-slime",
|
||||||
|
name: "Red Slime Hunter",
|
||||||
|
description:
|
||||||
|
"Take on red slimes for fire-element drops. Needs decent gear and healing.",
|
||||||
|
strategy_type: "combat",
|
||||||
|
config: {
|
||||||
|
monster_code: "red_slime",
|
||||||
|
auto_heal_threshold: 60,
|
||||||
|
heal_method: "rest",
|
||||||
|
min_inventory_slots: 3,
|
||||||
|
deposit_loot: true,
|
||||||
|
} satisfies CombatConfig,
|
||||||
|
category: "combat",
|
||||||
|
difficulty: "intermediate",
|
||||||
|
tags: ["xp", "drops", "slime", "fire"],
|
||||||
|
min_level: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "combat-wolf-hunter",
|
||||||
|
name: "Wolf Hunter",
|
||||||
|
description:
|
||||||
|
"Hunt wolves for wolf hair, bones, and solid XP. Requires reasonable combat stats.",
|
||||||
|
strategy_type: "combat",
|
||||||
|
config: {
|
||||||
|
monster_code: "wolf",
|
||||||
|
auto_heal_threshold: 60,
|
||||||
|
heal_method: "rest",
|
||||||
|
min_inventory_slots: 3,
|
||||||
|
deposit_loot: true,
|
||||||
|
} satisfies CombatConfig,
|
||||||
|
category: "combat",
|
||||||
|
difficulty: "intermediate",
|
||||||
|
tags: ["xp", "wolf hair", "bones"],
|
||||||
|
min_level: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "combat-skeleton",
|
||||||
|
name: "Skeleton Slayer",
|
||||||
|
description:
|
||||||
|
"Clear skeletons for bone drops and strong XP. Bring good gear.",
|
||||||
|
strategy_type: "combat",
|
||||||
|
config: {
|
||||||
|
monster_code: "skeleton",
|
||||||
|
auto_heal_threshold: 60,
|
||||||
|
heal_method: "rest",
|
||||||
|
min_inventory_slots: 3,
|
||||||
|
deposit_loot: true,
|
||||||
|
} satisfies CombatConfig,
|
||||||
|
category: "combat",
|
||||||
|
difficulty: "intermediate",
|
||||||
|
tags: ["xp", "bones", "undead"],
|
||||||
|
min_level: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "combat-ogre",
|
||||||
|
name: "Ogre Brawler",
|
||||||
|
description:
|
||||||
|
"Fight ogres for rare drops and high XP. Make sure your character is well-geared.",
|
||||||
|
strategy_type: "combat",
|
||||||
|
config: {
|
||||||
|
monster_code: "ogre",
|
||||||
|
auto_heal_threshold: 70,
|
||||||
|
heal_method: "rest",
|
||||||
|
min_inventory_slots: 5,
|
||||||
|
deposit_loot: true,
|
||||||
|
} satisfies CombatConfig,
|
||||||
|
category: "combat",
|
||||||
|
difficulty: "advanced",
|
||||||
|
tags: ["xp", "rare drops", "high level"],
|
||||||
|
min_level: 20,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Gathering Templates ----------
|
||||||
|
|
||||||
|
const GATHERING_TEMPLATES: GalleryTemplate[] = [
|
||||||
|
{
|
||||||
|
id: "gather-copper-ore",
|
||||||
|
name: "Copper Miner",
|
||||||
|
description:
|
||||||
|
"Mine copper ore, the most basic mining resource. Auto-deposits at the bank when full.",
|
||||||
|
strategy_type: "gathering",
|
||||||
|
config: {
|
||||||
|
resource_code: "copper_ore",
|
||||||
|
deposit_on_full: true,
|
||||||
|
max_loops: 0,
|
||||||
|
} satisfies GatheringConfig,
|
||||||
|
category: "gathering",
|
||||||
|
difficulty: "beginner",
|
||||||
|
tags: ["mining", "copper", "ore", "starter"],
|
||||||
|
min_level: 1,
|
||||||
|
skill_requirement: { skill: "mining", level: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gather-iron-ore",
|
||||||
|
name: "Iron Miner",
|
||||||
|
description:
|
||||||
|
"Mine iron ore for crafting iron equipment. Requires mining level 10.",
|
||||||
|
strategy_type: "gathering",
|
||||||
|
config: {
|
||||||
|
resource_code: "iron_ore",
|
||||||
|
deposit_on_full: true,
|
||||||
|
max_loops: 0,
|
||||||
|
} satisfies GatheringConfig,
|
||||||
|
category: "gathering",
|
||||||
|
difficulty: "intermediate",
|
||||||
|
tags: ["mining", "iron", "ore"],
|
||||||
|
min_level: 1,
|
||||||
|
skill_requirement: { skill: "mining", level: 10 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gather-gold-ore",
|
||||||
|
name: "Gold Miner",
|
||||||
|
description:
|
||||||
|
"Mine gold ore for valuable crafting materials. Requires mining level 20.",
|
||||||
|
strategy_type: "gathering",
|
||||||
|
config: {
|
||||||
|
resource_code: "gold_ore",
|
||||||
|
deposit_on_full: true,
|
||||||
|
max_loops: 0,
|
||||||
|
} satisfies GatheringConfig,
|
||||||
|
category: "gathering",
|
||||||
|
difficulty: "advanced",
|
||||||
|
tags: ["mining", "gold", "ore", "valuable"],
|
||||||
|
min_level: 1,
|
||||||
|
skill_requirement: { skill: "mining", level: 20 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gather-ash-tree",
|
||||||
|
name: "Ash Woodcutter",
|
||||||
|
description:
|
||||||
|
"Chop ash trees for ash wood. The starter woodcutting resource.",
|
||||||
|
strategy_type: "gathering",
|
||||||
|
config: {
|
||||||
|
resource_code: "ash_tree",
|
||||||
|
deposit_on_full: true,
|
||||||
|
max_loops: 0,
|
||||||
|
} satisfies GatheringConfig,
|
||||||
|
category: "gathering",
|
||||||
|
difficulty: "beginner",
|
||||||
|
tags: ["woodcutting", "wood", "ash", "starter"],
|
||||||
|
min_level: 1,
|
||||||
|
skill_requirement: { skill: "woodcutting", level: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gather-spruce-tree",
|
||||||
|
name: "Spruce Woodcutter",
|
||||||
|
description:
|
||||||
|
"Chop spruce trees for spruce wood. Requires woodcutting level 10.",
|
||||||
|
strategy_type: "gathering",
|
||||||
|
config: {
|
||||||
|
resource_code: "spruce_tree",
|
||||||
|
deposit_on_full: true,
|
||||||
|
max_loops: 0,
|
||||||
|
} satisfies GatheringConfig,
|
||||||
|
category: "gathering",
|
||||||
|
difficulty: "intermediate",
|
||||||
|
tags: ["woodcutting", "wood", "spruce"],
|
||||||
|
min_level: 1,
|
||||||
|
skill_requirement: { skill: "woodcutting", level: 10 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gather-birch-tree",
|
||||||
|
name: "Birch Woodcutter",
|
||||||
|
description:
|
||||||
|
"Chop birch trees for birch wood. Requires woodcutting level 20.",
|
||||||
|
strategy_type: "gathering",
|
||||||
|
config: {
|
||||||
|
resource_code: "birch_tree",
|
||||||
|
deposit_on_full: true,
|
||||||
|
max_loops: 0,
|
||||||
|
} satisfies GatheringConfig,
|
||||||
|
category: "gathering",
|
||||||
|
difficulty: "advanced",
|
||||||
|
tags: ["woodcutting", "wood", "birch"],
|
||||||
|
min_level: 1,
|
||||||
|
skill_requirement: { skill: "woodcutting", level: 20 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gather-gudgeon",
|
||||||
|
name: "Gudgeon Fisher",
|
||||||
|
description:
|
||||||
|
"Fish gudgeon, the basic fishing resource. Great for leveling cooking too.",
|
||||||
|
strategy_type: "gathering",
|
||||||
|
config: {
|
||||||
|
resource_code: "gudgeon",
|
||||||
|
deposit_on_full: true,
|
||||||
|
max_loops: 0,
|
||||||
|
} satisfies GatheringConfig,
|
||||||
|
category: "gathering",
|
||||||
|
difficulty: "beginner",
|
||||||
|
tags: ["fishing", "fish", "gudgeon", "starter", "cooking"],
|
||||||
|
min_level: 1,
|
||||||
|
skill_requirement: { skill: "fishing", level: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gather-shrimp",
|
||||||
|
name: "Shrimp Fisher",
|
||||||
|
description:
|
||||||
|
"Fish shrimp for cooking materials. Requires fishing level 10.",
|
||||||
|
strategy_type: "gathering",
|
||||||
|
config: {
|
||||||
|
resource_code: "shrimp",
|
||||||
|
deposit_on_full: true,
|
||||||
|
max_loops: 0,
|
||||||
|
} satisfies GatheringConfig,
|
||||||
|
category: "gathering",
|
||||||
|
difficulty: "intermediate",
|
||||||
|
tags: ["fishing", "fish", "shrimp", "cooking"],
|
||||||
|
min_level: 1,
|
||||||
|
skill_requirement: { skill: "fishing", level: 10 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Crafting Templates ----------
|
||||||
|
|
||||||
|
const CRAFTING_TEMPLATES: GalleryTemplate[] = [
|
||||||
|
{
|
||||||
|
id: "craft-copper-dagger",
|
||||||
|
name: "Copper Dagger Smith",
|
||||||
|
description:
|
||||||
|
"Craft copper daggers for weaponcrafting XP. Can auto-gather materials.",
|
||||||
|
strategy_type: "crafting",
|
||||||
|
config: {
|
||||||
|
item_code: "copper_dagger",
|
||||||
|
quantity: 0,
|
||||||
|
gather_materials: true,
|
||||||
|
recycle_excess: true,
|
||||||
|
} satisfies CraftingConfig,
|
||||||
|
category: "crafting",
|
||||||
|
difficulty: "beginner",
|
||||||
|
tags: ["weaponcrafting", "copper", "weapon", "starter"],
|
||||||
|
min_level: 1,
|
||||||
|
skill_requirement: { skill: "weaponcrafting", level: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "craft-copper-boots",
|
||||||
|
name: "Copper Boots Crafter",
|
||||||
|
description:
|
||||||
|
"Craft copper boots for gearcrafting XP. Auto-gathers copper and leather.",
|
||||||
|
strategy_type: "crafting",
|
||||||
|
config: {
|
||||||
|
item_code: "copper_boots",
|
||||||
|
quantity: 0,
|
||||||
|
gather_materials: true,
|
||||||
|
recycle_excess: true,
|
||||||
|
} satisfies CraftingConfig,
|
||||||
|
category: "crafting",
|
||||||
|
difficulty: "beginner",
|
||||||
|
tags: ["gearcrafting", "copper", "armor", "starter"],
|
||||||
|
min_level: 1,
|
||||||
|
skill_requirement: { skill: "gearcrafting", level: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "craft-copper-ring",
|
||||||
|
name: "Copper Ring Jeweler",
|
||||||
|
description:
|
||||||
|
"Craft copper rings for jewelrycrafting XP. Good starter jewelry.",
|
||||||
|
strategy_type: "crafting",
|
||||||
|
config: {
|
||||||
|
item_code: "copper_ring",
|
||||||
|
quantity: 0,
|
||||||
|
gather_materials: true,
|
||||||
|
recycle_excess: true,
|
||||||
|
} satisfies CraftingConfig,
|
||||||
|
category: "crafting",
|
||||||
|
difficulty: "beginner",
|
||||||
|
tags: ["jewelrycrafting", "copper", "ring", "starter"],
|
||||||
|
min_level: 1,
|
||||||
|
skill_requirement: { skill: "jewelrycrafting", level: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "craft-cooked-gudgeon",
|
||||||
|
name: "Gudgeon Cook",
|
||||||
|
description:
|
||||||
|
"Cook gudgeon for cooking XP and healing food. Pairs well with fishing.",
|
||||||
|
strategy_type: "crafting",
|
||||||
|
config: {
|
||||||
|
item_code: "cooked_gudgeon",
|
||||||
|
quantity: 0,
|
||||||
|
gather_materials: true,
|
||||||
|
recycle_excess: false,
|
||||||
|
} satisfies CraftingConfig,
|
||||||
|
category: "crafting",
|
||||||
|
difficulty: "beginner",
|
||||||
|
tags: ["cooking", "food", "healing", "starter"],
|
||||||
|
min_level: 1,
|
||||||
|
skill_requirement: { skill: "cooking", level: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "craft-iron-sword",
|
||||||
|
name: "Iron Sword Smith",
|
||||||
|
description:
|
||||||
|
"Craft iron swords for mid-level weaponcrafting XP and usable weapons.",
|
||||||
|
strategy_type: "crafting",
|
||||||
|
config: {
|
||||||
|
item_code: "iron_sword",
|
||||||
|
quantity: 0,
|
||||||
|
gather_materials: true,
|
||||||
|
recycle_excess: true,
|
||||||
|
} satisfies CraftingConfig,
|
||||||
|
category: "crafting",
|
||||||
|
difficulty: "intermediate",
|
||||||
|
tags: ["weaponcrafting", "iron", "weapon"],
|
||||||
|
min_level: 1,
|
||||||
|
skill_requirement: { skill: "weaponcrafting", level: 10 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Trading Templates ----------
|
||||||
|
|
||||||
|
const TRADING_TEMPLATES: GalleryTemplate[] = [
|
||||||
|
{
|
||||||
|
id: "trade-sell-loot",
|
||||||
|
name: "Loot Seller",
|
||||||
|
description:
|
||||||
|
"Automatically sell all loot from your bank on the Grand Exchange at market price.",
|
||||||
|
strategy_type: "trading",
|
||||||
|
config: {
|
||||||
|
mode: "sell_loot",
|
||||||
|
item_code: "",
|
||||||
|
min_price: 0,
|
||||||
|
max_price: 0,
|
||||||
|
quantity: 0,
|
||||||
|
} satisfies TradingConfig,
|
||||||
|
category: "trading",
|
||||||
|
difficulty: "beginner",
|
||||||
|
tags: ["gold", "sell", "loot", "passive income"],
|
||||||
|
min_level: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trade-buy-copper",
|
||||||
|
name: "Copper Buyer",
|
||||||
|
description:
|
||||||
|
"Buy copper ore from the Grand Exchange for crafting. Set price limits to avoid overpaying.",
|
||||||
|
strategy_type: "trading",
|
||||||
|
config: {
|
||||||
|
mode: "buy_materials",
|
||||||
|
item_code: "copper_ore",
|
||||||
|
min_price: 0,
|
||||||
|
max_price: 10,
|
||||||
|
quantity: 100,
|
||||||
|
} satisfies TradingConfig,
|
||||||
|
category: "trading",
|
||||||
|
difficulty: "intermediate",
|
||||||
|
tags: ["buy", "copper", "materials"],
|
||||||
|
min_level: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trade-flip-items",
|
||||||
|
name: "Market Flipper",
|
||||||
|
description:
|
||||||
|
"Buy items low and sell high on the Grand Exchange. Configure item and price range for profit.",
|
||||||
|
strategy_type: "trading",
|
||||||
|
config: {
|
||||||
|
mode: "flip",
|
||||||
|
item_code: "",
|
||||||
|
min_price: 0,
|
||||||
|
max_price: 0,
|
||||||
|
quantity: 0,
|
||||||
|
} satisfies TradingConfig,
|
||||||
|
category: "trading",
|
||||||
|
difficulty: "advanced",
|
||||||
|
tags: ["gold", "flip", "profit", "arbitrage"],
|
||||||
|
min_level: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Utility Templates ----------
|
||||||
|
|
||||||
|
const UTILITY_TEMPLATES: GalleryTemplate[] = [
|
||||||
|
{
|
||||||
|
id: "util-task-runner",
|
||||||
|
name: "Auto Task Runner",
|
||||||
|
description:
|
||||||
|
"Automatically accept, complete, and turn in NPC tasks for task coins and bonus XP.",
|
||||||
|
strategy_type: "task",
|
||||||
|
config: {} satisfies TaskConfig,
|
||||||
|
category: "utility",
|
||||||
|
difficulty: "beginner",
|
||||||
|
tags: ["tasks", "coins", "xp", "passive"],
|
||||||
|
min_level: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "util-skill-leveler",
|
||||||
|
name: "Skill Leveler",
|
||||||
|
description:
|
||||||
|
"Automatically chooses the most efficient activity to level up your skills. Set a target skill or let it auto-detect.",
|
||||||
|
strategy_type: "leveling",
|
||||||
|
config: {} satisfies LevelingConfig,
|
||||||
|
category: "utility",
|
||||||
|
difficulty: "beginner",
|
||||||
|
tags: ["xp", "leveling", "skills", "auto"],
|
||||||
|
min_level: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "util-mining-leveler",
|
||||||
|
name: "Mining Leveler",
|
||||||
|
description:
|
||||||
|
"Focus on leveling mining by choosing the best available ore for your current level.",
|
||||||
|
strategy_type: "leveling",
|
||||||
|
config: {
|
||||||
|
target_skill: "mining",
|
||||||
|
} satisfies LevelingConfig,
|
||||||
|
category: "utility",
|
||||||
|
difficulty: "beginner",
|
||||||
|
tags: ["xp", "mining", "leveling"],
|
||||||
|
min_level: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "util-woodcutting-leveler",
|
||||||
|
name: "Woodcutting Leveler",
|
||||||
|
description:
|
||||||
|
"Focus on leveling woodcutting by chopping the best available trees.",
|
||||||
|
strategy_type: "leveling",
|
||||||
|
config: {
|
||||||
|
target_skill: "woodcutting",
|
||||||
|
} satisfies LevelingConfig,
|
||||||
|
category: "utility",
|
||||||
|
difficulty: "beginner",
|
||||||
|
tags: ["xp", "woodcutting", "leveling"],
|
||||||
|
min_level: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "util-fishing-leveler",
|
||||||
|
name: "Fishing Leveler",
|
||||||
|
description:
|
||||||
|
"Focus on leveling fishing by catching the best available fish.",
|
||||||
|
strategy_type: "leveling",
|
||||||
|
config: {
|
||||||
|
target_skill: "fishing",
|
||||||
|
} satisfies LevelingConfig,
|
||||||
|
category: "utility",
|
||||||
|
difficulty: "beginner",
|
||||||
|
tags: ["xp", "fishing", "leveling"],
|
||||||
|
min_level: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Exports ----------
|
||||||
|
|
||||||
|
export const GALLERY_TEMPLATES: GalleryTemplate[] = [
|
||||||
|
...COMBAT_TEMPLATES,
|
||||||
|
...GATHERING_TEMPLATES,
|
||||||
|
...CRAFTING_TEMPLATES,
|
||||||
|
...TRADING_TEMPLATES,
|
||||||
|
...UTILITY_TEMPLATES,
|
||||||
|
].sort((a, b) => DIFFICULTY_ORDER[a.difficulty] - DIFFICULTY_ORDER[b.difficulty]);
|
||||||
|
|
||||||
|
export const GALLERY_CATEGORIES = [
|
||||||
|
{ key: "all", label: "All" },
|
||||||
|
{ key: "combat", label: "Combat" },
|
||||||
|
{ key: "gathering", label: "Gathering" },
|
||||||
|
{ key: "crafting", label: "Crafting" },
|
||||||
|
{ key: "trading", label: "Trading" },
|
||||||
|
{ key: "utility", label: "Utility" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type GalleryCategoryKey = (typeof GALLERY_CATEGORIES)[number]["key"];
|
||||||
281
frontend/src/components/character/character-automations.tsx
Normal file
281
frontend/src/components/character/character-automations.tsx
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Swords,
|
||||||
|
Pickaxe,
|
||||||
|
Hammer,
|
||||||
|
TrendingUp,
|
||||||
|
ClipboardList,
|
||||||
|
GraduationCap,
|
||||||
|
Bot,
|
||||||
|
Zap,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
useAutomations,
|
||||||
|
useAutomationStatuses,
|
||||||
|
} from "@/hooks/use-automations";
|
||||||
|
import { RunControls } from "@/components/automation/run-controls";
|
||||||
|
import {
|
||||||
|
GALLERY_TEMPLATES,
|
||||||
|
type GalleryTemplate,
|
||||||
|
} from "@/components/automation/gallery-templates";
|
||||||
|
import { GalleryActivateDialog } from "@/components/automation/gallery-activate-dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Character } from "@/lib/types";
|
||||||
|
|
||||||
|
const STRATEGY_ICONS: Record<string, typeof Swords> = {
|
||||||
|
combat: Swords,
|
||||||
|
gathering: Pickaxe,
|
||||||
|
crafting: Hammer,
|
||||||
|
trading: TrendingUp,
|
||||||
|
task: ClipboardList,
|
||||||
|
leveling: GraduationCap,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STRATEGY_COLORS: Record<
|
||||||
|
string,
|
||||||
|
{ text: string; bg: string }
|
||||||
|
> = {
|
||||||
|
combat: { text: "text-red-400", bg: "bg-red-500/10" },
|
||||||
|
gathering: { text: "text-green-400", bg: "bg-green-500/10" },
|
||||||
|
crafting: { text: "text-blue-400", bg: "bg-blue-500/10" },
|
||||||
|
trading: { text: "text-yellow-400", bg: "bg-yellow-500/10" },
|
||||||
|
task: { text: "text-purple-400", bg: "bg-purple-500/10" },
|
||||||
|
leveling: { text: "text-cyan-400", bg: "bg-cyan-500/10" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIFFICULTY_COLORS: Record<string, string> = {
|
||||||
|
beginner: "bg-green-500/10 text-green-400 border-green-500/30",
|
||||||
|
intermediate: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
|
||||||
|
advanced: "bg-red-500/10 text-red-400 border-red-500/30",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_SUGGESTIONS = 6;
|
||||||
|
|
||||||
|
interface CharacterAutomationsProps {
|
||||||
|
characterName: string;
|
||||||
|
character: Character;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CharacterAutomations({
|
||||||
|
characterName,
|
||||||
|
character,
|
||||||
|
}: CharacterAutomationsProps) {
|
||||||
|
const { data: automations, isLoading } = useAutomations();
|
||||||
|
const { data: statuses } = useAutomationStatuses();
|
||||||
|
const [selectedTemplate, setSelectedTemplate] =
|
||||||
|
useState<GalleryTemplate | null>(null);
|
||||||
|
|
||||||
|
const charAutomations = useMemo(
|
||||||
|
() =>
|
||||||
|
(automations ?? []).filter((a) => a.character_name === characterName),
|
||||||
|
[automations, characterName]
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusMap = useMemo(
|
||||||
|
() => new Map((statuses ?? []).map((s) => [s.config_id, s])),
|
||||||
|
[statuses]
|
||||||
|
);
|
||||||
|
|
||||||
|
const suggestions = useMemo(() => {
|
||||||
|
const activeStrategyTypes = new Set(
|
||||||
|
charAutomations.map((a) => a.strategy_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
return GALLERY_TEMPLATES.filter((t) => {
|
||||||
|
// Character meets min_level
|
||||||
|
if (character.level < t.min_level) return false;
|
||||||
|
|
||||||
|
// Character meets skill requirement
|
||||||
|
if (t.skill_requirement) {
|
||||||
|
const key =
|
||||||
|
`${t.skill_requirement.skill}_level` as keyof Character;
|
||||||
|
const level = character[key];
|
||||||
|
if (typeof level !== "number" || level < t.skill_requirement.level)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't suggest if character already has an automation with the same strategy_type + key config
|
||||||
|
const hasMatching = charAutomations.some((a) => {
|
||||||
|
if (a.strategy_type !== t.strategy_type) return false;
|
||||||
|
// Check key config fields match
|
||||||
|
const configKeys = Object.keys(t.config);
|
||||||
|
if (configKeys.length === 0) return true;
|
||||||
|
return configKeys.every(
|
||||||
|
(k) =>
|
||||||
|
JSON.stringify(a.config[k]) === JSON.stringify(t.config[k])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return !hasMatching;
|
||||||
|
}).slice(0, MAX_SUGGESTIONS);
|
||||||
|
}, [character, charAutomations]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Bot className="size-4 text-muted-foreground" />
|
||||||
|
Automations
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href="/automations">View all</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
{/* Active Automations */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && charAutomations.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground py-2">
|
||||||
|
No automations for this character.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{charAutomations.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{charAutomations.map((automation) => {
|
||||||
|
const status = statusMap.get(automation.id);
|
||||||
|
const currentStatus = status?.status ?? "stopped";
|
||||||
|
const actionsCount = status?.actions_count ?? 0;
|
||||||
|
const Icon =
|
||||||
|
STRATEGY_ICONS[automation.strategy_type] ?? Bot;
|
||||||
|
const colors = STRATEGY_COLORS[automation.strategy_type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={automation.id}
|
||||||
|
className="flex flex-col gap-2 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex size-8 shrink-0 items-center justify-center rounded-md",
|
||||||
|
colors?.bg ?? "bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"size-4",
|
||||||
|
colors?.text ?? "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Link
|
||||||
|
href={`/automations/${automation.id}`}
|
||||||
|
className="text-sm font-medium hover:text-primary transition-colors truncate block"
|
||||||
|
>
|
||||||
|
{automation.name}
|
||||||
|
<ExternalLink className="size-3 inline ml-1 opacity-50" />
|
||||||
|
</Link>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs capitalize",
|
||||||
|
colors?.text ?? "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{automation.strategy_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<RunControls
|
||||||
|
automationId={automation.id}
|
||||||
|
status={currentStatus}
|
||||||
|
actionsCount={actionsCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Suggested Automations */}
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||||
|
Suggested
|
||||||
|
</h3>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href="/automations">Browse all</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{suggestions.map((template) => {
|
||||||
|
const Icon =
|
||||||
|
STRATEGY_ICONS[template.strategy_type] ?? Zap;
|
||||||
|
const colors =
|
||||||
|
STRATEGY_COLORS[template.strategy_type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={template.id}
|
||||||
|
onClick={() => setSelectedTemplate(template)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-2.5 rounded-lg border p-3 text-left transition-all",
|
||||||
|
"hover:border-primary/30 hover:bg-accent/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex size-7 shrink-0 items-center justify-center rounded-md mt-0.5",
|
||||||
|
colors?.bg ?? "bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"size-3.5",
|
||||||
|
colors?.text ?? "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm font-medium block truncate">
|
||||||
|
{template.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground line-clamp-1">
|
||||||
|
{template.description}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] capitalize mt-1.5",
|
||||||
|
DIFFICULTY_COLORS[template.difficulty]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{template.difficulty}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<GalleryActivateDialog
|
||||||
|
template={selectedTemplate}
|
||||||
|
onClose={() => setSelectedTemplate(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { EQUIPMENT_SLOTS } from "@/lib/constants";
|
import { EQUIPMENT_SLOTS } from "@/lib/constants";
|
||||||
import type { Character } from "@/lib/types";
|
import type { Character } from "@/lib/types";
|
||||||
|
import { GameIcon } from "@/components/ui/game-icon";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface EquipmentGridProps {
|
interface EquipmentGridProps {
|
||||||
|
|
@ -54,6 +55,7 @@ export function EquipmentGrid({ character }: EquipmentGridProps) {
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
<GameIcon type="item" code={itemCode} size="md" />
|
||||||
<span className="text-xs font-medium text-foreground truncate">
|
<span className="text-xs font-medium text-foreground truncate">
|
||||||
{itemCode}
|
{itemCode}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
CardDescription,
|
CardDescription,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { GameIcon } from "@/components/ui/game-icon";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Character } from "@/lib/types";
|
import type { Character } from "@/lib/types";
|
||||||
|
|
||||||
|
|
@ -58,16 +59,14 @@ export function InventoryGrid({ character }: InventoryGridProps) {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item && (
|
{item && (
|
||||||
<>
|
<div className="relative flex items-center justify-center">
|
||||||
<span className="text-[9px] font-medium text-foreground text-center leading-tight truncate w-full">
|
<GameIcon type="item" code={item.code} size="md" />
|
||||||
{item.code}
|
|
||||||
</span>
|
|
||||||
{item.quantity > 1 && (
|
{item.quantity > 1 && (
|
||||||
<span className="text-[9px] text-muted-foreground">
|
<span className="absolute -bottom-0.5 -right-0.5 rounded bg-background/80 px-0.5 text-[9px] font-medium text-muted-foreground leading-tight">
|
||||||
x{item.quantity}
|
{item.quantity}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { MapPin, Coins, Swords, Shield, Clock, Target, Bot, Pickaxe } from "luci
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { GameIcon } from "@/components/ui/game-icon";
|
||||||
import type { Character, AutomationStatus } from "@/lib/types";
|
import type { Character, AutomationStatus } from "@/lib/types";
|
||||||
import { SKILLS, SKILL_COLOR_TEXT_MAP } from "@/lib/constants";
|
import { SKILLS, SKILL_COLOR_TEXT_MAP } from "@/lib/constants";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -96,7 +97,10 @@ export function CharacterCard({ character, automationStatus }: CharacterCardProp
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-0 pt-0 px-4">
|
<CardHeader className="pb-0 pt-0 px-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-base">{character.name}</CardTitle>
|
<div className="flex items-center gap-2">
|
||||||
|
<GameIcon type="character" code={character.skin} size="md" showTooltip={false} />
|
||||||
|
<CardTitle className="text-base">{character.name}</CardTitle>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{automationStatus &&
|
{automationStatus &&
|
||||||
automationStatus.status !== "stopped" && (
|
automationStatus.status !== "stopped" && (
|
||||||
|
|
@ -236,12 +240,14 @@ export function CharacterCard({ character, automationStatus }: CharacterCardProp
|
||||||
{(character.weapon_slot || character.shield_slot) && (
|
{(character.weapon_slot || character.shield_slot) && (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
{character.weapon_slot && (
|
{character.weapon_slot && (
|
||||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 gap-1">
|
||||||
|
<GameIcon type="item" code={character.weapon_slot} size="sm" showTooltip={false} />
|
||||||
{character.weapon_slot}
|
{character.weapon_slot}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{character.shield_slot && (
|
{character.shield_slot && (
|
||||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 gap-1">
|
||||||
|
<GameIcon type="item" code={character.shield_slot} size="sm" showTooltip={false} />
|
||||||
{character.shield_slot}
|
{character.shield_slot}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import {
|
||||||
useWebSocket,
|
useWebSocket,
|
||||||
type ConnectionStatus,
|
type ConnectionStatus,
|
||||||
} from "@/hooks/use-websocket";
|
} from "@/hooks/use-websocket";
|
||||||
|
import { AuthProvider } from "@/components/auth/auth-provider";
|
||||||
|
import { ApiKeyGate } from "@/components/auth/api-key-gate";
|
||||||
|
|
||||||
interface WebSocketContextValue {
|
interface WebSocketContextValue {
|
||||||
status: ConnectionStatus;
|
status: ConnectionStatus;
|
||||||
|
|
@ -53,10 +55,12 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<WebSocketProvider>
|
<AuthProvider>
|
||||||
{children}
|
|
||||||
<Toaster theme="dark" position="bottom-right" richColors />
|
<Toaster theme="dark" position="bottom-right" richColors />
|
||||||
</WebSocketProvider>
|
<ApiKeyGate>
|
||||||
|
<WebSocketProvider>{children}</WebSocketProvider>
|
||||||
|
</ApiKeyGate>
|
||||||
|
</AuthProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
104
frontend/src/components/ui/game-icon.tsx
Normal file
104
frontend/src/components/ui/game-icon.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const TYPE_PATHS: Record<string, string> = {
|
||||||
|
item: "items",
|
||||||
|
monster: "monsters",
|
||||||
|
resource: "resources",
|
||||||
|
character: "characters",
|
||||||
|
effect: "effects",
|
||||||
|
npc: "npcs",
|
||||||
|
badge: "badges",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZES = {
|
||||||
|
xs: 16,
|
||||||
|
sm: 24,
|
||||||
|
md: 32,
|
||||||
|
lg: 48,
|
||||||
|
xl: 64,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type GameIconType = keyof typeof TYPE_PATHS;
|
||||||
|
type GameIconSize = keyof typeof SIZES;
|
||||||
|
|
||||||
|
interface GameIconProps {
|
||||||
|
type: GameIconType;
|
||||||
|
code: string;
|
||||||
|
size?: GameIconSize;
|
||||||
|
className?: string;
|
||||||
|
showTooltip?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FallbackIcon({ code, px, className }: { code: string; px: number; className?: string }) {
|
||||||
|
const hue = [...code].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center rounded-sm font-bold text-white select-none shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: px,
|
||||||
|
height: px,
|
||||||
|
fontSize: Math.max(px * 0.4, 8),
|
||||||
|
backgroundColor: `hsl(${hue}, 50%, 35%)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{code.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GameIcon({
|
||||||
|
type,
|
||||||
|
code,
|
||||||
|
size = "md",
|
||||||
|
className,
|
||||||
|
showTooltip = true,
|
||||||
|
}: GameIconProps) {
|
||||||
|
const [errored, setErrored] = useState(false);
|
||||||
|
const px = SIZES[size];
|
||||||
|
const path = TYPE_PATHS[type] ?? "items";
|
||||||
|
const src = `https://artifactsmmo.com/images/${path}/${code}.png`;
|
||||||
|
|
||||||
|
const displayName = code.replaceAll("_", " ");
|
||||||
|
|
||||||
|
const icon = errored ? (
|
||||||
|
<FallbackIcon code={code} px={px} className={className} />
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={displayName}
|
||||||
|
width={px}
|
||||||
|
height={px}
|
||||||
|
className={cn("shrink-0 object-contain", className)}
|
||||||
|
onError={() => setErrored(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!showTooltip) return icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex shrink-0">{icon}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<span className="capitalize">{displayName}</span>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -84,6 +84,40 @@ async function deleteApi(path: string): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Auth API ----------
|
||||||
|
|
||||||
|
export interface AuthStatus {
|
||||||
|
has_token: boolean;
|
||||||
|
source: "env" | "user" | "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetTokenResponse {
|
||||||
|
success: boolean;
|
||||||
|
source: string;
|
||||||
|
account?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthStatus(): Promise<AuthStatus> {
|
||||||
|
return fetchApi<AuthStatus>("/api/auth/status");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAuthToken(token: string): Promise<SetTokenResponse> {
|
||||||
|
return postApi<SetTokenResponse>("/api/auth/token", { token });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAuthToken(): Promise<AuthStatus> {
|
||||||
|
const response = await fetch(`${API_URL}/api/auth/token`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<AuthStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Characters API ----------
|
||||||
|
|
||||||
export function getCharacters(): Promise<Character[]> {
|
export function getCharacters(): Promise<Character[]> {
|
||||||
return fetchApi<Character[]>("/api/characters");
|
return fetchApi<Character[]>("/api/characters");
|
||||||
}
|
}
|
||||||
|
|
@ -185,37 +219,43 @@ export function getAutomationLogs(
|
||||||
|
|
||||||
// ---------- Grand Exchange API ----------
|
// ---------- Grand Exchange API ----------
|
||||||
|
|
||||||
export function getExchangeOrders(): Promise<GEOrder[]> {
|
export async function getExchangeOrders(): Promise<GEOrder[]> {
|
||||||
return fetchApi<GEOrder[]>("/api/exchange/orders");
|
const res = await fetchApi<{ orders: GEOrder[] }>("/api/exchange/orders");
|
||||||
|
return res.orders;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getExchangeHistory(): Promise<GEOrder[]> {
|
export async function getExchangeHistory(): Promise<GEOrder[]> {
|
||||||
return fetchApi<GEOrder[]>("/api/exchange/history");
|
const res = await fetchApi<{ history: GEOrder[] }>("/api/exchange/history");
|
||||||
|
return res.history;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPriceHistory(itemCode: string): Promise<PricePoint[]> {
|
export async function getPriceHistory(itemCode: string): Promise<PricePoint[]> {
|
||||||
return fetchApi<PricePoint[]>(
|
const res = await fetchApi<{ entries: PricePoint[] }>(
|
||||||
`/api/exchange/prices/${encodeURIComponent(itemCode)}`
|
`/api/exchange/prices/${encodeURIComponent(itemCode)}`
|
||||||
);
|
);
|
||||||
|
return res.entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Events API ----------
|
// ---------- Events API ----------
|
||||||
|
|
||||||
export function getEvents(): Promise<GameEvent[]> {
|
export async function getEvents(): Promise<GameEvent[]> {
|
||||||
return fetchApi<GameEvent[]>("/api/events");
|
const res = await fetchApi<{ events: GameEvent[] }>("/api/events");
|
||||||
|
return res.events;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEventHistory(): Promise<GameEvent[]> {
|
export async function getEventHistory(): Promise<GameEvent[]> {
|
||||||
return fetchApi<GameEvent[]>("/api/events/history");
|
const res = await fetchApi<{ events: GameEvent[] }>("/api/events/history");
|
||||||
|
return res.events;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Logs & Analytics API ----------
|
// ---------- Logs & Analytics API ----------
|
||||||
|
|
||||||
export function getLogs(characterName?: string): Promise<ActionLog[]> {
|
export async function getLogs(characterName?: string): Promise<ActionLog[]> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (characterName) params.set("character", characterName);
|
if (characterName) params.set("character", characterName);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return fetchApi<ActionLog[]>(`/api/logs${qs ? `?${qs}` : ""}`);
|
const data = await fetchApi<ActionLog[] | { logs?: ActionLog[] }>(`/api/logs${qs ? `?${qs}` : ""}`);
|
||||||
|
return Array.isArray(data) ? data : (data?.logs ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAnalytics(
|
export function getAnalytics(
|
||||||
|
|
|
||||||
|
|
@ -145,8 +145,10 @@ export interface Resource {
|
||||||
|
|
||||||
export interface MapTile {
|
export interface MapTile {
|
||||||
name: string;
|
name: string;
|
||||||
|
skin: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
layer: string;
|
||||||
content?: {
|
content?: {
|
||||||
type: string;
|
type: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue