-
- World Map
-
-
- Interactive map of the game world. Click tiles for details, drag to
- pan, scroll to zoom.
-
+
+
+
+ World Map
+
+
+ Interactive map of the game world. Click tiles for details, drag to
+ pan, scroll to zoom. Press{" "}
+
+ /
+ {" "}
+ to search.
+
+
+
+ {/* Search */}
+
+
+
+ setSearchOpen(true)}
+ onChange={(e) => {
+ setSearchQuery(e.target.value);
+ setSearchOpen(true);
+ }}
+ />
+
+ {searchOpen && searchResults.length > 0 && (
+
+ {searchResults.map((r) => (
+
+ ))}
+
+ )}
+
{error && (
@@ -301,8 +864,25 @@ export default function MapPage() {
)}
- {/* Filters */}
+ {/* Layer selector + Filters */}
+
+
+ {LAYER_OPTIONS.map((layer) => (
+
+ ))}
+
+
{LEGEND_ITEMS.map((item) => (
+ {/* Tile skin image */}
+ {selectedTile.tile.skin && (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
})
+
+ )}
+
@@ -417,6 +1036,21 @@ export default function MapPage() {
{selectedTile.tile.content.code}
+
+ {/* Content icon image */}
+ {IMAGE_CONTENT_TYPES.has(selectedTile.tile.content.type) && (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+ )}
)}
diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx
index daa5402..10c3053 100644
--- a/frontend/src/app/settings/page.tsx
+++ b/frontend/src/app/settings/page.tsx
@@ -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
(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 (
@@ -67,29 +99,83 @@ export default function SettingsPage() {
-
- Connection
+
+ API Token
-
-
-
- setSettings((s) => ({ ...s, apiUrl: e.target.value }))
- }
- placeholder="http://localhost:8000"
- />
-
- The URL of the backend API server. Requires page reload to take
- effect.
-
+
+ Status:
+ {authStatus?.has_token ? (
+
+ Connected (
+ {authStatus.source === "env"
+ ? "environment"
+ : "user-provided"}
+ )
+
+ ) : (
+ Not configured
+ )}
+
+ {authStatus?.source !== "env" && (
+
+ )}
+
+ {authStatus?.source === "env" && (
+
+ Token is configured via environment variable. To change it,
+ update the ARTIFACTS_TOKEN in your .env file and restart the
+ backend.
+
+ )}
+
@@ -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,
}))
}
/>
diff --git a/frontend/src/components/auth/api-key-gate.tsx b/frontend/src/components/auth/api-key-gate.tsx
new file mode 100644
index 0000000..51af57d
--- /dev/null
+++ b/frontend/src/components/auth/api-key-gate.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ if (status?.has_token) {
+ return <>{children}>;
+ }
+
+ return ;
+}
+
+function ApiKeyForm({
+ onSubmit,
+}: {
+ onSubmit: (token: string) => Promise<{ success: boolean; error?: string }>;
+}) {
+ const [token, setToken] = useState("");
+ const [error, setError] = useState(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 (
+
+
+
+
+
+
+ Artifacts Dashboard
+
+ Enter your Artifacts MMO API token to get started.
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/auth/auth-provider.tsx b/frontend/src/components/auth/auth-provider.tsx
new file mode 100644
index 0000000..0d8a7cb
--- /dev/null
+++ b/frontend/src/components/auth/auth-provider.tsx
@@ -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;
+}
+
+const AuthContext = createContext({
+ 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(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 (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/automation/automation-gallery.tsx b/frontend/src/components/automation/automation-gallery.tsx
new file mode 100644
index 0000000..e70115a
--- /dev/null
+++ b/frontend/src/components/automation/automation-gallery.tsx
@@ -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 = {
+ 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 = {
+ 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 = {
+ all: Zap,
+ combat: Swords,
+ gathering: Pickaxe,
+ crafting: Hammer,
+ trading: TrendingUp,
+ utility: ClipboardList,
+};
+
+export function AutomationGallery() {
+ const [search, setSearch] = useState("");
+ const [category, setCategory] = useState("all");
+ const [selectedTemplate, setSelectedTemplate] =
+ useState(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 (
+
+ {/* Search & Category Filter */}
+
+
+
+ setSearch(e.target.value)}
+ className="pl-9"
+ />
+
+
+
+ {/* Category Tabs */}
+
+ {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 (
+ 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"
+ )}
+ >
+
+ {cat.label}
+
+ {count}
+
+
+ );
+ })}
+
+
+ {/* Template Grid */}
+ {filtered.length === 0 ? (
+
+
+
+ No automations match your search.
+
+
+ ) : (
+
+ {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 (
+
setSelectedTemplate(template)}
+ >
+ {/* Colored top bar */}
+
+
+
+
+
+
+
+
+ {template.name}
+
+
+ {template.description}
+
+
+
+
+
+
+ {template.difficulty}
+
+ {template.min_level > 1 && (
+
+ Lv. {template.min_level}+
+
+ )}
+ {template.skill_requirement && (
+
+ {template.skill_requirement.skill} {template.skill_requirement.level}+
+
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+ {/* Activation Dialog */}
+
setSelectedTemplate(null)}
+ />
+
+ );
+}
diff --git a/frontend/src/components/automation/gallery-activate-dialog.tsx b/frontend/src/components/automation/gallery-activate-dialog.tsx
new file mode 100644
index 0000000..691cbcd
--- /dev/null
+++ b/frontend/src/components/automation/gallery-activate-dialog.tsx
@@ -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 = {
+ combat: ,
+ gathering: ,
+ crafting: ,
+ trading: ,
+ task: ,
+ leveling: ,
+};
+
+const DIFFICULTY_COLORS: Record = {
+ 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>(
+ new Set()
+ );
+ const [config, setConfig] = useState>({});
+ 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 (
+
+ );
+}
diff --git a/frontend/src/components/automation/gallery-templates.ts b/frontend/src/components/automation/gallery-templates.ts
new file mode 100644
index 0000000..f02569d
--- /dev/null
+++ b/frontend/src/components/automation/gallery-templates.ts
@@ -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;
+ 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 = {
+ 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"];
diff --git a/frontend/src/components/character/character-automations.tsx b/frontend/src/components/character/character-automations.tsx
new file mode 100644
index 0000000..88b5153
--- /dev/null
+++ b/frontend/src/components/character/character-automations.tsx
@@ -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 = {
+ 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 = {
+ 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(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 (
+ <>
+
+
+
+
+
+ Automations
+
+
+ View all
+
+
+
+
+ {/* Active Automations */}
+ {isLoading && (
+
+
+
+ )}
+
+ {!isLoading && charAutomations.length === 0 && (
+
+ No automations for this character.
+
+ )}
+
+ {charAutomations.length > 0 && (
+
+ {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 (
+
+
+
+
+
+
+
+ {automation.name}
+
+
+
+ {automation.strategy_type}
+
+
+
+
e.stopPropagation()}>
+
+
+
+ );
+ })}
+
+ )}
+
+ {/* Suggested Automations */}
+ {suggestions.length > 0 && (
+
+
+
+ Suggested
+
+
+ Browse all
+
+
+
+ {suggestions.map((template) => {
+ const Icon =
+ STRATEGY_ICONS[template.strategy_type] ?? Zap;
+ const colors =
+ STRATEGY_COLORS[template.strategy_type];
+
+ return (
+
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"
+ )}
+ >
+
+
+
+
+
+ {template.name}
+
+
+ {template.description}
+
+
+ {template.difficulty}
+
+
+
+ );
+ })}
+
+
+ )}
+
+
+
+ setSelectedTemplate(null)}
+ />
+ >
+ );
+}
diff --git a/frontend/src/components/character/equipment-grid.tsx b/frontend/src/components/character/equipment-grid.tsx
index be31b05..4a5fef3 100644
--- a/frontend/src/components/character/equipment-grid.tsx
+++ b/frontend/src/components/character/equipment-grid.tsx
@@ -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) {
) : (
+
{itemCode}
diff --git a/frontend/src/components/character/inventory-grid.tsx b/frontend/src/components/character/inventory-grid.tsx
index a02f3ea..07a1f8c 100644
--- a/frontend/src/components/character/inventory-grid.tsx
+++ b/frontend/src/components/character/inventory-grid.tsx
@@ -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 && (
- <>
-
- {item.code}
-
+
+
{item.quantity > 1 && (
-
- x{item.quantity}
+
+ {item.quantity}
)}
- >
+
)}
);
diff --git a/frontend/src/components/dashboard/character-card.tsx b/frontend/src/components/dashboard/character-card.tsx
index b398bef..dbb2302 100644
--- a/frontend/src/components/dashboard/character-card.tsx
+++ b/frontend/src/components/dashboard/character-card.tsx
@@ -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
>
-
{character.name}
+
+
+ {character.name}
+
{automationStatus &&
automationStatus.status !== "stopped" && (
@@ -236,12 +240,14 @@ export function CharacterCard({ character, automationStatus }: CharacterCardProp
{(character.weapon_slot || character.shield_slot) && (
{character.weapon_slot && (
-
+
+
{character.weapon_slot}
)}
{character.shield_slot && (
-
+
+
{character.shield_slot}
)}
diff --git a/frontend/src/components/layout/providers.tsx b/frontend/src/components/layout/providers.tsx
index 024980c..94d0788 100644
--- a/frontend/src/components/layout/providers.tsx
+++ b/frontend/src/components/layout/providers.tsx
@@ -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 (
-
- {children}
+
-
+
+ {children}
+
+
);
}
diff --git a/frontend/src/components/ui/game-icon.tsx b/frontend/src/components/ui/game-icon.tsx
new file mode 100644
index 0000000..ee3fb99
--- /dev/null
+++ b/frontend/src/components/ui/game-icon.tsx
@@ -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 = {
+ 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 (
+
+ {code.charAt(0).toUpperCase()}
+
+ );
+}
+
+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 ? (
+
+ ) : (
+ setErrored(true)}
+ />
+ );
+
+ if (!showTooltip) return icon;
+
+ return (
+
+
+
+ {icon}
+
+
+ {displayName}
+
+
+
+ );
+}
diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts
index 1e2a27f..9de88cc 100644
--- a/frontend/src/lib/api-client.ts
+++ b/frontend/src/lib/api-client.ts
@@ -84,6 +84,40 @@ async function deleteApi(path: string): Promise {
}
}
+// ---------- 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 {
+ return fetchApi("/api/auth/status");
+}
+
+export async function setAuthToken(token: string): Promise {
+ return postApi("/api/auth/token", { token });
+}
+
+export async function clearAuthToken(): Promise {
+ 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;
+}
+
+// ---------- Characters API ----------
+
export function getCharacters(): Promise {
return fetchApi("/api/characters");
}
@@ -185,37 +219,43 @@ export function getAutomationLogs(
// ---------- Grand Exchange API ----------
-export function getExchangeOrders(): Promise {
- return fetchApi("/api/exchange/orders");
+export async function getExchangeOrders(): Promise {
+ const res = await fetchApi<{ orders: GEOrder[] }>("/api/exchange/orders");
+ return res.orders;
}
-export function getExchangeHistory(): Promise {
- return fetchApi("/api/exchange/history");
+export async function getExchangeHistory(): Promise {
+ const res = await fetchApi<{ history: GEOrder[] }>("/api/exchange/history");
+ return res.history;
}
-export function getPriceHistory(itemCode: string): Promise {
- return fetchApi(
+export async function getPriceHistory(itemCode: string): Promise {
+ const res = await fetchApi<{ entries: PricePoint[] }>(
`/api/exchange/prices/${encodeURIComponent(itemCode)}`
);
+ return res.entries;
}
// ---------- Events API ----------
-export function getEvents(): Promise {
- return fetchApi("/api/events");
+export async function getEvents(): Promise {
+ const res = await fetchApi<{ events: GameEvent[] }>("/api/events");
+ return res.events;
}
-export function getEventHistory(): Promise {
- return fetchApi("/api/events/history");
+export async function getEventHistory(): Promise {
+ const res = await fetchApi<{ events: GameEvent[] }>("/api/events/history");
+ return res.events;
}
// ---------- Logs & Analytics API ----------
-export function getLogs(characterName?: string): Promise {
+export async function getLogs(characterName?: string): Promise {
const params = new URLSearchParams();
if (characterName) params.set("character", characterName);
const qs = params.toString();
- return fetchApi(`/api/logs${qs ? `?${qs}` : ""}`);
+ const data = await fetchApi(`/api/logs${qs ? `?${qs}` : ""}`);
+ return Array.isArray(data) ? data : (data?.logs ?? []);
}
export function getAnalytics(
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 14b4580..f6fb6d9 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -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;