v0.2.0: Rich interactive map, automation gallery, auth & UX improvements
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:
Paweł Orzech 2026-03-01 20:18:29 +01:00
parent f845647934
commit 10781c7987
No known key found for this signature in database
37 changed files with 4243 additions and 208 deletions

View file

@ -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.
![Python](https://img.shields.io/badge/Python-3.12-blue?logo=python&logoColor=white) ![Python](https://img.shields.io/badge/Python-3.12-blue?logo=python&logoColor=white)
![Next.js](https://img.shields.io/badge/Next.js-15-black?logo=next.js&logoColor=white) ![Next.js](https://img.shields.io/badge/Next.js-15-black?logo=next.js&logoColor=white)
![FastAPI](https://img.shields.io/badge/FastAPI-0.115-009688?logo=fastapi&logoColor=white) ![FastAPI](https://img.shields.io/badge/FastAPI-0.115-009688?logo=fastapi&logoColor=white)
@ -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
View 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,
)

View file

@ -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,
} }

View file

@ -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")

View file

@ -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 ---

View file

@ -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,

View file

@ -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
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -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

File diff suppressed because it is too large Load diff

41
frontend/.gitignore vendored Normal file
View 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
View 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.

View file

@ -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;

View file

@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver

1
frontend/public/file.svg Normal file
View 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

View 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
View 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

View 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

View 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

View file

@ -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}

View file

@ -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">

View file

@ -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">

View file

@ -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>

View file

@ -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>
)} )}

View file

@ -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,
})) }))
} }
/> />

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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"];

View 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)}
/>
</>
);
}

View file

@ -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>

View file

@ -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>
); );

View file

@ -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>
)} )}

View file

@ -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>
); );
} }

View 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>
);
}

View file

@ -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(

View file

@ -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;