13
apps/hub/app/(app)/admin/changelog/[id]/page.tsx
Normal file
13
apps/hub/app/(app)/admin/changelog/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
151
apps/hub/app/(app)/admin/changelog/_components/Form.tsx
Normal file
151
apps/hub/app/(app)/admin/changelog/_components/Form.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
28
apps/hub/app/(app)/admin/changelog/action.ts
Normal file
28
apps/hub/app/(app)/admin/changelog/action.ts
Normal 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 } });
|
||||
};
|
||||
19
apps/hub/app/(app)/admin/changelog/layout.tsx
Normal file
19
apps/hub/app/(app)/admin/changelog/layout.tsx
Normal 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;
|
||||
5
apps/hub/app/(app)/admin/changelog/new/page.tsx
Normal file
5
apps/hub/app/(app)/admin/changelog/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ChangelogForm } from "../_components/Form";
|
||||
|
||||
export default () => {
|
||||
return <ChangelogForm />;
|
||||
};
|
||||
49
apps/hub/app/(app)/admin/changelog/page.tsx
Normal file
49
apps/hub/app/(app)/admin/changelog/page.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user