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

208 lines
7.0 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useNavigate } from "react-router";
import { useApi } from "../api/context";
import { errorMessage } from "../state/actions";
import { PLAYER_STATE_KEY } from "../state/player";
import { Button, Sheet } from "../ui";
/** The file a save is shared as: plain text, dated. */
export function saveFile(code: string, now = new Date()): File {
const day = now.toISOString().slice(0, 10);
return new File([code], `deadhead-save-${day}.txt`, { type: "text/plain" });
}
/** Shares the file where the device can (the share sheet), otherwise downloads it. */
async function shareFile(file: File): Promise<void> {
if (navigator.canShare?.({ files: [file] })) {
await navigator.share({ files: [file], title: "Deadhead save" });
return;
}
const url = URL.createObjectURL(file);
const link = document.createElement("a");
link.href = url;
link.download = file.name;
link.click();
URL.revokeObjectURL(url);
}
const codeBox =
"mt-1 h-28 w-full resize-none rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 font-mono text-xs break-all text-ink-100 focus:border-amber-400 focus:outline-none";
/** "Export save" (decision 0.5.92): the whole company as one code, to copy or share as a file. */
export function ExportSave() {
const api = useApi();
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const exported = useQuery({
queryKey: ["export-save"],
queryFn: () => api.call("exportSave", {}),
enabled: open,
gcTime: 0,
staleTime: 0,
});
const code = exported.data?.code;
const close = () => {
setOpen(false);
setCopied(false);
};
return (
<>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
Export save
</Button>
{open && (
<Sheet titleId="export-title" title="Export save" eyebrow="Your company">
<p className="mt-3 text-sm text-ink-200">
This code is your whole company. Keep it somewhere safe, or import it on another device.
</p>
{code ? (
<>
<textarea readOnly aria-label="Save code" value={code} className={codeBox} />
<div className="mt-3 flex gap-2">
<Button
block
onClick={() => {
void navigator.clipboard
?.writeText(code)
.then(() => setCopied(true))
.catch(() => setCopied(false));
}}
>
{copied ? "Copied" : "Copy"}
</Button>
<Button
variant="secondary"
block
onClick={() => void shareFile(saveFile(code)).catch(() => undefined)}
>
Share as file
</Button>
</div>
</>
) : exported.isError ? (
<p role="alert" className="mt-3 text-sm text-rose-300">
{errorMessage(exported.error)}
</p>
) : (
<p className="mt-3 text-sm text-ink-400">Packing up your company…</p>
)}
<Button variant="secondary" block className="mt-3" onClick={close}>
Done
</Button>
</Sheet>
)}
</>
);
}
/**
* "Import save": paste a code or open a file, then confirm. `hasCompany`
* warns that the company on this device is replaced.
*/
export function ImportSave({ hasCompany }: { hasCompany: boolean }) {
const api = useApi();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [code, setCode] = useState("");
const [confirming, setConfirming] = useState(false);
const importSave = useMutation({
mutationFn: () => api.call("importSave", { code }),
onSuccess: async ({ state }) => {
queryClient.setQueryData(PLAYER_STATE_KEY, state);
await queryClient.invalidateQueries();
close();
void navigate("/");
},
onError: () => setConfirming(false),
});
const close = () => {
setOpen(false);
setCode("");
setConfirming(false);
importSave.reset();
};
return (
<>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
Import save
</Button>
{open && (
<Sheet titleId="import-title" title="Import save" eyebrow="Save code" layer="z-30">
{hasCompany && (
<p
role="note"
className="mt-3 rounded-lg bg-amber-950/50 p-2 text-sm text-amber-200 ring-1 ring-amber-400/40"
>
This replaces the company on this device. Export it first if you want to keep it.
</p>
)}
<form
className="mt-3 space-y-3"
onSubmit={(e) => {
e.preventDefault();
if (!code.trim()) return;
if (confirming) importSave.mutate();
else setConfirming(true);
}}
>
<label className="block text-sm">
<span className="text-ink-300">Paste a save code</span>
<textarea
value={code}
onChange={(e) => {
setCode(e.target.value);
setConfirming(false);
}}
autoComplete="off"
spellCheck={false}
className={codeBox}
/>
</label>
<label className="block text-sm">
<span className="text-ink-300">…or open a save file</span>
<input
type="file"
accept=".txt,text/plain"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
void file.text().then((text) => {
setCode(text.trim());
setConfirming(false);
});
}
}}
className="mt-1 block w-full text-xs text-ink-300 file:mr-3 file:rounded-lg file:border-0 file:bg-space-700 file:px-3 file:py-1.5 file:text-ink-100"
/>
</label>
{importSave.isError && (
<p role="alert" className="text-sm text-rose-300">
{errorMessage(importSave.error)}
</p>
)}
{confirming && (
<p className="text-sm text-amber-200">
{hasCompany ? "Replace your company with this save?" : "Open this company?"}
</p>
)}
<div className="flex gap-2">
<Button type="submit" block disabled={!code.trim() || importSave.isPending}>
{confirming ? (hasCompany ? "Replace company" : "Open company") : "Import"}
</Button>
<Button variant="secondary" block onClick={close}>
Cancel
</Button>
</div>
</form>
</Sheet>
)}
</>
);
}