feat: add architecture, api routes and components
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
README.md
|
||||||
|
.next
|
||||||
|
.git
|
||||||
@@ -39,3 +39,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|||||||
+32
@@ -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"]
|
||||||
Binary file not shown.
@@ -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
|
||||||
+1
-1
@@ -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;
|
||||||
Generated
+1262
-30
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
apiKey: process.env.GOOGLE_MAPS_API_KEY || ""
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+122
-34
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
← Back to Gallery
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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'],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user