Initial implementation: Logbuch Sternwarte Welzheim
Vollständige Next.js 16 Webanwendung als Logbuch für die Sternwarte Welzheim. 4 Kuppeln (West/Ost/Süd/Pluto), BEO-basierte Authentifizierung mit erzwungenem Passwort-Wechsel beim Erstlogin, MySQL-Backend, Docker-Deployment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
38
Dockerfile
Normal file
38
Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ENV NEXT_PUBLIC_BUILD_DATE=${BUILD_DATE}
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
135
app/MainClient.tsx
Normal file
135
app/MainClient.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { KUPPELN } from '@/types/logbuch';
|
||||||
|
import type { Kuppel, LogbuchEintrag } from '@/types/logbuch';
|
||||||
|
import LogbuchForm from '@/components/LogbuchForm';
|
||||||
|
import LogbuchList from '@/components/LogbuchList';
|
||||||
|
import packageJson from '@/package.json';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
kuerzel: string;
|
||||||
|
beoId: number;
|
||||||
|
beoName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MainClient({ kuerzel, beoId, beoName }: Props) {
|
||||||
|
const [activeKuppel, setActiveKuppel] = useState<Kuppel>('West');
|
||||||
|
const [activeTab, setActiveTab] = useState<'eingabe' | 'liste'>('eingabe');
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const [editEntry, setEditEntry] = useState<LogbuchEintrag | null>(null);
|
||||||
|
|
||||||
|
const version = packageJson.version;
|
||||||
|
const buildDate =
|
||||||
|
process.env.NEXT_PUBLIC_BUILD_DATE ||
|
||||||
|
new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
|
||||||
|
const currentUserBeo = { ID: beoId, Kuerzel: kuerzel, Name: beoName };
|
||||||
|
|
||||||
|
function handleSaved() {
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
setEditEntry(null);
|
||||||
|
if (editEntry) setActiveTab('liste');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(entry: LogbuchEintrag) {
|
||||||
|
setEditEntry(entry);
|
||||||
|
setActiveTab('eingabe');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await fetch('/api/logout', { method: 'POST' });
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white py-4 px-4">
|
||||||
|
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">Logbuch — Sternwarte Welzheim</h1>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{kuerzel} — {beoName}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 rounded-lg text-gray-700"
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kuppel-Tabs */}
|
||||||
|
<div className="flex gap-1 mb-4 border-b-2 border-gray-300">
|
||||||
|
{KUPPELN.map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
onClick={() => { setActiveKuppel(k); setEditEntry(null); }}
|
||||||
|
className={`px-5 py-2 text-sm font-medium rounded-t-lg transition-colors ${
|
||||||
|
activeKuppel === k
|
||||||
|
? 'bg-[#85B7D7] text-black border-2 border-b-0 border-gray-300'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Kuppel {k}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Eingabe/Liste-Tabs */}
|
||||||
|
<div className="flex gap-1 mb-6 border-b border-gray-200">
|
||||||
|
{(['eingabe', 'liste'] as const).map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => { setActiveTab(tab); if (tab === 'eingabe' && editEntry) 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'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab === 'eingabe' ? 'Eingabe' : 'Liste'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'eingabe' && (
|
||||||
|
<div>
|
||||||
|
{editEntry && (
|
||||||
|
<div className="mb-4 text-sm text-amber-700 bg-amber-50 border border-amber-300 rounded-lg px-3 py-2">
|
||||||
|
Eintrag bearbeiten (ID {editEntry.ID})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<LogbuchForm
|
||||||
|
key={`${activeKuppel}-${editEntry?.ID ?? 'new'}`}
|
||||||
|
kuppel={activeKuppel}
|
||||||
|
currentUserBeo={currentUserBeo}
|
||||||
|
editEntry={editEntry}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'liste' && (
|
||||||
|
<LogbuchList
|
||||||
|
kuppel={activeKuppel}
|
||||||
|
refreshKey={refreshKey}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4">
|
||||||
|
<div>
|
||||||
|
<a href="mailto:rxf@gmx.de" className="text-blue-600 hover:underline">
|
||||||
|
mailto:rxf@gmx.de
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
Version {version} — {buildDate}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
app/api/beos/route.ts
Normal file
12
app/api/beos/route.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { query } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const rows = await query('SELECT ID, Kuerzel, Name FROM beos ORDER BY Name ASC');
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GET /api/beos:', error);
|
||||||
|
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
79
app/api/logbuch/[id]/route.ts
Normal file
79
app/api/logbuch/[id]/route.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { query, getPool } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
import type { SelectedObjekt } from '@/types/logbuch';
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const logbuchId = parseInt(id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { Kuppel, ArtFuehrung, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body;
|
||||||
|
|
||||||
|
await getPool().execute(
|
||||||
|
`UPDATE logbuch SET Kuppel=?, ArtFuehrung=?, Beginn=?, Ende=?, Besucher=?,
|
||||||
|
Bemerkungen=?, WetterTemp=?, WetterFeuchte=?, WetterDruck=?
|
||||||
|
WHERE ID=?`,
|
||||||
|
[
|
||||||
|
Kuppel, ArtFuehrung, Beginn, Ende,
|
||||||
|
Besucher ?? 0,
|
||||||
|
Bemerkungen?.slice(0, 500) || null,
|
||||||
|
Wetter?.temp ?? null,
|
||||||
|
Wetter?.feuchte ?? null,
|
||||||
|
Wetter?.druck ?? null,
|
||||||
|
logbuchId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
await query('DELETE FROM logbuch_beos WHERE LogbuchID = ?', [logbuchId]);
|
||||||
|
await query('DELETE FROM logbuch_objekte WHERE LogbuchID = ?', [logbuchId]);
|
||||||
|
|
||||||
|
for (const beoId of (beoIds as number[]) || []) {
|
||||||
|
await query('INSERT INTO logbuch_beos (LogbuchID, BeoID) VALUES (?, ?)', [logbuchId, beoId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const obj of (objekte as SelectedObjekt[]) || []) {
|
||||||
|
let objektId = obj.ID;
|
||||||
|
if (!objektId) {
|
||||||
|
const existing = await query('SELECT ID FROM objekte WHERE Name = ?', [obj.Name]) as { ID: number }[];
|
||||||
|
if (existing[0]) {
|
||||||
|
objektId = existing[0].ID;
|
||||||
|
} else {
|
||||||
|
const [ins] = await getPool().execute(
|
||||||
|
'INSERT INTO objekte (Name) VALUES (?)', [obj.Name]
|
||||||
|
) as [{ insertId: number }, unknown];
|
||||||
|
objektId = ins.insertId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await query('UPDATE objekte SET LastUsed = NOW() WHERE ID = ?', [objektId]);
|
||||||
|
await query(
|
||||||
|
'INSERT INTO logbuch_objekte (LogbuchID, ObjektID, ObjektName) VALUES (?, ?, ?)',
|
||||||
|
[logbuchId, objektId, obj.Name]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PUT /api/logbuch/[id]:', error);
|
||||||
|
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await query('DELETE FROM logbuch WHERE ID = ?', [parseInt(id)]);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('DELETE /api/logbuch/[id]:', error);
|
||||||
|
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/api/logbuch/route.ts
Normal file
92
app/api/logbuch/route.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { query } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
import type { SelectedObjekt } from '@/types/logbuch';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const kuppel = searchParams.get('kuppel') || 'West';
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT
|
||||||
|
l.ID, l.Kuppel, l.ArtFuehrung,
|
||||||
|
DATE_FORMAT(l.Beginn, '%Y-%m-%dT%H:%i') AS Beginn,
|
||||||
|
DATE_FORMAT(l.Ende, '%Y-%m-%dT%H:%i') AS Ende,
|
||||||
|
l.Besucher, l.Bemerkungen,
|
||||||
|
l.WetterTemp, l.WetterFeuchte, l.WetterDruck,
|
||||||
|
l.created_by, l.created_at,
|
||||||
|
GROUP_CONCAT(DISTINCT b.Kuerzel ORDER BY b.Kuerzel SEPARATOR ', ') AS BEOs,
|
||||||
|
GROUP_CONCAT(DISTINCT lo.ObjektName ORDER BY lo.ObjektName SEPARATOR ', ') AS Objekte
|
||||||
|
FROM logbuch l
|
||||||
|
LEFT JOIN logbuch_beos lb ON lb.LogbuchID = l.ID
|
||||||
|
LEFT JOIN beos b ON b.ID = lb.BeoID
|
||||||
|
LEFT JOIN logbuch_objekte lo ON lo.LogbuchID = l.ID
|
||||||
|
WHERE l.Kuppel = ?
|
||||||
|
GROUP BY l.ID
|
||||||
|
ORDER BY l.Beginn DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
[kuppel, limit]
|
||||||
|
);
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GET /api/logbuch:', error);
|
||||||
|
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { Kuppel, ArtFuehrung, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body;
|
||||||
|
|
||||||
|
const [result] = await (await import('@/lib/db')).getPool().execute(
|
||||||
|
`INSERT INTO logbuch (Kuppel, ArtFuehrung, Beginn, Ende, Besucher, Bemerkungen, WetterTemp, WetterFeuchte, WetterDruck, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
Kuppel, ArtFuehrung, Beginn, Ende,
|
||||||
|
Besucher ?? 0,
|
||||||
|
Bemerkungen?.slice(0, 500) || null,
|
||||||
|
Wetter?.temp ?? null,
|
||||||
|
Wetter?.feuchte ?? null,
|
||||||
|
Wetter?.druck ?? null,
|
||||||
|
session.beoId,
|
||||||
|
]
|
||||||
|
) as [{ insertId: number }, unknown];
|
||||||
|
|
||||||
|
const logbuchId = result.insertId;
|
||||||
|
|
||||||
|
for (const beoId of (beoIds as number[]) || []) {
|
||||||
|
await query('INSERT INTO logbuch_beos (LogbuchID, BeoID) VALUES (?, ?)', [logbuchId, beoId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const obj of (objekte as SelectedObjekt[]) || []) {
|
||||||
|
let objektId = obj.ID;
|
||||||
|
if (!objektId) {
|
||||||
|
const existing = await query('SELECT ID FROM objekte WHERE Name = ?', [obj.Name]) as { ID: number }[];
|
||||||
|
if (existing[0]) {
|
||||||
|
objektId = existing[0].ID;
|
||||||
|
} else {
|
||||||
|
const [ins] = await (await import('@/lib/db')).getPool().execute(
|
||||||
|
'INSERT INTO objekte (Name) VALUES (?)', [obj.Name]
|
||||||
|
) as [{ insertId: number }, unknown];
|
||||||
|
objektId = ins.insertId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await query('UPDATE objekte SET LastUsed = NOW() WHERE ID = ?', [objektId]);
|
||||||
|
await query(
|
||||||
|
'INSERT INTO logbuch_objekte (LogbuchID, ObjektID, ObjektName) VALUES (?, ?, ?)',
|
||||||
|
[logbuchId, objektId, obj.Name]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ id: logbuchId }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('POST /api/logbuch:', error);
|
||||||
|
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/api/logout/route.ts
Normal file
7
app/api/logout/route.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { deleteSession } from '@/lib/session';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
await deleteSession();
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
12
app/api/objekte/route.ts
Normal file
12
app/api/objekte/route.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { query } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const rows = await query('SELECT ID, Name FROM objekte ORDER BY LastUsed DESC LIMIT 100');
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GET /api/objekte:', error);
|
||||||
|
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
8
app/api/wetter/route.ts
Normal file
8
app/api/wetter/route.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const temp = Math.round((8 + Math.random() * 15) * 10) / 10;
|
||||||
|
const feuchte = Math.round((40 + Math.random() * 50) * 10) / 10;
|
||||||
|
const druck = Math.round((990 + Math.random() * 30) * 10) / 10;
|
||||||
|
return NextResponse.json({ temp, feuchte, druck });
|
||||||
|
}
|
||||||
41
app/change-password/actions.ts
Normal file
41
app/change-password/actions.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getSession, createSession } from '@/lib/session';
|
||||||
|
import { hashPassword } from '@/lib/auth';
|
||||||
|
import { query } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function changePassword(
|
||||||
|
_prevState: { error: string } | undefined,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<{ error: string }> {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) redirect('/login');
|
||||||
|
|
||||||
|
const newPassword = formData.get('newPassword') as string;
|
||||||
|
const confirmPassword = formData.get('confirmPassword') as string;
|
||||||
|
|
||||||
|
if (!newPassword || newPassword.length < 6) {
|
||||||
|
return { error: 'Das Passwort muss mindestens 6 Zeichen lang sein.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
return { error: 'Die Passwörter stimmen nicht überein.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashed = await hashPassword(newPassword);
|
||||||
|
await query(
|
||||||
|
'UPDATE beos SET Passwort = ?, MustChangePassword = 0 WHERE ID = ?',
|
||||||
|
[hashed, session.beoId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await createSession({
|
||||||
|
kuerzel: session.kuerzel,
|
||||||
|
beoId: session.beoId,
|
||||||
|
beoName: session.beoName,
|
||||||
|
mustChangePassword: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
112
app/change-password/page.tsx
Normal file
112
app/change-password/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useActionState, useState } from 'react';
|
||||||
|
import { changePassword } from './actions';
|
||||||
|
|
||||||
|
export default function ChangePasswordPage() {
|
||||||
|
const [state, action, isPending] = useActionState(changePassword, undefined);
|
||||||
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white py-4 px-4">
|
||||||
|
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">Logbuch — Sternwarte Welzheim</h1>
|
||||||
|
|
||||||
|
<div className="flex justify-center py-10">
|
||||||
|
<div className="w-full max-w-sm bg-white border border-gray-300 rounded-xl shadow-md p-8">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-2 text-center">Passwort ändern</h2>
|
||||||
|
<p className="text-sm text-amber-700 bg-amber-50 border border-amber-300 rounded-lg px-3 py-2 mb-6 text-center">
|
||||||
|
Bitte wählen Sie ein neues Passwort, bevor Sie fortfahren.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form action={action} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Neues Passwort
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="newPassword"
|
||||||
|
name="newPassword"
|
||||||
|
type={showNew ? 'text' : 'password'}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
className="w-full px-3 py-2 pr-10 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-blue-500 focus:outline-none text-sm"
|
||||||
|
placeholder="mind. 6 Zeichen"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNew((v) => !v)}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-500 hover:text-gray-800"
|
||||||
|
>
|
||||||
|
{showNew ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 4.411m0 0L21 21" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Passwort bestätigen
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
type={showConfirm ? 'text' : 'password'}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 pr-10 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-blue-500 focus:outline-none text-sm"
|
||||||
|
placeholder="Passwort wiederholen"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirm((v) => !v)}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-500 hover:text-gray-800"
|
||||||
|
>
|
||||||
|
{showConfirm ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 4.411m0 0L21 21" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state?.error && (
|
||||||
|
<div className="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
|
||||||
|
{state.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full py-2 px-4 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
{isPending ? 'Wird gespeichert...' : 'Passwort speichern'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,34 +1,15 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from 'next';
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import './globals.css';
|
||||||
import "./globals.css";
|
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: 'Logbuch — Sternwarte Welzheim',
|
||||||
description: "Generated by create next app",
|
description: 'Logbuch für die Sternwarte Welzheim',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="de">
|
||||||
<body
|
<body className="antialiased">{children}</body>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
37
app/login/actions.ts
Normal file
37
app/login/actions.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { verifyCredentials } from '@/lib/auth';
|
||||||
|
import { createSession } from '@/lib/session';
|
||||||
|
|
||||||
|
export async function login(
|
||||||
|
_prevState: { error: string } | undefined,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<{ error: string }> {
|
||||||
|
const kuerzel = (formData.get('username') as string)?.trim();
|
||||||
|
const password = formData.get('password') as string;
|
||||||
|
|
||||||
|
if (!kuerzel || !password) {
|
||||||
|
return { error: 'Bitte Kürzel und Passwort eingeben.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await verifyCredentials(kuerzel, password);
|
||||||
|
|
||||||
|
if (!result || !result.valid) {
|
||||||
|
return { error: 'Ungültiges Kürzel oder Passwort.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await createSession({
|
||||||
|
kuerzel: result.beo.Kuerzel,
|
||||||
|
beoId: result.beo.ID,
|
||||||
|
beoName: result.beo.Name,
|
||||||
|
mustChangePassword: result.beo.MustChangePassword === 1,
|
||||||
|
isAuthenticated: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.beo.MustChangePassword === 1) {
|
||||||
|
redirect('/change-password');
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
110
app/login/page.tsx
Normal file
110
app/login/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useActionState, useState } from 'react';
|
||||||
|
import { login } from './actions';
|
||||||
|
import packageJson from '@/package.json';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [state, loginAction, isPending] = useActionState(login, undefined);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
const version = packageJson.version;
|
||||||
|
const buildDate =
|
||||||
|
process.env.NEXT_PUBLIC_BUILD_DATE ||
|
||||||
|
new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white py-4 px-4">
|
||||||
|
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">Logbuch — Sternwarte Welzheim</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center py-10">
|
||||||
|
<div className="w-full max-w-sm bg-white border border-gray-300 rounded-xl shadow-md p-8">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-6 text-center">Anmeldung</h2>
|
||||||
|
|
||||||
|
<form action={loginAction} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Kürzel
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-blue-500 focus:outline-none text-sm"
|
||||||
|
placeholder="Kürzel"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="w-full px-3 py-2 pr-10 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-blue-500 focus:outline-none text-sm"
|
||||||
|
placeholder="Passwort"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
|
||||||
|
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-500 hover:text-gray-800"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 4.411m0 0L21 21" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state?.error && (
|
||||||
|
<div className="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
|
||||||
|
{state.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full py-2 px-4 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
{isPending ? 'Anmeldung läuft...' : 'Anmelden'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4">
|
||||||
|
<div>
|
||||||
|
<a href="mailto:rxf@gmx.de" className="text-blue-600 hover:underline">
|
||||||
|
mailto:rxf@gmx.de
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
Version {version} — {buildDate}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
app/page.tsx
73
app/page.tsx
@@ -1,65 +1,16 @@
|
|||||||
import Image from "next/image";
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
import MainClient from './MainClient';
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) redirect('/login');
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<MainClient
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
kuerzel={session.kuerzel}
|
||||||
<Image
|
beoId={session.beoId}
|
||||||
className="dark:invert"
|
beoName={session.beoName}
|
||||||
src="/next.svg"
|
/>
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
42
claude/Logbuch.md
Normal file
42
claude/Logbuch.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
Erstelle ein Logbuch für die Sternwarte in Welzheim.
|
||||||
|
+ Die Sternwarte hat 4 Kuppeln: West, Ost, Süd und Pluto, die West-Kuppel ist der Default. Für jede Kuppel gibt es ein eigenes Logbuch. Alle 4 Logbücher sind identisch gebaut.
|
||||||
|
+ Als Datenbank soll eine MYSQL verwendet werden
|
||||||
|
+ Als Sprache soll nextjs zum Einsatz kommen
|
||||||
|
+ Der Zugang zu der Webseite soll per User/Passwort gesichert werden
|
||||||
|
+ Der User soll folgende Einträge eingeben können
|
||||||
|
+ Kuppel (West, Ost ...)
|
||||||
|
+ Art der Führung. Auswahl aus folgenden Möglichkeiten:
|
||||||
|
+ Reguläre Führung
|
||||||
|
+ Sonderführung
|
||||||
|
+ BEO-Sitzung
|
||||||
|
+ Sonnenführung
|
||||||
|
+ Technischer Dienst
|
||||||
|
+ Beobachtung
|
||||||
|
+ Tag der offenen Tür
|
||||||
|
+ Sonstiges
|
||||||
|
Reguläre Führung ist der Default
|
||||||
|
+ Beginn der Führung (Datum und Uhrzeit, Datum ist vorausgefüllt mit dem aktuellen Datum)
|
||||||
|
+ Ende der Führung (wie Beginn)
|
||||||
|
+ Anzahl der Besucher
|
||||||
|
+ BEOs Hier ist der angemeldet User als 1. BEO vorbelegt, weitere können zugefügt werden
|
||||||
|
+ diese BEOs werden aus einer Tabelle in der MYSQL-DB gelesen
|
||||||
|
+ beobachtete Objekte
|
||||||
|
+ schon gespeicherte Objekte werden ebenfalls aus einer Tabelle in der MYSQL gelesen und zur Auswahl angeboten, eine Neueingabe ist natürlich möglich
|
||||||
|
+ ein Feld mit Bemerkungen; freier Text mit max. 500 Zeichen
|
||||||
|
+ die aktuellen Wetterdaten (werden aus aus dem Wetterarchiv ausgelesen, kann vorerst via MOCK simuliert werden)
|
||||||
|
+ Zusätzlich zur Eingabe kann die Liste der letzten Einträge (20 pro Seite) angezeigt werden als Tabelle
|
||||||
|
+ Umschaltung zwischen Eingabe und Liste über TABs
|
||||||
|
|
||||||
|
Besonderheit bei der Eingabe der beobachteten Objekte:
|
||||||
|
+ die Auswahlliste soll als ersten Eintrag 'Neu' haben
|
||||||
|
+ dann folgen die zuletzt eingetragenen Objekte in zeitlich absteigender Reihenfolge
|
||||||
|
|
||||||
|
User/Passwort-Eingabe
|
||||||
|
+ jeder User, der sich anmeldet, MUSS in der BEO-Tabelle in der DB vorhanden sein, verglichen wird sein Namenskürzel (das steht in der DB)
|
||||||
|
+ beim ersten mal meldet sich der User mit einem Default-Passwort an und wird aufgefordert, diese sofort zu ändern
|
||||||
|
+ falls er es nicht ändert, kann er nicht weiter machen
|
||||||
|
+ Das selbst gewählte Passwort wird dann in der BEO-Tabelle gespeichert und beim nächsten Login verwendet
|
||||||
|
|
||||||
|
Bitte stelle Rückfragen, falls etwas unklar ist.
|
||||||
|
|
||||||
|
|
||||||
70
components/BeoSelector.tsx
Normal file
70
components/BeoSelector.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { BeoOption } from '@/types/logbuch';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selected: BeoOption[];
|
||||||
|
onChange: (beos: BeoOption[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BeoSelector({ selected, onChange }: Props) {
|
||||||
|
const [all, setAll] = useState<BeoOption[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/beos')
|
||||||
|
.then((r) => { if (!r.ok) throw new Error('Fehler'); return r.json(); })
|
||||||
|
.then(setAll)
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedIds = new Set(selected.map((b) => b.ID));
|
||||||
|
const available = all.filter((b) => !selectedIds.has(b.ID));
|
||||||
|
|
||||||
|
function add(id: string) {
|
||||||
|
if (!id) return;
|
||||||
|
const beo = all.find((b) => b.ID === parseInt(id));
|
||||||
|
if (beo) onChange([...selected, beo]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id: number) {
|
||||||
|
onChange(selected.filter((b) => b.ID !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selected.map((b) => (
|
||||||
|
<span
|
||||||
|
key={b.ID}
|
||||||
|
className="inline-flex items-center gap-1 bg-blue-100 text-blue-800 text-sm px-2 py-1 rounded-full"
|
||||||
|
>
|
||||||
|
{b.Kuerzel} — {b.Name}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(b.ID)}
|
||||||
|
className="ml-1 text-blue-600 hover:text-red-600 font-bold leading-none"
|
||||||
|
aria-label={`${b.Kuerzel} entfernen`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{available.length > 0 && (
|
||||||
|
<select
|
||||||
|
className="px-3 py-1.5 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
|
||||||
|
value=""
|
||||||
|
onChange={(e) => add(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">+ BEO hinzufügen</option>
|
||||||
|
{available.map((b) => (
|
||||||
|
<option key={b.ID} value={b.ID}>
|
||||||
|
{b.Kuerzel} — {b.Name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
270
components/LogbuchForm.tsx
Normal file
270
components/LogbuchForm.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { Kuppel, ArtFuehrung, BeoOption, SelectedObjekt, Wetter, LogbuchEintrag } from '@/types/logbuch';
|
||||||
|
import { ARTEN } from '@/types/logbuch';
|
||||||
|
import BeoSelector from './BeoSelector';
|
||||||
|
import ObjektSelector from './ObjektSelector';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
kuppel: Kuppel;
|
||||||
|
currentUserBeo: BeoOption;
|
||||||
|
editEntry?: LogbuchEintrag | null;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalDatetimeValue(isoOrDatetime: string): string {
|
||||||
|
if (!isoOrDatetime) return '';
|
||||||
|
return isoOrDatetime.slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowLocalDatetime(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved }: Props) {
|
||||||
|
const [artFuehrung, setArtFuehrung] = useState<ArtFuehrung>('Reguläre Führung');
|
||||||
|
const [beginn, setBeginn] = useState(nowLocalDatetime());
|
||||||
|
const [ende, setEnde] = useState(nowLocalDatetime());
|
||||||
|
const [besucher, setBesucher] = useState(0);
|
||||||
|
const [beos, setBeos] = useState<BeoOption[]>([currentUserBeo]);
|
||||||
|
const [objekte, setObjekte] = useState<SelectedObjekt[]>([]);
|
||||||
|
const [bemerkungen, setBemerkungen] = useState('');
|
||||||
|
const [wetter, setWetter] = useState<Wetter | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/wetter')
|
||||||
|
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
|
||||||
|
.then(setWetter)
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editEntry) {
|
||||||
|
setArtFuehrung(editEntry.ArtFuehrung);
|
||||||
|
setBeginn(toLocalDatetimeValue(editEntry.Beginn));
|
||||||
|
setEnde(toLocalDatetimeValue(editEntry.Ende));
|
||||||
|
setBesucher(editEntry.Besucher);
|
||||||
|
setBemerkungen(editEntry.Bemerkungen ?? '');
|
||||||
|
if (editEntry.WetterTemp !== null) {
|
||||||
|
setWetter({
|
||||||
|
temp: editEntry.WetterTemp ?? 0,
|
||||||
|
feuchte: editEntry.WetterFeuchte ?? 0,
|
||||||
|
druck: editEntry.WetterDruck ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setArtFuehrung('Reguläre Führung');
|
||||||
|
setBeginn(nowLocalDatetime());
|
||||||
|
setEnde(nowLocalDatetime());
|
||||||
|
setBesucher(0);
|
||||||
|
setBeos([currentUserBeo]);
|
||||||
|
setObjekte([]);
|
||||||
|
setBemerkungen('');
|
||||||
|
}
|
||||||
|
}, [editEntry, currentUserBeo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editEntry && editEntry.BEOs) {
|
||||||
|
fetch('/api/beos')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((all: BeoOption[]) => {
|
||||||
|
const kuerzel = editEntry.BEOs.split(', ').map((k) => k.trim());
|
||||||
|
setBeos(all.filter((b) => kuerzel.includes(b.Kuerzel)));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
if (editEntry && editEntry.Objekte) {
|
||||||
|
const names = editEntry.Objekte.split(', ').map((n) => n.trim());
|
||||||
|
fetch('/api/objekte')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((all: { ID: number; Name: string }[]) => {
|
||||||
|
const result: SelectedObjekt[] = names.map((name) => {
|
||||||
|
const found = all.find((o) => o.Name === name);
|
||||||
|
return { ID: found?.ID ?? null, Name: name };
|
||||||
|
});
|
||||||
|
setObjekte(result);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}, [editEntry]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
setSuccess(false);
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
Kuppel: kuppel,
|
||||||
|
ArtFuehrung: artFuehrung,
|
||||||
|
Beginn: beginn,
|
||||||
|
Ende: ende,
|
||||||
|
Besucher: besucher,
|
||||||
|
beoIds: beos.map((b) => b.ID),
|
||||||
|
objekte,
|
||||||
|
Bemerkungen: bemerkungen,
|
||||||
|
Wetter: wetter,
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = editEntry ? `/api/logbuch/${editEntry.ID}` : '/api/logbuch';
|
||||||
|
const method = editEntry ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
setSuccess(true);
|
||||||
|
if (!editEntry) {
|
||||||
|
setBeginn(nowLocalDatetime());
|
||||||
|
setEnde(nowLocalDatetime());
|
||||||
|
setBesucher(0);
|
||||||
|
setBeos([currentUserBeo]);
|
||||||
|
setObjekte([]);
|
||||||
|
setBemerkungen('');
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
} catch {
|
||||||
|
setError('Fehler beim Speichern. Bitte erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Art der Führung</label>
|
||||||
|
<select
|
||||||
|
value={artFuehrung}
|
||||||
|
onChange={(e) => setArtFuehrung(e.target.value as ArtFuehrung)}
|
||||||
|
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
{ARTEN.map((a) => (
|
||||||
|
<option key={a} value={a}>{a}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Kuppel</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={kuppel}
|
||||||
|
readOnly
|
||||||
|
className="w-full px-3 py-2 border-2 border-gray-300 rounded-lg bg-gray-100 text-sm text-gray-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Beginn</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={beginn}
|
||||||
|
onChange={(e) => setBeginn(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Ende</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={ende}
|
||||||
|
onChange={(e) => setEnde(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Anzahl Besucher</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={besucher}
|
||||||
|
onChange={(e) => setBesucher(parseInt(e.target.value) || 0)}
|
||||||
|
min={0}
|
||||||
|
max={9999}
|
||||||
|
className="w-32 px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">BEOs</label>
|
||||||
|
<BeoSelector selected={beos} onChange={setBeos} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Beobachtete Objekte</label>
|
||||||
|
<ObjektSelector selected={objekte} onChange={setObjekte} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Bemerkungen
|
||||||
|
<span className="ml-2 text-gray-400 font-normal text-xs">({bemerkungen.length}/500)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={bemerkungen}
|
||||||
|
onChange={(e) => setBemerkungen(e.target.value.slice(0, 500))}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none resize-y"
|
||||||
|
placeholder="Freier Text (max. 500 Zeichen)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{wetter && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Wetter (aktuell)</label>
|
||||||
|
<div className="flex gap-4 text-sm text-gray-600 bg-gray-50 border border-gray-200 rounded-lg px-3 py-2">
|
||||||
|
<span>🌡 {wetter.temp} °C</span>
|
||||||
|
<span>💧 {wetter.feuchte} %</span>
|
||||||
|
<span>🌬 {wetter.druck} hPa</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && !editEntry && (
|
||||||
|
<div className="bg-green-50 border border-green-300 text-green-700 px-3 py-2 rounded-lg text-sm">
|
||||||
|
Eintrag gespeichert.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="px-6 py-2 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium rounded-lg transition-colors disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichern...' : editEntry ? 'Änderungen speichern' : 'Eintrag speichern'}
|
||||||
|
</button>
|
||||||
|
{editEntry && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSaved}
|
||||||
|
className="px-6 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
components/LogbuchList.tsx
Normal file
121
components/LogbuchList.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { Kuppel, LogbuchEintrag } from '@/types/logbuch';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
kuppel: Kuppel;
|
||||||
|
refreshKey: number;
|
||||||
|
onEdit: (entry: LogbuchEintrag) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dt: string): string {
|
||||||
|
if (!dt) return '';
|
||||||
|
const d = new Date(dt);
|
||||||
|
if (isNaN(d.getTime())) return dt;
|
||||||
|
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogbuchList({ kuppel, refreshKey, onEdit }: Props) {
|
||||||
|
const [entries, setEntries] = useState<LogbuchEintrag[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch(`/api/logbuch?kuppel=${encodeURIComponent(kuppel)}&limit=20`)
|
||||||
|
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
|
||||||
|
.then((data) => { setEntries(data); setLoading(false); })
|
||||||
|
.catch(() => { setError('Fehler beim Laden.'); setLoading(false); });
|
||||||
|
}, [kuppel, refreshKey]);
|
||||||
|
|
||||||
|
async function confirmDelete(id: number) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/logbuch/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
setEntries((prev) => prev.filter((e) => e.ID !== id));
|
||||||
|
} catch {
|
||||||
|
setError('Fehler beim Löschen.');
|
||||||
|
} finally {
|
||||||
|
setDeleteId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="text-gray-500 text-sm py-4">Lade Einträge...</div>;
|
||||||
|
if (error) return <div className="text-red-600 text-sm py-4">{error}</div>;
|
||||||
|
if (entries.length === 0) return <div className="text-gray-500 text-sm py-4">Keine Einträge vorhanden.</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-100 text-left">
|
||||||
|
<th className="px-3 py-2 border border-gray-300 whitespace-nowrap">Beginn</th>
|
||||||
|
<th className="px-3 py-2 border border-gray-300 whitespace-nowrap">Ende</th>
|
||||||
|
<th className="px-3 py-2 border border-gray-300">Art</th>
|
||||||
|
<th className="px-3 py-2 border border-gray-300 text-center">Besucher</th>
|
||||||
|
<th className="px-3 py-2 border border-gray-300">BEOs</th>
|
||||||
|
<th className="px-3 py-2 border border-gray-300">Objekte</th>
|
||||||
|
<th className="px-3 py-2 border border-gray-300">Bemerkungen</th>
|
||||||
|
<th className="px-3 py-2 border border-gray-300 text-center">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map((e) => (
|
||||||
|
<tr key={e.ID} className="hover:bg-gray-50">
|
||||||
|
<td className="px-3 py-2 border border-gray-200 whitespace-nowrap">{formatDateTime(e.Beginn)}</td>
|
||||||
|
<td className="px-3 py-2 border border-gray-200 whitespace-nowrap">{formatDateTime(e.Ende)}</td>
|
||||||
|
<td className="px-3 py-2 border border-gray-200">{e.ArtFuehrung}</td>
|
||||||
|
<td className="px-3 py-2 border border-gray-200 text-center">{e.Besucher}</td>
|
||||||
|
<td className="px-3 py-2 border border-gray-200">{e.BEOs || '—'}</td>
|
||||||
|
<td className="px-3 py-2 border border-gray-200">{e.Objekte || '—'}</td>
|
||||||
|
<td className="px-3 py-2 border border-gray-200 max-w-xs">
|
||||||
|
<span className="line-clamp-2">{e.Bemerkungen || ''}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 border border-gray-200 text-center whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(e)}
|
||||||
|
className="text-blue-600 hover:text-blue-800 mr-3 text-xs font-medium"
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteId(e.ID)}
|
||||||
|
className="text-red-600 hover:text-red-800 text-xs font-medium"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deleteId !== null && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl p-6 max-w-sm w-full mx-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-3">Eintrag löschen?</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-5">Dieser Eintrag wird unwiderruflich gelöscht.</p>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteId(null)}
|
||||||
|
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => confirmDelete(deleteId)}
|
||||||
|
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
components/ObjektSelector.tsx
Normal file
112
components/ObjektSelector.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { ObjektOption, SelectedObjekt } from '@/types/logbuch';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selected: SelectedObjekt[];
|
||||||
|
onChange: (objekte: SelectedObjekt[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ObjektSelector({ selected, onChange }: Props) {
|
||||||
|
const [all, setAll] = useState<ObjektOption[]>([]);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [showNewInput, setShowNewInput] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/objekte')
|
||||||
|
.then((r) => { if (!r.ok) throw new Error('Fehler'); return r.json(); })
|
||||||
|
.then(setAll)
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedNames = new Set(selected.map((o) => o.Name.toLowerCase()));
|
||||||
|
const available = all.filter((o) => !selectedNames.has(o.Name.toLowerCase()));
|
||||||
|
|
||||||
|
function add(value: string) {
|
||||||
|
if (value === 'neu') {
|
||||||
|
setShowNewInput(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const obj = all.find((o) => o.ID === parseInt(value));
|
||||||
|
if (obj && !selectedNames.has(obj.Name.toLowerCase())) {
|
||||||
|
onChange([...selected, { ID: obj.ID, Name: obj.Name }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNew() {
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name || selectedNames.has(name.toLowerCase())) return;
|
||||||
|
onChange([...selected, { ID: null, Name: name }]);
|
||||||
|
setNewName('');
|
||||||
|
setShowNewInput(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(name: string) {
|
||||||
|
onChange(selected.filter((o) => o.Name !== name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selected.map((o) => (
|
||||||
|
<span
|
||||||
|
key={o.Name}
|
||||||
|
className="inline-flex items-center gap-1 bg-green-100 text-green-800 text-sm px-2 py-1 rounded-full"
|
||||||
|
>
|
||||||
|
{o.Name}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(o.Name)}
|
||||||
|
className="ml-1 text-green-600 hover:text-red-600 font-bold leading-none"
|
||||||
|
aria-label={`${o.Name} entfernen`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="px-3 py-1.5 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
|
||||||
|
value=""
|
||||||
|
onChange={(e) => add(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">+ Objekt hinzufügen</option>
|
||||||
|
<option value="neu">— Neues Objekt eingeben —</option>
|
||||||
|
{available.map((o) => (
|
||||||
|
<option key={o.ID} value={o.ID}>
|
||||||
|
{o.Name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{showNewInput && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addNew(); } }}
|
||||||
|
placeholder="Objektname eingeben"
|
||||||
|
className="flex-1 px-3 py-1.5 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addNew}
|
||||||
|
className="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowNewInput(false); setNewName(''); }}
|
||||||
|
className="px-3 py-1.5 bg-gray-200 text-gray-700 text-sm rounded-lg hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
create_table.sql
Normal file
48
create_table.sql
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
CREATE TABLE beos (
|
||||||
|
ID INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
Kuerzel VARCHAR(10) NOT NULL UNIQUE,
|
||||||
|
Name VARCHAR(100) NOT NULL,
|
||||||
|
Passwort VARCHAR(255),
|
||||||
|
MustChangePassword TINYINT(1) DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE objekte (
|
||||||
|
ID INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
Name VARCHAR(200) NOT NULL UNIQUE,
|
||||||
|
LastUsed TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE logbuch (
|
||||||
|
ID INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
Kuppel ENUM('West','Ost','Süd','Pluto') NOT NULL DEFAULT 'West',
|
||||||
|
ArtFuehrung ENUM('Reguläre Führung','Sonderführung','BEO-Sitzung','Sonnenführung','Technischer Dienst','Beobachtung','Tag der offenen Tür','Sonstiges') NOT NULL DEFAULT 'Reguläre Führung',
|
||||||
|
Beginn DATETIME NOT NULL,
|
||||||
|
Ende DATETIME NOT NULL,
|
||||||
|
Besucher INT DEFAULT 0,
|
||||||
|
Bemerkungen TEXT,
|
||||||
|
WetterTemp DECIMAL(5,1),
|
||||||
|
WetterFeuchte DECIMAL(5,1),
|
||||||
|
WetterDruck DECIMAL(7,1),
|
||||||
|
created_by INT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES beos(ID)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE logbuch_beos (
|
||||||
|
ID INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
LogbuchID INT NOT NULL,
|
||||||
|
BeoID INT NOT NULL,
|
||||||
|
FOREIGN KEY (LogbuchID) REFERENCES logbuch(ID) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (BeoID) REFERENCES beos(ID)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE logbuch_objekte (
|
||||||
|
ID INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
LogbuchID INT NOT NULL,
|
||||||
|
ObjektID INT,
|
||||||
|
ObjektName VARCHAR(200) NOT NULL,
|
||||||
|
FOREIGN KEY (LogbuchID) REFERENCES logbuch(ID) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (ObjektID) REFERENCES objekte(ID)
|
||||||
|
);
|
||||||
48
deploy.sh
Executable file
48
deploy.sh
Executable file
@@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REGISTRY="docker.citysensor.de"
|
||||||
|
IMAGE_NAME="logbuch"
|
||||||
|
TAG="${1:-latest}"
|
||||||
|
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}"
|
||||||
|
BUILD_DATE=$(date +%d.%m.%Y)
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Logbuch Deploy Script"
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Registry: ${REGISTRY}"
|
||||||
|
echo "Image: ${IMAGE_NAME}"
|
||||||
|
echo "Tag: ${TAG}"
|
||||||
|
echo "Build-Datum: ${BUILD_DATE}"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>> Login zu ${REGISTRY}..."
|
||||||
|
docker login "${REGISTRY}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>> Richte Multiplatform Builder ein..."
|
||||||
|
if ! docker buildx inspect multiplatform-builder &>/dev/null; then
|
||||||
|
docker buildx create --name multiplatform-builder --driver docker-container --bootstrap
|
||||||
|
fi
|
||||||
|
docker buildx use multiplatform-builder
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ">>> Baue Multiplatform Docker Image und pushe..."
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--build-arg BUILD_DATE="${BUILD_DATE}" \
|
||||||
|
-t "${FULL_IMAGE}" \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Deploy erfolgreich abgeschlossen!"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "Auf dem Server ausführen:"
|
||||||
|
echo " docker pull ${FULL_IMAGE}"
|
||||||
|
echo " docker-compose -f docker-compose.prod.yml up -d"
|
||||||
|
echo ""
|
||||||
38
lib/auth.ts
Normal file
38
lib/auth.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { query } from './db';
|
||||||
|
|
||||||
|
export interface Beo {
|
||||||
|
ID: number;
|
||||||
|
Kuerzel: string;
|
||||||
|
Name: string;
|
||||||
|
Passwort: string | null;
|
||||||
|
MustChangePassword: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBeoByKuerzel(kuerzel: string): Promise<Beo | null> {
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT ID, Kuerzel, Name, Passwort, MustChangePassword FROM beos WHERE Kuerzel = ?',
|
||||||
|
[kuerzel]
|
||||||
|
) as Beo[];
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyCredentials(
|
||||||
|
kuerzel: string,
|
||||||
|
password: string
|
||||||
|
): Promise<{ beo: Beo; valid: boolean } | null> {
|
||||||
|
const beo = await getBeoByKuerzel(kuerzel);
|
||||||
|
if (!beo) return null;
|
||||||
|
|
||||||
|
if (!beo.Passwort) {
|
||||||
|
const valid = password === 'logbuch123';
|
||||||
|
return { beo, valid };
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, beo.Passwort);
|
||||||
|
return { beo, valid };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, 10);
|
||||||
|
}
|
||||||
27
lib/db.ts
Normal file
27
lib/db.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
import type { QueryResult } from 'mysql2/promise';
|
||||||
|
|
||||||
|
const dbConfig = {
|
||||||
|
host: process.env.DB_HOST || 'mydbase_mysql',
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASS,
|
||||||
|
database: process.env.DB_NAME || 'logbuch',
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pool: mysql.Pool | null = null;
|
||||||
|
|
||||||
|
export function getPool() {
|
||||||
|
if (!pool) {
|
||||||
|
pool = mysql.createPool(dbConfig);
|
||||||
|
}
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function query(sql: string, params?: (string | number | null)[]): Promise<QueryResult> {
|
||||||
|
const p = getPool();
|
||||||
|
const [rows] = await p.execute(sql, params || []);
|
||||||
|
return rows as QueryResult;
|
||||||
|
}
|
||||||
62
lib/session.ts
Normal file
62
lib/session.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { SignJWT, jwtVerify } from 'jose';
|
||||||
|
|
||||||
|
const SESSION_COOKIE_NAME = 'logbuch_session';
|
||||||
|
const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const secretKey = process.env.AUTH_SECRET || 'logbuch-secret-change-in-production';
|
||||||
|
const key = new TextEncoder().encode(secretKey);
|
||||||
|
|
||||||
|
export interface SessionData {
|
||||||
|
kuerzel: string;
|
||||||
|
beoId: number;
|
||||||
|
beoName: string;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encrypt(payload: SessionData): Promise<string> {
|
||||||
|
return new SignJWT(payload as unknown as Record<string, unknown>)
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(new Date(payload.expiresAt))
|
||||||
|
.sign(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decrypt(token: string): Promise<SessionData | null> {
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
|
||||||
|
return payload as unknown as SessionData;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(data: Omit<SessionData, 'expiresAt'>): Promise<void> {
|
||||||
|
const expiresAt = Date.now() + SESSION_DURATION;
|
||||||
|
const session: SessionData = { ...data, expiresAt };
|
||||||
|
const encrypted = await encrypt(session);
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(SESSION_COOKIE_NAME, encrypted, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
expires: expiresAt,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(): Promise<SessionData | null> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const cookie = cookieStore.get(SESSION_COOKIE_NAME);
|
||||||
|
if (!cookie?.value) return null;
|
||||||
|
const session = await decrypt(cookie.value);
|
||||||
|
if (!session || session.expiresAt < Date.now()) return null;
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSession(): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||||
|
}
|
||||||
@@ -1,7 +1,24 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: 'standalone',
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
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=()' },
|
||||||
|
{
|
||||||
|
key: 'Strict-Transport-Security',
|
||||||
|
value: 'max-age=63072000; includeSubDomains; preload',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
165
package-lock.json
generated
165
package-lock.json
generated
@@ -1,19 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "logbuch-next",
|
"name": "logbuch",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "logbuch-next",
|
"name": "logbuch",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"jose": "^6.2.2",
|
||||||
|
"mysql2": "^3.22.3",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -1539,6 +1543,13 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bcryptjs": {
|
||||||
|
"version": "2.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||||
|
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1564,8 +1575,8 @@
|
|||||||
"version": "20.19.39",
|
"version": "20.19.39",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||||
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -2423,6 +2434,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/aws-ssl-profiles": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axe-core": {
|
"node_modules/axe-core": {
|
||||||
"version": "4.11.3",
|
"version": "4.11.3",
|
||||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz",
|
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz",
|
||||||
@@ -2462,6 +2482,15 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.14",
|
"version": "1.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||||
@@ -2802,6 +2831,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/denque": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -3657,6 +3695,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/generate-function": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-property": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/generator-function": {
|
"node_modules/generator-function": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
||||||
@@ -3921,6 +3968,22 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -4243,6 +4306,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-property": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-regex": {
|
"node_modules/is-regex": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||||
@@ -4430,6 +4499,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -4841,6 +4919,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/long": {
|
||||||
|
"version": "5.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||||
|
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -4864,6 +4948,21 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lru.min": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"bun": ">=1.0.0",
|
||||||
|
"deno": ">=1.30.0",
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wellwelwel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -4938,6 +5037,40 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/mysql2": {
|
||||||
|
"version": "3.22.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.3.tgz",
|
||||||
|
"integrity": "sha512-uWWxvZSRvRhtBdh2CdcuK83YcOfPdmEeEYB069bAmPnV93QApDGVPuvCQOLjlh7tYHEWdgQPrn6kosDxHBVLkA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"aws-ssl-profiles": "^1.1.2",
|
||||||
|
"denque": "^2.1.0",
|
||||||
|
"generate-function": "^2.3.1",
|
||||||
|
"iconv-lite": "^0.7.2",
|
||||||
|
"long": "^5.3.2",
|
||||||
|
"lru.min": "^1.1.4",
|
||||||
|
"named-placeholders": "^1.1.6",
|
||||||
|
"sql-escaper": "^1.3.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/named-placeholders": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lru.min": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -5636,6 +5769,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -5867,6 +6006,21 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sql-escaper": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"bun": ">=1.0.0",
|
||||||
|
"deno": ">=2.0.0",
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stable-hash": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@@ -6354,7 +6508,6 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "logbuch-next",
|
"name": "logbuch",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -9,12 +9,16 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"jose": "^6.2.2",
|
||||||
|
"mysql2": "^3.22.3",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
42
proxy.ts
Normal file
42
proxy.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { jwtVerify } from 'jose';
|
||||||
|
|
||||||
|
const SESSION_COOKIE_NAME = 'logbuch_session';
|
||||||
|
const secretKey = process.env.AUTH_SECRET || 'logbuch-secret-change-in-production';
|
||||||
|
const key = new TextEncoder().encode(secretKey);
|
||||||
|
|
||||||
|
export async function proxy(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
if (pathname.startsWith('/login') || pathname.startsWith('/_next') || pathname.startsWith('/favicon')) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = request.cookies.get(SESSION_COOKIE_NAME);
|
||||||
|
|
||||||
|
if (!cookie?.value) {
|
||||||
|
return NextResponse.redirect(new URL('/login', request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(cookie.value, key, { algorithms: ['HS256'] });
|
||||||
|
const mustChange = payload.mustChangePassword as boolean;
|
||||||
|
|
||||||
|
if (mustChange && pathname !== '/change-password') {
|
||||||
|
return NextResponse.redirect(new URL('/change-password', request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mustChange && pathname === '/change-password') {
|
||||||
|
return NextResponse.redirect(new URL('/', request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.redirect(new URL('/login', request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||||
|
};
|
||||||
61
types/logbuch.ts
Normal file
61
types/logbuch.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
export type Kuppel = 'West' | 'Ost' | 'Süd' | 'Pluto';
|
||||||
|
export type ArtFuehrung =
|
||||||
|
| 'Reguläre Führung'
|
||||||
|
| 'Sonderführung'
|
||||||
|
| 'BEO-Sitzung'
|
||||||
|
| 'Sonnenführung'
|
||||||
|
| 'Technischer Dienst'
|
||||||
|
| 'Beobachtung'
|
||||||
|
| 'Tag der offenen Tür'
|
||||||
|
| 'Sonstiges';
|
||||||
|
|
||||||
|
export const KUPPELN: Kuppel[] = ['West', 'Ost', 'Süd', 'Pluto'];
|
||||||
|
export const ARTEN: ArtFuehrung[] = [
|
||||||
|
'Reguläre Führung',
|
||||||
|
'Sonderführung',
|
||||||
|
'BEO-Sitzung',
|
||||||
|
'Sonnenführung',
|
||||||
|
'Technischer Dienst',
|
||||||
|
'Beobachtung',
|
||||||
|
'Tag der offenen Tür',
|
||||||
|
'Sonstiges',
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface BeoOption {
|
||||||
|
ID: number;
|
||||||
|
Kuerzel: string;
|
||||||
|
Name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjektOption {
|
||||||
|
ID: number;
|
||||||
|
Name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectedObjekt {
|
||||||
|
ID: number | null;
|
||||||
|
Name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Wetter {
|
||||||
|
temp: number;
|
||||||
|
feuchte: number;
|
||||||
|
druck: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogbuchEintrag {
|
||||||
|
ID: number;
|
||||||
|
Kuppel: Kuppel;
|
||||||
|
ArtFuehrung: ArtFuehrung;
|
||||||
|
Beginn: string;
|
||||||
|
Ende: string;
|
||||||
|
Besucher: number;
|
||||||
|
Bemerkungen: string | null;
|
||||||
|
WetterTemp: number | null;
|
||||||
|
WetterFeuchte: number | null;
|
||||||
|
WetterDruck: number | null;
|
||||||
|
created_by: number | null;
|
||||||
|
created_at: string;
|
||||||
|
BEOs: string;
|
||||||
|
Objekte: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user