feat: initial scaffold - backend, frontend, Prisma schema, Docker
- Prisma schema: User, Session, RepoConfig, Preview, Job, WebhookToken, NoConfigComment, AdminSettings - Backend: auth (login/logout/me/first-user setup), webhook handler with HMAC verification, EC2 service, SSH service, deploy pipeline, job queue worker, cron workers - Frontend: Login with first-user detection, Dashboard, PreviewDetail with live log streaming, Settings, Repos config, Admin panel, SetupWizard, Privacy page - Docker Compose and Dockerfile for self-hosted deployment - Uses bcryptjs for Node 24 compatibility Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({ open, title, message, confirmLabel = "Confirm", cancelLabel = "Cancel", onConfirm, onCancel, danger }: Props) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
className="bg-white dark:bg-slate-800 rounded-xl shadow-xl p-6 max-w-md w-full"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-lg font-semibold mb-2">{title}</h3>
|
||||
<p className="text-gray-600 dark:text-slate-300 text-sm mb-6">{message}</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded-lg text-sm bg-gray-100 dark:bg-slate-700 hover:bg-gray-200 dark:hover:bg-slate-600 transition-colors"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`px-4 py-2 rounded-lg text-sm text-white font-medium transition-colors ${
|
||||
danger ? "bg-red-600 hover:bg-red-700" : "bg-blue-600 hover:bg-blue-700"
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { useTheme } from "../hooks/useTheme";
|
||||
import { api } from "../services/api";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { user, refresh } = useAuth();
|
||||
const { dark, toggle } = useTheme();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await api.auth.logout();
|
||||
await refresh();
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
const navLink = (to: string, label: string) => (
|
||||
<Link
|
||||
to={to}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
location.pathname === to || location.pathname.startsWith(to + "/")
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-gray-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-800"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="border-b border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 h-14 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/" className="font-bold text-lg text-blue-600 dark:text-blue-400 hover:opacity-80">
|
||||
🚀 PR Previews
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<nav className="flex items-center gap-1 overflow-x-auto">
|
||||
{navLink("/", "Previews")}
|
||||
{navLink("/repos", "Repos")}
|
||||
{navLink("/settings", "Settings")}
|
||||
{user.isAdmin && navLink("/admin", "Admin")}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-slate-800 text-lg"
|
||||
title="Toggle theme"
|
||||
>
|
||||
{dark ? "☀️" : "🌙"}
|
||||
</button>
|
||||
{user && (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-sm px-3 py-1.5 rounded-md bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 transition-colors"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 max-w-7xl mx-auto w-full px-4 py-6">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-gray-200 dark:border-slate-700 py-4 text-center text-xs text-gray-500 dark:text-slate-400">
|
||||
PR Previews — self-hosted preview environments for every pull request.{" "}
|
||||
<Link to="/privacy" className="underline hover:text-gray-700 dark:hover:text-slate-200">Privacy Policy</Link>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
function stripAnsi(str: string): string {
|
||||
return str.replace(/\x1B\[[\d;]*[mGKHFJsu]/g, "").replace(/\x1B\][^\x07]*\x07/g, "");
|
||||
}
|
||||
|
||||
function renderLogLine(line: string, idx: number) {
|
||||
if (line.startsWith("--- ") && (line.includes("Redeploy") || line.includes("truncated"))) {
|
||||
return (
|
||||
<span key={idx} className="text-slate-400 dark:text-slate-500 italic block">
|
||||
{line}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span key={idx} className="block">{stripAnsi(line)}</span>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
logs: string;
|
||||
autoScroll?: boolean;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
export function LogViewer({ logs, autoScroll = true, maxHeight = "500px" }: Props) {
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
const [pinned, setPinned] = useState(autoScroll);
|
||||
|
||||
useEffect(() => {
|
||||
if (pinned && endRef.current) {
|
||||
endRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [logs, pinned]);
|
||||
|
||||
const lines = logs.split("\n");
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
className="log-viewer bg-gray-950 dark:bg-black text-green-400 rounded-lg p-4 overflow-auto border border-gray-800"
|
||||
style={{ maxHeight }}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
|
||||
setPinned(atBottom);
|
||||
}}
|
||||
>
|
||||
<pre className="ansi-stripped text-xs">
|
||||
{lines.map((line, i) => renderLogLine(line, i))}
|
||||
</pre>
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
{!pinned && (
|
||||
<button
|
||||
onClick={() => { setPinned(true); endRef.current?.scrollIntoView({ behavior: "smooth" }); }}
|
||||
className="absolute bottom-4 right-4 text-xs bg-blue-600 text-white px-2 py-1 rounded shadow hover:bg-blue-700"
|
||||
>
|
||||
↓ Jump to bottom
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
type Status = "PROVISIONING" | "BUILDING" | "RUNNING" | "FAILED" | "STOPPED" | "IGNORED";
|
||||
|
||||
const CONFIG: Record<Status, { label: string; icon: string; cls: string }> = {
|
||||
PROVISIONING: { label: "Provisioning", icon: "🟡", cls: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" },
|
||||
BUILDING: { label: "Building", icon: "🟡", cls: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" },
|
||||
RUNNING: { label: "Running", icon: "🟢", cls: "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300" },
|
||||
FAILED: { label: "Failed", icon: "🔴", cls: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300" },
|
||||
STOPPED: { label: "Stopped", icon: "⚫", cls: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" },
|
||||
IGNORED: { label: "Ignored", icon: "⚫", cls: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" },
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: Status }) {
|
||||
const cfg = CONFIG[status] || CONFIG.STOPPED;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${cfg.cls}`}>
|
||||
<span>{cfg.icon}</span> {cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user