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() {
|
||||
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
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