131 lines
5.4 KiB
TypeScript
131 lines
5.4 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { Button } from "./Button";
|
|
import { Field } from "./Field";
|
|
import { Note } from "./Panel";
|
|
import { ApiError, type RenameResult } from "@/lib/api";
|
|
import { baseSlug, hostFor, slugError } from "@/lib/slug";
|
|
|
|
/*
|
|
* The rename control, and only the control — the same shape as RelinkPanel: an
|
|
* input that expands in place rather than a modal, because this app has no modal
|
|
* and one action with one field does not need one.
|
|
*
|
|
* The host preview is drawn from lib/slug.ts, a mirror of the Go rules. It can
|
|
* disagree with the server; the 409 that comes back is the answer that counts.
|
|
*
|
|
* movesHost is what separates a rename that moves a DNS host from one that only
|
|
* changes a label. Self-hosted instances and unprovisioned cloud placeholders
|
|
* have no address, so every word about old links breaking and signing in again
|
|
* is false for them — and a preview host they will never live at is worse than
|
|
* no preview at all.
|
|
*/
|
|
export function RenamePanel({
|
|
currentName,
|
|
currentSlug,
|
|
movesHost,
|
|
onRename,
|
|
}: {
|
|
currentName: string;
|
|
currentSlug: string;
|
|
movesHost: boolean;
|
|
onRename: (name: string) => Promise<RenameResult>;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [value, setValue] = useState(currentName);
|
|
const [error, setError] = useState<string | undefined>();
|
|
const [busy, setBusy] = useState(false);
|
|
const [done, setDone] = useState<RenameResult | undefined>();
|
|
|
|
const name = value.trim();
|
|
const derived = baseSlug(name);
|
|
const invalid = slugError(name);
|
|
// A cosmetic edit that lands on the same slug is still a rename worth doing —
|
|
// the name is what the customer reads. Only an empty or unchanged name is
|
|
// nothing to submit.
|
|
const unchanged = name === currentName.trim();
|
|
|
|
async function submit() {
|
|
setError(undefined);
|
|
setBusy(true);
|
|
try {
|
|
const res = await onRename(name);
|
|
setDone(res);
|
|
setOpen(false);
|
|
// The input is prefilled with the current name, and the current name
|
|
// is now this one. Leaving the old text in would make the next open
|
|
// look like an edit already in progress.
|
|
setValue(res.name);
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : "Rename failed. Try again.");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
// The note sits ABOVE the control rather than replacing it. A rename is not
|
|
// a one-shot action — a customer who mistypes the new name needs the panel
|
|
// back, and returning early here left them with a success message and no way
|
|
// to correct it short of a reload.
|
|
return (
|
|
<div className="grid gap-3">
|
|
{done &&
|
|
(movesHost ? (
|
|
<Note tone="warn">
|
|
<span className="grid gap-2">
|
|
<span>
|
|
This instance is now <strong>{done.name}</strong>, at{" "}
|
|
<span className="font-mono">{hostFor(done.slug)}</span>. The old address has stopped working, and
|
|
your sign-in does not follow it — you will need to sign in again there.
|
|
</span>
|
|
<a
|
|
href={done.login_url || `https://${hostFor(done.slug)}`}
|
|
className="justify-self-start font-mono text-[0.78rem] text-accent underline"
|
|
>
|
|
Open {hostFor(done.slug)} →
|
|
</a>
|
|
</span>
|
|
</Note>
|
|
) : (
|
|
<Note tone="warn">
|
|
This instance is now <strong>{done.name}</strong>.
|
|
</Note>
|
|
))}
|
|
{open && (
|
|
<Field
|
|
label="Instance name"
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
error={error ?? (name ? invalid : undefined)}
|
|
hint={
|
|
movesHost && name && !invalid ? (
|
|
<>
|
|
Moves to <span className="font-mono">{hostFor(derived)}</span>
|
|
{derived === currentSlug && " — the address does not change"}
|
|
</>
|
|
) : (
|
|
"Letters and digits; everything else becomes a hyphen."
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="line"
|
|
disabled={busy || (open && (!name || Boolean(invalid) || unchanged))}
|
|
onClick={() => (open ? submit() : setOpen(true))}
|
|
>
|
|
{busy ? "Renaming…" : "Rename instance"}
|
|
</Button>
|
|
{open && movesHost && (
|
|
<span className="text-[0.82rem] text-ink-3">
|
|
Anyone signed in will need to sign in again at the new address, and links to the old one stop working.
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|