deadhead-space-freight-tycoon/apps/web/src/screens/Settings.tsx

185 lines
6.0 KiB
TypeScript

import { useState } from "react";
import type { ReactNode } from "react";
import { WelcomeCards } from "../components/Welcome";
import { SpendingFloor } from "../components/IndependentPilots";
import { resetMapView } from "../map/camera";
import { About } from "./AboutSettings";
import { CompanyName, NewCompany } from "./CompanySettings";
import { DevTools } from "./DevTools";
import { ExportSave, ImportSave } from "./SaveSettings";
import { content } from "@deadhead/content";
import { sleepLabel } from "../components/FleetSleep";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences";
import { Button, Panel, Screen, ScreenHeader } from "../ui";
/** Developer tools only exist in non-production builds (CR-011). */
export function isDevBuild(): boolean {
return !import.meta.env.PROD;
}
/** Settings (CR-011): company, display, help, about and, in development, developer tools. */
export function Settings() {
const state = useLoadedPlayer();
const prefs = usePreferences();
const [howToPlay, setHowToPlay] = useState(false);
return (
<Screen>
<ScreenHeader title="Settings" eyebrow={state.company.name} icon="gear" />
<Section title="Company">
<CompanyName />
<SpendingFloor />
<SleepThreshold />
<div className="flex flex-wrap gap-2">
<ExportSave />
<ImportSave hasCompany />
<NewCompany />
</div>
</Section>
<Section title="Display">
<Choice<ClockFormat>
label="Clock"
value={prefs.clock}
options={[
["locale", "Device"],
["12h", "12-hour"],
["24h", "24-hour"],
]}
onChange={(clock) => prefs.set({ clock })}
/>
<Choice<CountdownFormat>
label="Countdowns"
value={prefs.countdown}
options={[
["real", "Real time"],
["game", "Game time"],
["both", "Both"],
]}
onChange={(countdown) => prefs.set({ countdown })}
/>
<Choice<"device" | "on" | "off">
label="Reduced motion"
value={prefs.reducedMotion === null ? "device" : prefs.reducedMotion ? "on" : "off"}
options={[
["device", "Device"],
["on", "On"],
["off", "Off"],
]}
onChange={(v) => prefs.set({ reducedMotion: v === "device" ? null : v === "on" })}
/>
</Section>
<Section title="Notifications">
<Choice<"on" | "off">
label="Arrivals, radio calls, rest, upgrades and a quiet fleet"
value={prefs.notifications ? "on" : "off"}
options={[
["on", "On"],
["off", "Off"],
]}
onChange={(v) => prefs.set({ notifications: v === "on" })}
/>
<p className="text-xs text-ink-400">On the iOS and Android apps only.</p>
</Section>
<Section title="Help">
<div className="flex flex-wrap gap-2">
<Button variant="secondary" size="sm" onClick={resetMapView}>
Reset map view
</Button>
<Button variant="secondary" size="sm" onClick={() => prefs.showTipsAgain()}>
Show tips again
</Button>
<Button variant="secondary" size="sm" onClick={() => setHowToPlay(true)}>
How to play
</Button>
</div>
{/* The welcome cards again, ending in Close (decision 0.5.132). */}
{howToPlay && <WelcomeCards final="close" onClose={() => setHowToPlay(false)} />}
</Section>
<Section title="About">
<About />
</Section>
{isDevBuild() && (
<Section title="Developer">
<DevTools />
</Section>
)}
</Screen>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
const id = `settings-${title.toLowerCase()}`;
return (
<Panel as="section" aria-labelledby={id} className="space-y-3">
<h2 id={id} className="font-display text-sm font-bold text-ink-300">
{title}
</h2>
{children}
</Panel>
);
}
/** "Fleet goes quiet after": the sleep threshold (decision 0.5.137), a company setting. */
function SleepThreshold() {
const state = useLoadedPlayer();
const save = useGameAction("setSleepThreshold");
if (state.tutorial) return null;
return (
<div>
<Choice<string>
label="Fleet goes quiet after"
value={String(state.fleetSleep.afterHours)}
options={content.config.fleetSleep.optionsHours.map((h) => [String(h), sleepLabel(h)])}
onChange={(value) => save.mutate({ hours: Number(value) })}
/>
<p className="mt-1 text-xs text-ink-500">
Without any orders from you for this long, Independent pilots stop taking new jobs.
</p>
{save.isError && (
<p role="alert" className="mt-1 text-sm text-rose-300">
{errorMessage(save.error)}
</p>
)}
</div>
);
}
/** A labelled radio group of a few options, styled as a segmented control. */
function Choice<T extends string>(props: {
label: string;
value: T;
options: [T, string][];
onChange: (value: T) => void;
}) {
return (
<fieldset className="flex flex-wrap items-center justify-between gap-2 text-sm">
<legend className="float-left text-ink-400">{props.label}</legend>
<div className="flex overflow-hidden rounded-lg border border-glass-line">
{props.options.map(([value, text]) => (
<label
key={value}
className="cursor-pointer px-3 py-1.5 has-checked:bg-white/14 has-checked:text-ink-50 has-focus-visible:ring-2"
>
<input
type="radio"
name={props.label}
value={value}
checked={props.value === value}
onChange={() => props.onChange(value)}
className="sr-only"
/>
{text}
</label>
))}
</div>
</fieldset>
);
}