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",