News page functionality

This commit is contained in:
strasmus1
2026-06-08 09:44:29 +00:00
parent fb134ec59b
commit f960f21315
14 changed files with 790 additions and 0 deletions
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS news_articles (
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
title TEXT NOT NULL,
content TEXT NOT NULL,
created_by bigint,
updated_by bigint,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TRIGGER update_news_articles_updated_at
BEFORE UPDATE ON news_articles
FOR EACH ROW
EXECUTE FUNCTION updated_at();
@@ -0,0 +1 @@
ALTER TABLE news_articles ADD COLUMN IF NOT EXISTS is_teacher_only BOOLEAN NOT NULL DEFAULT false;
+21
View File
@@ -724,6 +724,27 @@ ALTER TABLE public.multichoice_options
ADD CONSTRAINT multichoice_options_multichoice_content_id_fkey
FOREIGN KEY (multichoice_content_id) REFERENCES multichoice_content(id) ON DELETE CASCADE;
CREATE TABLE public.news_articles (
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
title text NOT NULL,
content text NOT NULL,
created_by bigint,
updated_by bigint,
is_teacher_only boolean NOT NULL DEFAULT false
);
CREATE TRIGGER update_news_articles_updated_at BEFORE UPDATE ON public.news_articles FOR EACH ROW EXECUTE FUNCTION updated_at();
ALTER TABLE public.news_articles
ADD CONSTRAINT news_articles_created_by_fkey
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE public.news_articles
ADD CONSTRAINT news_articles_updated_by_fkey
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL;
CREATE TABLE public.part_questions (
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
created_at timestamp with time zone NOT NULL DEFAULT now(),
+205
View File
@@ -0,0 +1,205 @@
"use client";
import { Button } from "@/components/ui/button";
import { Column, Row } from "@/components/ui/layout/container";
import { useState, useEffect } from "react";
import { useRequest } from "@/shared/hooks/useRequest";
import { useRouter, useSearchParams } from "next/navigation";
import Editor from "@/components/ui/editor";
import { withBasePath } from "@/shared/utils/basePath";
import AdminLastModified from "@/components/ui/admin-last-modified";
const defaultFormErrors = {
title: "",
content: "",
};
export default function CreateNewsArticle() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [isTeacherOnly, setIsTeacherOnly] = useState(false);
const [generalError, setGeneralError] = useState("");
const [formErrors, setFormErrors] = useState(defaultFormErrors);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoadingArticle, setIsLoadingArticle] = useState(false);
const [lastModifiedAt, setLastModifiedAt] = useState(null);
const [lastModifiedBy, setLastModifiedBy] = useState(null);
const makeRequest = useRequest();
const router = useRouter();
const searchParams = useSearchParams();
const articleId = searchParams.get("id");
const isEditing = Boolean(articleId);
useEffect(() => {
if (!articleId) return;
const loadArticle = async () => {
try {
setIsLoadingArticle(true);
const response = await fetch(
withBasePath(`/api/admin/news?id=${articleId}`)
);
if (!response.ok) {
throw new Error("Failed to load news article");
}
const article = await response.json();
if (article.error) {
throw new Error(article.error);
}
setTitle(article.title);
setContent(article.content);
setIsTeacherOnly(article.is_teacher_only || false);
setLastModifiedAt(article.updated_at || null);
setLastModifiedBy(article.updated_by || null);
} catch (error) {
console.error("Error loading news article:", error);
setGeneralError("Nachricht konnte nicht geladen werden.");
} finally {
setIsLoadingArticle(false);
}
};
loadArticle();
}, [articleId]);
const handleTitleChange = (e) => {
const val = e.target.value;
setTitle(val);
// Clear title error when user starts typing
if (formErrors.title) {
setFormErrors((prev) => ({ ...prev, title: "" }));
}
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
setGeneralError("");
setFormErrors(defaultFormErrors);
// Validate
const errors = { ...defaultFormErrors };
if (!title.trim()) {
errors.title = "Titel ist erforderlich";
}
if (!content.trim()) {
errors.content = "Inhalt ist erforderlich";
}
if (Object.values(errors).some((e) => e)) {
setFormErrors(errors);
setIsSubmitting(false);
return;
}
const method = isEditing ? "PUT" : "POST";
const url = "/admin/news";
const requestData = isEditing
? { id: articleId, title, content, is_teacher_only: isTeacherOnly }
: { title, content, is_teacher_only: isTeacherOnly };
const res = await makeRequest(url, requestData, { method });
if (res.status === 200 || res.status === 201) {
router.push("/admin/news");
}
} catch (e) {
console.error("Submission error:", e);
if (e.response?.data?.error) {
setGeneralError(e.response.data.error);
} else {
setGeneralError("Ein Fehler ist aufgetreten");
}
} finally {
setIsSubmitting(false);
}
};
const handleCancel = () => {
router.push("/admin/news");
};
if (isEditing && isLoadingArticle) {
return <p>Lädt Nachricht...</p>;
}
return (
<Column gap="md">
<h2>{isEditing ? "Nachricht bearbeiten" : "Nachricht erstellen"}</h2>
{isEditing && (
<AdminLastModified
updatedAt={lastModifiedAt}
updatedBy={lastModifiedBy}
/>
)}
<p>
{isEditing
? "Aktualisieren Sie die ausgewählte Nachricht."
: "Erstellen Sie eine neue Nachricht für die Startseite."}
</p>
{generalError && (
<p className="error" role="alert">
{generalError}
</p>
)}
<label>
Titel
<input
type="text"
value={title}
onChange={handleTitleChange}
placeholder="Geben Sie hier den Titel ein"
className={formErrors.title ? "error-input" : ""}
/>
{formErrors.title && <p className="error">{formErrors.title}</p>}
</label>
<Column gap="sm">
<label htmlFor="content">Inhalt</label>
<Editor
defaultContent={content}
updateEditorContent={(newContent) => {
setContent(newContent);
if (formErrors.content) {
setFormErrors((prev) => ({ ...prev, content: "" }));
}
}}
/>
{formErrors.content && <p className="error">{formErrors.content}</p>}
</Column>
<label>
<input
type="checkbox"
checked={isTeacherOnly}
onChange={(e) => setIsTeacherOnly(e.target.checked)}
/>
Nur für Lehrer sichtbar
</label>
<Row
justify={"space-between"}
align="center"
wrap="wrap"
gap="md"
mt={"xl"}
mb={"xl"}
>
<Button variant="outline" onClick={handleCancel}>
Abbrechen
</Button>
<Button
variant="secondary"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting
? isEditing
? "Speichern..."
: "Wird gesendet..."
: isEditing
? "Speichern"
: "Absenden"}
</Button>
</Row>
</Column>
);
}
+34
View File
@@ -0,0 +1,34 @@
.news-card {
background-color: var(--bg3);
padding: var(--u-md);
border-radius: var(--radius-md);
border: 1px solid var(--bg3);
border-left: 4px solid var(--fg2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s, border-left-color 0.2s;
}
.news-card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
border-left-color: var(--fg1);
}
.news-title {
color: var(--fg1);
margin: 0;
font-size: 1.35rem;
}
.news-date {
color: var(--fg2);
margin: 0;
font-size: 0.9rem;
font-weight: 500;
}
.news-meta {
font-size: 0.8rem;
color: var(--fg3);
margin-top: 0.5rem;
width: 100%;
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { Container, Column, Row } from "@/components/ui/layout/container";
import useQuery from "@/shared/hooks/useQuery";
import { useRequest } from "@/shared/hooks/useRequest";
import { useState } from "react";
import "./news.css";
import { LinkButton } from "@/components/ui/linkbutton";
import { Button } from "@/components/ui/button";
import RenderHTML from "@/components/ui/render-html/render-html";
export default function NewsList() {
const { data: articles, error, isLoading, refetch } = useQuery("/admin/news");
const makeRequest = useRequest();
const [deleting, setDeleting] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
if (isLoading) {
return <p>Wird geladen...</p>;
}
if (error) {
return <p className="error">Fehler beim Laden der Neuigkeiten</p>;
}
const handleDelete = async (id) => {
if (confirmDelete === id) {
try {
setDeleting(id);
await makeRequest(`/admin/news?id=${id}`, null, {
method: "DELETE",
});
await refetch();
setConfirmDelete(null);
} catch (error) {
console.error("Error deleting news article:", error);
alert("Fehler beim Löschen des Artikels");
} finally {
setDeleting(null);
}
} else {
setConfirmDelete(id);
// Auto-reset confirmation state after 3 seconds
setTimeout(() => {
if (setConfirmDelete) {
setConfirmDelete((current) => (current === id ? null : current));
}
}, 3000);
}
};
const sortedArticles = [...(articles || [])].sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at)
);
return (
<Column gap="md" width="100%">
<Row justify="space-between" gap="xl" width="100%" align="center">
<h2 id="news-top">Neuigkeiten</h2>
</Row>
<LinkButton href="/admin/news/create">
Neue Nachricht erstellen
</LinkButton>
{sortedArticles.length === 0 ? (
<p>
Keine Neuigkeiten vorhanden. Erstellen Sie eine neue Nachricht, um zu
beginnen.
</p>
) : (
<Container display="grid" gap="md">
{sortedArticles.map((article) => (
<Container key={article.id} className="news-card">
<Column gap="sm">
<h3 className="news-title">{article.title}</h3>
<p className="news-date">
{new Date(article.created_at).toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
})}
</p>
<RenderHTML data={article.content} disableGlossary />
<Row justify="flex-end" gap="sm" className="news-meta">
<LinkButton
href={`/admin/news/create?id=${article.id}`}
variant="outline"
size="sm"
>
Bearbeiten
</LinkButton>
<Button
variant={
confirmDelete === article.id ? "danger" : "outline"
}
size="sm"
width="fit"
onClick={() => handleDelete(article.id)}
disabled={deleting === article.id}
>
{deleting === article.id
? "Löschen..."
: confirmDelete === article.id
? "Bestätigen"
: "Löschen"}
</Button>
</Row>
</Column>
</Container>
))}
</Container>
)}
</Column>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { DB } from "@/backend/db";
import { NextResponse } from "next/server";
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (id) {
// Fetch single article by ID
const result = await DB.pool(
`SELECT id, title, content, created_at, updated_at, created_by, updated_by, is_teacher_only
FROM news_articles
WHERE id = $1`,
[id]
);
if (result.rows.length === 0) {
return NextResponse.json(
{ error: "News article not found" },
{ status: 404 }
);
}
return NextResponse.json(result.rows[0]);
}
// Fetch all articles
const articles = await DB.pool(
`SELECT id, title, content, created_at, updated_at, created_by, updated_by, is_teacher_only
FROM news_articles
ORDER BY created_at DESC`
);
return NextResponse.json(articles.rows);
} catch (error) {
console.error("Error fetching news articles:", error);
return NextResponse.json(
{ error: "Failed to fetch news articles" },
{ status: 500 }
);
}
}
export async function POST(request) {
try {
const { title, content, is_teacher_only } = await request.json();
if (!title || !content) {
return NextResponse.json(
{ error: "Title and content are required" },
{ status: 400 }
);
}
const result = await DB.pool(
`INSERT INTO news_articles (title, content, is_teacher_only)
VALUES ($1, $2, $3)
RETURNING id, title, content, created_at, updated_at, is_teacher_only`,
[title, content, is_teacher_only || false]
);
return NextResponse.json(result.rows[0], { status: 201 });
} catch (error) {
console.error("Error creating news article:", error);
return NextResponse.json(
{ error: "Failed to create news article" },
{ status: 500 }
);
}
}
export async function PUT(request) {
try {
const { id, title, content, is_teacher_only } = await request.json();
if (!id || !title || !content) {
return NextResponse.json(
{ error: "ID, title, and content are required" },
{ status: 400 }
);
}
const result = await DB.pool(
`UPDATE news_articles
SET title = $1, content = $2, is_teacher_only = $4, updated_at = now()
WHERE id = $3
RETURNING id, title, content, created_at, updated_at, is_teacher_only`,
[title, content, id, is_teacher_only || false]
);
if (result.rows.length === 0) {
return NextResponse.json(
{ error: "News article not found" },
{ status: 404 }
);
}
return NextResponse.json(result.rows[0]);
} catch (error) {
console.error("Error updating news article:", error);
return NextResponse.json(
{ error: "Failed to update news article" },
{ status: 500 }
);
}
}
export async function DELETE(request) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "ID is required" }, { status: 400 });
}
const result = await DB.pool(`DELETE FROM news_articles WHERE id = $1`, [
id,
]);
if (result.rowCount === 0) {
return NextResponse.json(
{ error: "News article not found" },
{ status: 404 }
);
}
return NextResponse.json({ message: "News article deleted successfully" });
} catch (error) {
console.error("Error deleting news article:", error);
return NextResponse.json(
{ error: "Failed to delete news article" },
{ status: 500 }
);
}
}
+21
View File
@@ -0,0 +1,21 @@
import { DB } from "@/backend/db";
import { NextResponse } from "next/server";
export async function GET() {
try {
const articles = await DB.pool(
`SELECT id, title, content, created_at, updated_at, is_teacher_only
FROM news_articles
WHERE is_teacher_only = false
ORDER BY created_at DESC`
);
return NextResponse.json(articles.rows);
} catch (error) {
console.error("Error fetching news articles:", error);
return NextResponse.json(
{ error: "Failed to fetch news articles" },
{ status: 500 }
);
}
}
+20
View File
@@ -0,0 +1,20 @@
import { DB } from "@/backend/db";
import { NextResponse } from "next/server";
export async function GET() {
try {
const articles = await DB.pool(
`SELECT id, title, content, created_at, updated_at, is_teacher_only
FROM news_articles
ORDER BY is_teacher_only DESC, created_at DESC`
);
return NextResponse.json(articles.rows);
} catch (error) {
console.error("Error fetching teacher news articles:", error);
return NextResponse.json(
{ error: "Failed to fetch teacher news articles" },
{ status: 500 }
);
}
}
+27
View File
@@ -0,0 +1,27 @@
.news-card {
background-color: var(--bg3);
padding: var(--u-md);
border-radius: var(--radius-md);
border: 1px solid var(--bg3);
border-left: 4px solid var(--fg2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s, border-left-color 0.2s;
}
.news-card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
border-left-color: var(--fg1);
}
.news-title {
color: var(--fg1);
margin: 0;
font-size: 1.35rem;
}
.news-date {
color: var(--fg2);
margin: 0;
font-size: 0.9rem;
font-weight: 500;
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { Container, Column, Row } from "@/components/ui/layout/container";
import useQuery from "@/shared/hooks/useQuery";
import RenderHTML from "@/components/ui/render-html/render-html";
import "./news.css";
export default function NewsPage() {
const { data: articles, error, isLoading } = useQuery("/news");
if (isLoading) {
return <p>Wird geladen...</p>;
}
if (error) {
return <p className="error">Fehler beim Laden der Neuigkeiten</p>;
}
const sortedArticles = [...(articles || [])].sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at)
);
return (
<Column gap="md" width="100%">
<Row justify="space-between" gap="xl" width="100%" align="center">
<h2>Neuigkeiten</h2>
</Row>
{sortedArticles.length === 0 ? (
<p>Keine Neuigkeiten vorhanden.</p>
) : (
<Container display="grid" gap="md">
{sortedArticles.map((article) => (
<Container key={article.id} className="news-card">
<Column gap="sm">
<h3 className="news-title">{article.title}</h3>
<p className="news-date">
{new Date(article.created_at).toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
})}
</p>
<RenderHTML data={article.content} disableGlossary />
</Column>
</Container>
))}
</Container>
)}
</Column>
);
}
+35
View File
@@ -0,0 +1,35 @@
.news-card {
background-color: var(--bg3);
padding: var(--u-md);
border-radius: var(--radius-md);
border: 1px solid var(--bg3);
border-left: 4px solid var(--fg2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s, border-left-color 0.2s;
}
.news-card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
border-left-color: var(--fg1);
}
.news-card--teacher {
border-left-color: var(--fg1);
}
.news-card--teacher:hover {
border-left-color: var(--fg1);
}
.news-title {
color: var(--fg1);
margin: 0;
font-size: 1.35rem;
}
.news-date {
color: var(--fg2);
margin: 0;
font-size: 0.9rem;
font-weight: 500;
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { Container, Column, Row } from "@/components/ui/layout/container";
import useQuery from "@/shared/hooks/useQuery";
import RenderHTML from "@/components/ui/render-html/render-html";
import "./news.css";
export default function TeacherNewsPage() {
const { data: articles, error, isLoading } = useQuery("/teacher/news");
if (isLoading) {
return <p>Wird geladen...</p>;
}
if (error) {
return <p className="error">Fehler beim Laden der Neuigkeiten</p>;
}
const sortedArticles = [...(articles || [])].sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at)
);
const teacherOnlyArticles = sortedArticles.filter((a) => a.is_teacher_only);
const generalArticles = sortedArticles.filter((a) => !a.is_teacher_only);
return (
<Column gap="md" width="100%">
<Row justify="space-between" gap="xl" width="100%" align="center">
<h2>Neuigkeiten</h2>
</Row>
{sortedArticles.length === 0 ? (
<p>Keine Neuigkeiten vorhanden.</p>
) : (
<Container display="grid" gap="md">
{teacherOnlyArticles.length > 0 && (
<>
<h3 style={{ marginTop: "0.5rem", marginBottom: "0" }}>
Für Lehrer
</h3>
{teacherOnlyArticles.map((article) => (
<Container
key={article.id}
className="news-card news-card--teacher"
>
<Column gap="sm">
<h4 className="news-title">{article.title}</h4>
<p className="news-date">
{new Date(article.created_at).toLocaleDateString(
"de-DE",
{
year: "numeric",
month: "long",
day: "numeric",
}
)}
</p>
<RenderHTML data={article.content} disableGlossary />
</Column>
</Container>
))}
</>
)}
{generalArticles.length > 0 && (
<>
{teacherOnlyArticles.length > 0 && (
<h3 style={{ marginTop: "1.5rem", marginBottom: "0" }}>
Allgemein
</h3>
)}
{generalArticles.map((article) => (
<Container key={article.id} className="news-card">
<Column gap="sm">
<h4 className="news-title">{article.title}</h4>
<p className="news-date">
{new Date(article.created_at).toLocaleDateString(
"de-DE",
{
year: "numeric",
month: "long",
day: "numeric",
}
)}
</p>
<RenderHTML data={article.content} disableGlossary />
</Column>
</Container>
))}
</>
)}
</Container>
)}
</Column>
);
}
@@ -115,6 +115,9 @@ function StudentSideBar() {
<Link className={styles.sidebarLink} href="/glossary">
Glossar
</Link>
<Link className={styles.sidebarLink} href="/news">
Neuigkeiten
</Link>
<Link className={styles.sidebarLink} href="/contact">
Brauchen Sie Hilfe?
</Link>
@@ -181,6 +184,16 @@ const extraLinks = [
];
const adminSidebarLinks = [
{
title: "Neuigkeiten",
link: "/teacher/news",
id: "teacher-news-view",
},
{
title: "Neuigkeiten (Verwaltung)",
link: "/admin/news",
id: "news",
},
{
title: "Übungen erstellen",
link: "/admin/create-exercise",