Multi-project support per organization (#246)
This commit is contained in:
parent
e16f8d5647
commit
076a7fb6d7
@ -1,6 +1,7 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { ChevronsUpDown, X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { getProjectNavGroups } from "@/client/navigation/items";
|
import { getProjectNavGroups } from "@/client/navigation/items";
|
||||||
|
import { ProjectSwitcher } from "@/client/features/projects/ProjectSwitcher";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -29,15 +30,11 @@ export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
|
|||||||
|
|
||||||
{/* Project picker */}
|
{/* Project picker */}
|
||||||
<div className="px-3 py-3 border-b border-base-300">
|
<div className="px-3 py-3 border-b border-base-300">
|
||||||
<div
|
<ProjectSwitcher
|
||||||
className="tooltip tooltip-bottom w-full"
|
activeProjectId={projectId}
|
||||||
data-tip="Multiple projects coming soon"
|
variant="sidebar"
|
||||||
>
|
onCloseDrawer={onNavigate}
|
||||||
<button className="btn btn-ghost btn-sm w-full justify-between font-medium text-sm cursor-default">
|
/>
|
||||||
<span className="truncate">Default</span>
|
|
||||||
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/40" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
|
|||||||
@ -87,10 +87,10 @@ export function GscReEngagementModal({
|
|||||||
// screen, and on return they'll either have a grant (which suppresses this
|
// screen, and on return they'll either have a grant (which suppresses this
|
||||||
// anyway) or have abandoned it — neither case should re-nag.
|
// anyway) or have abandoned it — neither case should re-nag.
|
||||||
persistDismiss();
|
persistDismiss();
|
||||||
// Land them on the project's Integrations page so they can pick a property
|
// Land them on the project's settings page so they can pick a property
|
||||||
// right after granting access (the grant alone has no property bound yet).
|
// right after granting access (the grant alone has no property bound yet).
|
||||||
const callbackURL = projectId
|
const callbackURL = projectId
|
||||||
? `${window.location.origin}/p/${projectId}/integrations`
|
? `${window.location.origin}/p/${projectId}/settings#search-console`
|
||||||
: window.location.href;
|
: window.location.href;
|
||||||
void startGscLink(callbackURL);
|
void startGscLink(callbackURL);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,19 +13,20 @@ import {
|
|||||||
listGscSites,
|
listGscSites,
|
||||||
setGscSite,
|
setGscSite,
|
||||||
} from "@/serverFunctions/gsc";
|
} from "@/serverFunctions/gsc";
|
||||||
import { getOrCreateDefaultProject } from "@/serverFunctions/projects";
|
import { getProjects } from "@/serverFunctions/projects";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Onboarding step for connecting Google Search Console: link the account-level
|
* Onboarding step for connecting Google Search Console: link the account-level
|
||||||
* OAuth grant, then bind a verified property to the user's default project —
|
* OAuth grant, then bind a verified property to the user's first project —
|
||||||
* the same binding the project's Integrations page does — so it's done in one
|
* the same binding the project's Integrations page does — so it's done in one
|
||||||
* place.
|
* place.
|
||||||
*/
|
*/
|
||||||
export function SearchConsoleOnboardingStep() {
|
export function SearchConsoleOnboardingStep() {
|
||||||
const projectQuery = useQuery({
|
const projectsQuery = useQuery({
|
||||||
queryKey: ["defaultProject"],
|
queryKey: ["projects"],
|
||||||
queryFn: () => getOrCreateDefaultProject(),
|
queryFn: () => getProjects(),
|
||||||
});
|
});
|
||||||
|
const projectId = projectsQuery.data?.[0]?.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -33,11 +34,7 @@ export function SearchConsoleOnboardingStep() {
|
|||||||
Connect with Google Search Console now?
|
Connect with Google Search Console now?
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{projectQuery.data ? (
|
{projectId ? <GscConnect projectId={projectId} /> : <Checking />}
|
||||||
<GscConnect projectId={projectQuery.data.id} />
|
|
||||||
) : (
|
|
||||||
<Checking />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-xs leading-relaxed text-base-content/55">
|
<p className="text-xs leading-relaxed text-base-content/55">
|
||||||
For now, Search Console data flows through the OpenSEO MCP. We're
|
For now, Search Console data flows through the OpenSEO MCP. We're
|
||||||
|
|||||||
111
src/client/features/projects/CreateProjectModal.tsx
Normal file
111
src/client/features/projects/CreateProjectModal.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Modal } from "@/client/components/Modal";
|
||||||
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
|
import { setLastProjectId } from "@/client/lib/active-project";
|
||||||
|
import { createProject } from "@/serverFunctions/projects";
|
||||||
|
|
||||||
|
export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [name, setName] = React.useState("");
|
||||||
|
const [domain, setDomain] = React.useState("");
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createProject({
|
||||||
|
data: { name: name.trim(), domain: domain.trim() || undefined },
|
||||||
|
}),
|
||||||
|
onSuccess: async (created) => {
|
||||||
|
setLastProjectId(created.id);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
onClose();
|
||||||
|
toast.success("Project created");
|
||||||
|
// Land on the new project's settings so they can connect Search Console
|
||||||
|
// and finish setting up the workspace.
|
||||||
|
void navigate({
|
||||||
|
to: "/p/$projectId/settings",
|
||||||
|
params: { projectId: created.id },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(getStandardErrorMessage(error, "Failed to create project")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending;
|
||||||
|
|
||||||
|
const handleSubmit = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (isPending) return;
|
||||||
|
if (!name.trim()) {
|
||||||
|
toast.error("Project name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
maxWidth="max-w-md"
|
||||||
|
onClose={isPending ? undefined : onClose}
|
||||||
|
labelledBy="create-project-title"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<h2 id="create-project-title" className="text-lg font-semibold">
|
||||||
|
New project
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<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)}
|
||||||
|
placeholder="Acme Inc."
|
||||||
|
maxLength={120}
|
||||||
|
autoFocus
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-base-content/50">
|
||||||
|
You can connect Search Console and set up rank tracking after
|
||||||
|
creating the project.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Create project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
225
src/client/features/projects/ProjectSettings.tsx
Normal file
225
src/client/features/projects/ProjectSettings.tsx
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/client/features/projects/ProjectSwitcher.tsx
Normal file
124
src/client/features/projects/ProjectSwitcher.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { Link, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Check, ChevronsUpDown, FolderCog } from "lucide-react";
|
||||||
|
import { getProjects } from "@/serverFunctions/projects";
|
||||||
|
import { setLastProjectId } from "@/client/lib/active-project";
|
||||||
|
import type { ProjectSummary } from "./types";
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectSwitcher({
|
||||||
|
activeProjectId,
|
||||||
|
variant = "topbar",
|
||||||
|
onCloseDrawer,
|
||||||
|
}: {
|
||||||
|
activeProjectId: string | null;
|
||||||
|
variant?: "topbar" | "sidebar";
|
||||||
|
// Mobile sidebar passes this so switching / navigating away also closes the
|
||||||
|
// drawer overlay.
|
||||||
|
onCloseDrawer?: () => void;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const projectsQuery = useQuery({
|
||||||
|
queryKey: ["projects"],
|
||||||
|
queryFn: () => getProjects(),
|
||||||
|
});
|
||||||
|
const projects = projectsQuery.data ?? [];
|
||||||
|
const activeProject =
|
||||||
|
projects.find((project) => project.id === activeProjectId) ?? null;
|
||||||
|
|
||||||
|
const isSidebar = variant === "sidebar";
|
||||||
|
|
||||||
|
const handleSelect = (project: ProjectSummary) => {
|
||||||
|
closeDropdown();
|
||||||
|
onCloseDrawer?.();
|
||||||
|
if (project.id === activeProjectId) return;
|
||||||
|
setLastProjectId(project.id);
|
||||||
|
void navigate({
|
||||||
|
to: "/p/$projectId/keywords",
|
||||||
|
params: { projectId: project.id },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`dropdown ${isSidebar ? "w-full" : "dropdown-end"}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Switch project"
|
||||||
|
className={
|
||||||
|
isSidebar
|
||||||
|
? "btn btn-ghost btn-sm w-full justify-between font-medium"
|
||||||
|
: "flex h-10 max-w-[12rem] items-center gap-2 rounded-full px-3 text-left transition-colors hover:bg-base-200/80"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate text-sm font-medium text-base-content">
|
||||||
|
{activeProject?.name ?? "Select project"}
|
||||||
|
</span>
|
||||||
|
{activeProject?.domain ? (
|
||||||
|
<span className="truncate text-xs font-normal text-base-content/50">
|
||||||
|
{activeProject.domain}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/40" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
tabIndex={0}
|
||||||
|
className={`dropdown-content z-30 menu rounded-box border border-base-300 bg-base-100 p-2 shadow-lg ${
|
||||||
|
isSidebar ? "w-full" : "mt-2 w-64"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{projects.map((project) => {
|
||||||
|
const isActive = project.id === activeProjectId;
|
||||||
|
return (
|
||||||
|
<li key={project.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(project)}
|
||||||
|
className={isActive ? "active" : ""}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate">{project.name}</span>
|
||||||
|
{project.domain ? (
|
||||||
|
<span className="truncate text-xs text-base-content/50">
|
||||||
|
{project.domain}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
{isActive ? (
|
||||||
|
<Check className="size-4 shrink-0 text-primary" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{projects.length > 0 ? (
|
||||||
|
<li>
|
||||||
|
<hr className="my-1 border-base-300" />
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
to="/projects"
|
||||||
|
onClick={() => {
|
||||||
|
closeDropdown();
|
||||||
|
onCloseDrawer?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderCog className="size-4" />
|
||||||
|
Manage projects
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/client/features/projects/types.ts
Normal file
7
src/client/features/projects/types.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
// Shape returned by the getProjects server function (a mapped project row).
|
||||||
|
export type ProjectSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
domain: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
@ -3,11 +3,10 @@ import { Link, useLocation } from "@tanstack/react-router";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronsUpDown,
|
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
FolderCog,
|
||||||
Menu,
|
Menu,
|
||||||
Plug,
|
|
||||||
Settings,
|
Settings,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@ -19,13 +18,15 @@ import {
|
|||||||
import { GscReEngagementModal } from "@/client/features/gsc/GscReEngagementModal";
|
import { GscReEngagementModal } from "@/client/features/gsc/GscReEngagementModal";
|
||||||
import {
|
import {
|
||||||
getProjectNavGroups,
|
getProjectNavGroups,
|
||||||
integrationsLinkOptions,
|
projectSettingsLinkOptions,
|
||||||
} from "@/client/navigation/items";
|
} from "@/client/navigation/items";
|
||||||
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
import { BILLING_ROUTE } from "@/shared/billing";
|
import { BILLING_ROUTE } from "@/shared/billing";
|
||||||
import { getSeoApiKeyStatus } from "@/serverFunctions/config";
|
import { getSeoApiKeyStatus } from "@/serverFunctions/config";
|
||||||
import { getOrCreateDefaultProject } from "@/serverFunctions/projects";
|
import { getProjects } from "@/serverFunctions/projects";
|
||||||
|
import { ProjectSwitcher } from "@/client/features/projects/ProjectSwitcher";
|
||||||
|
import { getLastProjectId } from "@/client/lib/active-project";
|
||||||
|
|
||||||
const DATAFORSEO_HELP_PATH = "/help/dataforseo-api-key";
|
const DATAFORSEO_HELP_PATH = "/help/dataforseo-api-key";
|
||||||
const SUPPORT_PATH = "/support";
|
const SUPPORT_PATH = "/support";
|
||||||
@ -44,12 +45,27 @@ export function AuthenticatedAppLayout({
|
|||||||
const setupModalRef = React.useRef<HTMLDivElement | null>(null);
|
const setupModalRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const [showMissingSeoApiKeyModal, setShowMissingSeoApiKeyModal] =
|
const [showMissingSeoApiKeyModal, setShowMissingSeoApiKeyModal] =
|
||||||
React.useState(false);
|
React.useState(false);
|
||||||
const defaultProjectQuery = useQuery({
|
// On non-project pages (e.g. /settings) there's no projectId in the URL, so
|
||||||
queryKey: ["defaultProject"],
|
// derive one for the nav/switcher: prefer the last-visited project, else the
|
||||||
queryFn: () => getOrCreateDefaultProject(),
|
// most recent. Reading localStorage in an effect keeps SSR/first render stable.
|
||||||
|
const projectsQuery = useQuery({
|
||||||
|
queryKey: ["projects"],
|
||||||
|
queryFn: () => getProjects(),
|
||||||
enabled: !projectId,
|
enabled: !projectId,
|
||||||
});
|
});
|
||||||
const headerProjectId = projectId ?? defaultProjectQuery.data?.id ?? null;
|
const [rememberedProjectId, setRememberedProjectId] = React.useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
setRememberedProjectId(getLastProjectId());
|
||||||
|
}, []);
|
||||||
|
const fallbackProjects = projectsQuery.data ?? [];
|
||||||
|
const fallbackProjectId =
|
||||||
|
fallbackProjects.find((project) => project.id === rememberedProjectId)
|
||||||
|
?.id ??
|
||||||
|
fallbackProjects[0]?.id ??
|
||||||
|
null;
|
||||||
|
const headerProjectId = projectId ?? fallbackProjectId;
|
||||||
const shouldCheckSeoApiKeyStatus = location.pathname !== BILLING_ROUTE;
|
const shouldCheckSeoApiKeyStatus = location.pathname !== BILLING_ROUTE;
|
||||||
const seoApiKeyStatusQuery = useQuery({
|
const seoApiKeyStatusQuery = useQuery({
|
||||||
queryKey: ["seoApiKeyStatus"],
|
queryKey: ["seoApiKeyStatus"],
|
||||||
@ -284,21 +300,7 @@ function TopNav({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center rounded-full border border-base-300 bg-base-100/70 px-1 py-1 shadow-sm">
|
<div className="flex items-center rounded-full border border-base-300 bg-base-100/70 px-1 py-1 shadow-sm">
|
||||||
<div
|
<ProjectSwitcher activeProjectId={projectId} variant="topbar" />
|
||||||
className="tooltip tooltip-left before:whitespace-nowrap"
|
|
||||||
data-tip="Multiple projects coming soon"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex h-10 cursor-default items-center gap-2 rounded-full px-3 text-left transition-colors hover:bg-base-200/80"
|
|
||||||
aria-label="Current project"
|
|
||||||
>
|
|
||||||
<span className="max-w-28 truncate text-sm font-medium text-base-content">
|
|
||||||
Default
|
|
||||||
</span>
|
|
||||||
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/35" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AccountMenu projectId={projectId} />
|
<AccountMenu projectId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
@ -363,11 +365,11 @@ function AccountMenu({
|
|||||||
{projectId ? (
|
{projectId ? (
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
{...integrationsLinkOptions(projectId)}
|
{...projectSettingsLinkOptions(projectId)}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Plug className="h-4 w-4" />
|
<FolderCog className="h-4 w-4" />
|
||||||
Integrations
|
Project settings
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
32
src/client/lib/active-project.ts
Normal file
32
src/client/lib/active-project.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
// Remembers the last project the user was looking at so the app can return them
|
||||||
|
// there on the next visit. Browser-local only (per-device); the server never
|
||||||
|
// trusts it — landing and the route guard always re-validate the id against the
|
||||||
|
// org's project list.
|
||||||
|
const LAST_PROJECT_KEY = "openseo:lastProjectId";
|
||||||
|
|
||||||
|
export function getLastProjectId(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(LAST_PROJECT_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLastProjectId(projectId: string): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(LAST_PROJECT_KEY, projectId);
|
||||||
|
} catch {
|
||||||
|
// Ignore private-mode / disabled-storage failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearLastProjectId(): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(LAST_PROJECT_KEY);
|
||||||
|
} catch {
|
||||||
|
// Ignore private-mode / disabled-storage failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -120,12 +120,11 @@ export function getProjectNavGroups(projectId: string) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Integrations is a setup surface, so it lives in the account dropdown rather
|
// Per-project configuration (name, domain, Search Console, delete) lives on the
|
||||||
// than the workflow nav — but it's still project-scoped (a property is bound
|
// project settings page rather than the workflow nav, hence the projectId param.
|
||||||
// per project), hence the projectId param.
|
export function projectSettingsLinkOptions(projectId: string) {
|
||||||
export function integrationsLinkOptions(projectId: string) {
|
|
||||||
return linkOptions({
|
return linkOptions({
|
||||||
to: "/p/$projectId/integrations" as const,
|
to: "/p/$projectId/settings" as const,
|
||||||
params: { projectId },
|
params: { projectId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
|
|||||||
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
|
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
|
||||||
import { Route as AppSupportRouteImport } from './routes/_app/support'
|
import { Route as AppSupportRouteImport } from './routes/_app/support'
|
||||||
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
|
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
|
||||||
|
import { Route as AppProjectsRouteImport } from './routes/_app/projects'
|
||||||
import { Route as AppBillingRouteImport } from './routes/_app/billing'
|
import { Route as AppBillingRouteImport } from './routes/_app/billing'
|
||||||
import { Route as AppAiRouteImport } from './routes/_app/ai'
|
import { Route as AppAiRouteImport } from './routes/_app/ai'
|
||||||
import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge'
|
import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge'
|
||||||
@ -33,11 +34,11 @@ import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/
|
|||||||
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
|
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
|
||||||
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
|
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
|
||||||
import { Route as ApiGscOauthCallbackRouteImport } from './routes/api/gsc/oauth/callback'
|
import { Route as ApiGscOauthCallbackRouteImport } from './routes/api/gsc/oauth/callback'
|
||||||
|
import { Route as ProjectPProjectIdSettingsRouteImport } from './routes/_project/p/$projectId/settings'
|
||||||
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
|
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
|
||||||
import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking'
|
import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking'
|
||||||
import { Route as ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer'
|
import { Route as ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer'
|
||||||
import { Route as ProjectPProjectIdKeywordsRouteImport } from './routes/_project/p/$projectId/keywords'
|
import { Route as ProjectPProjectIdKeywordsRouteImport } from './routes/_project/p/$projectId/keywords'
|
||||||
import { Route as ProjectPProjectIdIntegrationsRouteImport } from './routes/_project/p/$projectId/integrations'
|
|
||||||
import { Route as ProjectPProjectIdDomainRouteImport } from './routes/_project/p/$projectId/domain'
|
import { Route as ProjectPProjectIdDomainRouteImport } from './routes/_project/p/$projectId/domain'
|
||||||
import { Route as ProjectPProjectIdBrandLookupRouteImport } from './routes/_project/p/$projectId/brand-lookup'
|
import { Route as ProjectPProjectIdBrandLookupRouteImport } from './routes/_project/p/$projectId/brand-lookup'
|
||||||
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
|
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
|
||||||
@ -119,6 +120,11 @@ const AppSettingsRoute = AppSettingsRouteImport.update({
|
|||||||
path: '/settings',
|
path: '/settings',
|
||||||
getParentRoute: () => AppRouteRoute,
|
getParentRoute: () => AppRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppProjectsRoute = AppProjectsRouteImport.update({
|
||||||
|
id: '/projects',
|
||||||
|
path: '/projects',
|
||||||
|
getParentRoute: () => AppRouteRoute,
|
||||||
|
} as any)
|
||||||
const AppBillingRoute = AppBillingRouteImport.update({
|
const AppBillingRoute = AppBillingRouteImport.update({
|
||||||
id: '/billing',
|
id: '/billing',
|
||||||
path: '/billing',
|
path: '/billing',
|
||||||
@ -165,6 +171,12 @@ const ApiGscOauthCallbackRoute = ApiGscOauthCallbackRouteImport.update({
|
|||||||
path: '/api/gsc/oauth/callback',
|
path: '/api/gsc/oauth/callback',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ProjectPProjectIdSettingsRoute =
|
||||||
|
ProjectPProjectIdSettingsRouteImport.update({
|
||||||
|
id: '/settings',
|
||||||
|
path: '/settings',
|
||||||
|
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||||
|
} as any)
|
||||||
const ProjectPProjectIdSavedRoute = ProjectPProjectIdSavedRouteImport.update({
|
const ProjectPProjectIdSavedRoute = ProjectPProjectIdSavedRouteImport.update({
|
||||||
id: '/saved',
|
id: '/saved',
|
||||||
path: '/saved',
|
path: '/saved',
|
||||||
@ -188,12 +200,6 @@ const ProjectPProjectIdKeywordsRoute =
|
|||||||
path: '/keywords',
|
path: '/keywords',
|
||||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const ProjectPProjectIdIntegrationsRoute =
|
|
||||||
ProjectPProjectIdIntegrationsRouteImport.update({
|
|
||||||
id: '/integrations',
|
|
||||||
path: '/integrations',
|
|
||||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
|
||||||
} as any)
|
|
||||||
const ProjectPProjectIdDomainRoute = ProjectPProjectIdDomainRouteImport.update({
|
const ProjectPProjectIdDomainRoute = ProjectPProjectIdDomainRouteImport.update({
|
||||||
id: '/domain',
|
id: '/domain',
|
||||||
path: '/domain',
|
path: '/domain',
|
||||||
@ -249,6 +255,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
'/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
||||||
'/ai': typeof AppAiRoute
|
'/ai': typeof AppAiRoute
|
||||||
'/billing': typeof AppBillingRoute
|
'/billing': typeof AppBillingRoute
|
||||||
|
'/projects': typeof AppProjectsRoute
|
||||||
'/settings': typeof AppSettingsRoute
|
'/settings': typeof AppSettingsRoute
|
||||||
'/support': typeof AppSupportRoute
|
'/support': typeof AppSupportRoute
|
||||||
'/sign-in': typeof AuthSignInRoute
|
'/sign-in': typeof AuthSignInRoute
|
||||||
@ -264,11 +271,11 @@ export interface FileRoutesByFullPath {
|
|||||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||||
'/p/$projectId/integrations': typeof ProjectPProjectIdIntegrationsRoute
|
|
||||||
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||||
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||||
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||||
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||||
|
'/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
|
||||||
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
||||||
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
||||||
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
|
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
|
||||||
@ -284,6 +291,7 @@ export interface FileRoutesByTo {
|
|||||||
'/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
'/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
||||||
'/ai': typeof AppAiRoute
|
'/ai': typeof AppAiRoute
|
||||||
'/billing': typeof AppBillingRoute
|
'/billing': typeof AppBillingRoute
|
||||||
|
'/projects': typeof AppProjectsRoute
|
||||||
'/settings': typeof AppSettingsRoute
|
'/settings': typeof AppSettingsRoute
|
||||||
'/support': typeof AppSupportRoute
|
'/support': typeof AppSupportRoute
|
||||||
'/sign-in': typeof AuthSignInRoute
|
'/sign-in': typeof AuthSignInRoute
|
||||||
@ -297,10 +305,10 @@ export interface FileRoutesByTo {
|
|||||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||||
'/p/$projectId/integrations': typeof ProjectPProjectIdIntegrationsRoute
|
|
||||||
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||||
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||||
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||||
|
'/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
|
||||||
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
||||||
'/p/$projectId': typeof ProjectPProjectIdIndexRoute
|
'/p/$projectId': typeof ProjectPProjectIdIndexRoute
|
||||||
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
|
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
|
||||||
@ -320,6 +328,7 @@ export interface FileRoutesById {
|
|||||||
'/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
'/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
||||||
'/_app/ai': typeof AppAiRoute
|
'/_app/ai': typeof AppAiRoute
|
||||||
'/_app/billing': typeof AppBillingRoute
|
'/_app/billing': typeof AppBillingRoute
|
||||||
|
'/_app/projects': typeof AppProjectsRoute
|
||||||
'/_app/settings': typeof AppSettingsRoute
|
'/_app/settings': typeof AppSettingsRoute
|
||||||
'/_app/support': typeof AppSupportRoute
|
'/_app/support': typeof AppSupportRoute
|
||||||
'/_auth/sign-in': typeof AuthSignInRoute
|
'/_auth/sign-in': typeof AuthSignInRoute
|
||||||
@ -336,11 +345,11 @@ export interface FileRoutesById {
|
|||||||
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
'/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
'/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||||
'/_project/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
'/_project/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||||
'/_project/p/$projectId/integrations': typeof ProjectPProjectIdIntegrationsRoute
|
|
||||||
'/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
'/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||||
'/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
'/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||||
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||||
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||||
|
'/_project/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
|
||||||
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
||||||
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
||||||
'/_project/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
|
'/_project/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
|
||||||
@ -358,6 +367,7 @@ export interface FileRouteTypes {
|
|||||||
| '/.well-known/openai-apps-challenge'
|
| '/.well-known/openai-apps-challenge'
|
||||||
| '/ai'
|
| '/ai'
|
||||||
| '/billing'
|
| '/billing'
|
||||||
|
| '/projects'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/support'
|
| '/support'
|
||||||
| '/sign-in'
|
| '/sign-in'
|
||||||
@ -373,11 +383,11 @@ export interface FileRouteTypes {
|
|||||||
| '/p/$projectId/backlinks'
|
| '/p/$projectId/backlinks'
|
||||||
| '/p/$projectId/brand-lookup'
|
| '/p/$projectId/brand-lookup'
|
||||||
| '/p/$projectId/domain'
|
| '/p/$projectId/domain'
|
||||||
| '/p/$projectId/integrations'
|
|
||||||
| '/p/$projectId/keywords'
|
| '/p/$projectId/keywords'
|
||||||
| '/p/$projectId/prompt-explorer'
|
| '/p/$projectId/prompt-explorer'
|
||||||
| '/p/$projectId/rank-tracking'
|
| '/p/$projectId/rank-tracking'
|
||||||
| '/p/$projectId/saved'
|
| '/p/$projectId/saved'
|
||||||
|
| '/p/$projectId/settings'
|
||||||
| '/api/gsc/oauth/callback'
|
| '/api/gsc/oauth/callback'
|
||||||
| '/p/$projectId/'
|
| '/p/$projectId/'
|
||||||
| '/p/$projectId/rank-tracking/$configId'
|
| '/p/$projectId/rank-tracking/$configId'
|
||||||
@ -393,6 +403,7 @@ export interface FileRouteTypes {
|
|||||||
| '/.well-known/openai-apps-challenge'
|
| '/.well-known/openai-apps-challenge'
|
||||||
| '/ai'
|
| '/ai'
|
||||||
| '/billing'
|
| '/billing'
|
||||||
|
| '/projects'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/support'
|
| '/support'
|
||||||
| '/sign-in'
|
| '/sign-in'
|
||||||
@ -406,10 +417,10 @@ export interface FileRouteTypes {
|
|||||||
| '/p/$projectId/backlinks'
|
| '/p/$projectId/backlinks'
|
||||||
| '/p/$projectId/brand-lookup'
|
| '/p/$projectId/brand-lookup'
|
||||||
| '/p/$projectId/domain'
|
| '/p/$projectId/domain'
|
||||||
| '/p/$projectId/integrations'
|
|
||||||
| '/p/$projectId/keywords'
|
| '/p/$projectId/keywords'
|
||||||
| '/p/$projectId/prompt-explorer'
|
| '/p/$projectId/prompt-explorer'
|
||||||
| '/p/$projectId/saved'
|
| '/p/$projectId/saved'
|
||||||
|
| '/p/$projectId/settings'
|
||||||
| '/api/gsc/oauth/callback'
|
| '/api/gsc/oauth/callback'
|
||||||
| '/p/$projectId'
|
| '/p/$projectId'
|
||||||
| '/p/$projectId/rank-tracking/$configId'
|
| '/p/$projectId/rank-tracking/$configId'
|
||||||
@ -428,6 +439,7 @@ export interface FileRouteTypes {
|
|||||||
| '/.well-known/openai-apps-challenge'
|
| '/.well-known/openai-apps-challenge'
|
||||||
| '/_app/ai'
|
| '/_app/ai'
|
||||||
| '/_app/billing'
|
| '/_app/billing'
|
||||||
|
| '/_app/projects'
|
||||||
| '/_app/settings'
|
| '/_app/settings'
|
||||||
| '/_app/support'
|
| '/_app/support'
|
||||||
| '/_auth/sign-in'
|
| '/_auth/sign-in'
|
||||||
@ -444,11 +456,11 @@ export interface FileRouteTypes {
|
|||||||
| '/_project/p/$projectId/backlinks'
|
| '/_project/p/$projectId/backlinks'
|
||||||
| '/_project/p/$projectId/brand-lookup'
|
| '/_project/p/$projectId/brand-lookup'
|
||||||
| '/_project/p/$projectId/domain'
|
| '/_project/p/$projectId/domain'
|
||||||
| '/_project/p/$projectId/integrations'
|
|
||||||
| '/_project/p/$projectId/keywords'
|
| '/_project/p/$projectId/keywords'
|
||||||
| '/_project/p/$projectId/prompt-explorer'
|
| '/_project/p/$projectId/prompt-explorer'
|
||||||
| '/_project/p/$projectId/rank-tracking'
|
| '/_project/p/$projectId/rank-tracking'
|
||||||
| '/_project/p/$projectId/saved'
|
| '/_project/p/$projectId/saved'
|
||||||
|
| '/_project/p/$projectId/settings'
|
||||||
| '/api/gsc/oauth/callback'
|
| '/api/gsc/oauth/callback'
|
||||||
| '/_project/p/$projectId/'
|
| '/_project/p/$projectId/'
|
||||||
| '/_project/p/$projectId/rank-tracking/$configId'
|
| '/_project/p/$projectId/rank-tracking/$configId'
|
||||||
@ -578,6 +590,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppSettingsRouteImport
|
preLoaderRoute: typeof AppSettingsRouteImport
|
||||||
parentRoute: typeof AppRouteRoute
|
parentRoute: typeof AppRouteRoute
|
||||||
}
|
}
|
||||||
|
'/_app/projects': {
|
||||||
|
id: '/_app/projects'
|
||||||
|
path: '/projects'
|
||||||
|
fullPath: '/projects'
|
||||||
|
preLoaderRoute: typeof AppProjectsRouteImport
|
||||||
|
parentRoute: typeof AppRouteRoute
|
||||||
|
}
|
||||||
'/_app/billing': {
|
'/_app/billing': {
|
||||||
id: '/_app/billing'
|
id: '/_app/billing'
|
||||||
path: '/billing'
|
path: '/billing'
|
||||||
@ -641,6 +660,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiGscOauthCallbackRouteImport
|
preLoaderRoute: typeof ApiGscOauthCallbackRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/_project/p/$projectId/settings': {
|
||||||
|
id: '/_project/p/$projectId/settings'
|
||||||
|
path: '/settings'
|
||||||
|
fullPath: '/p/$projectId/settings'
|
||||||
|
preLoaderRoute: typeof ProjectPProjectIdSettingsRouteImport
|
||||||
|
parentRoute: typeof ProjectPProjectIdRouteRoute
|
||||||
|
}
|
||||||
'/_project/p/$projectId/saved': {
|
'/_project/p/$projectId/saved': {
|
||||||
id: '/_project/p/$projectId/saved'
|
id: '/_project/p/$projectId/saved'
|
||||||
path: '/saved'
|
path: '/saved'
|
||||||
@ -669,13 +695,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ProjectPProjectIdKeywordsRouteImport
|
preLoaderRoute: typeof ProjectPProjectIdKeywordsRouteImport
|
||||||
parentRoute: typeof ProjectPProjectIdRouteRoute
|
parentRoute: typeof ProjectPProjectIdRouteRoute
|
||||||
}
|
}
|
||||||
'/_project/p/$projectId/integrations': {
|
|
||||||
id: '/_project/p/$projectId/integrations'
|
|
||||||
path: '/integrations'
|
|
||||||
fullPath: '/p/$projectId/integrations'
|
|
||||||
preLoaderRoute: typeof ProjectPProjectIdIntegrationsRouteImport
|
|
||||||
parentRoute: typeof ProjectPProjectIdRouteRoute
|
|
||||||
}
|
|
||||||
'/_project/p/$projectId/domain': {
|
'/_project/p/$projectId/domain': {
|
||||||
id: '/_project/p/$projectId/domain'
|
id: '/_project/p/$projectId/domain'
|
||||||
path: '/domain'
|
path: '/domain'
|
||||||
@ -738,6 +757,7 @@ declare module '@tanstack/react-router' {
|
|||||||
interface AppRouteRouteChildren {
|
interface AppRouteRouteChildren {
|
||||||
AppAiRoute: typeof AppAiRoute
|
AppAiRoute: typeof AppAiRoute
|
||||||
AppBillingRoute: typeof AppBillingRoute
|
AppBillingRoute: typeof AppBillingRoute
|
||||||
|
AppProjectsRoute: typeof AppProjectsRoute
|
||||||
AppSettingsRoute: typeof AppSettingsRoute
|
AppSettingsRoute: typeof AppSettingsRoute
|
||||||
AppSupportRoute: typeof AppSupportRoute
|
AppSupportRoute: typeof AppSupportRoute
|
||||||
AppIndexRoute: typeof AppIndexRoute
|
AppIndexRoute: typeof AppIndexRoute
|
||||||
@ -747,6 +767,7 @@ interface AppRouteRouteChildren {
|
|||||||
const AppRouteRouteChildren: AppRouteRouteChildren = {
|
const AppRouteRouteChildren: AppRouteRouteChildren = {
|
||||||
AppAiRoute: AppAiRoute,
|
AppAiRoute: AppAiRoute,
|
||||||
AppBillingRoute: AppBillingRoute,
|
AppBillingRoute: AppBillingRoute,
|
||||||
|
AppProjectsRoute: AppProjectsRoute,
|
||||||
AppSettingsRoute: AppSettingsRoute,
|
AppSettingsRoute: AppSettingsRoute,
|
||||||
AppSupportRoute: AppSupportRoute,
|
AppSupportRoute: AppSupportRoute,
|
||||||
AppIndexRoute: AppIndexRoute,
|
AppIndexRoute: AppIndexRoute,
|
||||||
@ -797,11 +818,11 @@ interface ProjectPProjectIdRouteRouteChildren {
|
|||||||
ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute
|
ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute
|
||||||
ProjectPProjectIdBrandLookupRoute: typeof ProjectPProjectIdBrandLookupRoute
|
ProjectPProjectIdBrandLookupRoute: typeof ProjectPProjectIdBrandLookupRoute
|
||||||
ProjectPProjectIdDomainRoute: typeof ProjectPProjectIdDomainRoute
|
ProjectPProjectIdDomainRoute: typeof ProjectPProjectIdDomainRoute
|
||||||
ProjectPProjectIdIntegrationsRoute: typeof ProjectPProjectIdIntegrationsRoute
|
|
||||||
ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute
|
ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute
|
||||||
ProjectPProjectIdPromptExplorerRoute: typeof ProjectPProjectIdPromptExplorerRoute
|
ProjectPProjectIdPromptExplorerRoute: typeof ProjectPProjectIdPromptExplorerRoute
|
||||||
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||||
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
|
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
|
||||||
|
ProjectPProjectIdSettingsRoute: typeof ProjectPProjectIdSettingsRoute
|
||||||
ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute
|
ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -811,12 +832,12 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren =
|
|||||||
ProjectPProjectIdBacklinksRoute: ProjectPProjectIdBacklinksRoute,
|
ProjectPProjectIdBacklinksRoute: ProjectPProjectIdBacklinksRoute,
|
||||||
ProjectPProjectIdBrandLookupRoute: ProjectPProjectIdBrandLookupRoute,
|
ProjectPProjectIdBrandLookupRoute: ProjectPProjectIdBrandLookupRoute,
|
||||||
ProjectPProjectIdDomainRoute: ProjectPProjectIdDomainRoute,
|
ProjectPProjectIdDomainRoute: ProjectPProjectIdDomainRoute,
|
||||||
ProjectPProjectIdIntegrationsRoute: ProjectPProjectIdIntegrationsRoute,
|
|
||||||
ProjectPProjectIdKeywordsRoute: ProjectPProjectIdKeywordsRoute,
|
ProjectPProjectIdKeywordsRoute: ProjectPProjectIdKeywordsRoute,
|
||||||
ProjectPProjectIdPromptExplorerRoute: ProjectPProjectIdPromptExplorerRoute,
|
ProjectPProjectIdPromptExplorerRoute: ProjectPProjectIdPromptExplorerRoute,
|
||||||
ProjectPProjectIdRankTrackingRoute:
|
ProjectPProjectIdRankTrackingRoute:
|
||||||
ProjectPProjectIdRankTrackingRouteWithChildren,
|
ProjectPProjectIdRankTrackingRouteWithChildren,
|
||||||
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,
|
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,
|
||||||
|
ProjectPProjectIdSettingsRoute: ProjectPProjectIdSettingsRoute,
|
||||||
ProjectPProjectIdIndexRoute: ProjectPProjectIdIndexRoute,
|
ProjectPProjectIdIndexRoute: ProjectPProjectIdIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getOrCreateDefaultProject } from "@/serverFunctions/projects";
|
import { getProjects } from "@/serverFunctions/projects";
|
||||||
|
import {
|
||||||
|
clearLastProjectId,
|
||||||
|
getLastProjectId,
|
||||||
|
} from "@/client/lib/active-project";
|
||||||
import {
|
import {
|
||||||
getErrorCode,
|
getErrorCode,
|
||||||
getStandardErrorMessage,
|
getStandardErrorMessage,
|
||||||
@ -17,19 +21,29 @@ export const Route = createFileRoute("/_app/")({
|
|||||||
function IndexRedirect() {
|
function IndexRedirect() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { mutate, error, isError } = useMutation({
|
const { data, error, isError, refetch } = useQuery({
|
||||||
mutationFn: () => getOrCreateDefaultProject(),
|
queryKey: ["projects"],
|
||||||
onSuccess: (project) => {
|
queryFn: () => getProjects(),
|
||||||
void navigate({
|
retry: false,
|
||||||
to: "/p/$projectId/keywords",
|
|
||||||
params: { projectId: project.id },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mutate();
|
if (!data || data.length === 0) return;
|
||||||
}, [mutate]);
|
|
||||||
|
// localStorage is untrusted — only honor the remembered project if it's
|
||||||
|
// actually in the org's list; otherwise fall back to the most recent and
|
||||||
|
// clear the stale id.
|
||||||
|
const lastProjectId = getLastProjectId();
|
||||||
|
const target = data.find((project) => project.id === lastProjectId);
|
||||||
|
if (lastProjectId && !target) {
|
||||||
|
clearLastProjectId();
|
||||||
|
}
|
||||||
|
|
||||||
|
void navigate({
|
||||||
|
to: "/p/$projectId/keywords",
|
||||||
|
params: { projectId: (target ?? data[0]).id },
|
||||||
|
});
|
||||||
|
}, [data, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (getErrorCode(error) !== "PAYMENT_REQUIRED") {
|
if (getErrorCode(error) !== "PAYMENT_REQUIRED") {
|
||||||
@ -51,7 +65,7 @@ function IndexRedirect() {
|
|||||||
"An unexpected error occurred. Please check server logs.",
|
"An unexpected error occurred. Please check server logs.",
|
||||||
)}
|
)}
|
||||||
onRetry={() => {
|
onRetry={() => {
|
||||||
mutate();
|
void refetch();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -64,7 +78,7 @@ function IndexRedirect() {
|
|||||||
<UnauthenticatedErrorCard
|
<UnauthenticatedErrorCard
|
||||||
message="Please sign in to access your OpenSEO workspace."
|
message="Please sign in to access your OpenSEO workspace."
|
||||||
onRetry={() => {
|
onRetry={() => {
|
||||||
mutate();
|
void refetch();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
90
src/routes/_app/projects.tsx
Normal file
90
src/routes/_app/projects.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ChevronRight, Plus } from "lucide-react";
|
||||||
|
import { getProjects } from "@/serverFunctions/projects";
|
||||||
|
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 is a separate workspace with 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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating ? (
|
||||||
|
<CreateProjectModal onClose={() => setCreating(false)} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,25 +0,0 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_project/p/$projectId/integrations")({
|
|
||||||
component: IntegrationsRoute,
|
|
||||||
});
|
|
||||||
|
|
||||||
function IntegrationsRoute() {
|
|
||||||
const { projectId } = Route.useParams();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id="search-console"
|
|
||||||
className="mx-auto w-full max-w-4xl space-y-6 p-4 sm:p-6"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">Integrations</h1>
|
|
||||||
<p className="text-sm text-base-content/60">
|
|
||||||
Connect your data sources.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SearchConsoleConnectionCard projectId={projectId} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,4 +1,11 @@
|
|||||||
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
|
import {
|
||||||
|
Outlet,
|
||||||
|
createFileRoute,
|
||||||
|
redirect,
|
||||||
|
useLocation,
|
||||||
|
} from "@tanstack/react-router";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { setLastProjectId } from "@/client/lib/active-project";
|
||||||
import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard";
|
import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard";
|
||||||
import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner";
|
import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner";
|
||||||
import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect";
|
import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect";
|
||||||
@ -37,6 +44,18 @@ function ProjectLayout() {
|
|||||||
const authGate = useHostedAuthRouteGuard();
|
const authGate = useHostedAuthRouteGuard();
|
||||||
useOnboardingRedirect();
|
useOnboardingRedirect();
|
||||||
|
|
||||||
|
// Remember this as the last-visited project for the landing redirect.
|
||||||
|
// Settings is excluded: editing another project's settings is
|
||||||
|
// administration, not a context switch, so it shouldn't change which
|
||||||
|
// project the app opens next time.
|
||||||
|
const isSettingsPage = useLocation({
|
||||||
|
select: (l) => l.pathname.endsWith("/settings"),
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSettingsPage) return;
|
||||||
|
setLastProjectId(projectId);
|
||||||
|
}, [projectId, isSettingsPage]);
|
||||||
|
|
||||||
if (!authGate.canRenderAuthenticatedContent) {
|
if (!authGate.canRenderAuthenticatedContent) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
15
src/routes/_project/p/$projectId/settings.tsx
Normal file
15
src/routes/_project/p/$projectId/settings.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { ProjectSettings } from "@/client/features/projects/ProjectSettings";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_project/p/$projectId/settings")({
|
||||||
|
component: ProjectSettingsRoute,
|
||||||
|
});
|
||||||
|
|
||||||
|
function ProjectSettingsRoute() {
|
||||||
|
const { projectId } = Route.useParams();
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto bg-base-100">
|
||||||
|
<ProjectSettings projectId={projectId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
import { and, count, desc, eq } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { projects } from "@/db/schema";
|
import { projects } from "@/db/schema";
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
@ -10,15 +10,12 @@ async function listProjects(organizationId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDefaultProjectForOrganization(organizationId: string) {
|
async function countProjects(organizationId: string) {
|
||||||
return db.query.projects.findFirst({
|
const [row] = await db
|
||||||
where: and(
|
.select({ value: count() })
|
||||||
eq(projects.organizationId, organizationId),
|
.from(projects)
|
||||||
eq(projects.name, "Default"),
|
.where(eq(projects.organizationId, organizationId));
|
||||||
isNull(projects.domain),
|
return row?.value ?? 0;
|
||||||
),
|
|
||||||
orderBy: [desc(projects.createdAt), desc(projects.id)],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getProjectForOrganization(
|
async function getProjectForOrganization(
|
||||||
@ -33,25 +30,40 @@ async function getProjectForOrganization(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getProjectById(projectId: string) {
|
|
||||||
return db.query.projects.findFirst({
|
|
||||||
where: eq(projects.id, projectId),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createProject(
|
async function createProject(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
name: string,
|
name: string,
|
||||||
domain?: string,
|
domain?: string,
|
||||||
) {
|
) {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
await db.insert(projects).values({
|
const [row] = await db
|
||||||
id,
|
.insert(projects)
|
||||||
organizationId,
|
.values({ id, organizationId, name, domain })
|
||||||
name,
|
.returning();
|
||||||
domain,
|
return row;
|
||||||
});
|
}
|
||||||
return id;
|
|
||||||
|
async function updateProject(
|
||||||
|
projectId: string,
|
||||||
|
organizationId: string,
|
||||||
|
input: { name: string; domain?: string },
|
||||||
|
) {
|
||||||
|
const [row] = await db
|
||||||
|
.update(projects)
|
||||||
|
.set({ name: input.name, domain: input.domain ?? null })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projects.id, projectId),
|
||||||
|
eq(projects.organizationId, organizationId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError("NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tryCreateDefaultProject(organizationId: string) {
|
async function tryCreateDefaultProject(organizationId: string) {
|
||||||
@ -87,10 +99,10 @@ async function deleteProject(projectId: string, organizationId: string) {
|
|||||||
|
|
||||||
export const ProjectRepository = {
|
export const ProjectRepository = {
|
||||||
listProjects,
|
listProjects,
|
||||||
getDefaultProjectForOrganization,
|
countProjects,
|
||||||
getProjectForOrganization,
|
getProjectForOrganization,
|
||||||
getProjectById,
|
|
||||||
createProject,
|
createProject,
|
||||||
|
updateProject,
|
||||||
tryCreateDefaultProject,
|
tryCreateDefaultProject,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
createProject,
|
createProject,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
getOrCreateDefaultProject,
|
|
||||||
getProject,
|
|
||||||
getProjectForOrganization,
|
getProjectForOrganization,
|
||||||
listProjects,
|
listProjects,
|
||||||
|
listProjectsEnsuringOne,
|
||||||
|
updateProject,
|
||||||
} from "@/server/features/projects/services/projects";
|
} from "@/server/features/projects/services/projects";
|
||||||
|
|
||||||
export const ProjectService = {
|
export const ProjectService = {
|
||||||
listProjects,
|
listProjects,
|
||||||
|
listProjectsEnsuringOne,
|
||||||
createProject,
|
createProject,
|
||||||
|
updateProject,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
getOrCreateDefaultProject,
|
|
||||||
getProject,
|
|
||||||
getProjectForOrganization,
|
getProjectForOrganization,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -2,9 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
createProject: vi.fn(),
|
createProject: vi.fn(),
|
||||||
|
updateProject: vi.fn(),
|
||||||
deleteProject: vi.fn(),
|
deleteProject: vi.fn(),
|
||||||
getDefaultProjectForOrganization: vi.fn(),
|
countProjects: vi.fn(),
|
||||||
getProjectById: vi.fn(),
|
|
||||||
getProjectForOrganization: vi.fn(),
|
getProjectForOrganization: vi.fn(),
|
||||||
listProjects: vi.fn(),
|
listProjects: vi.fn(),
|
||||||
tryCreateDefaultProject: vi.fn(),
|
tryCreateDefaultProject: vi.fn(),
|
||||||
@ -21,33 +21,148 @@ const defaultProject = {
|
|||||||
createdAt: "2026-05-19 12:00:00",
|
createdAt: "2026-05-19 12:00:00",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const namedProject = {
|
||||||
|
id: "project_acme",
|
||||||
|
name: "Acme",
|
||||||
|
domain: "acme.com",
|
||||||
|
createdAt: "2026-05-20 12:00:00",
|
||||||
|
};
|
||||||
|
|
||||||
describe("project service", () => {
|
describe("project service", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recovers from the default project creation race", async () => {
|
describe("listProjectsEnsuringOne", () => {
|
||||||
mocks.getDefaultProjectForOrganization
|
it("returns existing projects without creating a Default", async () => {
|
||||||
.mockResolvedValueOnce(null)
|
mocks.listProjects.mockResolvedValue([namedProject]);
|
||||||
.mockResolvedValueOnce(defaultProject);
|
const { listProjectsEnsuringOne } = await import("./projects");
|
||||||
mocks.tryCreateDefaultProject.mockResolvedValue(null);
|
|
||||||
const { getOrCreateDefaultProject } = await import("./projects");
|
|
||||||
|
|
||||||
await expect(getOrCreateDefaultProject("org_1")).resolves.toEqual(
|
await expect(listProjectsEnsuringOne("org_1")).resolves.toEqual([
|
||||||
|
namedProject,
|
||||||
|
]);
|
||||||
|
expect(mocks.tryCreateDefaultProject).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.listProjects).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a Default when the org has no projects", async () => {
|
||||||
|
mocks.listProjects
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([defaultProject]);
|
||||||
|
mocks.tryCreateDefaultProject.mockResolvedValue("project_default");
|
||||||
|
const { listProjectsEnsuringOne } = await import("./projects");
|
||||||
|
|
||||||
|
await expect(listProjectsEnsuringOne("org_1")).resolves.toEqual([
|
||||||
defaultProject,
|
defaultProject,
|
||||||
);
|
]);
|
||||||
expect(mocks.tryCreateDefaultProject).toHaveBeenCalledWith("org_1");
|
expect(mocks.tryCreateDefaultProject).toHaveBeenCalledWith("org_1");
|
||||||
expect(mocks.getDefaultProjectForOrganization).toHaveBeenCalledTimes(2);
|
expect(mocks.listProjects).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not swallow unrelated default project create failures", async () => {
|
it("recovers from the Default creation race", async () => {
|
||||||
const error = new Error("D1 unavailable");
|
mocks.listProjects
|
||||||
mocks.getDefaultProjectForOrganization.mockResolvedValue(null);
|
.mockResolvedValueOnce([])
|
||||||
mocks.tryCreateDefaultProject.mockRejectedValue(error);
|
.mockResolvedValueOnce([defaultProject]);
|
||||||
const { getOrCreateDefaultProject } = await import("./projects");
|
// A racing request won the insert, so this call's onConflictDoNothing
|
||||||
|
// returns null — but the re-list still finds the Default.
|
||||||
|
mocks.tryCreateDefaultProject.mockResolvedValue(null);
|
||||||
|
const { listProjectsEnsuringOne } = await import("./projects");
|
||||||
|
|
||||||
await expect(getOrCreateDefaultProject("org_1")).rejects.toBe(error);
|
await expect(listProjectsEnsuringOne("org_1")).resolves.toEqual([
|
||||||
expect(mocks.getDefaultProjectForOrganization).toHaveBeenCalledTimes(1);
|
defaultProject,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createProject", () => {
|
||||||
|
it("returns the full created project", async () => {
|
||||||
|
mocks.createProject.mockResolvedValue(namedProject);
|
||||||
|
const { createProject } = await import("./projects");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createProject("org_1", { name: "Acme", domain: "acme.com" }),
|
||||||
|
).resolves.toEqual(namedProject);
|
||||||
|
expect(mocks.createProject).toHaveBeenCalledWith(
|
||||||
|
"org_1",
|
||||||
|
"Acme",
|
||||||
|
"acme.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the reserved Default conflict to a friendly CONFLICT", async () => {
|
||||||
|
mocks.createProject.mockRejectedValue(
|
||||||
|
new Error(
|
||||||
|
"UNIQUE constraint failed: projects.projects_one_default_per_organization_idx",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { createProject } = await import("./projects");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createProject("org_1", { name: "Default", domain: undefined }),
|
||||||
|
).rejects.toMatchObject({ code: "CONFLICT" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateProject", () => {
|
||||||
|
it("returns the updated project", async () => {
|
||||||
|
mocks.updateProject.mockResolvedValue(namedProject);
|
||||||
|
const { updateProject } = await import("./projects");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
updateProject("org_1", {
|
||||||
|
projectId: "project_acme",
|
||||||
|
name: "Acme",
|
||||||
|
domain: "acme.com",
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(namedProject);
|
||||||
|
expect(mocks.updateProject).toHaveBeenCalledWith(
|
||||||
|
"project_acme",
|
||||||
|
"org_1",
|
||||||
|
{ name: "Acme", domain: "acme.com" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the domain when none is provided", async () => {
|
||||||
|
const cleared = { ...namedProject, domain: null };
|
||||||
|
mocks.updateProject.mockResolvedValue(cleared);
|
||||||
|
const { updateProject } = await import("./projects");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
updateProject("org_1", {
|
||||||
|
projectId: "project_acme",
|
||||||
|
name: "Acme",
|
||||||
|
domain: undefined,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(cleared);
|
||||||
|
expect(mocks.updateProject).toHaveBeenCalledWith(
|
||||||
|
"project_acme",
|
||||||
|
"org_1",
|
||||||
|
{ name: "Acme", domain: undefined },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteProject", () => {
|
||||||
|
it("refuses to delete the org's only project", async () => {
|
||||||
|
mocks.countProjects.mockResolvedValue(1);
|
||||||
|
const { deleteProject } = await import("./projects");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
deleteProject("org_1", { projectId: "project_default" }),
|
||||||
|
).rejects.toMatchObject({ code: "CONFLICT" });
|
||||||
|
expect(mocks.deleteProject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes when more than one project remains", async () => {
|
||||||
|
mocks.countProjects.mockResolvedValue(2);
|
||||||
|
mocks.deleteProject.mockResolvedValue(undefined);
|
||||||
|
const { deleteProject } = await import("./projects");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
deleteProject("org_1", { projectId: "project_acme" }),
|
||||||
|
).resolves.toEqual({ success: true });
|
||||||
|
expect(mocks.deleteProject).toHaveBeenCalledWith("project_acme", "org_1");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
CreateProjectInput,
|
CreateProjectInput,
|
||||||
DeleteProjectInput,
|
DeleteProjectInput,
|
||||||
|
UpdateProjectInput,
|
||||||
} from "@/types/schemas/projects";
|
} from "@/types/schemas/projects";
|
||||||
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
|
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
@ -19,66 +20,94 @@ function mapProject(project: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The projects table's only unique index guards the auto-created ("Default",
|
||||||
|
// null) singleton. A UNIQUE violation while writing exactly that name/domain
|
||||||
|
// therefore means one already exists — gating on the input (not just the error
|
||||||
|
// string) keeps this from misclassifying any unrelated failure.
|
||||||
|
function isReservedDefaultConflict(
|
||||||
|
error: unknown,
|
||||||
|
input: { name: string; domain?: string },
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
input.name === "Default" &&
|
||||||
|
!input.domain &&
|
||||||
|
error instanceof Error &&
|
||||||
|
error.message.includes("UNIQUE constraint failed")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESERVED_DEFAULT_MESSAGE =
|
||||||
|
'A project named "Default" with no domain already exists. Pick a different name or add a domain.';
|
||||||
|
|
||||||
export async function listProjects(organizationId: string) {
|
export async function listProjects(organizationId: string) {
|
||||||
const rows = await ProjectRepository.listProjects(organizationId);
|
const rows = await ProjectRepository.listProjects(organizationId);
|
||||||
return rows.map(mapProject);
|
return rows.map(mapProject);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source of truth for "which projects does this org have", guaranteeing at least
|
||||||
|
// one. Count-based — never matches on the "Default" name — so renaming the last
|
||||||
|
// project does not cause a spurious second Default to be created on next visit.
|
||||||
|
export async function listProjectsEnsuringOne(organizationId: string) {
|
||||||
|
const existing = await listProjects(organizationId);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ProjectRepository.tryCreateDefaultProject(organizationId);
|
||||||
|
return listProjects(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createProject(
|
export async function createProject(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
input: CreateProjectInput,
|
input: CreateProjectInput,
|
||||||
) {
|
) {
|
||||||
const id = await ProjectRepository.createProject(
|
try {
|
||||||
|
const row = await ProjectRepository.createProject(
|
||||||
organizationId,
|
organizationId,
|
||||||
input.name,
|
input.name,
|
||||||
input.domain,
|
input.domain,
|
||||||
);
|
);
|
||||||
return { id };
|
return mapProject(row);
|
||||||
|
} catch (error) {
|
||||||
|
if (isReservedDefaultConflict(error, input)) {
|
||||||
|
throw new AppError("CONFLICT", RESERVED_DEFAULT_MESSAGE);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProject(
|
||||||
|
organizationId: string,
|
||||||
|
input: UpdateProjectInput,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const row = await ProjectRepository.updateProject(
|
||||||
|
input.projectId,
|
||||||
|
organizationId,
|
||||||
|
{ name: input.name, domain: input.domain },
|
||||||
|
);
|
||||||
|
return mapProject(row);
|
||||||
|
} catch (error) {
|
||||||
|
if (isReservedDefaultConflict(error, input)) {
|
||||||
|
throw new AppError("CONFLICT", RESERVED_DEFAULT_MESSAGE);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteProject(
|
export async function deleteProject(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
input: DeleteProjectInput,
|
input: DeleteProjectInput,
|
||||||
) {
|
) {
|
||||||
|
const remaining = await ProjectRepository.countProjects(organizationId);
|
||||||
|
if (remaining <= 1) {
|
||||||
|
throw new AppError("CONFLICT", "You can't delete your only project.");
|
||||||
|
}
|
||||||
|
|
||||||
await ProjectRepository.deleteProject(input.projectId, organizationId);
|
await ProjectRepository.deleteProject(input.projectId, organizationId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrCreateDefaultProject(organizationId: string) {
|
|
||||||
const existing =
|
|
||||||
await ProjectRepository.getDefaultProjectForOrganization(organizationId);
|
|
||||||
if (existing) {
|
|
||||||
return mapProject(existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = await ProjectRepository.tryCreateDefaultProject(organizationId);
|
|
||||||
if (id) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: "Default",
|
|
||||||
domain: null,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdProject =
|
|
||||||
await ProjectRepository.getDefaultProjectForOrganization(organizationId);
|
|
||||||
if (createdProject) {
|
|
||||||
return mapProject(createdProject);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new AppError("INTERNAL_ERROR");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProject(projectId: string) {
|
|
||||||
const project = await ProjectRepository.getProjectById(projectId);
|
|
||||||
if (!project) {
|
|
||||||
throw new AppError("NOT_FOUND");
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapProject(project);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProjectForOrganization(
|
export async function getProjectForOrganization(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|||||||
@ -161,7 +161,7 @@ describe("search console MCP tools", () => {
|
|||||||
const first = result.content[0];
|
const first = result.content[0];
|
||||||
expect(first.type).toBe("text");
|
expect(first.type).toBe("text");
|
||||||
expect(first.type === "text" && first.text).toContain(
|
expect(first.type === "text" && first.text).toContain(
|
||||||
"/p/project_1/integrations",
|
"/p/project_1/settings",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -183,7 +183,7 @@ describe("search console MCP tools", () => {
|
|||||||
});
|
});
|
||||||
const first = result.content[0];
|
const first = result.content[0];
|
||||||
expect(first.type === "text" && first.text).toContain(
|
expect(first.type === "text" && first.text).toContain(
|
||||||
"/p/project_1/integrations",
|
"/p/project_1/settings",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -31,8 +31,8 @@ type ProjectAuthContext = {
|
|||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function integrationsUrl(baseUrl: string, projectId: string): string {
|
function connectGscUrl(baseUrl: string, projectId: string): string {
|
||||||
return buildDashboardUrl(baseUrl, `/p/${projectId}/integrations`);
|
return buildDashboardUrl(baseUrl, `/p/${projectId}/settings#search-console`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Self-hosted GSC requires the operator to provide a Google OAuth client and
|
/** Self-hosted GSC requires the operator to provide a Google OAuth client and
|
||||||
@ -49,7 +49,7 @@ async function missingSelfHostedGoogleClientResponse(
|
|||||||
if (hosted || configured) return null;
|
if (hosted || configured) return null;
|
||||||
|
|
||||||
return mcpResponse({
|
return mcpResponse({
|
||||||
text: `This self-hosted OpenSEO deployment is not configured for Search Console yet. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and BETTER_AUTH_SECRET, then reconnect Search Console from Integrations. Setup docs: ${GSC_SELF_HOSTED_SETUP_DOCS_URL}`,
|
text: `This self-hosted OpenSEO deployment is not configured for Search Console yet. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and BETTER_AUTH_SECRET, then reconnect Search Console from the project's settings page. Setup docs: ${GSC_SELF_HOSTED_SETUP_DOCS_URL}`,
|
||||||
meta: buildProjectMeta(context, projectId),
|
meta: buildProjectMeta(context, projectId),
|
||||||
structuredContent: {
|
structuredContent: {
|
||||||
ok: false,
|
ok: false,
|
||||||
@ -196,11 +196,11 @@ export const getSearchConsolePerformanceTool = {
|
|||||||
);
|
);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
|
|
||||||
const connectUrl = integrationsUrl(context.baseUrl, args.projectId);
|
const connectUrl = connectGscUrl(context.baseUrl, args.projectId);
|
||||||
const meta = buildProjectMeta(
|
const meta = buildProjectMeta(
|
||||||
context,
|
context,
|
||||||
args.projectId,
|
args.projectId,
|
||||||
`/p/${args.projectId}/integrations`,
|
`/p/${args.projectId}/settings`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// GSC rejects searchAppearance combined with any other dimension.
|
// GSC rejects searchAppearance combined with any other dimension.
|
||||||
@ -337,11 +337,11 @@ export const inspectUrlsTool = {
|
|||||||
);
|
);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
|
|
||||||
const connectUrl = integrationsUrl(context.baseUrl, args.projectId);
|
const connectUrl = connectGscUrl(context.baseUrl, args.projectId);
|
||||||
const meta = buildProjectMeta(
|
const meta = buildProjectMeta(
|
||||||
context,
|
context,
|
||||||
args.projectId,
|
args.projectId,
|
||||||
`/p/${args.projectId}/integrations`,
|
`/p/${args.projectId}/settings`,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,12 +1,41 @@
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
||||||
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
import {
|
||||||
|
requireAuthenticatedContext,
|
||||||
|
requireProjectContext,
|
||||||
|
} from "@/serverFunctions/middleware";
|
||||||
|
import {
|
||||||
|
createProjectSchema,
|
||||||
|
deleteProjectSchema,
|
||||||
|
updateProjectSchema,
|
||||||
|
} from "@/types/schemas/projects";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const getOrCreateDefaultProject = createServerFn({ method: "POST" })
|
export const getProjects = createServerFn({ method: "POST" })
|
||||||
.middleware(requireAuthenticatedContext)
|
.middleware(requireAuthenticatedContext)
|
||||||
.handler(async ({ context }) =>
|
.handler(async ({ context }) =>
|
||||||
ProjectService.getOrCreateDefaultProject(context.organizationId),
|
ProjectService.listProjectsEnsuringOne(context.organizationId),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createProject = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireAuthenticatedContext)
|
||||||
|
.inputValidator((data: unknown) => createProjectSchema.parse(data))
|
||||||
|
.handler(async ({ data, context }) =>
|
||||||
|
ProjectService.createProject(context.organizationId, data),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const updateProject = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireProjectContext)
|
||||||
|
.inputValidator((data: unknown) => updateProjectSchema.parse(data))
|
||||||
|
.handler(async ({ data, context }) =>
|
||||||
|
ProjectService.updateProject(context.organizationId, data),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deleteProject = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireProjectContext)
|
||||||
|
.inputValidator((data: unknown) => deleteProjectSchema.parse(data))
|
||||||
|
.handler(async ({ data, context }) =>
|
||||||
|
ProjectService.deleteProject(context.organizationId, data),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getProjectAccess = createServerFn({ method: "POST" })
|
export const getProjectAccess = createServerFn({ method: "POST" })
|
||||||
|
|||||||
@ -1,13 +1,27 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const createProjectSchema = z.object({
|
const projectNameField = z
|
||||||
name: z.string().min(1, "Project name is required").max(120),
|
.string()
|
||||||
domain: z
|
.trim()
|
||||||
|
.min(1, "Project name is required")
|
||||||
|
.max(120);
|
||||||
|
|
||||||
|
const projectDomainField = z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.max(255)
|
.max(255)
|
||||||
.transform((value) => value || undefined)
|
.transform((value) => value || undefined)
|
||||||
.optional(),
|
.optional();
|
||||||
|
|
||||||
|
export const createProjectSchema = z.object({
|
||||||
|
name: projectNameField,
|
||||||
|
domain: projectDomainField,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateProjectSchema = z.object({
|
||||||
|
projectId: z.string().min(1),
|
||||||
|
name: projectNameField,
|
||||||
|
domain: projectDomainField,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteProjectSchema = z.object({
|
export const deleteProjectSchema = z.object({
|
||||||
@ -15,4 +29,5 @@ export const deleteProjectSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
|
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
|
||||||
|
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
|
||||||
export type DeleteProjectInput = z.infer<typeof deleteProjectSchema>;
|
export type DeleteProjectInput = z.infer<typeof deleteProjectSchema>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user