Add missing features: Login, Middleware, Tags Management, Edit Listing
This commit is contained in:
parent
8a0a28443b
commit
bd68daec83
349
src/app/(protected)/admin/listings/[slug]/page.tsx
Normal file
349
src/app/(protected)/admin/listings/[slug]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/app/(protected)/admin/listings/actions.ts
Normal file
117
src/app/(protected)/admin/listings/actions.ts
Normal 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}`);
|
||||||
|
}
|
||||||
33
src/app/(protected)/admin/tags/actions.ts
Normal file
33
src/app/(protected)/admin/tags/actions.ts
Normal 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");
|
||||||
|
}
|
||||||
119
src/app/(protected)/admin/tags/page.tsx
Normal file
119
src/app/(protected)/admin/tags/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/app/(protected)/layout.tsx
Normal file
89
src/app/(protected)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
53
src/app/login/page.tsx
Normal file
53
src/app/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,65 +1,5 @@
|
|||||||
import Image from "next/image";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
redirect("/dashboard");
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/middleware.ts
Normal file
29
src/middleware.ts
Normal 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).*)"],
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user