Allow changing the name of patch version

This commit is contained in:
Jared Schoeny 2025-12-19 22:51:53 -10:00
parent 81462b08f3
commit 2bde348cde
2 changed files with 199 additions and 6 deletions

View File

@ -433,6 +433,69 @@ export async function updatePatchChangelog(slug: string, patchId: number, change
return { ok: true };
}
export async function updatePatchVersion(slug: string, patchId: number, version: string): Promise<{ ok: true } | { ok: false; error: string }> {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Unauthorized" };
// Fetch hack and verify permissions
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author")
.eq("slug", slug)
.maybeSingle();
if (hErr || !hack) return { ok: false, error: "Hack not found" };
if (!canEditAsCreator({ created_by: hack.created_by, current_patch: hack.current_patch, original_author: hack.original_author }, user.id)) {
return { ok: false, error: "Forbidden" };
}
// Verify patch belongs to this hack
const { data: patch, error: pErr } = await supabase
.from("patches")
.select("id, parent_hack, version")
.eq("id", patchId)
.maybeSingle();
if (pErr || !patch || patch.parent_hack !== slug) {
return { ok: false, error: "Patch not found" };
}
// Trim and validate version
const trimmedVersion = version.trim();
if (!trimmedVersion) {
return { ok: false, error: "Version cannot be empty" };
}
// If version hasn't changed, return success
if (patch.version === trimmedVersion) {
return { ok: true };
}
// Check if version already exists for this hack (excluding current patch)
const { data: existing, error: vErr } = await supabase
.from("patches")
.select("id")
.eq("parent_hack", slug)
.eq("version", trimmedVersion)
.neq("id", patchId)
.maybeSingle();
if (vErr) return { ok: false, error: vErr.message };
if (existing) return { ok: false, error: "That version already exists for this hack." };
// Update version
const serviceClient = await createServiceClient();
const { error: updateErr } = await serviceClient
.from("patches")
.update({ version: trimmedVersion, updated_at: new Date().toISOString() })
.eq("id", patchId);
if (updateErr) return { ok: false, error: updateErr.message };
revalidatePath(`/hack/${slug}/versions`);
revalidatePath(`/hack/${slug}`);
return { ok: true };
}
export async function publishPatchVersion(slug: string, patchId: number): Promise<{ ok: true; willBecomeCurrent?: boolean } | { ok: false; error: string }> {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();

View File

@ -1,13 +1,13 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
import { FaChevronDown, FaChevronUp, FaStar, FaDownload, FaTrash, FaRotateLeft, FaUpload, FaCheck, FaPlus } from "react-icons/fa6";
import { FiEdit2 } from "react-icons/fi";
import { FiEdit2, FiEdit, FiX } from "react-icons/fi";
import VersionActions from "@/components/Hack/VersionActions";
import { getPatchDownloadUrl } from "@/app/hack/[slug]/actions";
import { updatePatchChangelog, updatePatchVersion } from "@/app/hack/[slug]/actions";
import { useRouter } from "next/navigation";
import { createClient } from "@/utils/supabase/client";
@ -43,6 +43,7 @@ export default function VersionList({ patches, currentPatchId, canEdit, hackSlug
const [expandedChangelogs, setExpandedChangelogs] = useState<Set<number>>(getInitialExpanded);
const [editingChangelog, setEditingChangelog] = useState<number | null>(null);
const [editingVersion, setEditingVersion] = useState<number | null>(null);
const [showArchived, setShowArchived] = useState(false);
const [archivedPatches, setArchivedPatches] = useState<Patch[]>([]);
const [loadingArchived, setLoadingArchived] = useState(false);
@ -124,8 +125,33 @@ export default function VersionList({ patches, currentPatchId, canEdit, hackSlug
<div className="flex items-start justify-between gap-3 sm:gap-4">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-1.5 sm:mb-2">
<h3 className="text-base sm:text-lg font-semibold">{patch.version}</h3>
{isCurrent && (
{editingVersion === patch.id ? (
<VersionEditor
patchId={patch.id}
initialVersion={patch.version}
hackSlug={hackSlug}
onSave={() => {
setEditingVersion(null);
router.refresh();
}}
onCancel={() => setEditingVersion(null)}
/>
) : (
<>
<h3 className="text-base sm:text-lg font-semibold">{patch.version}</h3>
{canEdit && (
<button
onClick={() => setEditingVersion(patch.id)}
className="inline-flex items-center justify-center rounded-md p-1.5 text-foreground/60 hover:text-foreground hover:bg-[var(--surface-2)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent)] touch-manipulation"
title="Edit version"
aria-label="Edit version"
>
<FiEdit size={14} />
</button>
)}
</>
)}
{isCurrent && editingVersion !== patch.id && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<FaStar size={10} />
Current
@ -176,6 +202,7 @@ export default function VersionList({ patches, currentPatchId, canEdit, hackSlug
onActionComplete={() => {
router.refresh();
setEditingChangelog(null);
setEditingVersion(null);
// Clear archived patches to force refetch if checkbox is toggled
// This ensures restored/archived patches don't show duplicates
setArchivedPatches([]);
@ -306,7 +333,6 @@ function ChangelogEditor({
setSaving(true);
setError(null);
try {
const { updatePatchChangelog } = await import("@/app/hack/[slug]/actions");
const result = await updatePatchChangelog(hackSlug, patchId, changelog);
if (result.ok) {
onSave();
@ -352,3 +378,107 @@ function ChangelogEditor({
);
}
function VersionEditor({
patchId,
initialVersion,
hackSlug,
onSave,
onCancel,
}: {
patchId: number;
initialVersion: string;
hackSlug: string;
onSave: () => void;
onCancel: () => void;
}) {
const [version, setVersion] = useState(initialVersion);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Auto-focus input on mount
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const handleSave = async () => {
const trimmedVersion = version.trim();
if (!trimmedVersion) {
setError("Version cannot be empty");
return;
}
if (trimmedVersion === initialVersion) {
onCancel();
return;
}
setSaving(true);
setError(null);
try {
const result = await updatePatchVersion(hackSlug, patchId, trimmedVersion);
if (result.ok) {
onSave();
} else {
setError(result.error || "Failed to update version");
}
} catch (e) {
setError("Failed to update version");
} finally {
setSaving(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
};
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2">
<input
ref={inputRef}
type="text"
value={version}
onChange={(e) => {
setVersion(e.target.value);
setError(null);
}}
onKeyDown={handleKeyDown}
disabled={saving}
className="w-auto min-w-[90px] rounded-md bg-[var(--surface-2)] px-2.5 py-1.5 text-base sm:text-lg font-semibold ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Version name"
/>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={handleSave}
disabled={saving || !version.trim()}
className="inline-flex items-center justify-center rounded-md bg-emerald-600 px-2.5 py-1.5 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed touch-manipulation"
title="Save version"
aria-label="Save version"
>
<FaCheck size={12} />
</button>
<button
onClick={onCancel}
disabled={saving}
className="inline-flex items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-1.5 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50 touch-manipulation"
title="Cancel editing"
aria-label="Cancel editing"
>
<FiX size={14} />
</button>
</div>
</div>
{error && <p className="mt-1 text-xs text-red-400">{error}</p>}
</div>
);
}