Bump version to 1.7.8
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "Logbuch Dev",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspace",
|
||||
"forwardPorts": [3000, 3306],
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
"label": "Next.js Dev Server",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"3306": {
|
||||
"label": "MySQL",
|
||||
"onAutoForward": "silent"
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "npm install",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"ms-vscode.vscode-typescript-next"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
app:
|
||||
image: mcr.microsoft.com/devcontainers/javascript-node:20
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
environment:
|
||||
DB_HOST: host.docker.internal
|
||||
DB_USER: logbuch
|
||||
DB_PASS: Ds!?f?f6X5B2
|
||||
DB_NAME: sternwarte
|
||||
DB_PORT: 3336
|
||||
AUTH_SECRET: 75c3075e200d50f2273c60edcea5aca58796831e2c99ce2a69fca0005d5920cd
|
||||
DEFAULT_PASSWORD: welzheim
|
||||
NODE_ENV: development
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
command: sleep infinity
|
||||
@@ -175,3 +175,28 @@ Zeigt alle bekannten Objekte mit ID, Name und Datum der letzten Verwendung.
|
||||
- **Neues Objekt anlegen**: Feld unten ausfüllen und **Hinzufügen** klicken.
|
||||
- **Objekt umbenennen**: Stift-Symbol ✎ in der Zeile anklicken, Namen ändern und mit **Speichern** bestätigen oder mit **Abbrechen** verwerfen.
|
||||
- **Objekt löschen**: × in der Zeile – es erscheint ein Bestätigungsdialog. Das Löschen ist **unwiderruflich** und entfernt das Objekt aus allen bestehenden Logbucheinträgen.
|
||||
|
||||
## Neue Features (Statistik Grafik Proxy)
|
||||
|
||||
Dieses Release ergänzt eine serverseitige Proxy-Lösung für das interne Statistik-Portal, damit geschützte Diagramme sicher eingeblendet werden können, ohne Zugangsdaten im Browser zu speichern.
|
||||
|
||||
- Was neu ist:
|
||||
- Server-seitiger Proxy unter `/api/statistik/grafik` und Catch-all `/api/statistik/grafik/*`.
|
||||
- Holt die Statistik-Seite mit Basic-Auth (serverseitig) und liefert sie an den Browser weiter.
|
||||
- Schreibt die HTML-Antwort so um, dass relative Assets (CSS/JS/Images) über die Proxy-URL geladen werden (es wird ein `<base href="/api/statistik/grafik/">` eingefügt).
|
||||
- Leitet auch AJAX-POSTs (z. B. `php/statistic.php`) weiter – Methoden und Bodies werden beibehalten.
|
||||
- Entfernt framing-blockierende Header (z. B. `X-Frame-Options`, CSP-Meta-Tags) in der proxied HTML-Antwort.
|
||||
|
||||
- Wichtige Environment-Variablen (nur serverseitig):
|
||||
- `STATISTIK_GRAFIK_URL` — Basis-URL des internen Statistik-Portals (z. B. `https://sternwarte-welzheim.de/intern/statistik`).
|
||||
- `STATISTIK_GRAFIK_USER` — Benutzername für Basic-Auth.
|
||||
- `STATISTIK_GRAFIK_PASS` — Passwort für Basic-Auth.
|
||||
|
||||
- UI-Änderung:
|
||||
- Der `Grafik`-Button in der Statistik-Ansicht öffnet die Statistik jetzt in einem neuen Fenster (`window.open('/api/statistik/grafik', '_blank')`). Die vorherige iframe-Integration wurde entfernt, weil manche Browser (insbesondere Firefox und Safari) Probleme mit eingebetteten, geschützten Seiten machen.
|
||||
|
||||
- Sicherheit & Hinweise:
|
||||
- Setze die `STATISTIK_...` Variablen in deiner Server-Umgebung (Docker secrets, CI/CD env vars, oder im Host-Umfeld). Niemals Zugangsdaten ins Repository committen.
|
||||
- Die Proxy-Route ist so konfiguriert, dass Assets und AJAX-Aufrufe über den gleichen Proxy laufen, damit die Seite vollständig funktioniert.
|
||||
|
||||
Wenn du möchtest, pushe ich die Änderungen an `ANLEITUNG.md` in `origin/main` für dich.
|
||||
|
||||
@@ -34,3 +34,24 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
||||
## Neue Features (Statistik Grafik Proxy)
|
||||
|
||||
- Proxy für das interne Statistik-Portal:
|
||||
- Neuer Server-seitiger Proxy unter `/api/statistik/grafik` (und Catch-all `/api/statistik/grafik/*`).
|
||||
- Holt die Statistik-Seite serverseitig mit Basic-Auth und gibt sie an den Browser weiter, damit Zugangsdaten nicht im Client landen.
|
||||
- Leitet auch CSS/JS/Images und AJAX-POSTs durch den Proxy (weitergeleitete Methoden und Bodies werden erhalten).
|
||||
- Die HTML-Antwort wird bereinigt und relative URLs so umgeschrieben, dass Assets über die Proxy-URL geladen werden (`<base href="/api/statistik/grafik/">`).
|
||||
|
||||
- Environment-Variablen (server-side only):
|
||||
- `STATISTIK_GRAFIK_URL` — Basis-URL des internen Statistik-Portals (z. B. `https://sternwarte-welzheim.de/intern/statistik`).
|
||||
- `STATISTIK_GRAFIK_USER` — Benutzername für Basic-Auth.
|
||||
- `STATISTIK_GRAFIK_PASS` — Passwort für Basic-Auth.
|
||||
|
||||
- UI-Änderung:
|
||||
- Der `Grafik`-Button in der Statistik-Ansicht öffnet jetzt die Statistik-Seite in einem neuen Fenster (`window.open('/api/statistik/grafik', '_blank')`). Die vorherige iframe-Integration wurde entfernt, da Browser (insb. Firefox/Safari) bei Einbettung Probleme mit X-Frame-Options/CSP gemacht haben.
|
||||
|
||||
- Middleware / Sicherheit:
|
||||
- Die Proxy-Route wird in der App-Auth-Middleware erlaubt, so dass der Proxy die Statistik-Seite auch ohne Benutzer-Session laden kann (Zugangskontrolle erfolgt über die serverseitigen Basic-Auth-Variablen).
|
||||
|
||||
Hinweis: Speichere sensible Zugangsdaten nicht in Repositories. Setze die drei `STATISTIK_...` Variablen in deiner Deployment-Umgebung (z. B. Docker secrets, CI/CD environment variables oder auf dem Server). Die Proxy-Implementierung entfernt framing-blockierende Header und schiebt relative Asset-Pfade durch den Proxy, um Kompatibilitätsprobleme mit Browsern zu vermeiden.
|
||||
|
||||
+21
-8
@@ -22,6 +22,8 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [editEntry, setEditEntry] = useState<LogbuchEintrag | null>(null);
|
||||
|
||||
const grafikSrc = '/api/statistik/grafik';
|
||||
|
||||
const version = packageJson.version;
|
||||
const buildDate =
|
||||
process.env.NEXT_PUBLIC_BUILD_DATE ||
|
||||
@@ -98,7 +100,10 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
|
||||
.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => { setActiveTab(tab); if (tab === 'eingabe') setEditEntry(null); }}
|
||||
onClick={() => {
|
||||
setActiveTab(tab);
|
||||
if (tab === 'eingabe') setEditEntry(null);
|
||||
}}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
activeTab === tab
|
||||
? 'border-[#85B7D7] text-gray-900'
|
||||
@@ -173,14 +178,22 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
|
||||
{/* Statistik-Tab */}
|
||||
{activeTab === 'statistik' && (
|
||||
<div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0">
|
||||
<div className="flex justify-between items-center mb-2 print:hidden">
|
||||
<div className="flex justify-between items-center mb-2 print:hidden gap-2">
|
||||
<span className="text-sm font-semibold text-gray-600">Statistik (alle Kuppeln)</span>
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg"
|
||||
>
|
||||
🖨 Drucken
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg"
|
||||
>
|
||||
🖨 Drucken
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(grafikSrc, '_blank')}
|
||||
className="text-sm px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"
|
||||
>
|
||||
📊 Grafik
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Statistik />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { grafikProxy } from '../proxy';
|
||||
|
||||
function getSlugFromRequest(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const prefix = '/api/statistik/grafik';
|
||||
const path = url.pathname;
|
||||
if (!path.startsWith(prefix)) return undefined;
|
||||
const suffix = path.slice(prefix.length);
|
||||
return suffix.split('/').filter(Boolean);
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
return grafikProxy(req, getSlugFromRequest(req));
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
return grafikProxy(req, getSlugFromRequest(req));
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const blockedHeaders = new Set([
|
||||
'x-frame-options',
|
||||
'content-security-policy',
|
||||
'content-security-policy-report-only',
|
||||
'frame-options',
|
||||
'content-encoding',
|
||||
'content-length',
|
||||
'transfer-encoding',
|
||||
]);
|
||||
|
||||
function buildProxyTarget(req: Request, slug?: string[]) {
|
||||
const upstreamUrl = process.env.STATISTIK_GRAFIK_URL;
|
||||
if (!upstreamUrl) {
|
||||
throw new Error('STATISTIK_GRAFIK_URL is not configured');
|
||||
}
|
||||
|
||||
const target = new URL(upstreamUrl);
|
||||
const pathSuffix = slug?.filter(Boolean).join('/') || '';
|
||||
if (pathSuffix) {
|
||||
target.pathname = `${target.pathname.replace(/\/$/, '')}/${pathSuffix}`;
|
||||
}
|
||||
target.search = new URL(req.url).search;
|
||||
return target.toString();
|
||||
}
|
||||
|
||||
function buildAuthHeaders() {
|
||||
const headers: Record<string, string> = {};
|
||||
const user = process.env.STATISTIK_GRAFIK_USER;
|
||||
const pass = process.env.STATISTIK_GRAFIK_PASS;
|
||||
if (user && pass) {
|
||||
const token = Buffer.from(`${user}:${pass}`).toString('base64');
|
||||
headers.Authorization = `Basic ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function rewriteHtml(body: string) {
|
||||
const proxyBase = '/api/statistik/grafik';
|
||||
const upstreamOrigin = new URL(process.env.STATISTIK_GRAFIK_URL!).origin;
|
||||
const escapedProxyBase = escapeRegExp(proxyBase);
|
||||
const proxyBasePattern = escapedProxyBase.replace(/\//g, '\\/');
|
||||
|
||||
return body
|
||||
.replace(/<meta[^>]*?(?:http-equiv|httpEquiv)\s*=\s*['"]?[^'">\s]+['"]?[^>]*>/ig, (m) => {
|
||||
if (/x-frame-options/i.test(m)) return '';
|
||||
if (/content-security-policy/i.test(m)) return '';
|
||||
if (/frame-ancestors/i.test(m)) return '';
|
||||
return m;
|
||||
})
|
||||
.replace(/<head([^>]*)>/i, `<head$1><base href="${proxyBase}/">`)
|
||||
.replace(new RegExp(`(href|src|action)=(["'])(?!${proxyBasePattern}\\/)\/`, 'gi'), `$1=$2${proxyBase}/`)
|
||||
.replace(new RegExp(`url\\((['"]?)(?!${proxyBasePattern}\\/)\/`, 'gi'), `url($1${proxyBase}/`)
|
||||
.replace(new RegExp(escapeRegExp(upstreamOrigin), 'g'), proxyBase);
|
||||
}
|
||||
|
||||
export async function grafikProxy(req: Request, slug?: string[]) {
|
||||
let targetUrl: string;
|
||||
try {
|
||||
targetUrl = buildProxyTarget(req, slug);
|
||||
} catch (error) {
|
||||
return new NextResponse((error as Error).message, { status: 500 });
|
||||
}
|
||||
|
||||
try {
|
||||
const requestHeaders: Record<string, string> = {};
|
||||
req.headers.forEach((value, key) => {
|
||||
const lower = key.toLowerCase();
|
||||
if (lower === 'host' || lower === 'content-length') return;
|
||||
requestHeaders[key] = value;
|
||||
});
|
||||
Object.assign(requestHeaders, buildAuthHeaders());
|
||||
|
||||
const body = ['GET', 'HEAD'].includes(req.method) ? undefined : await req.arrayBuffer();
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method: req.method,
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
cache: 'no-store',
|
||||
});
|
||||
const bodyBuf = await upstream.arrayBuffer();
|
||||
|
||||
const outHeaders: Record<string, string> = {};
|
||||
upstream.headers.forEach((value, key) => {
|
||||
if (!blockedHeaders.has(key.toLowerCase())) {
|
||||
outHeaders[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
const contentType = upstream.headers.get('content-type') || outHeaders['content-type'] || '';
|
||||
if (contentType.toLowerCase().includes('text/html')) {
|
||||
const text = Buffer.from(bodyBuf).toString('utf8');
|
||||
const cleaned = rewriteHtml(text);
|
||||
outHeaders['content-type'] = 'text/html; charset=utf-8';
|
||||
outHeaders['x-frame-options'] = 'ALLOWALL';
|
||||
return new NextResponse(cleaned, { status: upstream.status, headers: outHeaders });
|
||||
}
|
||||
|
||||
outHeaders['x-frame-options'] = 'ALLOWALL';
|
||||
return new NextResponse(Buffer.from(bodyBuf), { status: upstream.status, headers: outHeaders });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new NextResponse(`grafikProxy error: ${message}`, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { grafikProxy } from './proxy';
|
||||
|
||||
function getSlugFromRequest(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const prefix = '/api/statistik/grafik';
|
||||
const path = url.pathname;
|
||||
if (!path.startsWith(prefix)) return undefined;
|
||||
const suffix = path.slice(prefix.length);
|
||||
return suffix.split('/').filter(Boolean);
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
return grafikProxy(req, getSlugFromRequest(req));
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
return grafikProxy(req, getSlugFromRequest(req));
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
value: string; // "YYYY-MM-DD"
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||
];
|
||||
|
||||
const WEEKDAY_SHORT = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function monthKey(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`;
|
||||
}
|
||||
|
||||
function monthLabel(ym: string): string {
|
||||
const [ys, ms] = ym.split('-');
|
||||
const y = Number(ys);
|
||||
const m = Number(ms);
|
||||
return `${MONTH_NAMES[m - 1]} ${y}`;
|
||||
}
|
||||
|
||||
function shiftMonth(ym: string, delta: number): string {
|
||||
const [ys, ms] = ym.split('-');
|
||||
const d = new Date(Number(ys), Number(ms) - 1 + delta, 1);
|
||||
return monthKey(d);
|
||||
}
|
||||
|
||||
function parseISODate(v: string): Date | null {
|
||||
if (!isValidDateString(v)) return null;
|
||||
const [ys, ms, ds] = v.split('-');
|
||||
return new Date(Number(ys), Number(ms) - 1, Number(ds));
|
||||
}
|
||||
|
||||
function buildMonthGrid(ym: string): Array<number | null> {
|
||||
const [ys, ms] = ym.split('-');
|
||||
const y = Number(ys);
|
||||
const m = Number(ms);
|
||||
const first = new Date(y, m - 1, 1);
|
||||
const days = new Date(y, m, 0).getDate();
|
||||
const mondayStartOffset = (first.getDay() + 6) % 7;
|
||||
const cells: Array<number | null> = Array(mondayStartOffset).fill(null);
|
||||
for (let d = 1; d <= days; d += 1) cells.push(d);
|
||||
while (cells.length % 7 !== 0) cells.push(null);
|
||||
return cells;
|
||||
}
|
||||
|
||||
function isValidDateString(v: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) return false;
|
||||
const [ys, ms, ds] = v.split('-');
|
||||
const y = Number(ys);
|
||||
const m = Number(ms);
|
||||
const d = Number(ds);
|
||||
if (m < 1 || m > 12 || d < 1 || d > 31) return false;
|
||||
|
||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||
return (
|
||||
dt.getUTCFullYear() === y &&
|
||||
dt.getUTCMonth() === m - 1 &&
|
||||
dt.getUTCDate() === d
|
||||
);
|
||||
}
|
||||
|
||||
function normalize(v: string): string {
|
||||
const digits = v.replace(/\D/g, '').slice(0, 8);
|
||||
const y = digits.slice(0, 4);
|
||||
const m = digits.slice(4, 6);
|
||||
const d = digits.slice(6, 8);
|
||||
|
||||
if (digits.length <= 4) return y;
|
||||
if (digits.length <= 6) return `${y}-${m}`;
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export default function DateInput({ value, onChange, className = '' }: Props) {
|
||||
const [local, setLocal] = useState(value);
|
||||
const [error, setError] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [viewMonth, setViewMonth] = useState(monthKey(new Date()));
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedDate = useMemo(() => parseISODate(value), [value]);
|
||||
const dayCells = useMemo(() => buildMonthGrid(viewMonth), [viewMonth]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocal(value);
|
||||
setError(false);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const date = parseISODate(value);
|
||||
if (date) setViewMonth(monthKey(date));
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
function onDocMouseDown(ev: MouseEvent) {
|
||||
if (!wrapperRef.current) return;
|
||||
if (!wrapperRef.current.contains(ev.target as Node)) setOpen(false);
|
||||
}
|
||||
function onDocKeyDown(ev: KeyboardEvent) {
|
||||
if (ev.key === 'Escape') setOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', onDocMouseDown);
|
||||
document.addEventListener('keydown', onDocKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocMouseDown);
|
||||
document.removeEventListener('keydown', onDocKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleChange(raw: string) {
|
||||
setError(false);
|
||||
setLocal(normalize(raw));
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
if (local === '') {
|
||||
setLocal(value);
|
||||
setError(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isValidDateString(local)) {
|
||||
setError(false);
|
||||
onChange(local);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(true);
|
||||
}
|
||||
|
||||
function selectDay(day: number) {
|
||||
const [ys, ms] = viewMonth.split('-');
|
||||
const iso = `${ys}-${ms}-${pad(day)}`;
|
||||
setLocal(iso);
|
||||
setError(false);
|
||||
onChange(iso);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function isSelected(day: number): boolean {
|
||||
if (!selectedDate) return false;
|
||||
const [ys, ms] = viewMonth.split('-').map(Number);
|
||||
return selectedDate.getFullYear() === ys && selectedDate.getMonth() === ms - 1 && selectedDate.getDate() === day;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={`relative ${className}`}>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={local}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
placeholder="YYYY-MM-DD"
|
||||
maxLength={10}
|
||||
className={`w-full px-2 py-1 border-2 rounded-lg bg-white text-sm text-gray-900 font-mono text-center focus:outline-none ${
|
||||
error ? 'border-red-500 focus:border-red-500' : 'border-gray-400 focus:border-blue-500'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-xs text-gray-700 hover:bg-gray-50"
|
||||
aria-label="Kalender oeffnen"
|
||||
>
|
||||
Kalender
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full mt-1 z-20 w-64 border-2 border-gray-300 rounded-lg bg-white shadow-lg p-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMonth((m) => shiftMonth(m, -1))}
|
||||
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-sm"
|
||||
aria-label="Vorheriger Monat"
|
||||
>
|
||||
{'<'}
|
||||
</button>
|
||||
<div className="text-sm font-medium">{monthLabel(viewMonth)}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMonth((m) => shiftMonth(m, 1))}
|
||||
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-sm"
|
||||
aria-label="Naechster Monat"
|
||||
>
|
||||
{'>'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
{WEEKDAY_SHORT.map((wd) => (
|
||||
<div key={wd} className="text-[11px] text-center text-gray-500 py-1">
|
||||
{wd}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{dayCells.map((day, idx) => (
|
||||
<button
|
||||
key={`${viewMonth}-${idx}`}
|
||||
type="button"
|
||||
disabled={day === null}
|
||||
onClick={() => day !== null && selectDay(day)}
|
||||
className={`h-8 rounded text-sm ${
|
||||
day === null
|
||||
? 'text-transparent cursor-default'
|
||||
: isSelected(day)
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-50 hover:bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{day ?? '-'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="absolute left-0 top-full mt-0.5 text-xs text-red-600 whitespace-nowrap z-10">
|
||||
Ungueltiges Datum (YYYY-MM-DD)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -7,7 +7,6 @@ const nextConfig: NextConfig = {
|
||||
{
|
||||
source: '/(.*)',
|
||||
headers: [
|
||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||
@@ -19,6 +18,7 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "logbuch",
|
||||
"version": "1.7.7",
|
||||
"version": "1.7.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -13,6 +13,11 @@ export async function proxy(request: NextRequest) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Allow the statistik grafik proxy route to be called without an app session cookie
|
||||
if (pathname.startsWith('/api/statistik/grafik')) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const cookie = request.cookies.get(SESSION_COOKIE_NAME);
|
||||
|
||||
if (!cookie?.value) {
|
||||
@@ -31,7 +36,15 @@ export async function proxy(request: NextRequest) {
|
||||
return NextResponse.redirect(new URL('/', request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
if (pathname === '/api/statistik/grafik') {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
return NextResponse.next({
|
||||
headers: {
|
||||
'X-Frame-Options': 'DENY',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user