feat(PROJ-80,PROJ-81): Dark Mode Vervollständigung + Anhang-Online-Vorschau
- PROJ-80: hartkodierte Farben in ModulesTab/TenantsTab/TenantLDAPTab durch Theme-Tokens ersetzt, Mail-HTML-Container bewusst hell isoliert, Print im Dark Mode auf lesbare (nicht farbidentische) Ausgabe umgestellt - PROJ-81: neuer AttachmentPreviewDialog für PDF/Bild-Anhänge in /mail/[id], MIME-Whitelist ohne SVG, sandboxed iframe ohne allow-same-origin, Auto-Retry-Schleife bei Fehlern behoben - PROJ-82 angelegt: Print-Farbparität als Folge-Ticket (BUG-80-1-Rest) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WapWkrQusDuBMhaN8WyuXB
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a1bf581fdd
commit
0a02bd7112
@@ -170,3 +170,69 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* PROJ-80: Druck-/PDF-Ausgabe immer hell, unabhaengig vom Bildschirm-Theme.
|
||||
* GoBD/Auditor-Kontext: Ausdrucke muessen einheitlich und lesbar sein.
|
||||
*/
|
||||
@media print {
|
||||
html.dark {
|
||||
color-scheme: light;
|
||||
}
|
||||
html.dark,
|
||||
html.dark body {
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
/*
|
||||
* BUG-80-1: bewusst KEIN Wildcard-Reset auf transparent. Ein
|
||||
* "html.dark * { background-color: transparent }" haette auch Status-Badges
|
||||
* und Dashboard-Balken entfaerbt und damit die Aussage der Seite zerstoert.
|
||||
* Stattdessen werden nur die Flaechen-Container neutralisiert. Farbtraeger
|
||||
* werden ueber die Klasse .print-chip bzw. role="progressbar" ausgenommen und
|
||||
* unten mit einer definierten hellen Ersatzflaeche versehen.
|
||||
*/
|
||||
html.dark
|
||||
:is(
|
||||
div, section, article, header, footer, main, aside, nav,
|
||||
table, thead, tbody, tr, th, td,
|
||||
p, h1, h2, h3, h4, h5, h6, span, li
|
||||
):not(
|
||||
.print-chip,
|
||||
.print-chip *,
|
||||
[role="progressbar"],
|
||||
[role="progressbar"] *
|
||||
) {
|
||||
background-color: transparent !important;
|
||||
color: #000 !important;
|
||||
border-color: #999 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
/*
|
||||
* Farbtraeger (Badges, Status-Chips, Fortschritts-/Diagrammbalken): statt sie
|
||||
* zu entfaerben, bekommen sie eine definierte helle Ersatzflaeche mit Rahmen.
|
||||
* So bleiben sie im Ausdruck als abgesetztes Element erkennbar und lesbar,
|
||||
* ohne dass dunkle Theme-Farben auf Papier landen.
|
||||
*/
|
||||
html.dark .print-chip,
|
||||
html.dark [role="progressbar"] {
|
||||
background-color: #f2f2f2 !important;
|
||||
color: #000 !important;
|
||||
border: 1px solid #999 !important;
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
/* Gefuellter Anteil eines Balkens bleibt als dunklere Flaeche unterscheidbar. */
|
||||
html.dark [role="progressbar"] > * {
|
||||
background-color: #999 !important;
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
/* Mail-Inhalt und Logo-Container bleiben im Ausdruck weiss. */
|
||||
html.dark iframe,
|
||||
html.dark img {
|
||||
background-color: #fff !important;
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { OcrBadge } from "@/components/ocr-badge";
|
||||
import { RestoreMailButton } from "@/components/mail/RestoreMailButton";
|
||||
import { AttachmentPreviewDialog } from "@/components/mail/AttachmentPreviewDialog";
|
||||
import { FileText } from "lucide-react";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -299,7 +300,9 @@ function MailBodyView({ mail }: { mail: MailDetail }) {
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
{/* Mail-HTML bewusst immer auf hellem Grund: fremder Content mit eigenen
|
||||
Farbangaben wird durch App-Dark-Mode sonst unlesbar/verfaelscht. */}
|
||||
<div className="overflow-hidden rounded-md border bg-white">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={srcdoc}
|
||||
@@ -330,6 +333,7 @@ function AttachmentRow({
|
||||
attachment: MailAttachment;
|
||||
}) {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
|
||||
async function handleDownload() {
|
||||
setDownloading(true);
|
||||
@@ -354,14 +358,32 @@ function AttachmentRow({
|
||||
{attachment.content_type} · {formatBytes(attachment.size)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
>
|
||||
{downloading ? "..." : "Herunterladen"}
|
||||
</Button>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* BUG-81-2: Button auch fuer nicht-vorschaubare Typen (docx/xlsx),
|
||||
der Dialog zeigt dann den "Keine Vorschau verfuegbar"-Hinweis. */}
|
||||
<Button variant="ghost" size="sm" onClick={() => setPreviewOpen(true)}>
|
||||
Vorschau
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
>
|
||||
{downloading ? "..." : "Herunterladen"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{previewOpen && (
|
||||
<AttachmentPreviewDialog
|
||||
mailId={mailId}
|
||||
attachment={attachment}
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
onDownload={handleDownload}
|
||||
downloading={downloading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
"Planned": "bg-gray-100 text-gray-700",
|
||||
"In Progress": "bg-yellow-100 text-yellow-800",
|
||||
"In Review": "bg-blue-100 text-blue-800",
|
||||
"Deployed": "bg-green-100 text-green-800",
|
||||
"Removed": "bg-red-50 text-red-700 line-through",
|
||||
"Planned": "bg-muted text-muted-foreground",
|
||||
"In Progress": "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-200",
|
||||
"In Review": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200",
|
||||
"Deployed": "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200",
|
||||
"Removed": "bg-red-50 text-red-700 line-through dark:bg-red-900/40 dark:text-red-200",
|
||||
};
|
||||
|
||||
const statusCounts = (list: Feature[]) => ({
|
||||
@@ -37,15 +37,15 @@ export function ModulesTab() {
|
||||
{/* Summary bar */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "In Progress", value: counts.inProgress, color: "bg-yellow-100 text-yellow-800" },
|
||||
{ label: "In Review", value: counts.inReview, color: "bg-blue-100 text-blue-800" },
|
||||
{ label: "Deployed", value: counts.deployed, color: "bg-green-100 text-green-800" },
|
||||
{ label: "Geplant", value: counts.planned, color: "bg-gray-100 text-gray-700" },
|
||||
{ label: "In Progress", value: counts.inProgress, color: statusColors["In Progress"] },
|
||||
{ label: "In Review", value: counts.inReview, color: statusColors["In Review"] },
|
||||
{ label: "Deployed", value: counts.deployed, color: statusColors["Deployed"] },
|
||||
{ label: "Geplant", value: counts.planned, color: statusColors["Planned"] },
|
||||
].map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{s.label}</span>
|
||||
<span className={`text-lg font-bold px-2 py-0.5 rounded ${s.color}`}>
|
||||
<span className={`print-chip text-lg font-bold px-2 py-0.5 rounded ${s.color}`}>
|
||||
{s.value}
|
||||
</span>
|
||||
</CardContent>
|
||||
@@ -75,7 +75,7 @@ export function ModulesTab() {
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{f.name}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded-full ${statusColors[f.status]}`}>
|
||||
<span className={`print-chip text-xs font-medium px-2 py-1 rounded-full ${statusColors[f.status]}`}>
|
||||
{f.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
@@ -377,7 +377,8 @@ export function TenantLDAPTab({
|
||||
{ownLogoError && <p className="text-sm text-destructive">{ownLogoError}</p>}
|
||||
{ownLogoPreviewUrl ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center justify-center rounded border p-3 bg-muted/30">
|
||||
{/* Neutral heller Container unabhaengig vom Theme, damit auch dunkle Logos sichtbar bleiben */}
|
||||
<div className="flex items-center justify-center rounded border p-3 bg-white">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={ownLogoPreviewUrl} alt="Logo" className="max-h-20 max-w-40 object-contain" />
|
||||
</div>
|
||||
|
||||
@@ -325,7 +325,7 @@ export function TenantsTab({
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{logoPreviewUrl && (
|
||||
<div className="flex items-center justify-center rounded border p-4 bg-muted/30">
|
||||
<div className="flex items-center justify-center rounded border p-4 bg-white" title="Neutral heller Hintergrund, damit dunkle Logos in beiden Themes sichtbar bleiben">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={logoPreviewUrl} alt="Logo" className="max-h-32 max-w-full object-contain" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
import { downloadMailAttachment, type MailAttachment } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
/**
|
||||
* PROJ-81: Anhang-Online-Vorschau.
|
||||
*
|
||||
* Security (PROJ-61-Kontext, Stored XSS via SVG-Upload):
|
||||
* Der vom Server gelieferte Content-Type wird bewusst NICHT zum Rendern benutzt.
|
||||
* Der Anhang wird per authentifiziertem fetch als Blob geholt und dann mit einem
|
||||
* clientseitig erzwungenen MIME-Typ aus der Whitelist unten neu verpackt.
|
||||
* Dadurch kann eine als "rechnung.pdf" getarnte HTML-/SVG-Datei nicht als aktives
|
||||
* Dokument im App-Origin ausgefuehrt werden.
|
||||
*
|
||||
* - SVG ist absichtlich NICHT in der Whitelist (SVG kann Skripte enthalten).
|
||||
* - PDFs laufen in einem sandboxed iframe MIT allow-scripts, aber ohne
|
||||
* allow-same-origin: der Frame hat dadurch einen eigenen, undurchsichtigen
|
||||
* Origin und keinen Zugriff auf Cookies/DOM der App. allow-scripts ist noetig,
|
||||
* weil die browsereigenen PDF-Viewer sonst nicht rendern.
|
||||
* - Es entsteht kein oeffentlicher Link: die Blob-URL lebt nur im Tab und wird
|
||||
* beim Schliessen des Dialogs wieder freigegeben.
|
||||
*/
|
||||
|
||||
/** Dateiendung -> erzwungener MIME-Typ. Nur was hier steht, wird gerendert. */
|
||||
const IMAGE_TYPES: Record<string, string> = {
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
bmp: "image/bmp",
|
||||
};
|
||||
|
||||
const PDF_EXT = "pdf";
|
||||
|
||||
/** Ab dieser Groesse wird nicht automatisch geladen, sondern nachgefragt. */
|
||||
export const PREVIEW_SIZE_WARN_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
type PreviewKind = "image" | "pdf" | "unsupported";
|
||||
|
||||
function extensionOf(filename: string): string {
|
||||
const idx = filename.lastIndexOf(".");
|
||||
if (idx < 0 || idx === filename.length - 1) return "";
|
||||
return filename.slice(idx + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Entscheidet allein anhand der Dateiendung, ob und wie vorgeschaut wird.
|
||||
* Bewusst nicht anhand des Server-Content-Type (siehe Security-Hinweis oben).
|
||||
*/
|
||||
export function previewKindFor(attachment: MailAttachment): PreviewKind {
|
||||
const ext = extensionOf(attachment.filename);
|
||||
if (ext === PDF_EXT) return "pdf";
|
||||
if (ext in IMAGE_TYPES) return "image";
|
||||
return "unsupported";
|
||||
}
|
||||
|
||||
export function canPreview(attachment: MailAttachment): boolean {
|
||||
return previewKindFor(attachment) !== "unsupported";
|
||||
}
|
||||
|
||||
function forcedMimeType(attachment: MailAttachment): string {
|
||||
const ext = extensionOf(attachment.filename);
|
||||
if (ext === PDF_EXT) return "application/pdf";
|
||||
return IMAGE_TYPES[ext] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export interface AttachmentPreviewDialogProps {
|
||||
mailId: string;
|
||||
attachment: MailAttachment;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Download-Aktion der aufrufenden Zeile, bleibt im Dialog verfuegbar. */
|
||||
onDownload: () => void;
|
||||
downloading?: boolean;
|
||||
}
|
||||
|
||||
export function AttachmentPreviewDialog({
|
||||
mailId,
|
||||
attachment,
|
||||
open,
|
||||
onOpenChange,
|
||||
onDownload,
|
||||
downloading = false,
|
||||
}: AttachmentPreviewDialogProps) {
|
||||
const kind = previewKindFor(attachment);
|
||||
const isLarge = attachment.size > PREVIEW_SIZE_WARN_BYTES;
|
||||
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmedLarge, setConfirmedLarge] = useState(false);
|
||||
|
||||
// Blob-URL zuverlaessig freigeben, auch bei Unmount waehrend des Ladens.
|
||||
const urlRef = useRef<string | null>(null);
|
||||
const revoke = useCallback(() => {
|
||||
if (urlRef.current) {
|
||||
URL.revokeObjectURL(urlRef.current);
|
||||
urlRef.current = null;
|
||||
}
|
||||
setObjectUrl(null);
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { blob } = await downloadMailAttachment(mailId, attachment.index);
|
||||
// Erzwungener MIME-Typ statt Server-Angabe (siehe Security-Hinweis oben).
|
||||
const safeBlob = new Blob([blob], { type: forcedMimeType(attachment) });
|
||||
const url = URL.createObjectURL(safeBlob);
|
||||
urlRef.current = url;
|
||||
setObjectUrl(url);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: "Anhang konnte nicht geladen werden."
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [mailId, attachment]);
|
||||
|
||||
// Laden starten, sobald der Dialog offen ist und die Groesse unkritisch bzw.
|
||||
// vom User bestaetigt ist.
|
||||
//
|
||||
// BUG-81-1: `error` MUSS hier als Guard stehen. Ohne ihn kippt `loading` nach
|
||||
// einem Fehlschlag zurueck auf false, der Effekt feuert erneut und load()
|
||||
// laeuft endlos gegen den Anhang-Endpunkt (DSGVO-geloeschter Anhang, korrupte
|
||||
// Datei, passwortgeschuetztes PDF). Nach einem Fehler wird bewusst NICHT
|
||||
// automatisch erneut geladen — der User waehlt Download oder erneutes Oeffnen.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (kind === "unsupported") return;
|
||||
if (isLarge && !confirmedLarge) return;
|
||||
if (error) return;
|
||||
if (urlRef.current || loading) return;
|
||||
void load();
|
||||
}, [open, kind, isLarge, confirmedLarge, error, loading, load]);
|
||||
|
||||
// Aufraeumen beim Schliessen und beim Unmount.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
revoke();
|
||||
setError(null);
|
||||
setConfirmedLarge(false);
|
||||
}
|
||||
}, [open, revoke]);
|
||||
|
||||
useEffect(() => revoke, [revoke]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[90vh] w-[95vw] max-w-4xl flex-col gap-4">
|
||||
<DialogHeader className="pr-6">
|
||||
<DialogTitle className="truncate text-base" title={attachment.filename}>
|
||||
{attachment.filename}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{attachment.content_type} · {formatBytes(attachment.size)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-[240px] flex-1 overflow-auto rounded-md border bg-muted/30">
|
||||
{kind === "unsupported" && (
|
||||
<div className="flex h-full min-h-[240px] flex-col items-center justify-center gap-2 p-8 text-center">
|
||||
<p className="text-sm font-medium">Keine Vorschau verfügbar</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Für diesen Dateityp gibt es keine Online-Vorschau. Die Datei kann
|
||||
heruntergeladen werden.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind !== "unsupported" && isLarge && !confirmedLarge && (
|
||||
<div className="flex h-full min-h-[240px] flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<p className="text-sm font-medium">
|
||||
Große Datei ({formatBytes(attachment.size)})
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Die Vorschau kann längere Zeit dauern und viel Arbeitsspeicher
|
||||
belegen.
|
||||
</p>
|
||||
<Button size="sm" onClick={() => setConfirmedLarge(true)}>
|
||||
Trotzdem laden
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind !== "unsupported" && (!isLarge || confirmedLarge) && (
|
||||
<>
|
||||
{loading && (
|
||||
<div className="flex h-full min-h-[240px] items-center justify-center gap-2 p-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Vorschau wird geladen…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="p-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
Vorschau fehlgeschlagen: {error} Die Datei kann eventuell
|
||||
trotzdem heruntergeladen werden.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && objectUrl && kind === "image" && (
|
||||
// Neutral heller Grund: transparente PNGs bleiben in beiden Themes sichtbar.
|
||||
<div className="flex min-h-[240px] items-center justify-center bg-white p-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={objectUrl}
|
||||
alt={attachment.filename}
|
||||
className="max-h-[70vh] max-w-full object-contain"
|
||||
onError={() =>
|
||||
setError("Datei ist beschädigt oder kein gültiges Bild.")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
allow-scripts ohne allow-same-origin: der Frame laeuft in einem
|
||||
eigenen, undurchsichtigen Origin (kein Zugriff auf Cookies/DOM der
|
||||
App). Skripte sind noetig, weil die eingebauten PDF-Viewer der
|
||||
Browser sonst nicht rendern.
|
||||
*/}
|
||||
{!loading && !error && objectUrl && kind === "pdf" && (
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
title={`Vorschau: ${attachment.filename}`}
|
||||
sandbox="allow-scripts"
|
||||
className="h-[70vh] w-full border-0 bg-white"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={onDownload}
|
||||
disabled={downloading}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{downloading ? "Wird geladen…" : "Herunterladen"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Schließen
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -83,4 +83,7 @@ export const features: Feature[] = [
|
||||
{ id: "PROJ-66", name: "Backup-Strategie für Store, Keyfile, PostgreSQL", status: "Deployed", frontend: false, backend: true, lastUpdated: "2026-07-04", version: "1.0" },
|
||||
{ id: "PROJ-67", name: "Manticore Search Upgrade + Auto-Upgrade-Pfad", status: "Deployed", frontend: false, backend: true, lastUpdated: "2026-07-05", version: "1.0" },
|
||||
{ id: "PROJ-72", name: "Fix Superadmin kann Passwort/Rolle von Superadmin-Peers nicht ändern", status: "Deployed", frontend: false, backend: true, lastUpdated: "2026-07-27", version: "1.0" },
|
||||
{ id: "PROJ-80", name: "Dark Mode Vervollständigung (Konsistenz + Default/Persistenz)", status: "In Review", frontend: true, backend: false, lastUpdated: "2026-08-06", version: "1.0" },
|
||||
{ id: "PROJ-81", name: "Anhang-Online-Vorschau (PDF, Bilder)", status: "In Review", frontend: true, backend: false, lastUpdated: "2026-08-06", version: "1.0" },
|
||||
{ id: "PROJ-82", name: "Print-Farbparität zwischen Hell- und Dark-Mode-Ausdrucken", status: "Planned", frontend: true, backend: false, lastUpdated: "2026-08-06", version: "1.0" },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user