Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions viewer/openworlds/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,52 @@ function App() {
document.documentElement.setAttribute("data-palette", t.palette || "warm");
}, [t.palette]);

React.useEffect(() => {
let cancelled = false;

async function loadCampaignCatalog() {
try {
const response = await fetch("/openworlds/campaigns.json", { cache: "no-store" });
if (!response.ok) throw new Error(`campaign catalog ${response.status}`);
const payload = await response.json();
if (cancelled) return;

const nextCampaigns = Array.isArray(payload?.campaigns) ? payload.campaigns : [];
setState((s) => {
const activeStillExists = nextCampaigns.some((c) => c.id === s?.activeCampaign);
const preferred =
nextCampaigns.find((c) => c.current)?.id ||
nextCampaigns.find((c) => c.live)?.id ||
nextCampaigns[0]?.id ||
"";
return {
...s,
campaigns: nextCampaigns,
activeCampaign: activeStillExists ? s.activeCampaign : preferred,
campaignCatalog: {
loaded: true,
total: payload?.total ?? nextCampaigns.length,
source: "viewer",
},
};
});
} catch (error) {
if (cancelled) return;
setState((s) => ({
...s,
campaignCatalog: {
loaded: false,
source: "demo-fallback",
error: error?.message || "campaign catalog unavailable",
},
}));
}
}

loadCampaignCatalog();
return () => { cancelled = true; };
}, []);

// Keyboard shortcuts
React.useEffect(() => {
const onKey = (e) => {
Expand Down
81 changes: 69 additions & 12 deletions viewer/openworlds/screen-launcher.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,27 @@ function ScreenLauncher({ onNavigate, state, setState }) {
const [selected, setSelected] = React.useState(state?.activeCampaign || campaigns[0]?.id || "");
const [showNew, setShowNew] = React.useState(false);

React.useEffect(() => {
if (campaigns.some((c) => c.id === selected)) return;
const fallback = campaigns.some((c) => c.id === state?.activeCampaign)
? state.activeCampaign
: campaigns[0]?.id || "";
setSelected(fallback);
}, [campaigns, selected, state?.activeCampaign]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const onResume = () => {
const nextCampaign = selected || campaigns[0]?.id;
const nextCampaign = campaigns.some((c) => c.id === selected) ? selected : campaigns[0]?.id;
if (!nextCampaign) return;
const campaign = campaigns.find((c) => c.id === nextCampaign);
setState((s) => ({ ...s, activeCampaign: nextCampaign }));
if (campaign?.resumeUrl) {
window.location.assign(campaign.resumeUrl);
return;
}
if (campaign?.monitorUrl) {
window.location.assign(campaign.monitorUrl);
return;
}
onNavigate("table");
};

Expand Down Expand Up @@ -105,12 +122,15 @@ function ScreenLauncher({ onNavigate, state, setState }) {
</div>
);
}
const party = Array.isArray(c.party) ? c.party : [];
const party = normalizeCampaignParty(c.party);
const dayBadge = campaignDayBadge(c);
const chapter = campaignChapter(c);
const region = campaignRegion(c);
return (
<div>
{/* Top vignette with overlaid label */}
<div style={{ position: "relative" }}>
<Placeholder label={`vignette · ${c.region.toLowerCase()}`} h={140} style={{ width: "100%", boxShadow: "none" }} />
<Placeholder label={`vignette · ${region.toLowerCase()}`} h={140} style={{ width: "100%", boxShadow: "none" }} />
<div style={{
position: "absolute", inset: 0,
background: "linear-gradient(180deg, transparent 40%, rgba(40, 25, 10, 0.85) 100%)",
Expand All @@ -121,18 +141,18 @@ function ScreenLauncher({ onNavigate, state, setState }) {
}}>
<div>
<div className="eyebrow" style={{ color: "var(--gold-glow)", textShadow: "0 1px 2px rgba(0,0,0,0.6)" }}>
{c.system} · Chapter {c.chapter}
{c.system || "D&D 5e"} · Chapter {chapter}
</div>
<div style={{ fontFamily: "var(--f-display)", fontSize: 22, color: "var(--p-100)", letterSpacing: "0.04em", marginTop: 2, textShadow: "0 1px 2px rgba(0,0,0,0.7)" }}>
{c.title}
</div>
</div>
<Pill tone="royal">{c.day.split(" · ")[0] || c.day}</Pill>
<Pill tone={c.live ? "emerald" : "royal"}>{c.live ? "Live" : dayBadge}</Pill>
</div>
</div>

<div style={{ padding: "20px 28px 28px" }}>
<div className="hand" style={{ fontSize: 15, color: "var(--ink-700)" }}>{c.subtitle}</div>
<div className="hand" style={{ fontSize: 15, color: "var(--ink-700)" }}>{c.subtitle || region}</div>

{/* Stat strip with vertical brass dividers */}
<div style={{
Expand All @@ -147,7 +167,7 @@ function ScreenLauncher({ onNavigate, state, setState }) {
{ label: "Last sat", value: c.lastPlayed },
{ label: "Sessions", value: c.sessions },
{ label: "Heroes", value: party.length },
{ label: "Region", value: c.region },
{ label: "Region", value: region },
].map((s, i) => (
<div key={s.label} style={{
padding: "10px 12px",
Expand All @@ -172,6 +192,11 @@ function ScreenLauncher({ onNavigate, state, setState }) {
<div className="hand" style={{ fontSize: 12, marginTop: 4, color: "var(--ink-700)" }}>{p.name}</div>
</div>
))}
{party.length === 0 && (
<div className="hand muted" style={{ fontSize: 14, padding: "18px 8px", textAlign: "center" }}>
No party recorded yet.
</div>
)}
</div>

{/* Recap with side sketch */}
Expand All @@ -181,7 +206,7 @@ function ScreenLauncher({ onNavigate, state, setState }) {
<Placeholder label="sketch · last scene" w={100} h={120} framed />
</div>
<p className="body dropcap" style={{ marginTop: 0, fontSize: 15 }}>
{c.recap}
{c.recap || "This chronicle is ready to continue."}
</p>
</div>

Expand All @@ -192,7 +217,7 @@ function ScreenLauncher({ onNavigate, state, setState }) {
display: "flex", gap: 8,
}}>
<BrassButton onClick={onResume} size="lg" style={{ flex: 1 }}>
Resume Chronicle
{c.canResume ? "Resume Chronicle" : "View Chronicle"}
</BrassButton>
<BrassButton tone="ghost" size="sm" onClick={() => onNavigate("character")}>Roster</BrassButton>
<BrassButton tone="ghost" size="sm" onClick={() => onNavigate("journal")}>Journal</BrassButton>
Expand All @@ -213,6 +238,37 @@ function ScreenLauncher({ onNavigate, state, setState }) {
);
}

function normalizeCampaignParty(party) {
if (!Array.isArray(party)) return [];
return party.map((p) => {
if (typeof p === "string") return { name: p, short: "portrait" };
return {
name: p?.name || "Unknown",
short: p?.short || "portrait",
kind: p?.kind || "",
hp: p?.hp || "",
};
});
}

function campaignRegion(c) {
const value = c?.region ?? c?.location ?? c?.world;
if (value === undefined || value === null) return "Unknown";
const trimmed = String(value).trim();
return trimmed || "Unknown";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function campaignDayBadge(c) {
const day = typeof c?.day === "string" ? c.day : "";
return day.split(" · ")[0] || day || "Stale";
}

function campaignChapter(c) {
const chapter = c?.chapter;
if (chapter === undefined || chapter === null || chapter === "") return "I";
return String(chapter);
}

function Stat({ label, value }) {
return (
<div style={{
Expand All @@ -227,6 +283,7 @@ function Stat({ label, value }) {
}

function CampaignRow({ c, selected, onSelect }) {
const status = c?.live ? "Live" : (c?.sourceLabel || "Saved");
return (
<button
onClick={onSelect}
Expand All @@ -252,12 +309,12 @@ function CampaignRow({ c, selected, onSelect }) {
<div style={{ fontFamily: "var(--f-display)", fontSize: 17, letterSpacing: "0.08em", color: "var(--ink-900)" }}>
{c.title}
</div>
<div className="hand" style={{ fontSize: 14, color: "var(--ink-600)" }}>{c.subtitle}</div>
<div className="hand" style={{ fontSize: 14, color: "var(--ink-600)" }}>{c.subtitle || campaignRegion(c)}</div>
</div>
<div style={{ textAlign: "right" }}>
<div className="eyebrow">{c.lastPlayed}</div>
<div className="eyebrow">{status} · {c.lastPlayed || "unknown"}</div>
<div style={{ fontFamily: "var(--f-display)", fontSize: 12, letterSpacing: "0.18em", textTransform: "uppercase", color: "var(--ink-700)", marginTop: 4 }}>
Ch. {c.chapter}
Ch. {campaignChapter(c)}
</div>
</div>
</button>
Expand Down
Loading
Loading