+ Changelog Funktionalität

completed #95
This commit is contained in:
nocnico
2025-07-24 16:33:10 +02:00
parent 0b0f4fac2f
commit 0c80c046e1
14 changed files with 478 additions and 23 deletions

View File

@@ -0,0 +1,13 @@
import { prisma } from "@repo/db";
import { ChangelogForm } from "../_components/Form";
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const changelog = await prisma.changelog.findUnique({
where: {
id: parseInt(id),
},
});
if (!changelog) return <div>Changelog not found</div>;
return <ChangelogForm changelog={changelog} />;
}

View File

@@ -0,0 +1,151 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { ChangelogOptionalDefaultsSchema } from "@repo/db/zod";
import { useForm } from "react-hook-form";
import { Changelog } from "@repo/db";
import { FileText } from "lucide-react";
import { Input } from "../../../../_components/ui/Input";
import { useEffect, useState } from "react";
import { deleteChangelog, upsertChangelog } from "../action";
import { Button } from "../../../../_components/ui/Button";
import { redirect } from "next/navigation";
import Image from "next/image";
import dynamic from "next/dynamic";
import toast from "react-hot-toast";
const MarkdownEditor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false });
export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
const form = useForm({
resolver: zodResolver(ChangelogOptionalDefaultsSchema),
defaultValues: {
id: changelog?.id || undefined,
title: changelog?.title || "",
text: changelog?.text || "",
previewImage: changelog?.previewImage || "", // Changed to accept a URL as a string
},
});
const [markdownText, setMarkdownText] = useState(changelog?.text || "");
const [imageError, setImageError] = useState(false);
const [showImage, setShowImage] = useState(false);
const isValidImageUrl = (url: string) => {
if (!url) return false;
try {
const sanitizedUrl = url.trim(); // Remove leading/trailing spaces
const parsedUrl = new URL(sanitizedUrl, window.location.origin);
return parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:";
} catch (error) {
console.error("Invalid URL provided:", url, error);
return false;
}
};
const previewImage = form.watch("previewImage") || "";
useEffect(() => {
if (!isValidImageUrl(previewImage)) {
setImageError(true);
setShowImage(false); // Ensure image is hidden if URL is invalid
} else {
setImageError(false); // Reset error when previewImage changes and is valid
}
}, [previewImage]);
return (
<>
<form
onSubmit={form.handleSubmit(async (values) => {
await upsertChangelog(
{
...values,
text: markdownText,
},
changelog?.id,
);
toast.success("Daten gespeichert");
if (!changelog) redirect(`/admin/changelog`);
})}
className="grid grid-cols-6 gap-3"
>
<div className="card bg-base-200 col-span-6 shadow-xl">
<div className="card-body">
<h2 className="card-title">
<FileText className="h-5 w-5" /> Allgemeines
</h2>
<Input form={form} label="Titel" name="title" className="input-sm" />
<Input
form={form}
label="Bild-URL"
name="previewImage"
className="input-sm"
onChange={() => setShowImage(false)}
/>
<Button
onClick={() => setShowImage(true)}
type="button"
className="btn btn-primary btn-outline mt-2"
>
Bildvorschau anzeigen
</Button>
{(() => {
if (showImage && isValidImageUrl(previewImage) && !imageError) {
return (
<Image
src={previewImage}
alt="Preview"
width={200}
height={200}
className="mt-4 max-h-48"
onError={() => {
setImageError(true);
console.error("Failed to load image at URL:", previewImage);
}}
/>
);
} else if (imageError && showImage) {
return <p className="text-error">Bild konnte nicht geladen werden</p>;
}
})()}
</div>
</div>
<div className="card bg-base-200 col-span-6 shadow-xl">
<div className="card-body">
<h2 className="card-title">Beschreibung</h2>
<MarkdownEditor
value={markdownText}
onChange={(value) => setMarkdownText(value || "")}
className="min-h-96 w-full" // Increased height to make the editor bigger
/>
</div>
</div>
<div className="card bg-base-200 col-span-6 shadow-xl">
<div className="card-body">
<div className="flex w-full gap-4">
<Button
isLoading={form.formState.isSubmitting}
type="submit"
className="btn btn-primary flex-1"
>
Speichern
</Button>
{changelog && (
<Button
onClick={async () => {
await deleteChangelog(changelog.id);
redirect("/admin/changelog");
}}
className="btn btn-error"
>
Löschen
</Button>
)}
</div>
</div>
</div>
</form>
</>
);
};

View File

@@ -0,0 +1,28 @@
"use server";
import { prisma, Prisma, Changelog } from "@repo/db";
export const upsertChangelog = async (
changelog: Prisma.ChangelogCreateInput,
id?: Changelog["id"],
) => {
const newChangelog = id
? await prisma.changelog.update({
where: { id: id },
data: changelog,
})
: await prisma.$transaction(async (prisma) => {
const createdChangelog = await prisma.changelog.create({ data: changelog });
await prisma.user.updateMany({
data: { changelogAck: false },
});
return createdChangelog;
});
return newChangelog;
};
export const deleteChangelog = async (id: Changelog["id"]) => {
await prisma.changelog.delete({ where: { id: id } });
};

View File

@@ -0,0 +1,19 @@
import { Error } from "_components/Error";
import { getServerSession } from "api/auth/[...nextauth]/auth";
const AdminKeywordLayout = async ({ children }: { children: React.ReactNode }) => {
const session = await getServerSession();
if (!session) return <Error title="Nicht eingeloggt" statusCode={401} />;
const user = session.user;
if (!user?.permissions.includes("ADMIN_CHANGELOG"))
return <Error title="Keine Berechtigung" statusCode={403} />;
return <>{children}</>;
};
AdminKeywordLayout.displayName = "AdminKeywordLayout";
export default AdminKeywordLayout;

View File

@@ -0,0 +1,5 @@
import { ChangelogForm } from "../_components/Form";
export default () => {
return <ChangelogForm />;
};

View File

@@ -0,0 +1,49 @@
"use client";
import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { Keyword } from "@repo/db";
export default () => {
return (
<>
<PaginatedTable
stickyHeaders
initialOrderBy={[{ id: "title", desc: true }]}
prismaModel="changelog"
searchFields={["title"]}
columns={
[
{
header: "Title",
accessorKey: "title",
},
{
header: "Aktionen",
cell: ({ row }) => (
<div className="flex items-center gap-1">
<Link href={`/admin/changelog/${row.original.id}`}>
<button className="btn btn-sm">bearbeiten</button>
</Link>
</div>
),
},
] as ColumnDef<Keyword>[]
}
leftOfSearch={
<span className="flex items-center gap-2">
<DatabaseBackupIcon className="h-5 w-5" /> Changelogs
</span>
}
rightOfSearch={
<p className="flex items-center justify-between gap-2 text-left text-2xl font-semibold">
<Link href={"/admin/changelog/new"}>
<button className="btn btn-sm btn-outline btn-primary">Erstellen</button>
</Link>
</p>
}
/>
</>
);
};