Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions apps/web/src/components/app/release-admin-banners.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
import { CheckCircle2, XCircle } from "lucide-react";
import { cleanError } from "@/components/app/release-utils";
import { ActivityBars } from "@/components/ui/activity-bars";
import { Button } from "@/components/ui/button";
import type { ReleaseJob } from "@/hooks/use-release-stream";

type ReleaseJobBannersProps = {
job: ReleaseJob;
releaseInFlight: boolean;
isFailed: boolean;
isDone: boolean;
watchedRelease: boolean;
onShowProgress: () => void;
onDismiss: () => void;
};

/**
* In-flight / failed / finished banners for a release-create job. The page
* stays usable behind them — each banner just links into the progress view.
*/
export function ReleaseJobBanners({
job,
releaseInFlight,
isFailed,
isDone,
watchedRelease,
onShowProgress,
onDismiss,
}: ReleaseJobBannersProps): JSX.Element {
return (
<>
{releaseInFlight && (
<div className="flex items-center gap-3 rounded-lg border border-blue-500/30 bg-blue-500/10 px-3 py-2.5 text-sm text-blue-300">
<ActivityBars size={14} />
<span>
Releasing{" "}
<span className="font-semibold capitalize">{job.versionType}</span>
{job.tag && (
<>
{" "}
<span className="font-mono">{job.tag}</span>
</>
)}{" "}
— {job.phase}
</span>
<Button
size="sm"
variant="ghost-primary"
className="ml-auto shrink-0"
onClick={onShowProgress}
>
View progress
</Button>
</div>
)}
{isFailed && (
<div className="flex items-center gap-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
<XCircle className="h-4 w-4 shrink-0" />
<span className="min-w-0 truncate">
Release failed
{job.error ? `: ${cleanError(job.error)}` : ""}
</span>
<div className="ml-auto flex shrink-0 items-center gap-2">
<Button size="sm" variant="ghost-primary" onClick={onShowProgress}>
Details
</Button>
<Button size="sm" variant="ghost" onClick={onDismiss}>
Dismiss
</Button>
</div>
</div>
)}
{isDone && watchedRelease && (
<div className="flex items-center gap-3 rounded-lg border border-green-500/30 bg-green-500/10 px-3 py-2.5 text-sm text-green-400">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span>
Released <span className="font-mono font-semibold">{job.tag}</span>
</span>
<Button
size="sm"
variant="ghost"
className="ml-auto shrink-0"
onClick={onDismiss}
>
Dismiss
</Button>
</div>
)}
</>
);
}
136 changes: 136 additions & 0 deletions apps/web/src/components/app/release-admin-create.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
import { ShieldCheck, Sparkles, Zap } from "lucide-react";
import { bumpVersion } from "@/components/app/release-utils";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type {
ReleaseInfo,
ReleaseVersionType,
} from "@/hooks/use-release-stream";
import { cn } from "@/lib/utils";

const VERSION_CONFIG: Record<
ReleaseVersionType,
{ icon: typeof ShieldCheck; color: string }
> = {
patch: { icon: ShieldCheck, color: "text-status-working" },
minor: { icon: Sparkles, color: "text-status-done" },
major: { icon: Zap, color: "text-violet-400" },
};

type CreateReleaseSectionProps = {
info: ReleaseInfo | null;
bumpBase: string | null;
releaseError: string | null;
releaseInFlight: boolean;
confirmType: ReleaseVersionType | null;
onConfirmTypeChange: (type: ReleaseVersionType | null) => void;
onRelease: (versionType: ReleaseVersionType) => void;
};

/**
* The "Create release" section — one button per version bump plus the
* confirmation dialog. Hidden entirely when there is nothing to release.
*/
export function CreateReleaseSection({
info,
bumpBase,
releaseError,
releaseInFlight,
confirmType,
onConfirmTypeChange,
onRelease,
}: CreateReleaseSectionProps): JSX.Element | null {
if (
!info ||
!(
info.refMissing ||
Boolean(info.unreleasedFetchError) ||
info.unreleasedCount > 0
)
) {
return null;
}

return (
<div>
<div className="mb-3 text-[10px] uppercase tracking-widest text-muted-foreground">
Create release
</div>

{releaseError && (
<div className="mb-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{releaseError}
</div>
)}

<div className="flex flex-wrap gap-2">
{(["patch", "minor", "major"] as ReleaseVersionType[]).map((type) => {
const { icon: Icon, color } = VERSION_CONFIG[type];
const nextTag = bumpVersion(bumpBase, type);
return (
<Button
key={type}
onClick={() => onConfirmTypeChange(type)}
disabled={releaseInFlight}
className="gap-1.5"
>
<Icon className={cn("h-3.5 w-3.5", color)} />
<span className="capitalize">{type}</span>
{nextTag && (
<span className="font-mono text-xs text-muted-foreground">
{nextTag}
</span>
)}
</Button>
);
})}
</div>

<Dialog
open={confirmType !== null}
onOpenChange={(open) => {
if (!open) onConfirmTypeChange(null);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
Create {confirmType ?? ""} release
{confirmType && bumpVersion(bumpBase, confirmType) && (
<>
{" "}
<span className="font-mono">
{bumpVersion(bumpBase, confirmType)}
</span>
</>
)}
</DialogTitle>
<DialogDescription>
This triggers the release workflow against{" "}
<span className="font-mono">main</span> on GitHub.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => onConfirmTypeChange(null)}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => {
if (confirmType) onRelease(confirmType);
}}
>
Release
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
131 changes: 131 additions & 0 deletions apps/web/src/components/app/release-admin-list.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
import { CheckCircle2, ExternalLink } from "lucide-react";
import type { GitHubRelease } from "@/components/app/release-utils";
import { ActivityBars } from "@/components/ui/activity-bars";
import { Button } from "@/components/ui/button";
import { formatShortDateTime } from "@/lib/format";
import { cn } from "@/lib/utils";

type RecentReleasesProps = {
releases: GitHubRelease[];
releasesLoading: boolean;
promoteError: string | null;
promotingTag: string | null;
confirmPromoteTag: string | null;
onConfirmPromoteTagChange: (tag: string | null) => void;
onPromote: (tag: string) => void;
};

/**
* The "Recent releases" list, with the inline promote-to-stable confirm
* flow for prereleases.
*/
export function RecentReleases({
releases,
releasesLoading,
promoteError,
promotingTag,
confirmPromoteTag,
onConfirmPromoteTagChange,
onPromote,
}: RecentReleasesProps): JSX.Element {
return (
<div>
<div className="mb-3 text-[10px] uppercase tracking-widest text-muted-foreground">
Recent releases
</div>

{promoteError && (
<div className="mb-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{promoteError}
</div>
)}

{releasesLoading && releases.length === 0 && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<ActivityBars size={14} />
Loading...
</div>
)}

{releases.length > 0 && (
<div className="flex flex-col gap-1">
{releases.map((r) => (
<div
key={r.tag}
className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border border-white/[0.12] bg-white/[0.04] px-3 py-2.5"
>
<span className="font-mono text-sm font-semibold text-foreground">
{r.tag}
</span>
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide",
r.isPrerelease
? "bg-status-waiting/15 text-status-waiting"
: "bg-green-500/15 text-green-400"
)}
>
{r.isPrerelease ? "pre-release" : "stable"}
</span>
<span className="text-xs text-muted-foreground">
{formatShortDateTime(r.publishedAt)}
</span>
<div className="ml-auto flex max-w-full flex-wrap items-center justify-end gap-2">
{r.isPrerelease && confirmPromoteTag !== r.tag && (
<Button
size="sm"
variant="ghost-primary"
onClick={() => onConfirmPromoteTagChange(r.tag)}
disabled={promotingTag === r.tag}
>
{promotingTag === r.tag ? (
<ActivityBars size={12} />
) : (
"Promote"
)}
</Button>
)}
{r.isPrerelease && confirmPromoteTag === r.tag && (
<>
<span className="text-xs text-muted-foreground">
Mark stable + latest?
</span>
<Button
size="sm"
variant="ghost-primary"
onClick={() => onPromote(r.tag)}
>
Confirm
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => onConfirmPromoteTagChange(null)}
>
Cancel
</Button>
</>
)}
{!r.isPrerelease && (
<CheckCircle2 className="h-3.5 w-3.5 text-green-500/50" />
)}
<a
href={r.url}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</div>
))}
</div>
)}

{!releasesLoading && releases.length === 0 && (
<div className="text-sm text-muted-foreground">No releases found</div>
)}
</div>
);
}
Loading
Loading