adding initial world map frontend
This commit is contained in:
+101
-59
@@ -1,11 +1,12 @@
|
||||
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { addToLore } from "../lib/lore";
|
||||
import { useToast } from "./Toast";
|
||||
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
|
||||
import { usePrefillEffect } from "../lib/usePrefill";
|
||||
import { usePersistentState } from "../lib/usePersistentState";
|
||||
import { WorldMap } from "./WorldMap";
|
||||
|
||||
interface WorldResponse {
|
||||
name: string;
|
||||
@@ -14,6 +15,9 @@ interface WorldResponse {
|
||||
landmarks: string[];
|
||||
conflicts: string[];
|
||||
cultures: string[];
|
||||
// ponytail: DM-placed pin positions (normalized 0..1). UI-managed, not
|
||||
// returned by the LLM. Persisted with the world.
|
||||
pins?: Record<string, { x: number; y: number }>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -26,8 +30,22 @@ export function WorldBuilder({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
const [theme, setTheme] = usePersistentState<string>("world.theme", "high fantasy");
|
||||
const [world, setWorld] = usePersistentState<WorldResponse | null>("world.last", null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const { addToast } = useToast();
|
||||
|
||||
// ponytail: record a dragged pin's position into the world so it persists.
|
||||
function onPinMove(name: string, x: number, y: number) {
|
||||
setWorld((w) => w ? { ...w, pins: { ...(w.pins ?? {}), [name]: { x, y } } } : w);
|
||||
}
|
||||
|
||||
// ponytail: drop a selection that no longer exists on the map (e.g.
|
||||
// after a regenerate or loading a different world from history).
|
||||
useEffect(() => {
|
||||
if (!world || !selected) return;
|
||||
const names = [...ensureArray(world.regions), ...ensureArray(world.landmarks)].map(flattenValue);
|
||||
if (!names.includes(selected)) setSelected(null);
|
||||
}, [world, selected]);
|
||||
|
||||
// ponytail: genre presets as a quick-pick row.
|
||||
const THEME_PRESETS = [
|
||||
"high fantasy", "dark fantasy", "sword & sorcery",
|
||||
@@ -140,69 +158,93 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Regions */}
|
||||
{world.regions?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[var(--color-text-secondary)] text-xs font-medium mb-1.5">
|
||||
🏔 Regions
|
||||
</h4>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{ensureArray(world.regions).map((r, i) => (
|
||||
<div key={i} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] px-3 py-2 text-sm text-[var(--color-text-primary)]">
|
||||
{flattenValue(r)}
|
||||
{/* Map + side details. Two-pane on wide screens; stacked on narrow. */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||||
<div className="lg:col-span-2">
|
||||
<WorldMap
|
||||
regions={ensureArray(world.regions).map(flattenValue)}
|
||||
landmarks={ensureArray(world.landmarks).map(flattenValue)}
|
||||
pins={world.pins ?? {}}
|
||||
selected={selected}
|
||||
onSelect={setSelected}
|
||||
onPinMove={onPinMove}
|
||||
/>
|
||||
<p className="text-[10px] text-[var(--color-text-dim)] mt-1">
|
||||
Drag pins to place regions (●) and landmarks (✦). Click a pin to select it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Selected pin detail, or a hint to pick one. */}
|
||||
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3">
|
||||
{selected ? (
|
||||
<>
|
||||
<span className="text-[10px] uppercase tracking-wider text-[var(--color-gold-muted)]">
|
||||
{ensureArray(world.regions).map(flattenValue).includes(selected) ? "Region" : "Landmark"}
|
||||
</span>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mt-1">{selected}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[var(--color-text-dim)] text-xs italic">
|
||||
Click a pin on the map to inspect it here.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Regions (clickable → select on map) */}
|
||||
{world.regions?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[var(--color-text-secondary)] text-xs font-medium mb-1.5">🏔 Regions</h4>
|
||||
<div className="flex flex-col gap-1">
|
||||
{ensureArray(world.regions).map((r, i) => {
|
||||
const name = flattenValue(r);
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelected(name)}
|
||||
className={`text-left rounded-lg px-3 py-1.5 text-xs cursor-pointer transition-colors ${
|
||||
selected === name
|
||||
? "bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] border border-[var(--color-gold-bright)]"
|
||||
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] text-[var(--color-text-primary)] hover:border-[var(--color-gold-muted)]"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Landmarks */}
|
||||
{world.landmarks?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[var(--color-text-secondary)] text-xs font-medium mb-1.5">
|
||||
🏛 Landmarks
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ensureArray(world.landmarks).map((l, i) => (
|
||||
<span key={i} className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-3 py-1 text-xs">
|
||||
{flattenValue(l)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conflicts */}
|
||||
{world.conflicts?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[var(--color-text-secondary)] text-xs font-medium mb-1.5">
|
||||
⚔ Conflicts
|
||||
</h4>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{ensureArray(world.conflicts).map((c, i) => (
|
||||
<div key={i} className="rounded-lg bg-[var(--color-bg-surface)] border-l-2 border-[var(--color-danger)] px-3 py-2 text-sm text-[var(--color-text-primary)]">
|
||||
{flattenValue(c)}
|
||||
{/* Conflicts */}
|
||||
{world.conflicts?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[var(--color-text-secondary)] text-xs font-medium mb-1.5">⚔ Conflicts</h4>
|
||||
<div className="flex flex-col gap-1">
|
||||
{ensureArray(world.conflicts).map((c, i) => (
|
||||
<div key={i} className="rounded-lg bg-[var(--color-bg-surface)] border-l-2 border-[var(--color-danger)] px-3 py-1.5 text-xs text-[var(--color-text-primary)]">
|
||||
{flattenValue(c)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cultures */}
|
||||
{world.cultures?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[var(--color-text-secondary)] text-xs font-medium mb-1.5">
|
||||
👥 Cultures
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ensureArray(world.cultures).map((c, i) => (
|
||||
<span key={i} className="rounded-full bg-[var(--color-bg-surface)] border border-[var(--color-info)]/30 text-[var(--color-info)] px-3 py-1 text-xs">
|
||||
{flattenValue(c)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Cultures */}
|
||||
{world.cultures?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[var(--color-text-secondary)] text-xs font-medium mb-1.5">👥 Cultures</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ensureArray(world.cultures).map((c, i) => (
|
||||
<span key={i} className="rounded-full bg-[var(--color-bg-surface)] border border-[var(--color-info)]/30 text-[var(--color-info)] px-2.5 py-1 text-[11px]">
|
||||
{flattenValue(c)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user