- Add project description line to project header (from productVision) - Sidebar: add Activity nav item (Projects / Activity / Settings) - New Activity page: timeline feed with type filters (Atlas/Builds/Deploys/You) - New Activity layout using VIBNSidebar - Rewrite Deploy tab: Project URLs, Custom Domain, Env Vars, Deploy History — fully Stackless style, real data from project API, no more MOCK_PROJECT - Rewrite Project Settings tab: remove all Firebase refs (db, auth, Firestore) — General (name/description), Repo link, Collaborators, Export JSON/PDF, — Danger Zone with double-confirm delete — uses /api/projects/[id] PATCH for saves Made-with: Cursor
259 lines
10 KiB
TypeScript
259 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useParams, useRouter } from "next/navigation";
|
|
import { useSession } from "next-auth/react";
|
|
import { toast } from "sonner";
|
|
import { Loader2 } from "lucide-react";
|
|
|
|
interface Project {
|
|
id: string;
|
|
productName: string;
|
|
productVision?: string;
|
|
giteaRepo?: string;
|
|
giteaRepoUrl?: string;
|
|
status?: string;
|
|
}
|
|
|
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div style={{
|
|
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
|
|
letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 12,
|
|
}}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FieldLabel({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div style={{ fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6 }}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InfoCard({ children, style = {} }: { children: React.ReactNode; style?: React.CSSProperties }) {
|
|
return (
|
|
<div style={{
|
|
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
|
padding: "22px", marginBottom: 12, boxShadow: "0 1px 2px #1a1a1a05", ...style,
|
|
}}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ProjectSettingsPage() {
|
|
const params = useParams();
|
|
const router = useRouter();
|
|
const { data: session } = useSession();
|
|
const projectId = params.projectId as string;
|
|
const workspace = params.workspace as string;
|
|
|
|
const [project, setProject] = useState<Project | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
|
|
const [productName, setProductName] = useState("");
|
|
const [productVision, setProductVision] = useState("");
|
|
|
|
const userInitial = session?.user?.name?.[0]?.toUpperCase() ?? session?.user?.email?.[0]?.toUpperCase() ?? "?";
|
|
const userName = session?.user?.name ?? session?.user?.email?.split("@")[0] ?? "You";
|
|
|
|
useEffect(() => {
|
|
fetch(`/api/projects/${projectId}`)
|
|
.then((r) => r.json())
|
|
.then((d) => {
|
|
const p = d.project;
|
|
setProject(p);
|
|
setProductName(p?.productName ?? "");
|
|
setProductVision(p?.productVision ?? "");
|
|
})
|
|
.catch(() => toast.error("Failed to load project"))
|
|
.finally(() => setLoading(false));
|
|
}, [projectId]);
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const res = await fetch(`/api/projects/${projectId}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ productName, productVision }),
|
|
});
|
|
if (res.ok) {
|
|
toast.success("Saved");
|
|
setProject((p) => p ? { ...p, productName, productVision } : p);
|
|
} else {
|
|
toast.error("Failed to save");
|
|
}
|
|
} catch {
|
|
toast.error("An error occurred");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!confirmDelete) { setConfirmDelete(true); return; }
|
|
setDeleting(true);
|
|
try {
|
|
const res = await fetch("/api/projects/delete", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ projectId }),
|
|
});
|
|
if (res.ok) {
|
|
toast.success("Project deleted");
|
|
router.push(`/${workspace}/projects`);
|
|
} else {
|
|
toast.error("Failed to delete project");
|
|
}
|
|
} catch {
|
|
toast.error("An error occurred");
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif" }}>
|
|
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="vibn-enter"
|
|
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif" }}
|
|
>
|
|
<div style={{ maxWidth: 480 }}>
|
|
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
|
Project Settings
|
|
</h3>
|
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 24 }}>
|
|
Configure {project?.productName ?? "this project"}
|
|
</p>
|
|
|
|
{/* General */}
|
|
<InfoCard>
|
|
<SectionLabel>General</SectionLabel>
|
|
<FieldLabel>Project name</FieldLabel>
|
|
<input
|
|
value={productName}
|
|
onChange={(e) => setProductName(e.target.value)}
|
|
style={{ width: "100%", padding: "9px 13px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.84rem", color: "#1a1a1a", marginBottom: 16, boxSizing: "border-box" }}
|
|
/>
|
|
<FieldLabel>Description</FieldLabel>
|
|
<textarea
|
|
value={productVision}
|
|
onChange={(e) => setProductVision(e.target.value)}
|
|
rows={3}
|
|
style={{ width: "100%", padding: "9px 13px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.84rem", color: "#1a1a1a", resize: "vertical", boxSizing: "border-box" }}
|
|
/>
|
|
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 16 }}>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
style={{ padding: "8px 20px", borderRadius: 7, border: "none", background: "#1a1a1a", color: "#fff", fontSize: "0.78rem", fontWeight: 600, cursor: saving ? "not-allowed" : "pointer", opacity: saving ? 0.7 : 1, display: "flex", alignItems: "center", gap: 6 }}
|
|
>
|
|
{saving && <Loader2 style={{ width: 12, height: 12 }} className="animate-spin" />}
|
|
{saving ? "Saving…" : "Save"}
|
|
</button>
|
|
</div>
|
|
</InfoCard>
|
|
|
|
{/* Repo */}
|
|
{project?.giteaRepoUrl && (
|
|
<InfoCard>
|
|
<SectionLabel>Repository</SectionLabel>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#1a1a1a", fontWeight: 500 }}>{project.giteaRepo}</div>
|
|
</div>
|
|
<a href={project.giteaRepoUrl} target="_blank" rel="noopener noreferrer"
|
|
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none" }}>
|
|
View ↗
|
|
</a>
|
|
</div>
|
|
</InfoCard>
|
|
)}
|
|
|
|
{/* Collaborators */}
|
|
<InfoCard>
|
|
<SectionLabel>Collaborators</SectionLabel>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 0" }}>
|
|
<div style={{ width: 28, height: 28, borderRadius: "50%", background: "#f0ece4", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.68rem", fontWeight: 600, color: "#8a8478" }}>
|
|
{userInitial}
|
|
</div>
|
|
<span style={{ flex: 1, fontSize: "0.82rem", color: "#1a1a1a" }}>{userName}</span>
|
|
<span style={{ display: "inline-flex", alignItems: "center", padding: "3px 9px", borderRadius: 4, fontSize: "0.68rem", fontWeight: 600, color: "#6b6560", background: "#f0ece4" }}>Owner</span>
|
|
</div>
|
|
<button
|
|
style={{ width: "100%", marginTop: 12, padding: "8px 16px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.75rem", fontWeight: 600, cursor: "pointer" }}
|
|
onClick={() => toast.info("Team invites coming soon")}
|
|
>
|
|
+ Invite to project
|
|
</button>
|
|
</InfoCard>
|
|
|
|
{/* Export */}
|
|
<InfoCard>
|
|
<SectionLabel>Export</SectionLabel>
|
|
<p style={{ fontSize: "0.82rem", color: "#6b6560", marginBottom: 14, lineHeight: 1.6 }}>
|
|
Download your PRD or project data for external use.
|
|
</p>
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
<button
|
|
style={{ padding: "8px 16px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.75rem", fontWeight: 600, cursor: "pointer" }}
|
|
onClick={() => toast.info("PDF export coming soon")}
|
|
>
|
|
Export PRD as PDF
|
|
</button>
|
|
<button
|
|
style={{ padding: "8px 16px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.75rem", fontWeight: 600, cursor: "pointer" }}
|
|
onClick={async () => {
|
|
const res = await fetch(`/api/projects/${projectId}`);
|
|
const data = await res.json();
|
|
const blob = new Blob([JSON.stringify(data.project, null, 2)], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url; a.download = `${productName.replace(/\s+/g, "-")}.json`; a.click();
|
|
URL.revokeObjectURL(url);
|
|
}}
|
|
>
|
|
Export as JSON
|
|
</button>
|
|
</div>
|
|
</InfoCard>
|
|
|
|
{/* Danger zone */}
|
|
<div style={{ background: "#fff", border: "1px solid #f5d5d5", borderRadius: 10, padding: "20px" }}>
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
|
<div>
|
|
<div style={{ fontSize: "0.84rem", fontWeight: 500, color: "#d32f2f" }}>Delete project</div>
|
|
<div style={{ fontSize: "0.75rem", color: "#a09a90" }}>
|
|
{confirmDelete ? "Click again to confirm — this cannot be undone" : "This action cannot be undone"}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleDelete}
|
|
disabled={deleting}
|
|
style={{ padding: "6px 14px", borderRadius: 7, border: "1px solid #f5d5d5", background: confirmDelete ? "#d32f2f" : "#fff", color: confirmDelete ? "#fff" : "#d32f2f", fontSize: "0.72rem", fontWeight: 600, cursor: "pointer", transition: "all 0.15s", display: "flex", alignItems: "center", gap: 6 }}
|
|
>
|
|
{deleting && <Loader2 style={{ width: 12, height: 12 }} className="animate-spin" />}
|
|
{confirmDelete ? "Confirm Delete" : "Delete"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|