Initial commit: spreewaldzeit + Dockerfile for Coolify (Next.js + Prisma/SQLite)
This commit is contained in:
321
components/admin/ApartmentEditor.tsx
Normal file
321
components/admin/ApartmentEditor.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { Input, Label, Textarea, FieldError } from "@/components/ui/Input";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { formatPrice } from "@/lib/utils";
|
||||
|
||||
export interface EditorApartment {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
tagline: string;
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
priceFrom: number; // Cent
|
||||
maxGuests: number;
|
||||
bedrooms: number;
|
||||
sizeSqm: number;
|
||||
features: string[];
|
||||
images: string[];
|
||||
airbnbUrl: string | null;
|
||||
bookingUrl: string | null;
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
export function ApartmentEditor({ apartment }: { apartment: EditorApartment }) {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState(apartment);
|
||||
const [priceEuro, setPriceEuro] = useState(String(Math.round(apartment.priceFrom / 100)));
|
||||
const [airbnbUrl, setAirbnbUrl] = useState(apartment.airbnbUrl ?? "");
|
||||
const [bookingUrl, setBookingUrl] = useState(apartment.bookingUrl ?? "");
|
||||
const [featureInput, setFeatureInput] = useState("");
|
||||
const [imageInput, setImageInput] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
function update<K extends keyof EditorApartment>(key: K, value: EditorApartment[K]) {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
function addFeature() {
|
||||
const v = featureInput.trim();
|
||||
if (!v) return;
|
||||
update("features", [...form.features, v]);
|
||||
setFeatureInput("");
|
||||
}
|
||||
function removeFeature(i: number) {
|
||||
update("features", form.features.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
function addImage() {
|
||||
const v = imageInput.trim();
|
||||
if (!v) return;
|
||||
try {
|
||||
new URL(v);
|
||||
} catch {
|
||||
setMessage({ kind: "err", text: "Bild-URL ist ungültig." });
|
||||
return;
|
||||
}
|
||||
update("images", [...form.images, v]);
|
||||
setImageInput("");
|
||||
setMessage(null);
|
||||
}
|
||||
function removeImage(i: number) {
|
||||
update("images", form.images.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/apartments/${form.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: form.name,
|
||||
tagline: form.tagline,
|
||||
shortDescription: form.shortDescription,
|
||||
description: form.description,
|
||||
priceFrom: Math.max(0, Math.round(Number(priceEuro) * 100)),
|
||||
maxGuests: Number(form.maxGuests),
|
||||
bedrooms: Number(form.bedrooms),
|
||||
sizeSqm: Number(form.sizeSqm),
|
||||
features: form.features,
|
||||
images: form.images,
|
||||
airbnbUrl: airbnbUrl.trim() || "",
|
||||
bookingUrl: bookingUrl.trim() || "",
|
||||
published: form.published,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error ?? "Speichern fehlgeschlagen.");
|
||||
}
|
||||
setMessage({ kind: "ok", text: "Gespeichert." });
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setMessage({
|
||||
kind: "err",
|
||||
text: err instanceof Error ? err.message : "Unbekannter Fehler.",
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-cream border border-ink/10 rounded-sm p-6 md:p-8">
|
||||
<div className="flex items-start justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<div className="eyebrow mb-1">/{form.slug}</div>
|
||||
<h2 className="font-display text-3xl leading-tight">{form.name}</h2>
|
||||
<div className="mt-2 text-sm text-ink/60">
|
||||
ab {formatPrice(form.priceFrom)} · bis {form.maxGuests} Gäste · {form.sizeSqm} m²
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.published}
|
||||
onChange={(e) => update("published", e.target.checked)}
|
||||
className="accent-moss-600 h-4 w-4"
|
||||
/>
|
||||
Veröffentlicht
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<Input value={form.name} onChange={(e) => update("name", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Kurztitel / Tagline</Label>
|
||||
<Input value={form.tagline} onChange={(e) => update("tagline", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Label>Kurzbeschreibung</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={form.shortDescription}
|
||||
onChange={(e) => update("shortDescription", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Label>Beschreibung</Label>
|
||||
<Textarea
|
||||
rows={6}
|
||||
value={form.description}
|
||||
onChange={(e) => update("description", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Preis ab (€/Nacht)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={priceEuro}
|
||||
onChange={(e) => setPriceEuro(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Max. Gäste</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.maxGuests}
|
||||
onChange={(e) => update("maxGuests", Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Schlafzimmer</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.bedrooms}
|
||||
onChange={(e) => update("bedrooms", Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Größe (m²)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.sizeSqm}
|
||||
onChange={(e) => update("sizeSqm", Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="mt-10">
|
||||
<Label>Ausstattung</Label>
|
||||
<div className="flex flex-wrap gap-2 mt-3 mb-3">
|
||||
{form.features.map((f, i) => (
|
||||
<span
|
||||
key={`${f}-${i}`}
|
||||
className="inline-flex items-center gap-2 bg-parchment border border-ink/15 px-3 py-1 rounded-full text-sm"
|
||||
>
|
||||
{f}
|
||||
<button
|
||||
onClick={() => removeFeature(i)}
|
||||
aria-label="Entfernen"
|
||||
className="text-ink/40 hover:text-red-700 text-lg leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="z. B. Kachelofen"
|
||||
value={featureInput}
|
||||
onChange={(e) => setFeatureInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addFeature();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={addFeature}>
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bilder */}
|
||||
<div className="mt-10">
|
||||
<Label>Bilder (URLs)</Label>
|
||||
{form.images.length > 0 && (
|
||||
<ul className="grid grid-cols-3 md:grid-cols-5 gap-3 mt-3 mb-4">
|
||||
{form.images.map((src, i) => (
|
||||
<li key={`${src}-${i}`} className="relative aspect-square rounded-sm overflow-hidden bg-ink/5 group">
|
||||
<Image
|
||||
src={src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="200px"
|
||||
className="object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeImage(i)}
|
||||
className="absolute top-1 right-1 bg-ink/80 text-parchment text-xs w-6 h-6 rounded-full opacity-0 group-hover:opacity-100 transition"
|
||||
aria-label="Bild entfernen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="https://…"
|
||||
value={imageInput}
|
||||
onChange={(e) => setImageInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addImage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={addImage}>
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-ink/50">
|
||||
Für den MVP werden Bilder als URLs gepflegt. Später per Upload über S3/Cloudinary erweiterbar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buchungsplattformen */}
|
||||
<div className="mt-10">
|
||||
<Label>Buchungsplattformen</Label>
|
||||
<p className="mt-1 mb-4 text-xs text-ink/50">
|
||||
Wenn Airbnb- oder Booking.com-Links gesetzt sind, werden sie auf der Wohnungsseite als externe Buchungsoption angezeigt.
|
||||
</p>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Airbnb-Link</Label>
|
||||
<Input
|
||||
placeholder="https://www.airbnb.de/rooms/…"
|
||||
value={airbnbUrl}
|
||||
onChange={(e) => setAirbnbUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Booking.com-Link</Label>
|
||||
<Input
|
||||
placeholder="https://www.booking.com/hotel/…"
|
||||
value={bookingUrl}
|
||||
onChange={(e) => setBookingUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aktionen */}
|
||||
<div className="mt-10 pt-6 border-t border-ink/10 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="text-sm">
|
||||
{message && (
|
||||
<span className={message.kind === "ok" ? "text-moss-700" : "text-red-700"}>
|
||||
{message.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={save} disabled={saving} size="lg">
|
||||
{saving ? "Speichert…" : "Änderungen speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user