Add missing features: Login, Middleware, Tags Management, Edit Listing

This commit is contained in:
AI 2026-03-11 06:04:02 +00:00
parent 8a0a28443b
commit bd68daec83
9 changed files with 794 additions and 62 deletions

View File

@ -0,0 +1,349 @@
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updateListing, deleteListing, addNote, addTagToListing, removeTagFromListing } from "../actions";
export default async function EditListingPage({
params,
}: {
params: { slug: string };
}) {
const listing = await prisma.listing.findUnique({
where: { slug: params.slug },
include: {
tags: {
include: { tag: true },
},
notes: {
orderBy: { createdAt: "desc" },
},
},
});
if (!listing) {
redirect("/listings");
}
const allTags = await prisma.tag.findMany({
orderBy: { name: "asc" },
});
const listingTagIds = listing.tags.map((t) => t.tagId);
const availableTags = allTags.filter((t) => !listingTagIds.includes(t.id));
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="container mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold"> Listing bearbeiten</h1>
<a
href={`/listings/${listing.slug}`}
className="text-blue-600 hover:underline"
>
Zurück zur Detailseite
</a>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Form */}
<div className="lg:col-span-2">
<Card>
<CardHeader>
<CardTitle>Grunddaten</CardTitle>
</CardHeader>
<CardContent>
<form action={updateListing} className="space-y-4">
<input type="hidden" name="id" value={listing.id} />
<div>
<Label htmlFor="title">Titel</Label>
<Input
id="title"
name="title"
defaultValue={listing.title}
required
/>
</div>
<div>
<Label htmlFor="locationText">Ort</Label>
<Input
id="locationText"
name="locationText"
defaultValue={listing.locationText || ""}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="nightlyPrice">Preis pro Nacht ()</Label>
<Input
id="nightlyPrice"
name="nightlyPrice"
type="number"
step="0.01"
defaultValue={listing.nightlyPrice || ""}
/>
</div>
<div>
<Label htmlFor="rating">Bewertung</Label>
<Input
id="rating"
name="rating"
type="number"
step="0.01"
max="5"
defaultValue={listing.rating || ""}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<Label htmlFor="bedrooms">Schlafzimmer</Label>
<Input
id="bedrooms"
name="bedrooms"
type="number"
defaultValue={listing.bedrooms || ""}
/>
</div>
<div>
<Label htmlFor="beds">Betten</Label>
<Input
id="beds"
name="beds"
type="number"
defaultValue={listing.beds || ""}
/>
</div>
<div>
<Label htmlFor="bathrooms">Badezimmer</Label>
<Input
id="bathrooms"
name="bathrooms"
type="number"
step="0.5"
defaultValue={listing.bathrooms || ""}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="guestCount">Gäste (offiziell)</Label>
<Input
id="guestCount"
name="guestCount"
type="number"
defaultValue={listing.guestCount || ""}
/>
</div>
<div>
<Label htmlFor="maxSleepingPlaces">Max Schlafplätze</Label>
<Input
id="maxSleepingPlaces"
name="maxSleepingPlaces"
type="number"
defaultValue={listing.maxSleepingPlaces || ""}
/>
</div>
</div>
<div>
<Label htmlFor="hostName">Host Name</Label>
<Input
id="hostName"
name="hostName"
defaultValue={listing.hostName || ""}
/>
</div>
<div>
<Label htmlFor="description">Beschreibung</Label>
<textarea
id="description"
name="description"
rows={4}
className="w-full p-2 border rounded-md"
defaultValue={listing.description || ""}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="status">Status</Label>
<select
id="status"
name="status"
defaultValue={listing.status}
className="w-full p-2 border rounded-md"
>
<option value="NEW">Neu</option>
<option value="INTERESTING">Interessant</option>
<option value="SHORTLIST">Kurzliste</option>
<option value="BOOKED">Gebucht</option>
<option value="REJECTED">Abgelehnt</option>
</select>
</div>
<div>
<Label htmlFor="isFavorite">Favorit</Label>
<select
id="isFavorite"
name="isFavorite"
defaultValue={listing.isFavorite.toString()}
className="w-full p-2 border rounded-md"
>
<option value="false">Nein</option>
<option value="true">Ja</option>
</select>
</div>
</div>
<div className="flex gap-4 pt-4">
<Button type="submit" className="flex-1">
Änderungen speichern
</Button>
</div>
</form>
<form action={deleteListing} className="mt-4">
<input type="hidden" name="id" value={listing.id} />
<Button
type="submit"
variant="destructive"
className="w-full"
onClick={(e) => {
if (!confirm("Möchten Sie dieses Listing wirklich löschen?")) {
e.preventDefault();
}
}}
>
🗑 Listing löschen
</Button>
</form>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Tags */}
<Card>
<CardHeader>
<CardTitle>🏷 Tags</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
{listing.tags.map((lt) => (
<div
key={lt.tag.id}
className="flex items-center gap-1 px-2 py-1 rounded-full text-white text-sm"
style={{ backgroundColor: lt.tag.color || "#6366f1" }}
>
{lt.tag.name}
<form action={removeTagFromListing}>
<input type="hidden" name="listingId" value={listing.id} />
<input type="hidden" name="tagId" value={lt.tag.id} />
<button type="submit" className="hover:opacity-70">
×
</button>
</form>
</div>
))}
{listing.tags.length === 0 && (
<p className="text-slate-500 text-sm">Keine Tags</p>
)}
</div>
{availableTags.length > 0 && (
<form action={addTagToListing}>
<input type="hidden" name="listingId" value={listing.id} />
<select
name="tagId"
className="w-full p-2 border rounded-md text-sm"
>
<option value="">Tag hinzufügen...</option>
{availableTags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
))}
</select>
<Button type="submit" size="sm" className="mt-2 w-full">
Tag hinzufügen
</Button>
</form>
)}
</CardContent>
</Card>
{/* Notes */}
<Card>
<CardHeader>
<CardTitle>📝 Notizen</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2 max-h-48 overflow-y-auto">
{listing.notes.map((note) => (
<div key={note.id} className="p-2 bg-slate-50 rounded text-sm">
{note.body}
<p className="text-xs text-slate-400 mt-1">
{new Date(note.createdAt).toLocaleDateString("de-DE")}
</p>
</div>
))}
{listing.notes.length === 0 && (
<p className="text-slate-500 text-sm">Keine Notizen</p>
)}
</div>
<form action={addNote}>
<input type="hidden" name="listingId" value={listing.id} />
<textarea
name="body"
placeholder="Neue Notiz..."
rows={2}
className="w-full p-2 border rounded-md text-sm"
required
/>
<Button type="submit" size="sm" className="mt-2 w-full">
Notiz hinzufügen
</Button>
</form>
</CardContent>
</Card>
{/* Info */}
<Card>
<CardHeader>
<CardTitle> Info</CardTitle>
</CardHeader>
<CardContent className="text-sm space-y-2">
<p>
<span className="text-slate-500">Importiert:</span>{" "}
{new Date(listing.importedAt).toLocaleDateString("de-DE")}
</p>
<p>
<span className="text-slate-500">Airbnb URL:</span>
<br />
<a
href={listing.airbnbUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline truncate block"
>
{listing.airbnbUrl}
</a>
</p>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,117 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function updateListing(formData: FormData) {
const id = formData.get("id") as string;
const title = formData.get("title") as string;
const locationText = formData.get("locationText") as string;
const nightlyPrice = parseFloat(formData.get("nightlyPrice") as string) || null;
const rating = parseFloat(formData.get("rating") as string) || null;
const reviewCount = parseInt(formData.get("reviewCount") as string) || null;
const bedrooms = parseInt(formData.get("bedrooms") as string) || null;
const beds = parseInt(formData.get("beds") as string) || null;
const bathrooms = parseFloat(formData.get("bathrooms") as string) || null;
const guestCount = parseInt(formData.get("guestCount") as string) || null;
const maxSleepingPlaces = parseInt(formData.get("maxSleepingPlaces") as string) || null;
const hostName = formData.get("hostName") as string;
const description = formData.get("description") as string;
const status = formData.get("status") as string;
const isFavorite = formData.get("isFavorite") === "true";
await prisma.listing.update({
where: { id },
data: {
title,
locationText,
nightlyPrice,
rating,
reviewCount,
bedrooms,
beds,
bathrooms,
guestCount,
maxSleepingPlaces,
hostName,
description,
status,
isFavorite,
},
});
revalidatePath("/listings");
revalidatePath(`/listings/${id}`);
redirect(`/listings`);
}
export async function deleteListing(formData: FormData) {
const id = formData.get("id") as string;
await prisma.listing.delete({
where: { id },
});
revalidatePath("/listings");
redirect("/listings");
}
export async function toggleFavorite(formData: FormData) {
const id = formData.get("id") as string;
const isFavorite = formData.get("isFavorite") === "true";
await prisma.listing.update({
where: { id },
data: { isFavorite: !isFavorite },
});
revalidatePath("/listings");
revalidatePath(`/listings/${id}`);
}
export async function addNote(formData: FormData) {
const listingId = formData.get("listingId") as string;
const body = formData.get("body") as string;
if (!body?.trim()) return;
await prisma.adminNote.create({
data: {
listingId,
body: body.trim(),
},
});
revalidatePath(`/listings/${listingId}`);
}
export async function addTagToListing(formData: FormData) {
const listingId = formData.get("listingId") as string;
const tagId = formData.get("tagId") as string;
await prisma.listingTag.create({
data: {
listingId,
tagId,
},
});
revalidatePath(`/listings/${listingId}`);
}
export async function removeTagFromListing(formData: FormData) {
const listingId = formData.get("listingId") as string;
const tagId = formData.get("tagId") as string;
await prisma.listingTag.delete({
where: {
listingId_tagId: {
listingId,
tagId,
},
},
});
revalidatePath(`/listings/${listingId}`);
}

View File

@ -0,0 +1,33 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function createTag(formData: FormData) {
const name = formData.get("name") as string;
const color = formData.get("color") as string;
if (!name) return;
const slug = name.toLowerCase().replace(/\s+/g, "-");
await prisma.tag.upsert({
where: { slug },
update: { color },
create: { name, slug, color },
});
revalidatePath("/admin/tags");
}
export async function deleteTag(formData: FormData) {
const tagId = formData.get("tagId") as string;
if (!tagId) return;
await prisma.tag.delete({
where: { id: tagId },
});
revalidatePath("/admin/tags");
}

View File

@ -0,0 +1,119 @@
import { prisma } from "@/lib/prisma";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { createTag, deleteTag } from "./actions";
export default async function TagsPage() {
const tags = await prisma.tag.findMany({
include: {
_count: {
select: { listings: true },
},
},
orderBy: { name: "asc" },
});
const defaultColors = [
"#6366f1", // Indigo
"#10b981", // Emerald
"#f59e0b", // Amber
"#ef4444", // Red
"#8b5cf6", // Violet
"#ec4899", // Pink
"#14b8a6", // Teal
"#f97316", // Orange
];
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="container mx-auto">
<h1 className="text-3xl font-bold mb-8">🏷 Tags verwalten</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Create Tag Form */}
<Card>
<CardHeader>
<CardTitle>Neuen Tag erstellen</CardTitle>
</CardHeader>
<CardContent>
<form action={createTag} className="space-y-4">
<div>
<Label htmlFor="name">Name</Label>
<Input
id="name"
name="name"
placeholder="z.B. Günstig, Luxus, Favorit"
required
/>
</div>
<div>
<Label htmlFor="color">Farbe</Label>
<div className="flex gap-2 mt-2">
{defaultColors.map((color) => (
<label key={color} className="cursor-pointer">
<input
type="radio"
name="color"
value={color}
className="sr-only"
/>
<div
className="w-8 h-8 rounded-full border-2 border-transparent hover:border-slate-400 transition"
style={{ backgroundColor: color }}
/>
</label>
))}
</div>
</div>
<Button type="submit" className="w-full">
Tag erstellen
</Button>
</form>
</CardContent>
</Card>
{/* Existing Tags */}
<Card>
<CardHeader>
<CardTitle>Alle Tags ({tags.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{tags.map((tag) => (
<div
key={tag.id}
className="flex items-center justify-between p-3 bg-slate-50 rounded-lg"
>
<div className="flex items-center gap-3">
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: tag.color || "#6366f1" }}
/>
<span className="font-medium">{tag.name}</span>
<span className="text-sm text-slate-500">
({tag._count.listings} Listings)
</span>
</div>
<form action={deleteTag}>
<input type="hidden" name="tagId" value={tag.id} />
<Button variant="destructive" size="sm">
Löschen
</Button>
</form>
</div>
))}
{tags.length === 0 && (
<p className="text-slate-500 text-center py-4">
Noch keine Tags vorhanden
</p>
)}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,89 @@
import { auth, signOut } from "@/lib/auth";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Button } from "@/components/ui/button";
export default async function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session) {
redirect("/login");
}
return (
<div className="min-h-screen bg-slate-50">
{/* Navigation */}
<nav className="bg-white border-b border-slate-200 sticky top-0 z-50">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-6">
<Link href="/dashboard" className="text-xl font-bold">
🏠 Airbnb Finder
</Link>
<div className="hidden md:flex items-center gap-4">
<Link
href="/dashboard"
className="text-slate-600 hover:text-slate-900 transition"
>
Dashboard
</Link>
<Link
href="/listings"
className="text-slate-600 hover:text-slate-900 transition"
>
Listings
</Link>
<Link
href="/compare"
className="text-slate-600 hover:text-slate-900 transition"
>
Vergleich
</Link>
<Link
href="/map"
className="text-slate-600 hover:text-slate-900 transition"
>
Karte
</Link>
<Link
href="/admin/import"
className="text-slate-600 hover:text-slate-900 transition"
>
Import
</Link>
<Link
href="/admin/tags"
className="text-slate-600 hover:text-slate-900 transition"
>
Tags
</Link>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-slate-500 hidden sm:block">
{session.user?.name || "Admin"}
</span>
<form
action={async () => {
"use server";
await signOut();
}}
>
<Button variant="outline" size="sm">
Logout
</Button>
</form>
</div>
</div>
</div>
</nav>
{/* Main Content */}
<main>{children}</main>
</div>
);
}

View File

@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

53
src/app/login/page.tsx Normal file
View File

@ -0,0 +1,53 @@
import { signIn } from "@/lib/auth";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function LoginPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-100">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">🏠 Airbnb Finder</CardTitle>
<p className="text-slate-500">Admin Login</p>
</CardHeader>
<CardContent>
<form
action={async (formData) => {
"use server";
await signIn("credentials", formData);
}}
className="space-y-4"
>
<div>
<Label htmlFor="username">Benutzername</Label>
<Input
id="username"
name="username"
type="text"
placeholder="admin"
required
className="mt-1"
/>
</div>
<div>
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
name="password"
type="password"
placeholder="••••••••"
required
className="mt-1"
/>
</div>
<Button type="submit" className="w-full">
Anmelden
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,65 +1,5 @@
import Image from "next/image";
import { redirect } from "next/navigation";
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<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">
<Image
className="dark:invert"
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>
);
redirect("/dashboard");
}

29
src/middleware.ts Normal file
View File

@ -0,0 +1,29 @@
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isOnLogin = req.nextUrl.pathname.startsWith("/login");
const isApiRoute = req.nextUrl.pathname.startsWith("/api");
// Allow API routes (they handle their own auth)
if (isApiRoute) {
return NextResponse.next();
}
// Redirect to login if not authenticated
if (!isLoggedIn && !isOnLogin) {
return NextResponse.redirect(new URL("/login", req.nextUrl));
}
// Redirect to dashboard if already logged in and on login page
if (isLoggedIn && isOnLogin) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
return NextResponse.next();
});
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};