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.
**[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)
![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)
@ -41,7 +45,7 @@
1. Clone the repository:
```bash
git clone https://github.com/yourusername/artifacts-dashboard.git
git clone https://github.com/pawelorzech/artifacts-dashboard.git
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")
async def get_analytics(
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"),
) -> dict[str, Any]:
"""Get analytics aggregations for a character.
Returns XP history, gold history, and estimated actions per hour.
If no character is specified, aggregates across all characters with snapshots.
"""
analytics = AnalyticsService()
async with async_session_factory() as db:
xp_history = await analytics.get_xp_history(db, character, hours)
gold_history = await analytics.get_gold_history(db, character, hours)
actions_rate = await analytics.get_actions_per_hour(db, character)
if character:
characters = [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 {
"character": character,
"hours": hours,
"xp_history": xp_history,
"gold_history": gold_history,
"actions_rate": actions_rate,
"xp_history": all_xp,
"gold_history": all_gold,
"actions_per_hour": round(total_actions_per_hour, 1),
}

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.events import router as events_router
from app.api.logs import router as logs_router
from app.api.auth import router as auth_router
# Automation engine
from app.engine.pathfinder import Pathfinder
@ -235,6 +236,7 @@ app.include_router(ws_router)
app.include_router(exchange_router)
app.include_router(events_router)
app.include_router(logs_router)
app.include_router(auth_router)
@app.get("/health")

View file

@ -1,6 +1,7 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
# --- Inventory ---
@ -101,10 +102,22 @@ class ContentSchema(BaseModel):
class MapSchema(BaseModel):
name: str = ""
skin: str = ""
x: int
y: int
layer: str = "overworld"
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 ---

View file

@ -15,6 +15,15 @@ logger = logging.getLogger(__name__)
class AnalyticsService:
"""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(
self,
db: AsyncSession,

View file

@ -59,10 +59,11 @@ class ArtifactsClient:
RETRY_BASE_DELAY: float = 1.0
def __init__(self) -> None:
self._token = settings.artifacts_token
self._client = httpx.AsyncClient(
base_url=settings.artifacts_api_url,
headers={
"Authorization": f"Bearer {settings.artifacts_token}",
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
@ -77,6 +78,28 @@ class ArtifactsClient:
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
# ------------------------------------------------------------------

View file

@ -65,6 +65,16 @@ class GameWebSocketClient:
pass
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
# ------------------------------------------------------------------

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";
const nextConfig: NextConfig = {
/* config options here */
turbopack: {
root: __dirname,
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "artifactsmmo.com",
pathname: "/images/**",
},
],
},
};
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,
Bot,
Loader2,
LayoutGrid,
List,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@ -29,13 +31,14 @@ import {
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
useAutomations,
useAutomationStatuses,
useDeleteAutomation,
useControlAutomation,
} from "@/hooks/use-automations";
import { RunControls } from "@/components/automation/run-controls";
import { AutomationGallery } from "@/components/automation/automation-gallery";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
@ -101,6 +104,28 @@ export default function AutomationsPage() {
</Button>
</div>
<Tabs defaultValue="gallery">
<TabsList>
<TabsTrigger value="gallery" className="gap-1.5">
<LayoutGrid className="size-3.5" />
Gallery
</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>
<TabsContent value="gallery" className="mt-6">
<AutomationGallery />
</TabsContent>
<TabsContent value="active" className="mt-6">
{error && (
<Card className="border-destructive bg-destructive/10 p-4">
<p className="text-sm text-destructive">
@ -119,7 +144,8 @@ export default function AutomationsPage() {
<Card className="p-8 text-center">
<Bot className="size-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground mb-4">
No automations configured yet. Create one to get started.
No automations configured yet. Pick one from the Gallery or
create a custom one.
</p>
<Button onClick={() => router.push("/automations/new")}>
<Plus className="size-4" />
@ -204,6 +230,8 @@ export default function AutomationsPage() {
</Table>
</Card>
)}
</TabsContent>
</Tabs>
<Dialog
open={deleteTarget !== null}

View file

@ -12,6 +12,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { GameIcon } from "@/components/ui/game-icon";
import { useBank } from "@/hooks/use-bank";
interface BankItem {
@ -172,7 +173,10 @@ export default function BankPage() {
className="py-3 px-3 hover:bg-accent/30 transition-colors"
>
<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}
</p>
<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 { InventoryGrid } from "@/components/character/inventory-grid";
import { SkillBars } from "@/components/character/skill-bars";
import { CharacterAutomations } from "@/components/character/character-automations";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -124,6 +125,9 @@ export default function CharacterPage({
<SkillBars character={character} />
</div>
{/* Automations */}
<CharacterAutomations characterName={decodedName} character={character} />
{/* Manual Actions */}
<Card>
<CardHeader className="pb-3">

View file

@ -33,6 +33,7 @@ import {
usePriceHistory,
} from "@/hooks/use-exchange";
import { PriceChart } from "@/components/exchange/price-chart";
import { GameIcon } from "@/components/ui/game-icon";
import type { GEOrder } from "@/lib/types";
function formatDate(dateStr: string): string {
@ -93,7 +94,12 @@ function OrdersTable({
<TableBody>
{filtered.map((order) => (
<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>
<Badge
variant="outline"
@ -233,7 +239,7 @@ export default function ExchangePage() {
<Card>
<CardHeader>
<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}
</CardTitle>
</CardHeader>

View file

@ -1,14 +1,32 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Loader2, Plus, Minus, RotateCcw } from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Loader2,
Plus,
Minus,
RotateCcw,
Search,
Layers,
} from "lucide-react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { useMaps } from "@/hooks/use-game-data";
import { useCharacters } from "@/hooks/use-characters";
import type { MapTile, Character } from "@/lib/types";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CONTENT_COLORS: Record<string, string> = {
monster: "#ef4444",
monsters: "#ef4444",
@ -24,6 +42,8 @@ const CONTENT_COLORS: Record<string, string> = {
const EMPTY_COLOR = "#1f2937";
const CHARACTER_COLOR = "#facc15";
const GRID_LINE_COLOR = "#374151";
const BASE_CELL_SIZE = 18;
const IMAGE_BASE = "https://artifactsmmo.com/images";
const LEGEND_ITEMS = [
{ label: "Monsters", color: "#ef4444", key: "monster" },
@ -37,28 +57,107 @@ const LEGEND_ITEMS = [
{ 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 {
if (!tile.content?.type) return 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 {
tile: MapTile;
characters: Character[];
}
interface SearchResult {
tile: MapTile;
label: string;
}
export default function MapPage() {
const { data: tiles, isLoading, error } = useMaps();
const { data: characters } = useCharacters();
const canvasRef = useRef<HTMLCanvasElement>(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 [offset, setOffset] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
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>>({
monster: true,
@ -72,33 +171,38 @@ export default function MapPage() {
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(() => {
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,
maxX = -Infinity,
minY = Infinity,
maxY = -Infinity;
for (const tile of tiles) {
for (const tile of layerTiles) {
if (tile.x < minX) minX = tile.x;
if (tile.x > maxX) maxX = tile.x;
if (tile.y < minY) minY = tile.y;
if (tile.y > maxY) maxY = tile.y;
}
return { minX, maxX, minY, maxY };
}, [tiles]);
}, [layerTiles]);
// Build tile lookup
const tileMap = useMemo(() => {
if (!tiles) return new Map<string, MapTile>();
const map = new Map<string, MapTile>();
for (const tile of tiles) {
map.set(`${tile.x},${tile.y}`, tile);
}
for (const tile of layerTiles) map.set(`${tile.x},${tile.y}`, tile);
return map;
}, [tiles]);
}, [layerTiles]);
// Character positions
const charPositions = useMemo(() => {
if (!characters) return new Map<string, Character[]>();
const map = new Map<string, Character[]>();
@ -110,13 +214,50 @@ export default function MapPage() {
return map;
}, [characters]);
const BASE_CELL_SIZE = 18;
const cellSize = BASE_CELL_SIZE * zoom;
// ---- Image preloading ----
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 canvas = canvasRef.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 width = container.clientWidth;
@ -136,15 +277,13 @@ export default function MapPage() {
const gridWidth = bounds.maxX - bounds.minX + 1;
const gridHeight = bounds.maxY - bounds.minY + 1;
const centerOffsetX = (width - gridWidth * cellSize) / 2 + offset.x;
const centerOffsetY = (height - gridHeight * cellSize) / 2 + offset.y;
// Draw tiles
for (const tile of tiles) {
for (const tile of layerTiles) {
const contentType = tile.content?.type ?? "empty";
// Normalize plural to singular for filter check
const normalizedType = contentType === "monsters" ? "monster" : contentType === "resources" ? "resource" : contentType;
const normalizedType = normalizeContentType(contentType);
if (!filters[normalizedType] && normalizedType !== "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 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)
continue;
// Try to draw skin image
const skinImg = tile.skin ? imageCache.get(skinUrl(tile.skin)) : null;
if (skinImg) {
ctx.drawImage(skinImg, px, py, cellSize, cellSize);
} else {
ctx.fillStyle = getTileColor(tile);
ctx.fillRect(px, py, cellSize - 1, cellSize - 1);
}
// Grid lines when zoomed in
// 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) {
ctx.strokeStyle = GRID_LINE_COLOR;
ctx.strokeStyle = skinImg
? "rgba(55, 65, 81, 0.3)"
: GRID_LINE_COLOR;
ctx.lineWidth = 0.5;
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
@ -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(() => {
drawMap();
}, [drawMap]);
scheduleDraw();
}, [drawMap, drawMinimap, scheduleDraw]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver(() => drawMap());
const observer = new ResizeObserver(() => scheduleDraw());
observer.observe(container);
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>) {
// Ignore clicks after dragging
if (dragDistRef.current > 5) return;
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container || !tiles) return;
if (!canvas || !container || layerTiles.length === 0) return;
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
@ -231,8 +615,10 @@ export default function MapPage() {
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 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;
@ -252,10 +638,19 @@ export default function MapPage() {
if (e.button !== 0) return;
setDragging(true);
setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y });
dragDistRef.current = 0;
}
function handleMouseMove(e: React.MouseEvent) {
// Tooltip handling
updateTooltip(e);
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({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y,
@ -266,10 +661,87 @@ export default function MapPage() {
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) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setZoom((z) => Math.max(0.3, Math.min(5, z + delta)));
const container = containerRef.current;
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() {
@ -281,18 +753,109 @@ export default function MapPage() {
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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
World Map
</h1>
<p className="text-sm text-muted-foreground mt-1">
Interactive map of the game world. Click tiles for details, drag to
pan, scroll to zoom.
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>
{error && (
<Card className="border-destructive bg-destructive/10 p-4">
<p className="text-sm text-destructive">
@ -301,8 +864,25 @@ export default function MapPage() {
</Card>
)}
{/* Filters */}
{/* Layer selector + Filters */}
<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) => (
<button
key={item.key}
@ -335,6 +915,19 @@ export default function MapPage() {
</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
ref={canvasRef}
className={`${dragging ? "cursor-grabbing" : "cursor-grab"}`}
@ -342,23 +935,37 @@ export default function MapPage() {
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onMouseLeave={handleMouseLeave}
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 */}
<div className="absolute right-3 bottom-3 flex flex-col gap-1">
<Button
size="icon-sm"
variant="secondary"
onClick={() => setZoom((z) => Math.min(5, z + 0.2))}
onClick={() => setZoom((z) => Math.min(5, z * 1.2))}
>
<Plus className="size-4" />
</Button>
<Button
size="icon-sm"
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" />
</Button>
@ -367,7 +974,7 @@ export default function MapPage() {
</Button>
</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">
{Math.round(zoom * 100)}%
</div>
@ -388,6 +995,18 @@ export default function MapPage() {
</button>
</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>
<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">
{selectedTile.tile.content.code}
</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>
)}

View file

@ -1,7 +1,14 @@
"use client";
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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -9,16 +16,15 @@ import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
interface AppSettings {
apiUrl: string;
characterRefreshInterval: number;
automationRefreshInterval: number;
mapAutoRefresh: boolean;
}
const DEFAULT_SETTINGS: AppSettings = {
apiUrl: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000",
characterRefreshInterval: 5,
automationRefreshInterval: 3,
mapAutoRefresh: true,
@ -35,6 +41,9 @@ function loadSettings(): AppSettings {
export default function SettingsPage() {
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
const { status: authStatus, setToken, removeToken } = useAuth();
const [newToken, setNewToken] = useState("");
const [tokenLoading, setTokenLoading] = useState(false);
useEffect(() => {
setSettings(loadSettings());
@ -55,6 +64,29 @@ export default function SettingsPage() {
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 (
<div className="space-y-6">
<div>
@ -67,29 +99,83 @@ export default function SettingsPage() {
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Settings className="size-4" />
Connection
<KeyRound className="size-4" />
API Token
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="api-url">Backend API URL</Label>
<Input
id="api-url"
value={settings.apiUrl}
onChange={(e) =>
setSettings((s) => ({ ...s, apiUrl: e.target.value }))
}
placeholder="http://localhost:8000"
/>
<p className="text-xs text-muted-foreground">
The URL of the backend API server. Requires page reload to take
effect.
</p>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Status:</span>
{authStatus?.has_token ? (
<Badge variant="default">
Connected (
{authStatus.source === "env"
? "environment"
: "user-provided"}
)
</Badge>
) : (
<Badge variant="destructive">Not configured</Badge>
)}
</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>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
@ -114,7 +200,8 @@ export default function SettingsPage() {
onChange={(e) =>
setSettings((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) =>
setSettings((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 { EQUIPMENT_SLOTS } from "@/lib/constants";
import type { Character } from "@/lib/types";
import { GameIcon } from "@/components/ui/game-icon";
import { cn } from "@/lib/utils";
interface EquipmentGridProps {
@ -54,6 +55,7 @@ export function EquipmentGrid({ character }: EquipmentGridProps) {
</span>
) : (
<div className="flex items-center gap-1.5">
<GameIcon type="item" code={itemCode} size="md" />
<span className="text-xs font-medium text-foreground truncate">
{itemCode}
</span>

View file

@ -8,6 +8,7 @@ import {
CardDescription,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { GameIcon } from "@/components/ui/game-icon";
import { cn } from "@/lib/utils";
import type { Character } from "@/lib/types";
@ -58,16 +59,14 @@ export function InventoryGrid({ character }: InventoryGridProps) {
)}
>
{item && (
<>
<span className="text-[9px] font-medium text-foreground text-center leading-tight truncate w-full">
{item.code}
</span>
<div className="relative flex items-center justify-center">
<GameIcon type="item" code={item.code} size="md" />
{item.quantity > 1 && (
<span className="text-[9px] text-muted-foreground">
x{item.quantity}
<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">
{item.quantity}
</span>
)}
</>
</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 { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { GameIcon } from "@/components/ui/game-icon";
import type { Character, AutomationStatus } from "@/lib/types";
import { SKILLS, SKILL_COLOR_TEXT_MAP } from "@/lib/constants";
import { cn } from "@/lib/utils";
@ -96,7 +97,10 @@ export function CharacterCard({ character, automationStatus }: CharacterCardProp
>
<CardHeader className="pb-0 pt-0 px-4">
<div className="flex items-center justify-between">
<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">
{automationStatus &&
automationStatus.status !== "stopped" && (
@ -236,12 +240,14 @@ export function CharacterCard({ character, automationStatus }: CharacterCardProp
{(character.weapon_slot || character.shield_slot) && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{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}
</Badge>
)}
{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}
</Badge>
)}

View file

@ -7,6 +7,8 @@ import {
useWebSocket,
type ConnectionStatus,
} from "@/hooks/use-websocket";
import { AuthProvider } from "@/components/auth/auth-provider";
import { ApiKeyGate } from "@/components/auth/api-key-gate";
interface WebSocketContextValue {
status: ConnectionStatus;
@ -53,10 +55,12 @@ export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<WebSocketProvider>
{children}
<AuthProvider>
<Toaster theme="dark" position="bottom-right" richColors />
</WebSocketProvider>
<ApiKeyGate>
<WebSocketProvider>{children}</WebSocketProvider>
</ApiKeyGate>
</AuthProvider>
</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[]> {
return fetchApi<Character[]>("/api/characters");
}
@ -185,37 +219,43 @@ export function getAutomationLogs(
// ---------- Grand Exchange API ----------
export function getExchangeOrders(): Promise<GEOrder[]> {
return fetchApi<GEOrder[]>("/api/exchange/orders");
export async function getExchangeOrders(): Promise<GEOrder[]> {
const res = await fetchApi<{ orders: GEOrder[] }>("/api/exchange/orders");
return res.orders;
}
export function getExchangeHistory(): Promise<GEOrder[]> {
return fetchApi<GEOrder[]>("/api/exchange/history");
export async function getExchangeHistory(): Promise<GEOrder[]> {
const res = await fetchApi<{ history: GEOrder[] }>("/api/exchange/history");
return res.history;
}
export function getPriceHistory(itemCode: string): Promise<PricePoint[]> {
return fetchApi<PricePoint[]>(
export async function getPriceHistory(itemCode: string): Promise<PricePoint[]> {
const res = await fetchApi<{ entries: PricePoint[] }>(
`/api/exchange/prices/${encodeURIComponent(itemCode)}`
);
return res.entries;
}
// ---------- Events API ----------
export function getEvents(): Promise<GameEvent[]> {
return fetchApi<GameEvent[]>("/api/events");
export async function getEvents(): Promise<GameEvent[]> {
const res = await fetchApi<{ events: GameEvent[] }>("/api/events");
return res.events;
}
export function getEventHistory(): Promise<GameEvent[]> {
return fetchApi<GameEvent[]>("/api/events/history");
export async function getEventHistory(): Promise<GameEvent[]> {
const res = await fetchApi<{ events: GameEvent[] }>("/api/events/history");
return res.events;
}
// ---------- Logs & Analytics API ----------
export function getLogs(characterName?: string): Promise<ActionLog[]> {
export async function getLogs(characterName?: string): Promise<ActionLog[]> {
const params = new URLSearchParams();
if (characterName) params.set("character", characterName);
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(

View file

@ -145,8 +145,10 @@ export interface Resource {
export interface MapTile {
name: string;
skin: string;
x: number;
y: number;
layer: string;
content?: {
type: string;
code: string;