153 lines
5.3 KiB
TypeScript

import * as React from "react";
import { Link, createFileRoute } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronRight, Plus } from "lucide-react";
import { toast } from "sonner";
import {
getArchivedProjects,
getProjects,
restoreProject,
} from "@/serverFunctions/projects";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { getLastProjectId } from "@/client/lib/active-project";
import { CreateProjectModal } from "@/client/features/projects/CreateProjectModal";
export const Route = createFileRoute("/_app/projects")({
component: ProjectsPage,
});
function ProjectsPage() {
const [creating, setCreating] = React.useState(false);
// Read after mount to keep SSR/first render stable.
const [currentProjectId, setCurrentProjectId] = React.useState<string | null>(
null,
);
React.useEffect(() => {
setCurrentProjectId(getLastProjectId());
}, []);
const projectsQuery = useQuery({
queryKey: ["projects"],
queryFn: () => getProjects(),
});
const projects = projectsQuery.data ?? [];
return (
<div className="h-full overflow-auto bg-base-100 px-4 py-8 pb-24 md:px-6 md:py-12 md:pb-8">
<div className="mx-auto w-full max-w-2xl space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Projects</h1>
<p className="mt-1 text-sm text-base-content/60">
Each project has its own Search Console, rank tracking, and
audits.
</p>
</div>
<button
type="button"
className="btn btn-primary btn-sm shrink-0"
onClick={() => setCreating(true)}
>
<Plus className="size-4" />
New project
</button>
</div>
{projectsQuery.isLoading ? (
<div className="flex justify-center py-10">
<span className="loading loading-spinner loading-md" />
</div>
) : (
<ul className="divide-y divide-base-300 overflow-hidden rounded-lg border border-base-300">
{projects.map((project) => (
<li key={project.id}>
<Link
to="/p/$projectId/settings"
params={{ projectId: project.id }}
className="flex items-center justify-between gap-3 p-3 transition-colors hover:bg-base-200/40"
>
<span className="flex min-w-0 flex-col">
<span className="flex items-center gap-2">
<span className="truncate font-medium">
{project.name}
</span>
{project.id === currentProjectId ? (
<span className="shrink-0 rounded-full bg-base-300/70 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-base-content/60">
Current
</span>
) : null}
</span>
<span className="truncate text-xs text-base-content/50">
{project.domain ?? "No domain set"}
</span>
</span>
<ChevronRight className="size-4 shrink-0 text-base-content/40" />
</Link>
</li>
))}
</ul>
)}
<ArchivedProjects />
</div>
{creating ? (
<CreateProjectModal onClose={() => setCreating(false)} />
) : null}
</div>
);
}
function ArchivedProjects() {
const queryClient = useQueryClient();
const archivedQuery = useQuery({
queryKey: ["projects", "archived"],
queryFn: () => getArchivedProjects(),
});
const archived = archivedQuery.data ?? [];
const restoreMutation = useMutation({
mutationFn: (projectId: string) =>
restoreProject({ data: { archivedProjectId: projectId } }),
onSuccess: async () => {
// Prefix match invalidates both the active and archived lists.
await queryClient.invalidateQueries({ queryKey: ["projects"] });
toast.success("Project restored");
},
onError: (error) =>
toast.error(getStandardErrorMessage(error, "Failed to restore project")),
});
if (archived.length === 0) return null;
return (
<section className="space-y-3">
<h2 className="text-sm font-medium text-base-content/50">Archived</h2>
<ul className="divide-y divide-base-300 overflow-hidden rounded-lg border border-base-300">
{archived.map((project) => (
<li
key={project.id}
className="flex items-center justify-between gap-3 p-3"
>
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium text-base-content/70">
{project.name}
</span>
<span className="truncate text-xs text-base-content/50">
{project.domain ?? "No domain set"}
</span>
</span>
<button
type="button"
className="btn btn-ghost btn-sm shrink-0"
onClick={() => restoreMutation.mutate(project.id)}
disabled={restoreMutation.isPending}
>
Restore
</button>
</li>
))}
</ul>
</section>
);
}