feat: add architecture, api routes and components

This commit is contained in:
2026-07-06 02:27:06 +02:00
parent 91dbfa7d77
commit 2fe32edc3a
31 changed files with 2796 additions and 148 deletions
+7
View File
@@ -0,0 +1,7 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
+2
View File
@@ -39,3 +39,5 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
/src/generated/prisma
+32
View File
@@ -0,0 +1,32 @@
FROM node:20-bullseye-slim AS base
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/package.json /app/dev.d[b] ./
EXPOSE 3000
CMD ["sh", "-c", "mkdir -p /app/data && DATABASE_URL=file:/app/data/prod.db npx prisma@6 db push && node server.js"]
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
version: '3.8'
services:
gsv_gallery:
image: atakanozban/gsv_gallery:latest
container_name: gsv_gallery_test
ports:
- "3000:3000"
environment:
- PORT=3000
- DATABASE_URL=file:/app/data/prod.db
- GOOGLE_MAPS_API_KEY=senin_gercek_api_keyin
- ADMIN_USERNAME=atakan
- ADMIN_PASSWORD=testpass
- GOOGLE_CLIENT_ID=google_id
- GOOGLE_CLIENT_SECRET=google_secret
volumes:
- ./data:/app/data
restart: unless-stopped
+2 -2
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: "standalone",
}; };
export default nextConfig; export default nextConfig;
+1262 -30
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -4,21 +4,30 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "npx prisma generate && next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@googlemaps/js-api-loader": "^2.0.2",
"@googlemaps/markerclusterer": "^2.6.2",
"@tmcw/togeojson": "^7.1.2",
"@types/geojson": "^7946.0.16",
"bootstrap": "^5.3.8",
"googleapis": "^171.4.0",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"
}, },
"devDependencies": { "devDependencies": {
"@prisma/client": "^6.4.0",
"@types/google.maps": "^3.58.1",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"prisma": "^6.4.0",
"typescript": "^5" "typescript": "^5"
} }
} }
BIN
View File
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-1.1.x", "debian-openssl-3.0.x"]
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Location {
id String @id @default(uuid())
title String
city String
tags String
captureDate DateTime
visibility String @default("public") // 'public' or 'unlisted'
gpxData String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+20
View File
@@ -0,0 +1,20 @@
import AdminLocationForm from "@/components/AdminLocationForm";
export default async function EditLocationPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<div className="card shadow-sm border border-secondary mt-4 bg-dark text-white">
<div className="card-header bg-dark border-secondary py-3">
<h5 className="mb-0">Edit Location</h5>
</div>
<div className="card-body">
<AdminLocationForm locationId={id} />
</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import Link from "next/link";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const handleLogout = async () => {
try {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/login");
router.refresh();
} catch (e) {
console.error("Logout failed", e);
}
};
const closeAdminSidebar = () => {
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
const offcanvasEl = document.getElementById('adminSidebar');
if (offcanvasEl && (window as any).bootstrap) {
try {
const bsOffcanvas = (window as any).bootstrap.Offcanvas.getInstance(offcanvasEl) || new (window as any).bootstrap.Offcanvas(offcanvasEl);
if (bsOffcanvas) {
bsOffcanvas.hide();
}
} catch (e) {
console.error('Error closing admin offcanvas:', e);
offcanvasEl.classList.remove('show', 'showing');
}
}
}
};
return (
<div className="d-flex h-100" style={{ minHeight: "100vh" }}>
{/* Mobile Header with Toggle Button */}
<div className="d-md-none bg-dark text-white p-3 d-flex justify-content-between align-items-center w-100 position-fixed top-0 z-3" style={{ height: "60px" }}>
<span className="fs-5">Admin Panel</span>
<button className="btn btn-outline-light" type="button" data-bs-toggle="offcanvas" data-bs-target="#adminSidebar" aria-controls="adminSidebar">
</button>
</div>
{/* Sidebar (Offcanvas on mobile, visible on desktop) */}
<div className="offcanvas-md offcanvas-start bg-dark text-white border-end border-secondary d-flex flex-column flex-shrink-0 p-3" tabIndex={-1} id="adminSidebar" style={{ width: "280px" }}>
<div className="offcanvas-header d-md-none border-bottom border-secondary mb-3 p-0 pb-3">
<h5 className="offcanvas-title">Admin Panel</h5>
<button type="button" className="btn-close btn-close-white" onClick={closeAdminSidebar} aria-label="Close"></button>
</div>
<Link href="/admin" className="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-white text-decoration-none d-none d-md-flex">
<span className="fs-4">Admin Panel</span>
</Link>
<hr className="d-none d-md-block" />
<ul className="nav nav-pills flex-column mb-auto">
<li>
<Link href="/" className="nav-link text-white" onClick={closeAdminSidebar}>
Go to Site
</Link>
</li>
<li>
<button onClick={handleLogout} className="nav-link text-white text-start w-100 bg-transparent border-0">
Log out
</button>
</li>
</ul>
</div>
{/* Main Content Area */}
<div className="w-100 overflow-auto bg-dark text-white pt-5 pt-md-0" data-bs-theme="dark">
<div className="container mt-4 p-4">{children}</div>
</div>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import AdminLocationForm from "@/components/AdminLocationForm";
export default function NewLocationPage() {
return (
<div className="card shadow-sm border border-secondary mt-4 bg-dark text-white">
<div className="card-header bg-dark border-secondary py-3">
<h5 className="mb-0">Add New Location</h5>
</div>
<div className="card-body">
<AdminLocationForm />
</div>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Location } from "@prisma/client";
export default function AdminDashboard() {
const [locations, setLocations] = useState<Location[]>([]);
const [loading, setLoading] = useState(true);
const fetchLocations = async () => {
try {
const res = await fetch("/api/admin/locations");
if (res.ok) {
const data = await res.json();
setLocations(data);
}
} catch (error) {
console.error("Failed to fetch locations", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchLocations();
}, []);
const handleDelete = async (id: string) => {
if (!confirm("Are you sure you want to delete this location?")) return;
try {
const res = await fetch(`/api/admin/locations/${id}`, {
method: "DELETE",
});
if (res.ok) {
fetchLocations();
} else {
alert("Failed to delete");
}
} catch (error) {
console.error(error);
}
};
if (loading) return <div className="text-white mt-4">Loading...</div>;
return (
<>
<div className="card shadow-sm border-0 mb-4 bg-dark text-white">
<div className="card-header bg-dark border-secondary d-flex justify-content-between align-items-center py-3">
<h5 className="mb-0 gsv-title">Manage Locations (Projects)</h5>
<Link href="/admin/new" className="btn btn-primary btn-sm">
+ Add New Project
</Link>
</div>
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-dark table-hover mb-0">
<thead>
<tr>
<th>Title</th>
<th>City</th>
<th>GPX Route</th>
<th>Visibility</th>
<th className="text-end">Actions</th>
</tr>
</thead>
<tbody>
{locations.map((loc) => (
<tr key={loc.id}>
<td>{loc.title}</td>
<td>{loc.city}</td>
<td>
{loc.gpxData ? (
<span className="badge bg-primary">Has Route</span>
) : (
<span className="badge bg-secondary">No Route</span>
)}
</td>
<td>
<span
className={`badge bg-${loc.visibility === "public" ? "success" : "secondary"
}`}
>
{loc.visibility}
</span>
</td>
<td className="text-end">
<Link
href={`/admin/edit/${loc.id}`}
className="btn btn-sm btn-outline-light me-2"
>
Edit
</Link>
<button
onClick={() => handleDelete(loc.id)}
className="btn btn-sm btn-outline-danger"
>
Delete
</button>
</td>
</tr>
))}
{locations.length === 0 && (
<tr>
<td colSpan={5} className="text-center py-4 text-muted">
No locations found. Start by adding one.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const location = await prisma.location.update({
where: { id },
// @ts-ignore
data: {
title: body.title,
city: body.city,
gpxData: body.gpxData,
tags: body.tags,
captureDate: new Date(body.captureDate),
visibility: body.visibility,
},
});
return NextResponse.json(location);
} catch (error) {
console.error("Error updating location:", error);
return NextResponse.json({ error: "Failed to update location" }, { status: 500 });
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
await prisma.location.delete({
where: { id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting location:", error);
return NextResponse.json({ error: "Failed to delete location" }, { status: 500 });
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const locations = await prisma.location.findMany({
orderBy: {
createdAt: "desc",
},
});
return NextResponse.json(locations);
} catch (error) {
console.error("Error fetching all locations:", error);
return NextResponse.json({ error: "Failed to fetch locations" }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
// Convert strings to float for lat/lng, or they might already be numbers
const location = await prisma.location.create({
data: {
title: body.title,
city: body.city,
gpxData: body.gpxData,
tags: body.tags,
captureDate: new Date(body.captureDate),
visibility: body.visibility || "public",
},
});
return NextResponse.json(location);
} catch (error: any) {
console.error("Error creating location:", error);
return NextResponse.json({ error: error instanceof Error ? error.message : "Failed to create location" }, { status: 500 });
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
export async function POST(request: Request) {
try {
// const { password } = await request.json();
const { username, password } = await request.json();
const correctPassword = process.env.ADMIN_PASSWORD;
const correctUsername = process.env.ADMIN_USERNAME || "admin";
if (!correctPassword) {
console.error("ADMIN_PASSWORD environment variable is not set.");
return NextResponse.json({ error: "Server Configuration Error" }, { status: 500 });
}
// if (password === correctPassword) {
if (username === correctUsername && password === correctPassword) {
const response = NextResponse.json({ success: true });
// Set an HTTP-only cookie that expires in 30 days
response.cookies.set({
name: 'admin_session',
value: 'authenticated',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/',
});
return response;
}
// return NextResponse.json({ error: "Invalid password." }, { status: 401 });
return NextResponse.json({ error: "Invalid credentials." }, { status: 401 });
} catch (error) {
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
}
}
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
export async function POST() {
const response = NextResponse.json({ success: true });
// Hard delete the cookie
response.cookies.delete('admin_session');
return response;
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
apiKey: process.env.GOOGLE_MAPS_API_KEY || ""
});
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const locations = await prisma.location.findMany({
where: {
visibility: "public",
},
orderBy: {
createdAt: "desc",
},
});
return NextResponse.json(locations);
} catch (error) {
console.error("Error fetching public locations:", error);
return NextResponse.json({ error: "Failed to fetch locations" }, { status: 500 });
}
}
+123 -35
View File
@@ -1,42 +1,130 @@
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
html,
body { body {
max-width: 100vw;
overflow-x: hidden;
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0; margin: 0;
padding: 0;
font-family: "Google Sans", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #202124;
color: #e8eaed;
} }
a { ::-webkit-scrollbar {
color: inherit; width: 8px;
text-decoration: none; height: 8px;
} }
@media (prefers-color-scheme: dark) { ::-webkit-scrollbar-track {
html { background: #202124;
color-scheme: dark; }
::-webkit-scrollbar-thumb {
background: #5f6368;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #80868b;
}
.gsv-navbar {
background-color: #202124;
border-bottom: 1px solid #3c4043;
}
.gsv-sidebar {
background-color: #202124;
border-right: 1px solid #3c4043;
}
.gsv-search-container {
border-bottom: 1px solid #3c4043;
}
.gsv-input {
background-color: #303134;
border: 1px solid #5f6368;
color: #e8eaed;
}
.gsv-input:focus {
background-color: #303134;
border-color: #8ab4f8;
color: #e8eaed;
box-shadow: 0 0 0 1px #8ab4f8;
}
.gsv-input::placeholder {
color: #9aa0a6;
}
select.gsv-input option {
background-color: #303134;
color: #e8eaed;
}
.gsv-card {
background-color: #303134;
border: 1px solid transparent;
color: #e8eaed;
transition: all 0.2s ease-in-out;
border-radius: 8px;
}
.gsv-card:hover {
background-color: #3c4043;
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
.gsv-card-active {
background-color: #42474b;
border: 1px solid #8ab4f8;
}
.gsv-title {
color: #e8eaed;
font-weight: 500;
}
.gsv-subtitle {
color: #9aa0a6;
}
.gsv-btn-share {
color: #8ab4f8;
border: 1px solid #5f6368;
background-color: transparent;
transition: all 0.2s ease-in-out;
}
.gsv-btn-share:hover {
background-color: rgba(138, 180, 248, 0.04);
color: #a8c7fa;
border-color: #8ab4f8;
}
.gsv-btn-maps {
background-color: #8ab4f8;
color: #202124;
font-weight: 500;
border: none;
transition: all 0.2s ease-in-out;
}
.gsv-btn-maps:hover {
background-color: #a8c7fa;
color: #202124;
}
.offcanvas-backdrop {
opacity: 0 !important;
background-color: transparent !important;
}
.gsv-search-wrapper {
max-width: 150px;
}
@media (min-width: 768px) {
.gsv-search-wrapper {
max-width: 400px;
} }
} }
+10 -16
View File
@@ -1,20 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import "bootstrap/dist/css/bootstrap.min.css";
import "./globals.css"; import "./globals.css";
import BootstrapClient from "@/components/BootstrapClient";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Street View Gallery",
description: "Generated by create next app", description: "Public gallery of Google Street View panoramas",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -23,9 +14,12 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en" data-bs-theme="dark">
<body className={`${geistSans.variable} ${geistMono.variable}`}> <body>
{children} <div className="container-fluid p-0">
{children}
</div>
<BootstrapClient />
</body> </body>
</html> </html>
); );
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
// const res = await fetch("/api/auth/login", {
// method: "POST",
// headers: { "Content-Type": "application/json" },
// body: JSON.stringify({ password }),
// });
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (res.ok) {
router.push("/admin");
router.refresh();
} else {
const data = await res.json();
// setError(data.error || "Invalid password");
setError(data.error || "Invalid credentials");
}
} catch (err) {
setError("Something went wrong. Please try again.");
} finally {
setLoading(false);
}
};
return (
<div className="container-fluid vh-100 d-flex justify-content-center align-items-center bg-dark">
<div className="card bg-dark text-white shadow-lg border-0" style={{ width: "100%", maxWidth: "400px" }}>
<div className="card-header border-bottom border-dark text-center py-4">
<h4 className="mb-0 gsv-title">Admin Portal</h4>
<small className="text-light opacity-75">Portfolio Management</small>
</div>
<div className="card-body p-4">
<form onSubmit={handleLogin}>
{error && (
<div className="alert alert-danger py-2 px-3 small" role="alert">
{error}
</div>
)}
<div className="mb-3">
<label className="form-label small text-uppercase fw-bold opacity-75">Username</label>
<input
type="text"
className="form-control bg-dark border-secondary text-white"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter username..."
required
autoFocus
/>
</div>
{/* Original secure password input block:
<div className="mb-4">
<label className="form-label small text-uppercase fw-bold opacity-75">Password</label>
<input
type="password"
className="form-control bg-dark border-secondary text-white"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password..."
required
autoFocus
/>
</div>
*/}
<div className="mb-4">
<label className="form-label small text-uppercase fw-bold opacity-75">Password</label>
<input
type="password"
className="form-control bg-dark border-secondary text-white"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password..."
required
/>
</div>
{/* Original button block:
<button
type="submit"
className="btn btn-primary w-100 fw-bold shadow-sm"
disabled={loading || !password}
>
{loading ? "Authenticating..." : "Unlock"}
</button>
*/}
<button
type="submit"
className="btn btn-primary w-100 fw-bold shadow-sm"
disabled={loading || !username || !password}
>
{loading ? "Authenticating..." : "Unlock"}
</button>
</form>
</div>
<div className="card-footer border-top border-dark text-center py-3">
<button onClick={() => router.push("/")} className="btn btn-link text-light text-decoration-none small opacity-75 p-0">
&larr; Back to Gallery
</button>
</div>
</div>
</div>
);
}
-1
View File
@@ -93,7 +93,6 @@ a.secondary {
border-color: var(--button-secondary-border); border-color: var(--button-secondary-border);
} }
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
a.primary:hover { a.primary:hover {
background: var(--button-primary-hover); background: var(--button-primary-hover);
+2 -63
View File
@@ -1,66 +1,5 @@
import Image from "next/image"; import Gallery from "@/components/Gallery";
import styles from "./page.module.css";
export default function Home() { export default function Home() {
return ( return <Gallery />;
<div className={styles.page}>
<main className={styles.main}>
<Image
className={styles.logo}
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className={styles.intro}>
<h1>To get started, edit the page.tsx file.</h1>
<p>
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"
target="_blank"
rel="noopener noreferrer"
>
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"
target="_blank"
rel="noopener noreferrer"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className={styles.ctas}>
<a
className={styles.primary}
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className={styles.logo}
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className={styles.secondary}
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
} }
+202
View File
@@ -0,0 +1,202 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { Location } from "@prisma/client";
import { setOptions, importLibrary } from "@googlemaps/js-api-loader";
export default function AdminLocationForm({
locationId,
}: {
locationId?: string;
}) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(!!locationId);
const mapRef = useRef<HTMLDivElement>(null);
const googleMapObj = useRef<google.maps.Map | null>(null);
const markerRef = useRef<google.maps.marker.AdvancedMarkerElement | null>(null);
const [formData, setFormData] = useState({
title: "",
city: "",
gpxData: "",
tags: "",
captureDate: new Date().toISOString().split("T")[0],
visibility: "public",
});
useEffect(() => {
if (locationId) {
// Fetch entire list and find by ID (simple approach) or create a GET /api/admin/locations/[id] single route.
// Since we don't have a single GET endpoint, let's fetch all and filter.
fetch("/api/admin/locations")
.then((res) => res.json())
.then((data: Location[]) => {
if (Array.isArray(data)) {
const loc = data.find((l) => l.id === locationId);
if (loc) {
setFormData({
title: loc.title,
city: loc.city,
gpxData: loc.gpxData || "",
tags: loc.tags,
captureDate: new Date(loc.captureDate).toISOString().split("T")[0],
visibility: loc.visibility,
});
}
} else {
console.error("API returned non-array data for locations:", data);
}
setFetching(false);
})
.catch((err) => {
console.error("Error fetching admin locations:", err);
setFetching(false);
});
}
}, [locationId]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result as string;
setFormData((prev) => ({ ...prev, gpxData: text }));
};
reader.readAsText(file);
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
const endpoint = locationId
? `/api/admin/locations/${locationId}`
: "/api/admin/locations";
const method = locationId ? "PUT" : "POST";
try {
const res = await fetch(endpoint, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (res.ok) {
router.push("/admin");
} else {
const errorData = await res.json().catch(() => ({}));
alert(`Operation failed: ${errorData.error || res.statusText}`);
}
} catch (error: any) {
console.error(error);
alert(`Network error: ${error.message}`);
} finally {
setLoading(false);
}
};
if (fetching) return <div>Loading...</div>;
return (
<form onSubmit={handleSubmit} className="row g-3 text-white">
<div className="col-md-6">
<label className="form-label">Title</label>
<input
type="text"
className="form-control bg-dark text-white border-secondary"
name="title"
value={formData.title}
onChange={handleChange}
required
/>
</div>
<div className="col-md-6">
<label className="form-label">City</label>
<input
type="text"
className="form-control bg-dark text-white border-secondary"
name="city"
value={formData.city}
onChange={handleChange}
required
/>
</div>
<div className="col-12 mt-4">
<label className="form-label fw-bold">Attach GPX Route</label>
<input
type="file"
className="form-control bg-dark text-white border-secondary"
accept=".gpx"
onChange={handleFileChange}
required={!locationId} // Required only on creation
/>
<small className="text-muted">Upload the .gpx file for this project's journey.</small>
{formData.gpxData && (
<div className="mt-2 text-success">
GPX data loaded ({formData.gpxData.length} bytes)
</div>
)}
</div>
<div className="col-md-6">
<label className="form-label">Tags (comma separated)</label>
<input
type="text"
className="form-control bg-dark text-white border-secondary"
name="tags"
value={formData.tags}
onChange={handleChange}
/>
</div>
<div className="col-md-4">
<label className="form-label">Capture Date</label>
<input
type="date"
className="form-control bg-dark text-white border-secondary"
name="captureDate"
value={formData.captureDate}
onChange={handleChange}
required
/>
</div>
<div className="col-md-2">
<label className="form-label">Visibility</label>
<select
className="form-select bg-dark text-white border-secondary"
name="visibility"
value={formData.visibility}
onChange={handleChange}
>
<option value="public">Public</option>
<option value="unlisted">Unlisted</option>
</select>
</div>
<div className="col-12 mt-4">
<button
type="submit"
className="btn btn-primary me-2"
disabled={loading}
>
{loading ? "Saving..." : "Save Location"}
</button>
<button
type="button"
onClick={() => router.push("/admin")}
className="btn btn-secondary"
>
Cancel
</button>
</div>
</form>
);
}
+11
View File
@@ -0,0 +1,11 @@
"use client";
import { useEffect } from "react";
export default function BootstrapClient() {
useEffect(() => {
require("bootstrap/dist/js/bootstrap.bundle.min.js");
}, []);
return null;
}
+520
View File
@@ -0,0 +1,520 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { importLibrary, setOptions } from "@googlemaps/js-api-loader";
import { Location } from "@prisma/client";
import { gpx } from "@tmcw/togeojson";
export default function Gallery() {
const [locations, setLocations] = useState<Location[]>([]);
const [filteredLocations, setFilteredLocations] = useState<Location[]>([]);
const [selectedLocation, setSelectedLocation] = useState<Location | null>(null);
const [search, setSearch] = useState("");
const [viewingStreetView, setViewingStreetView] = useState(false);
const [iframeUrl, setIframeUrl] = useState<string | null>(null);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
const [toastMessage, setToastMessage] = useState<string | null>(null);
const [totalMiles, setTotalMiles] = useState<number>(0);
const [geometryLoaded, setGeometryLoaded] = useState(false);
// Sunucudan dynamic olarak çekeceğimiz key için state
const [runtimeApiKey, setRuntimeApiKey] = useState<string>("");
const locationsRef = useRef<Location[]>([]);
const mapRef = useRef<HTMLDivElement>(null);
const miniMapRef = useRef<HTMLDivElement>(null);
const googleMapObj = useRef<google.maps.Map | null>(null);
const miniMapObj = useRef<google.maps.Map | null>(null);
const miniMapMarkerObj = useRef<google.maps.marker.AdvancedMarkerElement | null>(null);
const svServiceRef = useRef<google.maps.StreetViewService | null>(null);
const activePolylinesRef = useRef<{ id: string; polyline: google.maps.Polyline; bounds: google.maps.LatLngBounds; firstCoord: google.maps.LatLngLiteral }[]>([]);
// 1. Sayfa mount olduğunda runtime API keyi kap gel
useEffect(() => {
fetch("/api/config")
.then((res) => res.json())
.then((data) => {
if (data.apiKey) setRuntimeApiKey(data.apiKey);
})
.catch((err) => console.error("Error fetching config:", err));
}, []);
// Fetch locations
useEffect(() => {
const queryParams = new URLSearchParams(window.location.search);
const initialLocId = queryParams.get("locationId");
fetch("/api/locations")
.then((res) => res.json())
.then((data) => {
if (Array.isArray(data)) {
setLocations(data);
locationsRef.current = data;
setFilteredLocations(data);
if (data.length > 0) {
const targetLoc = data.find((l: Location) => l.id === initialLocId) || data[0];
setSelectedLocation(targetLoc);
}
} else {
console.error("API returned non-array data:", data);
}
})
.catch((err) => console.error("Error fetching locations:", err));
}, []);
// Filter locations
useEffect(() => {
const term = search.toLowerCase();
setFilteredLocations(
locations.filter((loc) => {
return loc.title.toLowerCase().includes(term) || loc.city.toLowerCase().includes(term) || (loc.tags && loc.tags.toLowerCase().includes(term));
})
);
}, [search, locations]);
// Load Google Maps API & Initialize
useEffect(() => {
if (!runtimeApiKey) return; // Key gelene kadar bekle, haritayı patlatma
setOptions({
key: runtimeApiKey, // Dynamic runtime key devrede
v: "weekly",
});
importLibrary("maps").then(async () => {
const { Map } = await google.maps.importLibrary("maps") as google.maps.MapsLibrary;
const { AdvancedMarkerElement } = await google.maps.importLibrary("marker") as google.maps.MarkerLibrary;
const { StreetViewPanorama } = await google.maps.importLibrary("streetView") as google.maps.StreetViewLibrary;
await google.maps.importLibrary("geometry");
setGeometryLoaded(true);
svServiceRef.current = new google.maps.StreetViewService();
googleMapObj.current = new Map(mapRef.current as HTMLElement, {
center: { lat: 20, lng: 0 },
zoom: 2,
mapId: "STREET_VIEW_GALLERY_MAP",
});
miniMapObj.current = new Map(miniMapRef.current as HTMLElement, {
center: { lat: 20, lng: 0 },
zoom: 14,
mapId: "STREET_VIEW_MINI_MAP",
disableDefaultUI: true,
zoomControl: false,
mapTypeControl: false,
streetViewControl: false,
fullscreenControl: false,
clickableIcons: false,
keyboardShortcuts: false
});
miniMapMarkerObj.current = new AdvancedMarkerElement({
map: miniMapObj.current,
position: { lat: 0, lng: 0 },
});
const defaultStreetView = googleMapObj.current.getStreetView();
defaultStreetView.setOptions({ enableCloseButton: false });
defaultStreetView.addListener("visible_changed", () => {
if (defaultStreetView.getVisible()) {
const pos = defaultStreetView.getPosition();
defaultStreetView.setVisible(false);
if (pos) {
// Pegman drop interception key'i yenilendi
setIframeUrl(`https://www.google.com/maps/embed/v1/streetview?key=${runtimeApiKey}&location=${pos.lat()},${pos.lng()}&heading=0&pitch=0&fov=90`);
setViewingStreetView(true);
if (miniMapObj.current && miniMapMarkerObj.current) {
miniMapObj.current.setCenter(pos);
miniMapMarkerObj.current.position = pos;
}
}
}
});
});
}, [runtimeApiKey]); // runtimeApiKey gelince tetiklenecek
// Draw all GPX routes for filtered locations
useEffect(() => {
if (!googleMapObj.current || typeof window === 'undefined' || !window.google || !geometryLoaded || !runtimeApiKey) return;
// Clear existing routes
activePolylinesRef.current.forEach((p) => p.polyline.setMap(null));
activePolylinesRef.current = [];
const bounds = new google.maps.LatLngBounds();
let hasPoints = false;
let calculatedMeters = 0;
const newThumbnails: Record<string, string> = {};
filteredLocations.forEach((loc) => {
if (!loc.gpxData) return;
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(loc.gpxData, "text/xml");
const geoJson = gpx(xmlDoc);
let coords: google.maps.LatLngLiteral[] = [];
let locBounds = new google.maps.LatLngBounds();
geoJson.features.forEach((feature) => {
if (feature.geometry?.type === "LineString") {
coords = coords.concat(feature.geometry.coordinates.map((c: any) => ({ lat: c[1], lng: c[0] })));
} else if (feature.geometry?.type === "MultiLineString") {
feature.geometry.coordinates.forEach((line: any) => {
coords = coords.concat(line.map((c: any) => ({ lat: c[1], lng: c[0] })));
});
} else if (feature.geometry?.type === "Point") {
coords.push({ lat: feature.geometry.coordinates[1], lng: feature.geometry.coordinates[0] });
}
});
if (coords.length > 0) {
calculatedMeters += google.maps.geometry.spherical.computeLength(coords);
const polyline = new google.maps.Polyline({
path: coords,
strokeColor: "#0000FF",
strokeOpacity: 0.6,
strokeWeight: 4,
clickable: true,
map: googleMapObj.current,
zIndex: selectedLocation?.id === loc.id ? 10 : 1,
});
const miniPolyline = new google.maps.Polyline({
path: coords,
strokeColor: "#0000FF",
strokeOpacity: 0.8,
strokeWeight: 4,
map: miniMapObj.current,
zIndex: 1,
clickable: true
});
coords.forEach((c) => {
bounds.extend(c);
locBounds.extend(c);
hasPoints = true;
});
activePolylinesRef.current.push({
id: loc.id,
polyline,
bounds: locBounds,
firstCoord: coords[0]
});
// Main Map click
polyline.addListener("click", (clickEvent: google.maps.MapMouseEvent) => {
if (!clickEvent.latLng) return;
const clickPos = clickEvent.latLng;
setIframeUrl(`https://www.google.com/maps/embed/v1/streetview?key=${runtimeApiKey}&location=${clickPos.lat()},${clickPos.lng()}&heading=0&pitch=0&fov=90`);
setViewingStreetView(true);
if (miniMapObj.current && miniMapMarkerObj.current) {
miniMapObj.current.setCenter(clickPos);
miniMapMarkerObj.current.position = clickPos;
}
});
// Mini Map click
miniPolyline.addListener("click", (clickEvent: google.maps.MapMouseEvent) => {
if (!clickEvent.latLng) return;
const clickPos = clickEvent.latLng;
setIframeUrl(`https://www.google.com/maps/embed/v1/streetview?key=${runtimeApiKey}&location=${clickPos.lat()},${clickPos.lng()}&heading=0&pitch=0&fov=90`);
setViewingStreetView(true);
if (miniMapObj.current && miniMapMarkerObj.current) {
miniMapObj.current.setCenter(clickPos);
miniMapMarkerObj.current.position = clickPos;
}
});
// Generate Static Map Thumbnail URL (Runtime key kullanıyor)
try {
const maxThumbnailPoints = 150;
const step = Math.max(1, Math.floor(coords.length / maxThumbnailPoints));
const simplifiedCoords = coords.filter((_, i) => i % step === 0);
const encodedPath = google.maps.geometry.encoding.encodePath(simplifiedCoords);
newThumbnails[loc.id] = `https://maps.googleapis.com/maps/api/staticmap?size=600x250&scale=2&path=weight:3%7Ccolor:blue%7Cenc:${encodedPath}&key=${runtimeApiKey}`;
} catch (err) {
console.error("Failed to generate static map thumbnail", err);
}
}
});
setThumbnails(newThumbnails);
setTotalMiles(calculatedMeters * 0.000621371);
if (hasPoints) {
googleMapObj.current.fitBounds(bounds);
}
}, [filteredLocations, geometryLoaded, selectedLocation, runtimeApiKey]); // runtimeApiKey dependecy eklendi
// Handle Selection Highlights and panning smoothly
useEffect(() => {
if (!googleMapObj.current || !geometryLoaded) return;
let didPan = false;
activePolylinesRef.current.forEach((item) => {
if (selectedLocation?.id === item.id) {
item.polyline.setOptions({ strokeColor: "#00FF00", strokeOpacity: 1.0, strokeWeight: 6, zIndex: 10 });
if (!didPan) {
googleMapObj.current!.fitBounds(item.bounds);
didPan = true;
}
} else {
item.polyline.setOptions({ strokeColor: "#0000FF", strokeOpacity: 0.6, strokeWeight: 4, zIndex: 1 });
}
});
}, [selectedLocation, geometryLoaded]);
// Update URL when selection changes
useEffect(() => {
if (!selectedLocation) return;
if (typeof window !== "undefined") {
const url = new URL(window.location.href);
url.searchParams.set("locationId", selectedLocation.id);
window.history.pushState({}, "", url.toString());
}
}, [selectedLocation]);
const closeMobileSidebar = () => {
setIsMobileSidebarOpen(false);
};
const handleLocationSelect = (loc: Location) => {
setViewingStreetView(false);
setIframeUrl(null);
if (selectedLocation?.id === loc.id) {
const item = activePolylinesRef.current.find(p => p.id === loc.id);
if (item && googleMapObj.current) {
googleMapObj.current.fitBounds(item.bounds);
}
} else {
setSelectedLocation(loc);
}
closeMobileSidebar();
};
return (
<div className="container-fluid vh-100 d-flex flex-column p-0 overflow-hidden gsv-background">
<nav className="navbar navbar-dark gsv-navbar px-2 px-md-3 mt-0 d-flex justify-content-between align-items-center z-3">
<div className="d-flex align-items-center gap-2">
<button
className="btn btn-outline-light btn-sm d-md-none"
type="button"
onClick={() => setIsMobileSidebarOpen(true)}
aria-controls="gallerySidebar"
>
</button>
<span className="navbar-brand mb-0 h1 gsv-title fs-md-4 d-none d-lg-block">Street View Gallery</span>
</div>
<div className="mx-auto flex-grow-1 px-2 px-md-4 gsv-search-wrapper">
<input
type="text"
className="form-control form-control-sm gsv-input bg-dark text-white border-secondary w-100"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="text-white fw-medium d-flex align-items-center gap-2 flex-shrink-0" style={{ fontSize: "0.85rem", whiteSpace: "nowrap" }}>
{totalMiles > 0 && <span>📍 {totalMiles.toFixed(1)} miles tracked</span>}
</div>
</nav>
<div className="row flex-grow-1 g-0 position-relative" style={{ minHeight: 0 }}>
{/* Left: List */}
<div
className={`col-md-3 offcanvas-md offcanvas-start gsv-sidebar d-flex flex-column bg-dark h-100 border-end border-secondary ${isMobileSidebarOpen ? 'show' : ''}`}
tabIndex={-1}
id="gallerySidebar"
aria-labelledby="gallerySidebarLabel"
style={isMobileSidebarOpen ? { visibility: 'visible' } : undefined}
>
<div className="offcanvas-header d-md-none border-bottom border-secondary text-white p-3 d-flex justify-content-between align-items-center">
<h5 className="offcanvas-title m-0" id="gallerySidebarLabel">Locations</h5>
<button type="button" className="btn-close btn-close-white" onClick={closeMobileSidebar} aria-label="Close"></button>
</div>
<div className="offcanvas-body p-0 d-flex flex-column flex-grow-1" style={{ minHeight: 0 }}>
<div className="flex-grow-1 p-2" style={{ overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
{filteredLocations.map((loc) => (
<div
key={loc.id}
className={`card mb-2 gsv-card cursor-pointer p-0 shadow-sm ${selectedLocation?.id === loc.id ? "gsv-card-active border-success border-2" : "border-secondary"
}`}
onClick={() => handleLocationSelect(loc)}
style={{ cursor: "pointer", overflow: "hidden" }}
>
{/* --- MOBILE LAYOUT --- */}
<div className="row g-0 h-100 d-md-none">
<div className="col-5 bg-dark border-end border-secondary position-relative" style={{ minHeight: "105px", overflow: "hidden" }}>
{thumbnails[loc.id] ? (
<img
src={thumbnails[loc.id]}
alt="Route thumbnail"
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
objectFit: "cover",
objectPosition: "center",
transform: "scale(1.2) translateY(5%)",
transformOrigin: "center bottom"
}}
/>
) : (
<div className="w-100 h-100 d-flex align-items-center justify-content-center position-absolute top-0 start-0">
<span className="text-secondary" style={{ fontSize: "0.7rem" }}>Loading...</span>
</div>
)}
</div>
<div className="col-7 bg-dark d-flex flex-column justify-content-center p-2 text-white">
<h6 className="card-title mb-1 text-truncate gsv-title" style={{ fontSize: "0.9rem" }} title={loc.title}>{loc.title}</h6>
<p className="card-text mb-1 text-truncate gsv-subtitle" style={{ fontSize: "0.75rem", lineHeight: "1.2" }}>
{loc.city}<br />{new Date(loc.captureDate).toLocaleDateString()}
</p>
<div className="d-flex justify-content-between align-items-center mt-auto">
{loc.tags && (
<small className="text-secondary text-truncate" style={{ maxWidth: "100%", fontSize: "0.7rem", display: "block" }}>{loc.tags}</small>
)}
{!loc.gpxData && (
<span className="badge bg-secondary" style={{ fontSize: "0.55rem" }}>No Route</span>
)}
</div>
</div>
</div>
{/* --- DESKTOP LAYOUT --- */}
<div className="d-none d-md-block">
<div className="card-img-top bg-dark border-bottom border-secondary d-flex align-items-center justify-content-center" style={{ width: "100%", height: "140px", flexShrink: 0, overflow: "hidden", position: "relative" }}>
{thumbnails[loc.id] ? (
<img
src={thumbnails[loc.id]}
alt="Route thumbnail"
style={{
width: "100%",
height: "100%",
objectFit: "cover",
objectPosition: "center",
transform: "scale(1.2) translateY(5%)",
transformOrigin: "center bottom"
}}
/>
) : (
<span className="text-secondary small">Loading Map...</span>
)}
</div>
<div className="card-body p-2 bg-dark text-white">
<h6 className="card-title mb-1 text-truncate gsv-title" title={loc.title}>{loc.title}</h6>
<p className="card-text small mb-1 text-truncate gsv-subtitle">
{loc.city} {new Date(loc.captureDate).toLocaleDateString()}
</p>
<div className="d-flex justify-content-between align-items-center mt-1">
<small className="text-secondary text-truncate" style={{ maxWidth: "100%" }}>{loc.tags}</small>
</div>
<div className="d-flex justify-content-between align-items-center mt-1">
{!loc.gpxData && (
<span className="badge bg-secondary" style={{ fontSize: "0.6rem" }}>No Route Data</span>
)}
</div>
</div>
</div>
</div>
))}
{filteredLocations.length === 0 && (
<div className="text-center text-muted mt-4 p-3 bg-dark rounded">No locations found.</div>
)}
{locations.length > 0 && (
<div className="text-secondary mt-3 mb-2" style={{ fontSize: "0.75rem", textAlign: "center", textTransform: "uppercase", letterSpacing: "1px" }}>
Images dating back to {
new Date(Math.min(...locations.map(l => new Date(l.captureDate).getTime()))).getFullYear()
}
</div>
)}
</div>
</div>
</div>
{/* Main Panel */}
<div className="col-12 col-md-9 position-relative bg-black d-flex flex-column p-0">
<div ref={mapRef} style={{ width: "100%", height: "100%", position: "absolute", zIndex: 0 }} />
{viewingStreetView && iframeUrl && (
<>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 1,
backgroundColor: '#000'
}}
>
<iframe
width="100%"
height="100%"
style={{ border: 0 }}
loading="lazy"
allowFullScreen
src={iframeUrl}
></iframe>
</div>
<button
className="btn btn-dark position-absolute shadow-lg"
style={{ top: "60px", left: "10px", zIndex: 2, display: "flex", alignItems: "center", gap: "8px" }}
onClick={() => {
setViewingStreetView(false);
setIframeUrl(null);
}}
>
<span></span> Back to Map
</button>
</>
)}
<div
ref={miniMapRef}
className={`shadow-lg border border-3 border-dark rounded ${viewingStreetView ? 'd-block' : 'd-none'}`}
style={{
position: "absolute",
bottom: "20px",
left: "20px",
width: "200px",
height: "200px",
zIndex: 4,
pointerEvents: "auto"
}}
/>
</div>
</div>
{isMobileSidebarOpen && (
<div
className="offcanvas-backdrop fade show d-md-none"
onClick={() => setIsMobileSidebarOpen(false)}
/>
)}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { PrismaClient } from "@prisma/client";
// PrismaClient is attached to the `global` object in development to prevent
// exhausting your database connection limit.
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const isAuthRoute = request.nextUrl.pathname.startsWith('/login') || request.nextUrl.pathname.startsWith('/api/auth');
const isAdminRoute = request.nextUrl.pathname.startsWith('/admin') || request.nextUrl.pathname.startsWith('/api/admin');
// If trying to access admin routes, verify the session cookie
if (isAdminRoute) {
const adminSession = request.cookies.get('admin_session');
// Allow GET /api/admin/locations (used by the public gallery)
if (request.nextUrl.pathname === '/api/admin/locations' && request.method === 'GET') {
return NextResponse.next();
}
// Redirect to login if no session cookie exists
if (!adminSession?.value) {
if (request.nextUrl.pathname.startsWith('/api/')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return NextResponse.redirect(new URL('/login', request.url));
}
}
// If trying to access login page but already authenticated, redirect to admin
if (request.nextUrl.pathname === '/login') {
const adminSession = request.cookies.get('admin_session');
if (adminSession?.value) {
return NextResponse.redirect(new URL('/admin', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/admin/:path*', '/api/admin/:path*', '/login'],
};