diff --git a/gradesa/data/migrations/00055_create_news_table.sql b/gradesa/data/migrations/00055_create_news_table.sql
new file mode 100644
index 0000000..dc147bc
--- /dev/null
+++ b/gradesa/data/migrations/00055_create_news_table.sql
@@ -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();
diff --git a/gradesa/data/migrations/00056_add_teacher_only_to_news.sql b/gradesa/data/migrations/00056_add_teacher_only_to_news.sql
new file mode 100644
index 0000000..51641ba
--- /dev/null
+++ b/gradesa/data/migrations/00056_add_teacher_only_to_news.sql
@@ -0,0 +1 @@
+ALTER TABLE news_articles ADD COLUMN IF NOT EXISTS is_teacher_only BOOLEAN NOT NULL DEFAULT false;
diff --git a/gradesa/data/schema.sql b/gradesa/data/schema.sql
index 49b6bf7..5a5ec1c 100644
--- a/gradesa/data/schema.sql
+++ b/gradesa/data/schema.sql
@@ -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(),
diff --git a/gradesa/src/app/admin/news/create/page.js b/gradesa/src/app/admin/news/create/page.js
new file mode 100644
index 0000000..ed9e552
--- /dev/null
+++ b/gradesa/src/app/admin/news/create/page.js
@@ -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
Lädt Nachricht...
;
+ }
+
+ return (
+
+ {isEditing ? "Nachricht bearbeiten" : "Nachricht erstellen"}
+ {isEditing && (
+
+ )}
+
+ {isEditing
+ ? "Aktualisieren Sie die ausgewählte Nachricht."
+ : "Erstellen Sie eine neue Nachricht für die Startseite."}
+
+ {generalError && (
+
+ {generalError}
+
+ )}
+
+
+
+ {
+ setContent(newContent);
+ if (formErrors.content) {
+ setFormErrors((prev) => ({ ...prev, content: "" }));
+ }
+ }}
+ />
+ {formErrors.content && {formErrors.content}
}
+
+
+
+
+
+
+
+ );
+}
diff --git a/gradesa/src/app/admin/news/news.css b/gradesa/src/app/admin/news/news.css
new file mode 100644
index 0000000..e8826f2
--- /dev/null
+++ b/gradesa/src/app/admin/news/news.css
@@ -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%;
+}
diff --git a/gradesa/src/app/admin/news/page.js b/gradesa/src/app/admin/news/page.js
new file mode 100644
index 0000000..27fe675
--- /dev/null
+++ b/gradesa/src/app/admin/news/page.js
@@ -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 Wird geladen...
;
+ }
+
+ if (error) {
+ return Fehler beim Laden der Neuigkeiten
;
+ }
+
+ 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 (
+
+
+ Neuigkeiten
+
+
+ Neue Nachricht erstellen
+
+
+ {sortedArticles.length === 0 ? (
+
+ Keine Neuigkeiten vorhanden. Erstellen Sie eine neue Nachricht, um zu
+ beginnen.
+
+ ) : (
+
+ {sortedArticles.map((article) => (
+
+
+ {article.title}
+
+ {new Date(article.created_at).toLocaleDateString("de-DE", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })}
+
+
+
+
+ Bearbeiten
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/gradesa/src/app/api/admin/news/route.js b/gradesa/src/app/api/admin/news/route.js
new file mode 100644
index 0000000..390925d
--- /dev/null
+++ b/gradesa/src/app/api/admin/news/route.js
@@ -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 }
+ );
+ }
+}
diff --git a/gradesa/src/app/api/news/route.js b/gradesa/src/app/api/news/route.js
new file mode 100644
index 0000000..f84de45
--- /dev/null
+++ b/gradesa/src/app/api/news/route.js
@@ -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 }
+ );
+ }
+}
diff --git a/gradesa/src/app/api/teacher/news/route.js b/gradesa/src/app/api/teacher/news/route.js
new file mode 100644
index 0000000..74e2f6a
--- /dev/null
+++ b/gradesa/src/app/api/teacher/news/route.js
@@ -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 }
+ );
+ }
+}
diff --git a/gradesa/src/app/news/news.css b/gradesa/src/app/news/news.css
new file mode 100644
index 0000000..b1db458
--- /dev/null
+++ b/gradesa/src/app/news/news.css
@@ -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;
+}
diff --git a/gradesa/src/app/news/page.js b/gradesa/src/app/news/page.js
new file mode 100644
index 0000000..a25d5cc
--- /dev/null
+++ b/gradesa/src/app/news/page.js
@@ -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 Wird geladen...
;
+ }
+
+ if (error) {
+ return Fehler beim Laden der Neuigkeiten
;
+ }
+
+ const sortedArticles = [...(articles || [])].sort(
+ (a, b) => new Date(b.created_at) - new Date(a.created_at)
+ );
+
+ return (
+
+
+ Neuigkeiten
+
+
+ {sortedArticles.length === 0 ? (
+ Keine Neuigkeiten vorhanden.
+ ) : (
+
+ {sortedArticles.map((article) => (
+
+
+ {article.title}
+
+ {new Date(article.created_at).toLocaleDateString("de-DE", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })}
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/gradesa/src/app/teacher/news/news.css b/gradesa/src/app/teacher/news/news.css
new file mode 100644
index 0000000..d98d96d
--- /dev/null
+++ b/gradesa/src/app/teacher/news/news.css
@@ -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;
+}
diff --git a/gradesa/src/app/teacher/news/page.js b/gradesa/src/app/teacher/news/page.js
new file mode 100644
index 0000000..348d6f3
--- /dev/null
+++ b/gradesa/src/app/teacher/news/page.js
@@ -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 Wird geladen...
;
+ }
+
+ if (error) {
+ return Fehler beim Laden der Neuigkeiten
;
+ }
+
+ 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 (
+
+
+ Neuigkeiten
+
+
+ {sortedArticles.length === 0 ? (
+ Keine Neuigkeiten vorhanden.
+ ) : (
+
+ {teacherOnlyArticles.length > 0 && (
+ <>
+
+ Für Lehrer
+
+ {teacherOnlyArticles.map((article) => (
+
+
+ {article.title}
+
+ {new Date(article.created_at).toLocaleDateString(
+ "de-DE",
+ {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }
+ )}
+
+
+
+
+ ))}
+ >
+ )}
+
+ {generalArticles.length > 0 && (
+ <>
+ {teacherOnlyArticles.length > 0 && (
+
+ Allgemein
+
+ )}
+ {generalArticles.map((article) => (
+
+
+ {article.title}
+
+ {new Date(article.created_at).toLocaleDateString(
+ "de-DE",
+ {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }
+ )}
+
+
+
+
+ ))}
+ >
+ )}
+
+ )}
+
+ );
+}
diff --git a/gradesa/src/components/ui/sidebar/sidebar.js b/gradesa/src/components/ui/sidebar/sidebar.js
index 26293bf..d574183 100644
--- a/gradesa/src/components/ui/sidebar/sidebar.js
+++ b/gradesa/src/components/ui/sidebar/sidebar.js
@@ -115,6 +115,9 @@ function StudentSideBar() {
Glossar
+
+ Neuigkeiten
+
Brauchen Sie Hilfe?
@@ -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",