208 lines
7.0 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|