226 lines
7.0 KiB
TypeScript
226 lines
7.0 KiB
TypeScript
import * as React from "react";
|
|
import { Link, useNavigate } from "@tanstack/react-router";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { ChevronLeft } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard";
|
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
|
import {
|
|
clearLastProjectId,
|
|
getLastProjectId,
|
|
} from "@/client/lib/active-project";
|
|
import {
|
|
deleteProject,
|
|
getProjects,
|
|
updateProject,
|
|
} from "@/serverFunctions/projects";
|
|
import type { ProjectSummary } from "./types";
|
|
|
|
export function ProjectSettings({ projectId }: { projectId: string }) {
|
|
const projectsQuery = useQuery({
|
|
queryKey: ["projects"],
|
|
queryFn: () => getProjects(),
|
|
});
|
|
const projects = projectsQuery.data ?? [];
|
|
const project = projects.find((entry) => entry.id === projectId) ?? null;
|
|
|
|
if (!project) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<span className="loading loading-spinner loading-md" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto w-full max-w-2xl space-y-8 p-4 py-8 sm:p-6 md:py-12">
|
|
<div className="space-y-4">
|
|
<Link
|
|
to="/projects"
|
|
className="inline-flex items-center gap-1 text-sm text-base-content/60 transition-colors hover:text-base-content"
|
|
>
|
|
<ChevronLeft className="size-4" />
|
|
Projects
|
|
</Link>
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">
|
|
Project settings
|
|
</h1>
|
|
<p className="text-sm text-base-content/60">{project.name}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* key resets the form's local state when switching between projects */}
|
|
<GeneralSection key={project.id} project={project} />
|
|
|
|
<section id="search-console" className="space-y-3 scroll-mt-6">
|
|
<h2 className="text-sm font-medium text-base-content/50">
|
|
Search Console
|
|
</h2>
|
|
<SearchConsoleConnectionCard projectId={projectId} />
|
|
</section>
|
|
|
|
<DangerSection project={project} canDelete={projects.length > 1} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GeneralSection({ project }: { project: ProjectSummary }) {
|
|
const queryClient = useQueryClient();
|
|
const [name, setName] = React.useState(project.name);
|
|
const [domain, setDomain] = React.useState(project.domain ?? "");
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: () =>
|
|
updateProject({
|
|
data: {
|
|
projectId: project.id,
|
|
name: name.trim(),
|
|
domain: domain.trim() || undefined,
|
|
},
|
|
}),
|
|
onSuccess: async () => {
|
|
await queryClient.invalidateQueries({ queryKey: ["projects"] });
|
|
toast.success("Project updated");
|
|
},
|
|
onError: (error) =>
|
|
toast.error(getStandardErrorMessage(error, "Failed to update project")),
|
|
});
|
|
|
|
const isDirty =
|
|
name.trim() !== project.name ||
|
|
(domain.trim() || "") !== (project.domain ?? "");
|
|
|
|
const handleSubmit = (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
if (updateMutation.isPending) return;
|
|
if (!name.trim()) {
|
|
toast.error("Project name is required");
|
|
return;
|
|
}
|
|
updateMutation.mutate();
|
|
};
|
|
|
|
return (
|
|
<section className="space-y-3">
|
|
<h2 className="text-sm font-medium text-base-content/50">General</h2>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<label className="flex flex-col gap-1.5 text-sm">
|
|
<span className="font-medium">Name</span>
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
onChange={(event) => setName(event.target.value)}
|
|
maxLength={120}
|
|
className="input input-bordered w-full"
|
|
/>
|
|
</label>
|
|
|
|
<label className="flex flex-col gap-1.5 text-sm">
|
|
<span className="font-medium">
|
|
Domain <span className="text-base-content/50">(optional)</span>
|
|
</span>
|
|
<input
|
|
type="text"
|
|
value={domain}
|
|
onChange={(event) => setDomain(event.target.value)}
|
|
placeholder="example.com"
|
|
maxLength={255}
|
|
className="input input-bordered w-full"
|
|
/>
|
|
</label>
|
|
|
|
<div className="flex justify-end">
|
|
<button
|
|
type="submit"
|
|
className="btn btn-primary btn-sm"
|
|
disabled={updateMutation.isPending || !isDirty}
|
|
>
|
|
Save changes
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function DangerSection({
|
|
project,
|
|
canDelete,
|
|
}: {
|
|
project: ProjectSummary;
|
|
canDelete: boolean;
|
|
}) {
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const [confirming, setConfirming] = React.useState(false);
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: () => deleteProject({ data: { projectId: project.id } }),
|
|
onSuccess: async () => {
|
|
if (getLastProjectId() === project.id) clearLastProjectId();
|
|
await queryClient.invalidateQueries({ queryKey: ["projects"] });
|
|
toast.success("Project deleted");
|
|
// Re-resolve to a remaining project via the landing redirect.
|
|
void navigate({ to: "/" });
|
|
},
|
|
onError: (error) =>
|
|
toast.error(getStandardErrorMessage(error, "Failed to delete project")),
|
|
});
|
|
|
|
return (
|
|
<section className="space-y-3 border-t border-base-300 pt-8">
|
|
<h2 className="text-sm font-medium text-base-content/50">
|
|
Delete project
|
|
</h2>
|
|
|
|
{confirming ? (
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-base-content/70">
|
|
Deleting{" "}
|
|
<span className="font-medium text-base-content">
|
|
{project.name}
|
|
</span>{" "}
|
|
permanently removes its Search Console connection, rank tracking,
|
|
audits, and saved keywords. This can't be undone.
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
className="btn btn-error btn-sm"
|
|
onClick={() => deleteMutation.mutate()}
|
|
disabled={deleteMutation.isPending}
|
|
>
|
|
Yes, delete project
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost btn-sm"
|
|
onClick={() => setConfirming(false)}
|
|
disabled={deleteMutation.isPending}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-between gap-4">
|
|
<p className="text-sm text-base-content/60">
|
|
{canDelete
|
|
? "Permanently delete this project and all of its data."
|
|
: "You can't delete your only project."}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline btn-error btn-sm shrink-0"
|
|
onClick={() => setConfirming(true)}
|
|
disabled={!canDelete}
|
|
>
|
|
Delete project
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|