127
apps/hub/app/(app)/_components/Changelog.tsx
Normal file
127
apps/hub/app/(app)/_components/Changelog.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Button } from "@repo/shared-components";
|
||||||
|
import MDEditor from "@uiw/react-md-editor";
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
import { updateChangelogAck } from "./ChangelogActions";
|
||||||
|
|
||||||
|
export const ChangelogModal = ({
|
||||||
|
latestChangelog,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
latestChangelog: { title: string; text: string; previewImage: string } | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) => {
|
||||||
|
if (!isOpen || !latestChangelog) return null;
|
||||||
|
|
||||||
|
const handleClose = async () => {
|
||||||
|
onClose();
|
||||||
|
await updateChangelogAck();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="changelogModalToggle"
|
||||||
|
className="modal-toggle"
|
||||||
|
onChange={handleClose}
|
||||||
|
/>
|
||||||
|
<dialog open className="modal p-4">
|
||||||
|
<div className="modal-box max-h-11/12 w-11/12 max-w-2xl overflow-y-auto">
|
||||||
|
<form method="dialog">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-circle btn-ghost absolute right-3 top-3"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<h3 className="flex items-center gap-2 text-lg font-bold">
|
||||||
|
<span className="text-primary">{latestChangelog.title}</span> ist nun Verfügbar!
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
{latestChangelog.previewImage && (
|
||||||
|
<Image
|
||||||
|
src={latestChangelog.previewImage}
|
||||||
|
alt="Preview"
|
||||||
|
width={800}
|
||||||
|
height={400}
|
||||||
|
className="mt-4 h-auto w-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-base-content/80 mb-2 mt-4 text-left">
|
||||||
|
<MDEditor.Markdown
|
||||||
|
source={latestChangelog.text}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-action">
|
||||||
|
<Button className="btn btn-info btn-outline" onClick={handleClose}>
|
||||||
|
Weiter zum HUB
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="modal-backdrop" htmlFor="changelogModalToggle">
|
||||||
|
Close
|
||||||
|
</label>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChangelogBtn = ({
|
||||||
|
latestChangelog,
|
||||||
|
}: {
|
||||||
|
latestChangelog: { title: string; text: string; previewImage: string } | null;
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!latestChangelog) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href="#!"
|
||||||
|
className="hover:text-primary flex items-center gap-1"
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} /> {latestChangelog.title}
|
||||||
|
</a>
|
||||||
|
<ChangelogModal
|
||||||
|
latestChangelog={latestChangelog}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OpenChangelogOnPageload = ({
|
||||||
|
latestChangelog,
|
||||||
|
}: {
|
||||||
|
latestChangelog: { title: string; text: string; previewImage: string } | null;
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
|
|
||||||
|
if (!latestChangelog) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ChangelogModal
|
||||||
|
latestChangelog={latestChangelog}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
37
apps/hub/app/(app)/_components/ChangelogActions.tsx
Normal file
37
apps/hub/app/(app)/_components/ChangelogActions.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"use server";
|
||||||
|
import { prisma } from "@repo/db";
|
||||||
|
import { getServerSession } from "../../api/auth/[...nextauth]/auth";
|
||||||
|
|
||||||
|
export async function getLatestChangelog() {
|
||||||
|
try {
|
||||||
|
const latestChangelog = await prisma.changelog.findMany({
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
take: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (latestChangelog.length > 0 && latestChangelog[0]) {
|
||||||
|
return {
|
||||||
|
title: latestChangelog[0].title,
|
||||||
|
text: latestChangelog[0].text,
|
||||||
|
previewImage: latestChangelog[0].previewImage || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch latest changelog:", error);
|
||||||
|
throw new Error("Failed to fetch latest changelog");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateChangelogAck() {
|
||||||
|
const session = await getServerSession();
|
||||||
|
if (session?.user) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data: { changelogAck: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,14 @@ import Image from "next/image";
|
|||||||
import { DiscordLogoIcon, InstagramLogoIcon, ReaderIcon } from "@radix-ui/react-icons";
|
import { DiscordLogoIcon, InstagramLogoIcon, ReaderIcon } from "@radix-ui/react-icons";
|
||||||
import YoutubeSvg from "./youtube_wider.svg";
|
import YoutubeSvg from "./youtube_wider.svg";
|
||||||
import FacebookSvg from "./facebook.svg";
|
import FacebookSvg from "./facebook.svg";
|
||||||
|
import { ChangelogBtn } from "./Changelog";
|
||||||
|
import { getLatestChangelog } from "./ChangelogActions";
|
||||||
|
|
||||||
|
export const Footer = async () => {
|
||||||
|
const latestChangelog = await getLatestChangelog();
|
||||||
|
|
||||||
export const Footer = () => {
|
|
||||||
return (
|
return (
|
||||||
<footer className="footer flex justify-between items-center p-4 bg-base-200 mt-4 rounded-lg shadow-md">
|
<footer className="footer bg-base-200 mt-4 flex items-center justify-between rounded-lg p-4 shadow-md">
|
||||||
{/* Left: Impressum & Datenschutz */}
|
{/* Left: Impressum & Datenschutz */}
|
||||||
<div className="flex gap-4 text-sm">
|
<div className="flex gap-4 text-sm">
|
||||||
<a href="https://virtualairrescue.com/impressum/" className="hover:text-primary">
|
<a href="https://virtualairrescue.com/impressum/" className="hover:text-primary">
|
||||||
@@ -14,6 +18,7 @@ export const Footer = () => {
|
|||||||
<a href="https://virtualairrescue.com/datenschutz/" className="hover:text-primary">
|
<a href="https://virtualairrescue.com/datenschutz/" className="hover:text-primary">
|
||||||
Datenschutzerklärung
|
Datenschutzerklärung
|
||||||
</a>
|
</a>
|
||||||
|
<ChangelogBtn latestChangelog={latestChangelog} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Center: Copyright */}
|
{/* Center: Copyright */}
|
||||||
@@ -28,7 +33,7 @@ export const Footer = () => {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hover:text-primary"
|
className="hover:text-primary"
|
||||||
>
|
>
|
||||||
<DiscordLogoIcon className="w-5 h-5" />
|
<DiscordLogoIcon className="h-5 w-5" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -39,7 +44,7 @@ export const Footer = () => {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hover:text-primary text-white"
|
className="hover:text-primary text-white"
|
||||||
>
|
>
|
||||||
<Image src={YoutubeSvg} className="invert w-5 h5" alt="Youtube Icon" />
|
<Image src={YoutubeSvg} className="h5 w-5 invert" alt="Youtube Icon" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,7 +55,7 @@ export const Footer = () => {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hover:text-primary text-white"
|
className="hover:text-primary text-white"
|
||||||
>
|
>
|
||||||
<Image src={FacebookSvg} className="invert w-5 h5" alt="Youtube Icon" />
|
<Image src={FacebookSvg} className="h5 w-5 invert" alt="Youtube Icon" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,12 +66,12 @@ export const Footer = () => {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hover:text-primary"
|
className="hover:text-primary"
|
||||||
>
|
>
|
||||||
<InstagramLogoIcon className="w-5 h-5" />
|
<InstagramLogoIcon className="h-5 w-5" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="tooltip tooltip-top" data-tip="Knowledgebase">
|
<div className="tooltip tooltip-top" data-tip="Knowledgebase">
|
||||||
<a href="https://docs.virtualairrescue.com/" className="hover:text-primary">
|
<a href="https://docs.virtualairrescue.com/" className="hover:text-primary">
|
||||||
<ReaderIcon className="w-5 h-5" />
|
<ReaderIcon className="h-5 w-5" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,6 +5,8 @@ import { getServerSession } from "../api/auth/[...nextauth]/auth";
|
|||||||
import { EmailVerification } from "_components/EmailVerification";
|
import { EmailVerification } from "_components/EmailVerification";
|
||||||
import { FirstPath } from "./_components/FirstPath";
|
import { FirstPath } from "./_components/FirstPath";
|
||||||
import { Penalty } from "_components/Penalty";
|
import { Penalty } from "_components/Penalty";
|
||||||
|
import { getLatestChangelog } from "(app)/_components/ChangelogActions";
|
||||||
|
import { OpenChangelogOnPageload } from "(app)/_components/Changelog";
|
||||||
|
|
||||||
import { Footer } from "(app)/_components/Footer";
|
import { Footer } from "(app)/_components/Footer";
|
||||||
|
|
||||||
@@ -22,6 +24,8 @@ export default async function RootLayout({
|
|||||||
|
|
||||||
if (!session) redirect(`/login`);
|
if (!session) redirect(`/login`);
|
||||||
|
|
||||||
|
const latestChangelog = !session.user.changelogAck ? await getLatestChangelog() : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="hero min-h-screen"
|
className="hero min-h-screen"
|
||||||
@@ -31,8 +35,8 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
<div className="hero-overlay bg-opacity-30"></div>
|
<div className="hero-overlay bg-opacity-30"></div>
|
||||||
{/* Card */}
|
{/* Card */}
|
||||||
<div className="hero-content text-neutral-content text-center w-full max-w-full h-full m-10">
|
<div className="hero-content text-neutral-content m-10 h-full w-full max-w-full text-center">
|
||||||
<div className="card bg-base-100 shadow-2xl w-full min-h-full h-full max-h-[calc(100vh-13rem)] p-4 flex flex-col mr-24 ml-24">
|
<div className="card bg-base-100 ml-24 mr-24 flex h-full max-h-[calc(100vh-13rem)] min-h-full w-full flex-col p-4 shadow-2xl">
|
||||||
{/* Top Navbar */}
|
{/* Top Navbar */}
|
||||||
<HorizontalNav />
|
<HorizontalNav />
|
||||||
|
|
||||||
@@ -42,7 +46,7 @@ export default async function RootLayout({
|
|||||||
<VerticalNav />
|
<VerticalNav />
|
||||||
|
|
||||||
{/* Scrollbarer Content-Bereich */}
|
{/* Scrollbarer Content-Bereich */}
|
||||||
<div className="flex-grow bg-base-100 px-6 rounded-lg shadow-md ml-4 overflow-auto h-full max-w-full w-full">
|
<div className="bg-base-100 ml-4 h-full w-full max-w-full flex-grow overflow-auto rounded-lg px-6 shadow-md">
|
||||||
<Penalty />
|
<Penalty />
|
||||||
{!session?.user.emailVerified && (
|
{!session?.user.emailVerified && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@@ -50,6 +54,9 @@ export default async function RootLayout({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!session.user.pathSelected && <FirstPath />}
|
{!session.user.pathSelected && <FirstPath />}
|
||||||
|
{session.user.pathSelected && latestChangelog && (
|
||||||
|
<OpenChangelogOnPageload latestChangelog={latestChangelog} />
|
||||||
|
)}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const VerticalNav = async () => {
|
|||||||
return p.startsWith("ADMIN");
|
return p.startsWith("ADMIN");
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<ul className="menu flex-nowrap w-64 bg-base-300 p-3 rounded-lg shadow-md font-semibold">
|
<ul className="menu bg-base-300 w-64 flex-nowrap rounded-lg p-3 font-semibold shadow-md">
|
||||||
<li>
|
<li>
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<HomeIcon /> Dashboard
|
<HomeIcon /> Dashboard
|
||||||
@@ -99,6 +99,11 @@ export const VerticalNav = async () => {
|
|||||||
<Link href="/admin/penalty">Audit-Log</Link>
|
<Link href="/admin/penalty">Audit-Log</Link>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
{session.user.permissions.includes("ADMIN_CHANGELOG") && (
|
||||||
|
<li>
|
||||||
|
<Link href="/admin/changelog">Changelog</Link>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
</li>
|
</li>
|
||||||
@@ -112,7 +117,7 @@ export const HorizontalNav = async () => {
|
|||||||
if (!session?.user) return <Error statusCode={401} title="Benutzer nicht authentifiziert!" />;
|
if (!session?.user) return <Error statusCode={401} title="Benutzer nicht authentifiziert!" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="navbar bg-base-200 shadow-md rounded-lg mb-4">
|
<div className="navbar bg-base-200 mb-4 rounded-lg shadow-md">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Link href="/" className="flex items-center">
|
<Link href="/" className="flex items-center">
|
||||||
<Image
|
<Image
|
||||||
@@ -123,12 +128,12 @@ export const HorizontalNav = async () => {
|
|||||||
className="ml-2 mr-3"
|
className="ml-2 mr-3"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<h2 className="normal-case text-xl font-semibold">Virtual Air Rescue - HUB</h2>
|
<h2 className="text-xl font-semibold normal-case">Virtual Air Rescue - HUB</h2>
|
||||||
</Link>
|
</Link>
|
||||||
<WarningAlert />
|
<WarningAlert />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center ml-auto">
|
<div className="ml-auto flex items-center">
|
||||||
<ul className="flex space-x-2 px-1 items-center">
|
<ul className="flex items-center space-x-2 px-1">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href={process.env.NEXT_PUBLIC_DISPATCH_URL + "/tracker"}
|
href={process.env.NEXT_PUBLIC_DISPATCH_URL + "/tracker"}
|
||||||
|
|||||||
BIN
apps/hub/public/changelogImages/Frog.jpg
Normal file
BIN
apps/hub/public/changelogImages/Frog.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 447 KiB |
7
packages/database/prisma/schema/changelog.prisma
Normal file
7
packages/database/prisma/schema/changelog.prisma
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
model Changelog {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
title String
|
||||||
|
previewImage String?
|
||||||
|
text String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ enum PERMISSION {
|
|||||||
ADMIN_MESSAGE
|
ADMIN_MESSAGE
|
||||||
ADMIN_KICK
|
ADMIN_KICK
|
||||||
ADMIN_HELIPORT
|
ADMIN_HELIPORT
|
||||||
|
ADMIN_CHANGELOG
|
||||||
AUDIO
|
AUDIO
|
||||||
PILOT
|
PILOT
|
||||||
DISPO
|
DISPO
|
||||||
@@ -33,6 +34,7 @@ model User {
|
|||||||
password String
|
password String
|
||||||
vatsimCid String? @map(name: "vatsim_cid")
|
vatsimCid String? @map(name: "vatsim_cid")
|
||||||
moodleId Int? @map(name: "moodle_id")
|
moodleId Int? @map(name: "moodle_id")
|
||||||
|
changelogAck Boolean @default(false)
|
||||||
|
|
||||||
// Settings:
|
// Settings:
|
||||||
pathSelected Boolean @default(false)
|
pathSelected Boolean @default(false)
|
||||||
|
|||||||
Reference in New Issue
Block a user