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.
|
- **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 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.
|
- **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.
|
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.
|
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 [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [editEntry, setEditEntry] = useState<LogbuchEintrag | null>(null);
|
const [editEntry, setEditEntry] = useState<LogbuchEintrag | null>(null);
|
||||||
|
|
||||||
|
const grafikSrc = '/api/statistik/grafik';
|
||||||
|
|
||||||
const version = packageJson.version;
|
const version = packageJson.version;
|
||||||
const buildDate =
|
const buildDate =
|
||||||
process.env.NEXT_PUBLIC_BUILD_DATE ||
|
process.env.NEXT_PUBLIC_BUILD_DATE ||
|
||||||
@@ -98,7 +100,10 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
|
|||||||
.map((tab) => (
|
.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
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 ${
|
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||||
activeTab === tab
|
activeTab === tab
|
||||||
? 'border-[#85B7D7] text-gray-900'
|
? 'border-[#85B7D7] text-gray-900'
|
||||||
@@ -173,14 +178,22 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
|
|||||||
{/* Statistik-Tab */}
|
{/* Statistik-Tab */}
|
||||||
{activeTab === 'statistik' && (
|
{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="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>
|
<span className="text-sm font-semibold text-gray-600">Statistik (alle Kuppeln)</span>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => window.print()}
|
<button
|
||||||
className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg"
|
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>
|
🖨 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>
|
</div>
|
||||||
<Statistik />
|
<Statistik />
|
||||||
</div>
|
</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: '/(.*)',
|
source: '/(.*)',
|
||||||
headers: [
|
headers: [
|
||||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
|
||||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||||
@@ -19,6 +18,7 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "logbuch",
|
"name": "logbuch",
|
||||||
"version": "1.7.7",
|
"version": "1.7.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export async function proxy(request: NextRequest) {
|
|||||||
return NextResponse.next();
|
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);
|
const cookie = request.cookies.get(SESSION_COOKIE_NAME);
|
||||||
|
|
||||||
if (!cookie?.value) {
|
if (!cookie?.value) {
|
||||||
@@ -31,7 +36,15 @@ export async function proxy(request: NextRequest) {
|
|||||||
return NextResponse.redirect(new URL('/', request.url));
|
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 {
|
} catch {
|
||||||
return NextResponse.redirect(new URL('/login', request.url));
|
return NextResponse.redirect(new URL('/login', request.url));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user