projects layout
This commit is contained in:
69
apps/web/src/components/projects/add-project-panel.tsx
Normal file
69
apps/web/src/components/projects/add-project-panel.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { ProviderChips } from "./provider-chips";
|
||||
import type { GitProviderAccountOption } from "./provider-chips";
|
||||
import { RepositorySelector } from "./repository-selector";
|
||||
|
||||
export const AddProjectPanel = ({
|
||||
accounts,
|
||||
existingSourceUrls,
|
||||
onClose,
|
||||
onProjectCreated,
|
||||
}: {
|
||||
readonly accounts: readonly GitProviderAccountOption[] | undefined;
|
||||
readonly existingSourceUrls: readonly string[];
|
||||
readonly onClose: () => void;
|
||||
readonly onProjectCreated: (projectId: string) => void;
|
||||
}) => {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
dialog?.showModal();
|
||||
|
||||
return () => dialog?.close();
|
||||
}, []);
|
||||
|
||||
return createPortal(
|
||||
<dialog
|
||||
aria-labelledby="add-project-heading"
|
||||
className="fixed inset-0 z-50 m-0 flex h-full w-full max-w-none items-center justify-center bg-[#20201d]/45 p-2 backdrop-blur-[2px] sm:p-6"
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
ref={dialogRef}
|
||||
>
|
||||
<section className="relative flex h-[calc(100svh-1rem)] w-full max-w-2xl flex-col overflow-hidden rounded bg-[#faf9f4] text-[#20201d] shadow-2xl sm:h-[min(46rem,calc(100svh-3rem))]">
|
||||
<div className="flex shrink-0 items-center justify-between gap-5 border-b border-[#d7d3c7] px-5 py-4">
|
||||
<h2
|
||||
className="text-base font-semibold tracking-tight text-[#20201d]"
|
||||
id="add-project-heading"
|
||||
>
|
||||
Add project
|
||||
</h2>
|
||||
<button
|
||||
aria-label="Close add project"
|
||||
className="grid size-8 shrink-0 place-items-center rounded text-[#747168] transition-colors hover:bg-[#f2f0e7] hover:text-[#20201d]"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-hidden p-5">
|
||||
<ProviderChips accounts={accounts} />
|
||||
|
||||
<RepositorySelector
|
||||
existingSourceUrls={existingSourceUrls}
|
||||
onProjectCreated={onProjectCreated}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,7 @@ export const ContextEditor = ({
|
||||
const updateInstructions = useMutation(api.projects.updateInstructions);
|
||||
const [draft, setDraft] = useState<string>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const textareaId = `context-textarea-${projectId}`;
|
||||
|
||||
const currentDraft = draft ?? instructions ?? "";
|
||||
const dirty = draft !== undefined && draft !== (instructions ?? "");
|
||||
@@ -40,13 +41,13 @@ export const ContextEditor = ({
|
||||
<div className="mt-3">
|
||||
<label
|
||||
className="text-xs font-medium text-[#20201d]"
|
||||
htmlFor="context-textarea"
|
||||
htmlFor={textareaId}
|
||||
>
|
||||
Context
|
||||
</label>
|
||||
<textarea
|
||||
className="mt-1 h-24 w-full resize-y border border-[#c9c5b9] bg-[#fffefa] p-2 text-xs outline-none focus:border-[#55564e]"
|
||||
id="context-textarea"
|
||||
id={textareaId}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
placeholder="Project-specific instructions for agents..."
|
||||
value={currentDraft}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
|
||||
export const EmptyProjects = () => (
|
||||
<div className="mt-12 text-center">
|
||||
<span className="grid size-11 place-items-center bg-[#20201d] text-white">
|
||||
<FolderGit2 className="size-5" />
|
||||
</span>
|
||||
<h1 className="mt-6 text-2xl font-semibold text-[#20201d]">
|
||||
Connect your first project
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-[#68665e]">
|
||||
Choose a Git provider below to get started.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -1,31 +1,76 @@
|
||||
import { FileText, FolderGit2 } from "lucide-react";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import type { ProjectView } from "@code/primitives/project";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
FolderGit2,
|
||||
GitBranch,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
export const ProjectCard = ({
|
||||
instructions,
|
||||
name,
|
||||
projectId,
|
||||
sourceUrl,
|
||||
}: {
|
||||
readonly instructions: string;
|
||||
readonly name: string;
|
||||
readonly projectId: string;
|
||||
readonly sourceUrl: string;
|
||||
}) => (
|
||||
<Link
|
||||
className="block border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
|
||||
to={`/?project=${projectId}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderGit2 className="size-4 text-[#747168]" />
|
||||
<h3 className="text-sm font-semibold text-[#20201d]">{name}</h3>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-[#747168]">{sourceUrl}</p>
|
||||
{instructions ? (
|
||||
<div className="mt-2 flex items-center gap-1 text-xs text-[#747168]">
|
||||
<FileText className="size-3" />
|
||||
<span className="truncate">Context configured</span>
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
import { ContextEditor } from "./context-editor";
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
export const ProjectCard = ({ project }: { readonly project: ProjectView }) => {
|
||||
const [source] = project.sources;
|
||||
const contextDocuments = project.contextDocuments.filter(
|
||||
(document) => document.content.trim().length > 0
|
||||
);
|
||||
const repositoryLabel =
|
||||
source?.repositoryPath ?? source?.host ?? "Repository";
|
||||
|
||||
return (
|
||||
<article className="group flex min-w-0 flex-col border border-[#d7d3c7] bg-[#fffefa] transition-colors hover:border-[#aaa69a]">
|
||||
<Link
|
||||
className="flex min-w-0 flex-1 flex-col p-5 outline-none focus-visible:ring-2 focus-visible:ring-[#55564e] focus-visible:ring-inset"
|
||||
to={`/?project=${project.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span className="grid size-10 shrink-0 place-items-center bg-[#20201d] text-[#fffefa]">
|
||||
<FolderGit2 className="size-4" />
|
||||
</span>
|
||||
<ArrowUpRight className="mt-1 size-4 shrink-0 text-[#858277] transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-[#20201d]" />
|
||||
</div>
|
||||
<div className="mt-5 min-w-0">
|
||||
<h3 className="truncate text-base font-semibold tracking-tight text-[#20201d]">
|
||||
{project.name}
|
||||
</h3>
|
||||
<p className="mt-1 truncate text-sm text-[#68665e]">
|
||||
{repositoryLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap gap-x-4 gap-y-2 border-t border-[#e4e0d4] pt-4 text-xs text-[#747168]">
|
||||
{source?.defaultBranch ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<GitBranch className="size-3.5" />
|
||||
{source.defaultBranch}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<BookOpen className="size-3.5" />
|
||||
{contextDocuments.length} context{" "}
|
||||
{contextDocuments.length === 1 ? "source" : "sources"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-[#858277]">
|
||||
Updated {dateFormatter.format(project.updatedAt)}
|
||||
</p>
|
||||
</Link>
|
||||
<details className="border-t border-[#e4e0d4]">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between px-5 py-3 text-xs font-medium text-[#68665e] marker:content-none hover:text-[#20201d] [&::-webkit-details-marker]:hidden">
|
||||
Project context
|
||||
<ChevronDown className="size-3.5 transition-transform [[open]_&]:rotate-180" />
|
||||
</summary>
|
||||
<div className="border-t border-[#e4e0d4] px-5 pb-5">
|
||||
<ContextEditor projectId={project.id as unknown as Id<"projects">} />
|
||||
</div>
|
||||
</details>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
65
apps/web/src/components/projects/projects-grid.tsx
Normal file
65
apps/web/src/components/projects/projects-grid.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { ProjectView } from "@code/primitives/project";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { ProjectCard } from "./project-card";
|
||||
|
||||
export const ProjectsGrid = ({
|
||||
onSearchChange,
|
||||
projects,
|
||||
search,
|
||||
totalProjects,
|
||||
}: {
|
||||
readonly onSearchChange: (value: string) => void;
|
||||
readonly projects: readonly ProjectView[];
|
||||
readonly search: string;
|
||||
readonly totalProjects: number;
|
||||
}) => (
|
||||
<section aria-labelledby="projects-heading" className="mt-10">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2
|
||||
className="text-lg font-semibold tracking-tight text-[#20201d]"
|
||||
id="projects-heading"
|
||||
>
|
||||
Your projects
|
||||
</h2>
|
||||
<span className="text-xs tabular-nums text-[#858277]">
|
||||
{totalProjects}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[#68665e]">
|
||||
Choose a repository to continue its project conversation.
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative block w-full sm:w-64">
|
||||
<span className="sr-only">Search projects</span>
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-[#858277]" />
|
||||
<input
|
||||
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] pr-3 pl-9 text-sm text-[#20201d] outline-none placeholder:text-[#858277] focus:border-[#55564e]"
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Search projects"
|
||||
type="search"
|
||||
value={search}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{projects.length > 0 ? (
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 border border-dashed border-[#c9c5b9] bg-[#fffefa]/60 p-8 text-center">
|
||||
<p className="text-sm font-medium text-[#20201d]">
|
||||
No matching projects
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#747168]">
|
||||
Try a different project name or repository.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
51
apps/web/src/components/projects/projects-header.tsx
Normal file
51
apps/web/src/components/projects/projects-header.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { ArrowLeft, FolderGit2, Plus } from "lucide-react";
|
||||
|
||||
export const ProjectsHeader = ({
|
||||
hasProjects,
|
||||
onGoHome,
|
||||
onOpenAddProject,
|
||||
}: {
|
||||
readonly hasProjects: boolean;
|
||||
readonly onGoHome: () => void;
|
||||
readonly onOpenAddProject: () => void;
|
||||
}) => (
|
||||
<header className="flex flex-col gap-5 border-b border-[#d7d3c7] pb-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid size-11 shrink-0 place-items-center bg-[#20201d] text-[#fffefa]">
|
||||
<FolderGit2 className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-[11px] font-medium tracking-[0.16em] text-[#858277] uppercase">
|
||||
Zopu workspace
|
||||
</p>
|
||||
<h1 className="mt-0.5 text-2xl font-semibold tracking-tight text-[#20201d]">
|
||||
Projects
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasProjects ? (
|
||||
<Button
|
||||
className="border-[#c9c5b9] bg-[#fffefa] text-[#20201d] hover:bg-[#f5f3eb]"
|
||||
onClick={onGoHome}
|
||||
size="lg"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
Workspace
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
className="bg-[#20201d] text-[#fffefa] hover:bg-[#3a3933]"
|
||||
onClick={onOpenAddProject}
|
||||
size="lg"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add project
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
153
apps/web/src/components/projects/provider-chips.tsx
Normal file
153
apps/web/src/components/projects/provider-chips.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { authClient } from "@code/auth/web";
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import {
|
||||
CheckCircle2,
|
||||
GitBranch,
|
||||
LoaderCircle,
|
||||
Server,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { PuterConnectForm } from "./puter-connect-form";
|
||||
|
||||
export interface GitProviderAccountOption {
|
||||
readonly externalUsername: string;
|
||||
readonly id: string;
|
||||
readonly provider: "github" | "gitea";
|
||||
readonly serverUrl: string;
|
||||
readonly status:
|
||||
| "pending-auth"
|
||||
| "active"
|
||||
| "reauth-required"
|
||||
| "revoked"
|
||||
| "unavailable";
|
||||
}
|
||||
|
||||
interface ProviderChipsProps {
|
||||
readonly accounts: readonly GitProviderAccountOption[] | undefined;
|
||||
}
|
||||
|
||||
interface ProviderChipProps {
|
||||
readonly account: GitProviderAccountOption | undefined;
|
||||
readonly icon: React.ElementType;
|
||||
readonly label: string;
|
||||
readonly loading: boolean;
|
||||
readonly onClick: () => void;
|
||||
}
|
||||
|
||||
const isHealthy = (status: GitProviderAccountOption["status"]) =>
|
||||
status === "active";
|
||||
|
||||
const ProviderStatusIcon = ({ connected }: { readonly connected: boolean }) =>
|
||||
connected ? (
|
||||
<CheckCircle2 className="size-3 text-emerald-400" />
|
||||
) : (
|
||||
<XCircle className="size-3 text-amber-500" />
|
||||
);
|
||||
|
||||
const ProviderChip = ({
|
||||
account,
|
||||
icon: Icon,
|
||||
label,
|
||||
loading,
|
||||
onClick,
|
||||
}: ProviderChipProps) => {
|
||||
const connected = account !== undefined && isHealthy(account.status);
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-pressed={connected}
|
||||
className={
|
||||
connected
|
||||
? "h-8 gap-1.5 rounded border-[#d7d3c7] bg-[#f5f3eb] px-3 text-[#20201d] hover:bg-[#f5f3eb]"
|
||||
: "h-8 gap-1.5 rounded border-[#c9c5b9] bg-[#fffefa] px-3 text-[#68665e] hover:bg-[#f5f3eb]"
|
||||
}
|
||||
disabled={loading || connected}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Icon className="size-3.5 text-[#747168]" />
|
||||
)}
|
||||
<span className="text-xs font-medium">
|
||||
{connected ? account.externalUsername : `Connect ${label}`}
|
||||
</span>
|
||||
{account === undefined ? null : (
|
||||
<ProviderStatusIcon connected={connected} />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProviderChips = ({ accounts }: ProviderChipsProps) => {
|
||||
const [pendingProvider, setPendingProvider] = useState<
|
||||
"github" | "gitea" | undefined
|
||||
>();
|
||||
const [showPuterForm, setShowPuterForm] = useState(false);
|
||||
|
||||
const githubAccount = accounts?.find(
|
||||
(account) => account.provider === "github"
|
||||
);
|
||||
const puterAccount = accounts?.find(
|
||||
(account) => account.provider === "gitea"
|
||||
);
|
||||
|
||||
const linkGithub = async () => {
|
||||
if (pendingProvider) {
|
||||
return;
|
||||
}
|
||||
setPendingProvider("github");
|
||||
try {
|
||||
await authClient.linkSocial({
|
||||
callbackURL: `${window.location.origin}/projects?resume=github`,
|
||||
provider: "github",
|
||||
});
|
||||
} finally {
|
||||
setPendingProvider(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePuterChipClick = () => {
|
||||
if (puterAccount && isHealthy(puterAccount.status)) {
|
||||
return;
|
||||
}
|
||||
setShowPuterForm((value) => !value);
|
||||
};
|
||||
|
||||
const handlePuterConnected = () => {
|
||||
setShowPuterForm(false);
|
||||
};
|
||||
|
||||
const accountsLoading = accounts === undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ProviderChip
|
||||
account={githubAccount}
|
||||
icon={GitBranch}
|
||||
label="GitHub"
|
||||
loading={accountsLoading || pendingProvider === "github"}
|
||||
onClick={linkGithub}
|
||||
/>
|
||||
<ProviderChip
|
||||
account={puterAccount}
|
||||
icon={Server}
|
||||
label="Puter Git"
|
||||
loading={accountsLoading || pendingProvider === "gitea"}
|
||||
onClick={handlePuterChipClick}
|
||||
/>
|
||||
</div>
|
||||
{showPuterForm ? (
|
||||
<div className="rounded border border-[#d7d3c7] bg-[#fffefa] p-3 shadow-sm">
|
||||
<PuterConnectForm onConnected={handlePuterConnected} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,19 +8,28 @@ export const ProviderSection = () => {
|
||||
const [showPuterForm, setShowPuterForm] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="mt-10">
|
||||
<h2 className="text-sm font-semibold text-[#20201d]">Git providers</h2>
|
||||
<p className="mt-1 text-xs text-[#68665e]">
|
||||
Connect a repository to create a Project.
|
||||
<section aria-labelledby="provider-connections-heading">
|
||||
<p className="text-[11px] font-medium tracking-[0.16em] text-[#858277] uppercase">
|
||||
Need a repository?
|
||||
</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<h3
|
||||
className="mt-1 text-base font-semibold text-[#20201d]"
|
||||
id="provider-connections-heading"
|
||||
>
|
||||
Connect a Git provider
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-[#68665e]">
|
||||
Link GitHub for OAuth access or add a Puter Git personal access token.
|
||||
</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
<GitHubConnectButton />
|
||||
<button
|
||||
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
|
||||
aria-expanded={showPuterForm}
|
||||
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-3 text-left transition-colors hover:bg-[#f5f3eb]"
|
||||
onClick={() => setShowPuterForm((value) => !value)}
|
||||
type="button"
|
||||
>
|
||||
<Server className="size-5 text-[#20201d]" />
|
||||
<Server className="size-4 text-[#20201d]" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="text-sm font-semibold text-[#20201d]">Puter Git</p>
|
||||
<p className="text-xs text-[#747168]">
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { RepositorySelector } from "./repository-selector";
|
||||
|
||||
export const RepositorySection = ({
|
||||
onProjectCreated,
|
||||
}: {
|
||||
readonly onProjectCreated: (projectId: string) => void;
|
||||
}) => (
|
||||
<section className="mt-6">
|
||||
<h2 className="text-sm font-semibold text-[#20201d]">Your repositories</h2>
|
||||
<p className="mt-1 text-xs text-[#68665e]">
|
||||
Select a repository to create a Project.
|
||||
</p>
|
||||
<RepositorySelector onProjectCreated={onProjectCreated} />
|
||||
</section>
|
||||
);
|
||||
@@ -1,23 +1,27 @@
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
FolderGit2,
|
||||
GitBranch,
|
||||
Globe2,
|
||||
LoaderCircle,
|
||||
Lock,
|
||||
Search,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { useRepositoryPicker } from "@/hooks/use-repository-picker";
|
||||
import type { GitRepositoryOption } from "@/hooks/use-repository-picker";
|
||||
|
||||
const listRepositoriesRef = makeFunctionReference<
|
||||
"query",
|
||||
Record<string, never>,
|
||||
readonly {
|
||||
cloneUrl: string;
|
||||
defaultBranch: string;
|
||||
fullName: string;
|
||||
id: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
privacy: "public" | "private";
|
||||
provider: "github" | "gitea";
|
||||
webUrl: string;
|
||||
}[]
|
||||
readonly GitRepositoryOption[]
|
||||
>("gitProvisioning:listRepositories");
|
||||
|
||||
interface CreateProjectResult {
|
||||
@@ -30,62 +34,241 @@ const createProjectFromRepositoryRef = makeFunctionReference<
|
||||
CreateProjectResult
|
||||
>("gitProvisioning:createProjectFromRepository");
|
||||
|
||||
const providerLabels = {
|
||||
all: "All",
|
||||
gitea: "Puter Git",
|
||||
github: "GitHub",
|
||||
} as const;
|
||||
|
||||
export const RepositorySelector = ({
|
||||
existingSourceUrls,
|
||||
onProjectCreated,
|
||||
}: {
|
||||
readonly existingSourceUrls: readonly string[];
|
||||
readonly onProjectCreated: (projectId: string) => void;
|
||||
}) => {
|
||||
const repositories = useQuery(listRepositoriesRef, {});
|
||||
const createProject = useMutation(createProjectFromRepositoryRef);
|
||||
const [error, setError] = useState<string>();
|
||||
const [pending, setPending] = useState<string>();
|
||||
const {
|
||||
availableRepositories,
|
||||
privacyFilter,
|
||||
providerFilter,
|
||||
search,
|
||||
setPrivacyFilter,
|
||||
setProviderFilter,
|
||||
setSearch,
|
||||
} = useRepositoryPicker({
|
||||
existingSourceUrls,
|
||||
repositories,
|
||||
});
|
||||
|
||||
const create = async (repoId: string, name: string) => {
|
||||
setPending(repoId);
|
||||
const create = async (repository: GitRepositoryOption) => {
|
||||
if (pending) {
|
||||
return;
|
||||
}
|
||||
setError(undefined);
|
||||
setPending(repository.id);
|
||||
try {
|
||||
const result = await createProject({
|
||||
gitRepositoryId: repoId as Id<"gitRepositories">,
|
||||
name,
|
||||
gitRepositoryId: repository.id as Id<"gitRepositories">,
|
||||
name: repository.name,
|
||||
});
|
||||
onProjectCreated(String(result.projectId));
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "Could not create this project"
|
||||
);
|
||||
} finally {
|
||||
setPending(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
if (repositories === undefined) {
|
||||
return <LoaderCircle className="size-4 animate-spin text-[#747168]" />;
|
||||
}
|
||||
if (repositories.length === 0) {
|
||||
if (repositories === undefined || availableRepositories === undefined) {
|
||||
return (
|
||||
<p className="text-xs text-[#747168]">
|
||||
No repositories found. Create one on your provider first, or import a
|
||||
public repository below.
|
||||
</p>
|
||||
<div className="grid min-h-48 flex-1 place-items-center border border-[#d7d3c7] bg-[#fffefa]">
|
||||
<LoaderCircle className="size-5 animate-spin text-[#747168]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isFiltered =
|
||||
Boolean(search) || providerFilter !== "all" || privacyFilter !== "all";
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-2">
|
||||
{repositories.map((repo) => (
|
||||
<button
|
||||
className="flex w-full items-center justify-between border border-[#d7d3c7] bg-[#fffefa] p-3 text-left transition-colors hover:bg-[#f5f3eb]"
|
||||
key={repo.id}
|
||||
onClick={() => create(repo.id, repo.name)}
|
||||
type="button"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[#20201d]">
|
||||
{repo.fullName}
|
||||
</p>
|
||||
<p className="text-xs text-[#747168]">
|
||||
{repo.provider} - {repo.defaultBranch}
|
||||
</p>
|
||||
</div>
|
||||
{pending === repo.id ? (
|
||||
<LoaderCircle className="size-4 animate-spin text-[#747168]" />
|
||||
<section
|
||||
aria-labelledby="repository-picker-heading"
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3
|
||||
className="text-base font-semibold text-[#20201d]"
|
||||
id="repository-picker-heading"
|
||||
>
|
||||
Imports
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-[#747168]">
|
||||
Repositories already added as projects are hidden.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<Link
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-[#20201d] underline decoration-[#858277] underline-offset-4 hover:decoration-[#20201d]"
|
||||
to="/?import=public"
|
||||
>
|
||||
Import public Git URL
|
||||
<ArrowUpRight className="size-3" />
|
||||
</Link>
|
||||
<p className="text-xs tabular-nums text-[#858277]">
|
||||
{availableRepositories.length} available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid shrink-0 gap-3 sm:flex sm:flex-wrap sm:items-center sm:justify-between">
|
||||
<label className="relative min-w-0 sm:max-w-[16rem]">
|
||||
<span className="sr-only">Search repositories</span>
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-[#858277]" />
|
||||
<input
|
||||
className="h-9 w-full rounded border border-[#d7d3c7] bg-[#fffefa] pr-3 pl-9 text-sm text-[#20201d] outline-none ring-[#d7d3c7] placeholder:text-[#858277] focus:border-[#858277] focus:ring-1"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search repositories"
|
||||
type="search"
|
||||
value={search}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<fieldset className="flex flex-wrap gap-1">
|
||||
<legend className="sr-only">Repository provider</legend>
|
||||
{(
|
||||
Object.keys(providerLabels) as (keyof typeof providerLabels)[]
|
||||
).map((provider) => (
|
||||
<Button
|
||||
aria-pressed={providerFilter === provider}
|
||||
className={
|
||||
providerFilter === provider
|
||||
? "h-7 rounded border-transparent bg-[#20201d] px-2.5 text-[11px] text-[#fffefa] hover:bg-[#3a3933]"
|
||||
: "h-7 rounded border-transparent bg-[#e8e6dc] px-2.5 text-[11px] text-[#68665e] hover:bg-[#dedcd2]"
|
||||
}
|
||||
key={provider}
|
||||
onClick={() => setProviderFilter(provider)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{providerLabels[provider]}
|
||||
</Button>
|
||||
))}
|
||||
</fieldset>
|
||||
<fieldset className="flex flex-wrap gap-1">
|
||||
<legend className="sr-only">Repository visibility</legend>
|
||||
{(["all", "private", "public"] as const).map((privacy) => (
|
||||
<Button
|
||||
aria-pressed={privacyFilter === privacy}
|
||||
className={
|
||||
privacyFilter === privacy
|
||||
? "h-7 rounded border-transparent bg-[#20201d] px-2.5 text-[11px] text-[#fffefa] hover:bg-[#3a3933]"
|
||||
: "h-7 rounded border-transparent bg-[#e8e6dc] px-2.5 text-[11px] text-[#68665e] hover:bg-[#dedcd2]"
|
||||
}
|
||||
key={privacy}
|
||||
onClick={() => setPrivacyFilter(privacy)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{privacy === "all" ? "All" : privacy}
|
||||
</Button>
|
||||
))}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="mt-3 border border-red-300 bg-red-50 p-2.5 text-xs leading-5 text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{availableRepositories.length > 0 ? (
|
||||
<div className="mt-3 min-h-0 flex-1 divide-y divide-[#e8e6dc] overflow-y-auto rounded border border-[#d7d3c7] bg-[#fffefa]">
|
||||
{availableRepositories.map((repository) => {
|
||||
const ProviderIcon =
|
||||
repository.provider === "github" ? GitBranch : Server;
|
||||
const VisibilityIcon =
|
||||
repository.privacy === "private" ? Lock : Globe2;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 p-3"
|
||||
key={repository.id}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded border border-[#d7d3c7] bg-[#f5f3eb] text-[#68665e]">
|
||||
<ProviderIcon className="size-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-[#20201d]">
|
||||
{repository.fullName}
|
||||
</p>
|
||||
<div className="mt-0.5 flex flex-wrap gap-x-2 gap-y-0.5 text-[11px] text-[#747168]">
|
||||
<span>{repository.defaultBranch}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<VisibilityIcon className="size-3" />
|
||||
{repository.privacy}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="h-7 shrink-0 rounded border-[#55564e] bg-[#20201d] px-2.5 text-[11px] text-[#fffefa] hover:bg-[#3a3933]"
|
||||
disabled={pending !== undefined}
|
||||
onClick={() => void create(repository)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{pending === repository.id ? (
|
||||
<LoaderCircle className="size-3 animate-spin" />
|
||||
) : (
|
||||
<FolderGit2 className="size-3" />
|
||||
)}
|
||||
{pending === repository.id ? "Creating" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 rounded border border-dashed border-[#c9c5b9] bg-[#fffefa] p-6 text-center">
|
||||
<FolderGit2 className="mx-auto size-5 text-[#858277]" />
|
||||
<p className="mt-3 text-sm font-medium text-[#20201d]">
|
||||
{isFiltered
|
||||
? "No repositories match these filters"
|
||||
: "No repositories available"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-[#68665e]">
|
||||
{isFiltered
|
||||
? "Change your search or filters to see other repositories."
|
||||
: "Connect a Git provider or import a public repository to continue."}
|
||||
</p>
|
||||
{isFiltered ? (
|
||||
<Button
|
||||
className="mt-4 border-[#c9c5b9] bg-[#fffefa] text-[#20201d] hover:bg-[#f5f3eb]"
|
||||
onClick={() => {
|
||||
setPrivacyFilter("all");
|
||||
setProviderFilter("all");
|
||||
setSearch("");
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { Menu, Settings } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { FolderGit2, Menu } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import type { WorkspaceState } from "@/lib/workspace/types";
|
||||
|
||||
import { ProjectSettingsPanel } from "./project-settings-panel";
|
||||
|
||||
export const ProjectHeader = ({
|
||||
onOpenDrawer,
|
||||
workspace,
|
||||
@@ -13,11 +10,10 @@ export const ProjectHeader = ({
|
||||
readonly onOpenDrawer: () => void;
|
||||
readonly workspace: WorkspaceState;
|
||||
}) => {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const works = workspace.works ?? [];
|
||||
|
||||
return (
|
||||
<header className="relative flex h-14 shrink-0 items-center border-b border-[#d7d3c7] bg-[#faf9f4] px-4">
|
||||
<header className="flex h-14 shrink-0 items-center border-b border-[#d7d3c7] bg-[#faf9f4] px-4">
|
||||
<div className="min-w-0 flex-1 pr-2">
|
||||
<select
|
||||
aria-label="Current project"
|
||||
@@ -35,15 +31,13 @@ export const ProjectHeader = ({
|
||||
Conversation to proposed Work
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Project settings"
|
||||
className="mr-2 size-9"
|
||||
onClick={() => setSettingsOpen((open) => !open)}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
<Link
|
||||
aria-label="Projects"
|
||||
className="mr-2 grid size-9 place-items-center border border-[#c9c5b9] bg-[#fffefa] text-[#20201d] transition-colors hover:bg-[#f5f3eb]"
|
||||
to="/projects"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
<FolderGit2 className="size-4" />
|
||||
</Link>
|
||||
<button
|
||||
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
|
||||
onClick={onOpenDrawer}
|
||||
@@ -52,12 +46,6 @@ export const ProjectHeader = ({
|
||||
<Menu className="size-4" /> Work{" "}
|
||||
{workspace.works === undefined ? "…" : works.length}
|
||||
</button>
|
||||
{settingsOpen ? (
|
||||
<ProjectSettingsPanel
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
workspace={workspace}
|
||||
/>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { WorkspaceState } from "@/lib/workspace/types";
|
||||
|
||||
export const ProjectSettingsPanel = ({
|
||||
onClose,
|
||||
workspace,
|
||||
}: {
|
||||
readonly onClose: () => void;
|
||||
readonly workspace: WorkspaceState;
|
||||
}) => {
|
||||
const [username, setUsername] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const handleClearOperationError = () => workspace.clearOperationError();
|
||||
|
||||
return (
|
||||
<section className="absolute right-4 top-12 z-40 w-[min(92vw,360px)] border border-[#c9c5b9] bg-[#fffefa] p-4 shadow-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold">Project Git</h2>
|
||||
<button aria-label="Close settings" onClick={onClose} type="button">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[#747168]">
|
||||
{workspace.projectGitConnection
|
||||
? `${workspace.projectGitConnection.provider} · ${workspace.projectGitConnection.serverUrl}`
|
||||
: "No Git credentials attached"}
|
||||
</p>
|
||||
{workspace.operationError ? (
|
||||
<p className="mt-2 flex items-start gap-1.5 border border-red-300 bg-red-50 px-2 py-1.5 text-xs text-red-800">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 leading-5">
|
||||
{workspace.operationError.message}
|
||||
</span>
|
||||
<button
|
||||
aria-label="Dismiss error"
|
||||
className="shrink-0"
|
||||
onClick={handleClearOperationError}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void workspace.authorizeGithub()}
|
||||
>
|
||||
Authorize GitHub
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void workspace.connectLinkedGithub()}>
|
||||
Use GitHub
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2 border-t border-[#e7e3d9] pt-4">
|
||||
<input
|
||||
aria-label="Puter Git username"
|
||||
className="h-9 w-full border px-2 text-xs"
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="Username (optional)"
|
||||
value={username}
|
||||
/>
|
||||
<input
|
||||
aria-label="Gitea access token"
|
||||
className="h-9 w-full border px-2 text-xs"
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
placeholder="Personal access token"
|
||||
type="password"
|
||||
value={token}
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!token.trim()}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void workspace.connectGitea({
|
||||
token,
|
||||
username: username || undefined,
|
||||
});
|
||||
setToken("");
|
||||
}}
|
||||
>
|
||||
Connect Gitea
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
25
apps/web/src/hooks/use-filtered-projects.ts
Normal file
25
apps/web/src/hooks/use-filtered-projects.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ProjectView } from "@code/primitives/project";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export const useFilteredProjects = ({
|
||||
projects,
|
||||
search,
|
||||
}: {
|
||||
readonly projects: readonly ProjectView[];
|
||||
readonly search: string;
|
||||
}) =>
|
||||
useMemo(() => {
|
||||
const searchTerm = search.trim().toLowerCase();
|
||||
if (!searchTerm) {
|
||||
return projects;
|
||||
}
|
||||
|
||||
return projects.filter((project) => {
|
||||
const [source] = project.sources;
|
||||
return (
|
||||
project.name.toLowerCase().includes(searchTerm) ||
|
||||
source?.repositoryPath.toLowerCase().includes(searchTerm) ||
|
||||
source?.host.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
});
|
||||
}, [projects, search]);
|
||||
22
apps/web/src/hooks/use-projects-page-state.ts
Normal file
22
apps/web/src/hooks/use-projects-page-state.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export const useProjectsPageState = ({
|
||||
hasProjects,
|
||||
}: {
|
||||
readonly hasProjects: boolean | undefined;
|
||||
}) => {
|
||||
const [addProjectOpen, setAddProjectOpen] = useState<boolean>();
|
||||
const [projectSearch, setProjectSearch] = useState("");
|
||||
const isAddProjectOpen = addProjectOpen ?? hasProjects === false;
|
||||
|
||||
const closeAddProject = useCallback(() => setAddProjectOpen(false), []);
|
||||
const openAddProject = useCallback(() => setAddProjectOpen(true), []);
|
||||
|
||||
return {
|
||||
closeAddProject,
|
||||
isAddProjectOpen,
|
||||
openAddProject,
|
||||
projectSearch,
|
||||
setProjectSearch,
|
||||
};
|
||||
};
|
||||
64
apps/web/src/hooks/use-repository-picker.ts
Normal file
64
apps/web/src/hooks/use-repository-picker.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export interface GitRepositoryOption {
|
||||
readonly cloneUrl: string;
|
||||
readonly defaultBranch: string;
|
||||
readonly fullName: string;
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly owner: string;
|
||||
readonly privacy: "public" | "private";
|
||||
readonly provider: "github" | "gitea";
|
||||
readonly webUrl: string;
|
||||
}
|
||||
|
||||
type ProviderFilter = "all" | GitRepositoryOption["provider"];
|
||||
type PrivacyFilter = "all" | GitRepositoryOption["privacy"];
|
||||
|
||||
export const useRepositoryPicker = ({
|
||||
existingSourceUrls,
|
||||
repositories,
|
||||
}: {
|
||||
readonly existingSourceUrls: readonly string[];
|
||||
readonly repositories: readonly GitRepositoryOption[] | undefined;
|
||||
}) => {
|
||||
const [privacyFilter, setPrivacyFilter] = useState<PrivacyFilter>("all");
|
||||
const [providerFilter, setProviderFilter] = useState<ProviderFilter>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const availableRepositories = useMemo(() => {
|
||||
if (!repositories) {
|
||||
return;
|
||||
}
|
||||
const existingUrls = new Set(existingSourceUrls);
|
||||
const searchTerm = search.trim().toLowerCase();
|
||||
|
||||
return repositories
|
||||
.filter((repository) => !existingUrls.has(repository.webUrl))
|
||||
.filter(
|
||||
(repository) =>
|
||||
providerFilter === "all" || repository.provider === providerFilter
|
||||
)
|
||||
.filter(
|
||||
(repository) =>
|
||||
privacyFilter === "all" || repository.privacy === privacyFilter
|
||||
)
|
||||
.filter(
|
||||
(repository) =>
|
||||
!searchTerm ||
|
||||
repository.fullName.toLowerCase().includes(searchTerm) ||
|
||||
repository.owner.toLowerCase().includes(searchTerm)
|
||||
)
|
||||
.toSorted((left, right) => left.fullName.localeCompare(right.fullName));
|
||||
}, [existingSourceUrls, privacyFilter, providerFilter, repositories, search]);
|
||||
|
||||
return {
|
||||
availableRepositories,
|
||||
privacyFilter,
|
||||
providerFilter,
|
||||
search,
|
||||
setPrivacyFilter,
|
||||
setProviderFilter,
|
||||
setSearch,
|
||||
};
|
||||
};
|
||||
@@ -1,17 +1,17 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { useAction, useQuery } from "convex/react";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
import { ContextEditor } from "@/components/projects/context-editor";
|
||||
import { EmptyProjects } from "@/components/projects/empty-projects";
|
||||
import { AddProjectPanel } from "@/components/projects/add-project-panel";
|
||||
import { LoadingState } from "@/components/projects/loading-state";
|
||||
import { ProjectCard } from "@/components/projects/project-card";
|
||||
import { ProviderSection } from "@/components/projects/provider-section";
|
||||
import { RepositorySection } from "@/components/projects/repository-section";
|
||||
import { ProjectsGrid } from "@/components/projects/projects-grid";
|
||||
import { ProjectsHeader } from "@/components/projects/projects-header";
|
||||
import type { GitProviderAccountOption } from "@/components/projects/provider-chips";
|
||||
import { useFilteredProjects } from "@/hooks/use-filtered-projects";
|
||||
import { useProjectsPageState } from "@/hooks/use-projects-page-state";
|
||||
|
||||
const connectGithubRef = makeFunctionReference<
|
||||
"action",
|
||||
@@ -23,18 +23,33 @@ export default function ProjectsRoute() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const projects = useQuery(api.projects.list, {});
|
||||
const providerAccounts = useQuery(
|
||||
api.gitProvisioning.listProviderAccounts,
|
||||
{}
|
||||
) as readonly GitProviderAccountOption[] | undefined;
|
||||
const connectGithubAction = useAction(connectGithubRef);
|
||||
const [resumeError, setResumeError] = useState<string>();
|
||||
const pageState = useProjectsPageState({
|
||||
hasProjects: projects === undefined ? undefined : projects.length > 0,
|
||||
});
|
||||
const { openAddProject } = pageState;
|
||||
const handleProjectSearchChange = (value: string) => {
|
||||
pageState.setProjectSearch(value);
|
||||
};
|
||||
const filteredProjects = useFilteredProjects({
|
||||
projects: projects ?? [],
|
||||
search: pageState.projectSearch,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const resume = searchParams.get("resume");
|
||||
if (resume !== "github") {
|
||||
if (searchParams.get("resume") !== "github") {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
await connectGithubAction({});
|
||||
setSearchParams({}, { replace: true });
|
||||
openAddProject();
|
||||
} catch (caughtError) {
|
||||
setResumeError(
|
||||
caughtError instanceof Error
|
||||
@@ -43,79 +58,68 @@ export default function ProjectsRoute() {
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, [searchParams, setSearchParams, connectGithubAction]);
|
||||
}, [connectGithubAction, openAddProject, searchParams, setSearchParams]);
|
||||
|
||||
if (projects === undefined) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const existingSourceUrls = projects.flatMap((project) =>
|
||||
project.sources.map((source) => source.url)
|
||||
);
|
||||
const hasProjects = projects.length > 0;
|
||||
const handleProjectCreated = (projectId: string) => {
|
||||
void navigate(`/?project=${projectId}`, { replace: true });
|
||||
};
|
||||
const handleCloseAddProject = () => {
|
||||
pageState.closeAddProject();
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-svh bg-[#f2f0e7] px-5 py-8 text-[#20201d]">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">Projects</h1>
|
||||
{hasProjects ? (
|
||||
<Button
|
||||
className="h-9"
|
||||
onClick={() => navigate("/")}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Home
|
||||
</Button>
|
||||
) : null}
|
||||
</header>
|
||||
<main className="min-h-svh bg-[#f2f0e7] px-5 py-7 text-[#20201d] sm:px-8 sm:py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<ProjectsHeader
|
||||
hasProjects={hasProjects}
|
||||
onGoHome={() => void navigate("/")}
|
||||
onOpenAddProject={openAddProject}
|
||||
/>
|
||||
|
||||
{resumeError ? (
|
||||
<p className="mt-4 border border-red-300 bg-red-50 p-3 text-xs text-red-700">
|
||||
<p className="mt-6 border border-red-300 bg-red-50 p-3 text-xs leading-5 text-red-700">
|
||||
{resumeError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{hasProjects ? null : <EmptyProjects />}
|
||||
|
||||
{hasProjects ? (
|
||||
<section className="mt-6 grid gap-3">
|
||||
{projects.map((project) => (
|
||||
<div key={project.id}>
|
||||
<ProjectCard
|
||||
instructions={
|
||||
project.contextDocuments.find(
|
||||
(document) => document.kind === "readme"
|
||||
)?.content ?? ""
|
||||
}
|
||||
name={project.name}
|
||||
projectId={project.id}
|
||||
sourceUrl={project.sources[0]?.url ?? ""}
|
||||
/>
|
||||
<ContextEditor
|
||||
projectId={project.id as unknown as Id<"projects">}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<ProjectsGrid
|
||||
onSearchChange={handleProjectSearchChange}
|
||||
projects={filteredProjects}
|
||||
search={pageState.projectSearch}
|
||||
totalProjects={projects.length}
|
||||
/>
|
||||
) : (
|
||||
<section className="mt-10 max-w-2xl">
|
||||
<p className="text-[11px] font-medium tracking-[0.16em] text-[#858277] uppercase">
|
||||
Begin here
|
||||
</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold tracking-tight text-[#20201d]">
|
||||
Connect the repository where work happens.
|
||||
</h2>
|
||||
<p className="mt-3 max-w-xl text-base leading-7 text-[#68665e]">
|
||||
Every project has its own conversation, durable context, and work
|
||||
history. Start by choosing a repository.
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<RepositorySection onProjectCreated={handleProjectCreated} />
|
||||
<ProviderSection />
|
||||
|
||||
<section className="mt-8">
|
||||
<p className="text-xs text-[#68665e]">
|
||||
Or import a public repository:
|
||||
</p>
|
||||
<Link
|
||||
className="mt-2 inline-flex items-center gap-2 text-sm text-[#20201d] underline"
|
||||
to="/?import=public"
|
||||
>
|
||||
Import public Git URL
|
||||
</Link>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
{pageState.isAddProjectOpen ? (
|
||||
<AddProjectPanel
|
||||
accounts={providerAccounts}
|
||||
existingSourceUrls={existingSourceUrls}
|
||||
onClose={handleCloseAddProject}
|
||||
onProjectCreated={handleProjectCreated}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user