187 lines
6.3 KiB
TypeScript
187 lines
6.3 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { toast } from "sonner";
|
|
import { useState, useTransition } from "react";
|
|
import { Bell, FileText, Megaphone } from "lucide-react";
|
|
|
|
import { markAnnouncementRead } from "@/actions/announcements";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { MarkdownContent } from "@/components/markdown-content";
|
|
|
|
export type NewsTickerItemDto = {
|
|
id: string;
|
|
title: string;
|
|
content: string;
|
|
excerpt: string;
|
|
createdAt: string;
|
|
authorName: string;
|
|
isUnread: boolean;
|
|
attachments: {
|
|
id: string;
|
|
fileName: string;
|
|
fileUrl: string;
|
|
fileType: string;
|
|
}[];
|
|
};
|
|
|
|
export function NewsTicker({
|
|
items,
|
|
archive = false,
|
|
}: {
|
|
items: NewsTickerItemDto[];
|
|
archive?: boolean;
|
|
}) {
|
|
const [selected, setSelected] = useState<NewsTickerItemDto | null>(null);
|
|
const [readIds, setReadIds] = useState<Set<string>>(new Set());
|
|
const [, startTransition] = useTransition();
|
|
|
|
function openAnnouncement(item: NewsTickerItemDto) {
|
|
setSelected(item);
|
|
if (!item.isUnread || readIds.has(item.id)) return;
|
|
|
|
startTransition(async () => {
|
|
try {
|
|
const result = await markAnnouncementRead(item.id);
|
|
if (result.error) {
|
|
toast.error(result.error);
|
|
return;
|
|
}
|
|
setReadIds((current) => new Set(current).add(item.id));
|
|
} catch {
|
|
toast.error("Lesestatus konnte nicht gespeichert werden.");
|
|
}
|
|
});
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Card className="mb-8 border-[var(--good)] bg-[var(--good-soft)]">
|
|
<CardHeader className="pb-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2 text-lg">
|
|
<Megaphone className="h-5 w-5 text-[var(--good)] [stroke-width:1.7]" />
|
|
{archive ? "Mitteilungen" : "Neues aus der Kita"}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{archive
|
|
? "Veröffentlichte Ankündigungen deiner Kita."
|
|
: "Die neuesten offiziellen Ankündigungen deiner Kita."}
|
|
</CardDescription>
|
|
</div>
|
|
{archive ? (
|
|
<Badge variant="secondary">{items.length} Meldungen</Badge>
|
|
) : (
|
|
<Button asChild variant="outline" size="sm">
|
|
<Link href="/dashboard/mitteilungen">Alle Mitteilungen</Link>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-3 lg:grid-cols-3">
|
|
{items.length === 0 ? (
|
|
<p className="col-span-full py-4 text-sm text-[var(--ink-soft)]">
|
|
Noch keine Mitteilungen für deine Empfängergruppe.
|
|
</p>
|
|
) : null}
|
|
{items.map((item) => {
|
|
const isUnread = item.isUnread && !readIds.has(item.id);
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
onClick={() => openAnnouncement(item)}
|
|
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-4 text-left transition hover:border-[var(--accent-mid)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
|
>
|
|
<div className="mb-3 flex items-start justify-between gap-2">
|
|
<div className="flex items-center gap-2 text-xs text-[var(--ink-muted)]">
|
|
<Bell className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
|
{item.createdAt}
|
|
</div>
|
|
{isUnread && <Badge variant="success">NEU</Badge>}
|
|
</div>
|
|
<h3 className="line-clamp-2 font-semibold leading-snug text-[var(--ink)]">
|
|
{item.title}
|
|
</h3>
|
|
<p className="mt-2 line-clamp-3 text-sm leading-6 text-[var(--ink-soft)]">
|
|
{item.excerpt}
|
|
</p>
|
|
{item.attachments.length > 0 && (
|
|
<p className="mt-3 flex items-center gap-1 text-xs font-medium text-[var(--accent-deep)]">
|
|
<FileText className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
|
{item.attachments.length} Anhang
|
|
{item.attachments.length === 1 ? "" : "e"}
|
|
</p>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Dialog
|
|
open={!!selected}
|
|
onOpenChange={(open) => !open && setSelected(null)}
|
|
>
|
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
|
{selected && (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle className="text-2xl">{selected.title}</DialogTitle>
|
|
<DialogDescription>
|
|
{selected.createdAt} · {selected.authorName}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm">
|
|
<MarkdownContent content={selected.content} />
|
|
</div>
|
|
|
|
{selected.attachments.length > 0 && (
|
|
<div>
|
|
<p className="mb-2 text-sm font-medium">Anhänge</p>
|
|
<div className="grid gap-2">
|
|
{selected.attachments.map((attachment) => (
|
|
<Button
|
|
key={attachment.id}
|
|
asChild
|
|
variant="outline"
|
|
className="justify-start"
|
|
>
|
|
<a
|
|
href={attachment.fileUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
<FileText className="h-4 w-4 [stroke-width:1.7]" />
|
|
{attachment.fileName}
|
|
</a>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|