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:
2026-04-27 17:11:27 +02:00
parent f0a86627e5
commit 4e53a7a5cd
29 changed files with 1827 additions and 97 deletions

View 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>
);
}