mirror of
https://github.com/ThisTine/Snip.git
synced 2026-10-18 02:51:38 +07:00
111 lines
3.5 KiB
TypeScript
111 lines
3.5 KiB
TypeScript
import { AnimatePresence, motion } from "framer-motion";
|
|
import { useEffect, useState } from "react";
|
|
import { api } from "../lib/api";
|
|
import type { ShortLink } from "../lib/types";
|
|
import { prettyHost, shortUrl } from "../lib/format";
|
|
import { isValidUrl, normalizeUrl } from "../lib/shortcode";
|
|
import { Modal } from "./ui/Modal";
|
|
import { Button } from "./ui/Button";
|
|
|
|
interface Props {
|
|
link: ShortLink | null;
|
|
onClose: () => void;
|
|
onSaved: (updated: ShortLink, message: string) => void;
|
|
}
|
|
|
|
export function EditLinkModal({ link, onClose, onSaved }: Props) {
|
|
const open = Boolean(link);
|
|
const [draft, setDraft] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Reset the form whenever a different link opens the modal.
|
|
useEffect(() => {
|
|
setDraft(link?.longUrl ?? "");
|
|
setError(null);
|
|
setBusy(false);
|
|
}, [link?.id, link?.longUrl]);
|
|
|
|
if (!link) return <Modal open={false} onClose={onClose} children={null} />;
|
|
|
|
const changed = normalizeUrl(draft) !== link.longUrl;
|
|
|
|
async function save() {
|
|
if (!link) return;
|
|
if (!isValidUrl(draft)) {
|
|
setError("That doesn't look like a valid URL.");
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const updated = await api.updateLink(link.id, {
|
|
longUrl: normalizeUrl(draft),
|
|
});
|
|
onSaved(updated, "Link updated");
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Could not save changes.");
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal open={open} onClose={onClose} title="Edit link">
|
|
{/* Short code is permanent — shown read-only so existing shares keep working. */}
|
|
<div className="-mt-2 mb-4 flex items-center justify-between gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3">
|
|
<div className="min-w-0">
|
|
<p className="text-[11px] uppercase tracking-wide text-muted">Short link</p>
|
|
<p className="truncate font-mono text-sm font-semibold text-ink">
|
|
{shortUrl(link.code)}
|
|
</p>
|
|
</div>
|
|
<span className="shrink-0 rounded-full border border-line px-2 py-0.5 text-[10px] text-muted">
|
|
permanent
|
|
</span>
|
|
</div>
|
|
|
|
<label className="mb-1.5 block text-[13px] font-medium text-muted">
|
|
Destination URL
|
|
</label>
|
|
<input
|
|
autoFocus
|
|
value={draft}
|
|
onChange={(e) => {
|
|
setDraft(e.target.value);
|
|
if (error) setError(null);
|
|
}}
|
|
onKeyDown={(e) => e.key === "Enter" && save()}
|
|
spellCheck={false}
|
|
className={`h-11 w-full rounded-xl border bg-surface-2 px-3.5 font-mono text-sm text-ink outline-none transition-colors ${
|
|
error ? "border-red-400" : "border-line focus:border-[var(--ink)]"
|
|
}`}
|
|
/>
|
|
<p className="mt-2 text-xs text-muted">
|
|
Currently points to {prettyHost(link.longUrl)}.
|
|
</p>
|
|
|
|
<AnimatePresence>
|
|
{error && (
|
|
<motion.p
|
|
initial={{ opacity: 0, y: -4 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0 }}
|
|
className="mt-3 text-[13px] font-medium text-red-500"
|
|
>
|
|
{error}
|
|
</motion.p>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<div className="mt-5 flex items-center gap-2">
|
|
<Button onClick={save} disabled={busy || !changed}>
|
|
{busy ? "Saving…" : "Save changes"}
|
|
</Button>
|
|
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|