Add admin panel, gallery, and security hardening.
Replace forgeable cookie auth with signed JWT sessions, protect admin APIs, add input validation, and improve Docker deployment config.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
.env*
|
||||
*.db
|
||||
dev.db
|
||||
prisma/*.db
|
||||
build_log.txt
|
||||
@@ -33,9 +33,16 @@ yarn-error.log*
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# database
|
||||
*.db
|
||||
dev.db
|
||||
prisma/*.db
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
FROM node:20-bullseye-slim AS base
|
||||
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
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 . .
|
||||
|
||||
ARG GOOGLE_MAPS_API_KEY
|
||||
ENV GOOGLE_MAPS_API_KEY=$GOOGLE_MAPS_API_KEY
|
||||
|
||||
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"
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
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 ./package.json
|
||||
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["sh", "-c", "npx prisma@5.22.0 db push && node server.js"]
|
||||
+23
-1
@@ -1,7 +1,29 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(), microphone=(), geolocation=()",
|
||||
},
|
||||
{
|
||||
key: "Strict-Transport-Security",
|
||||
value: "max-age=63072000; includeSubDomains; preload",
|
||||
},
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+956
-35
File diff suppressed because it is too large
Load Diff
+13
-2
@@ -4,21 +4,32 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build": "npx prisma generate && next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"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",
|
||||
"jose": "^6.2.3",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@types/google.maps": "^3.58.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"prisma": "^5.22.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,72 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
import { parseLocationBody } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin();
|
||||
if (!auth.authorized) return auth.response;
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = parseLocationBody(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const location = await prisma.location.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: data.title,
|
||||
city: data.city,
|
||||
gpxData: data.gpxData ?? null,
|
||||
tags: data.tags,
|
||||
captureDate: new Date(data.captureDate),
|
||||
visibility: data.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 }> }
|
||||
) {
|
||||
const auth = await requireAdmin();
|
||||
if (!auth.authorized) return auth.response;
|
||||
|
||||
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,64 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
import { parseLocationBody } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const auth = await requireAdmin();
|
||||
if (!auth.authorized) return auth.response;
|
||||
|
||||
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) {
|
||||
const auth = await requireAdmin();
|
||||
if (!auth.authorized) return auth.response;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const parsed = parseLocationBody(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const location = await prisma.location.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
city: data.city,
|
||||
gpxData: data.gpxData ?? null,
|
||||
tags: data.tags,
|
||||
captureDate: new Date(data.captureDate),
|
||||
visibility: data.visibility,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(location);
|
||||
} catch (error) {
|
||||
console.error("Error creating location:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create location" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
createSessionToken,
|
||||
getSessionCookieOptions,
|
||||
} from "@/lib/session";
|
||||
import { safeCompare } from "@/lib/auth";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
function getClientIp(request: Request): string {
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
if (forwarded) return forwarded.split(",")[0].trim();
|
||||
return request.headers.get("x-real-ip") ?? "unknown";
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const ip = getClientIp(request);
|
||||
if (!checkRateLimit(`login:${ip}`)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many login attempts. Try again later." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const { username, password } = await request.json();
|
||||
const correctPassword = process.env.ADMIN_PASSWORD;
|
||||
const correctUsername = process.env.ADMIN_USERNAME;
|
||||
|
||||
if (!correctPassword || !correctUsername) {
|
||||
console.error(
|
||||
"ADMIN_PASSWORD and ADMIN_USERNAME environment variables must be set."
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Server Configuration Error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof username !== "string" ||
|
||||
typeof password !== "string" ||
|
||||
!safeCompare(username, correctUsername) ||
|
||||
!safeCompare(password, correctPassword)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid credentials." },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const token = await createSessionToken();
|
||||
const response = NextResponse.json({ success: true });
|
||||
|
||||
response.cookies.set({
|
||||
...getSessionCookieOptions(),
|
||||
value: token,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCookieOptions, SESSION_COOKIE } from "@/lib/session";
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ success: true });
|
||||
|
||||
response.cookies.set({
|
||||
...getSessionCookieOptions(0),
|
||||
name: SESSION_COOKIE,
|
||||
value: "",
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
+123
-35
@@ -1,42 +1,130 @@
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
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;
|
||||
padding: 0;
|
||||
font-family: "Google Sans", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background-color: #202124;
|
||||
color: #e8eaed;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
::-webkit-scrollbar-track {
|
||||
background: #202124;
|
||||
}
|
||||
|
||||
::-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 { Geist, Geist_Mono } from "next/font/google";
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
import BootstrapClient from "@/components/BootstrapClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Street View Gallery",
|
||||
description: "Public gallery of Google Street View panoramas",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,9 +14,12 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
{children}
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<body>
|
||||
<div className="container-fluid p-0">
|
||||
{children}
|
||||
</div>
|
||||
<BootstrapClient />
|
||||
</body>
|
||||
</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);
|
||||
}
|
||||
|
||||
/* Enable hover only on non-touch devices */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
a.primary:hover {
|
||||
background: var(--button-primary-hover);
|
||||
|
||||
+2
-63
@@ -1,66 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import styles from "./page.module.css";
|
||||
import Gallery from "@/components/Gallery";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return <Gallery />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Location } from "@prisma/client";
|
||||
import { MAX_GPX_BYTES } from "@/lib/validation";
|
||||
|
||||
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;
|
||||
|
||||
if (file.size > MAX_GPX_BYTES) {
|
||||
alert(`GPX file must be under ${MAX_GPX_BYTES / (1024 * 1024)} MB`);
|
||||
e.target.value = "";
|
||||
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,545 @@
|
||||
"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);
|
||||
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 }[]>([]);
|
||||
|
||||
// Fetch runtime API key from server (GOOGLE_MAPS_API_KEY env var)
|
||||
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;
|
||||
|
||||
setOptions({
|
||||
key: runtimeApiKey,
|
||||
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"); // For distance calculations
|
||||
|
||||
setGeometryLoaded(true);
|
||||
|
||||
// Initialize Street View Service
|
||||
svServiceRef.current = new google.maps.StreetViewService();
|
||||
|
||||
// Initialize Map
|
||||
googleMapObj.current = new Map(mapRef.current as HTMLElement, {
|
||||
center: { lat: 20, lng: 0 },
|
||||
zoom: 2,
|
||||
mapId: "STREET_VIEW_GALLERY_MAP",
|
||||
});
|
||||
|
||||
// Initialize Mini Map
|
||||
miniMapObj.current = new Map(miniMapRef.current as HTMLElement, {
|
||||
center: { lat: 20, lng: 0 },
|
||||
zoom: 14,
|
||||
mapId: "STREET_VIEW_MINI_MAP",
|
||||
disableDefaultUI: true, // This hides everything (pegman, map types, etc.)
|
||||
zoomControl: false,
|
||||
mapTypeControl: false,
|
||||
streetViewControl: false,
|
||||
fullscreenControl: false,
|
||||
clickableIcons: false,
|
||||
keyboardShortcuts: false
|
||||
});
|
||||
|
||||
miniMapMarkerObj.current = new AdvancedMarkerElement({
|
||||
map: miniMapObj.current,
|
||||
position: { lat: 0, lng: 0 },
|
||||
});
|
||||
|
||||
// Intercept Pegman Drops
|
||||
const defaultStreetView = googleMapObj.current.getStreetView();
|
||||
// We must hide the close button since we are intercepting anyway, but just in case
|
||||
defaultStreetView.setOptions({ enableCloseButton: false });
|
||||
|
||||
defaultStreetView.addListener("visible_changed", () => {
|
||||
if (defaultStreetView.getVisible()) {
|
||||
const pos = defaultStreetView.getPosition();
|
||||
// Intercept the drop: immediately hide the buggy WebGL canvas
|
||||
defaultStreetView.setVisible(false);
|
||||
|
||||
if (pos) {
|
||||
// const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
|
||||
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]);
|
||||
|
||||
// Draw all GPX routes for filtered locations
|
||||
useEffect(() => {
|
||||
if (!googleMapObj.current || typeof window === 'undefined' || !window.google || !geometryLoaded) 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) {
|
||||
// Calculate distance
|
||||
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, // Elevate selected outline
|
||||
});
|
||||
|
||||
// Also draw on mini map
|
||||
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]
|
||||
});
|
||||
// Add Google Street View click detection (Main Map)
|
||||
polyline.addListener("click", (clickEvent: google.maps.MapMouseEvent) => {
|
||||
if (!clickEvent.latLng) return;
|
||||
const clickPos = clickEvent.latLng;
|
||||
// const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// Add Google Street View click detection (Mini Map)
|
||||
miniPolyline.addListener("click", (clickEvent: google.maps.MapMouseEvent) => {
|
||||
if (!clickEvent.latLng) return;
|
||||
const clickPos = clickEvent.latLng;
|
||||
// const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
|
||||
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
|
||||
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);
|
||||
// const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
|
||||
// Requesting a high-res (scale=2) map, and a slightly taller size so the Google logo sits proportionally smaller at the bottom right.
|
||||
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);
|
||||
|
||||
// Update stats
|
||||
setTotalMiles(calculatedMeters * 0.000621371);
|
||||
|
||||
// Fit bounds for all visible filtered locations
|
||||
if (hasPoints) {
|
||||
googleMapObj.current.fitBounds(bounds);
|
||||
}
|
||||
|
||||
}, [filteredLocations, geometryLoaded, selectedLocation]); // Note: dependency on selectedLocation is REMOVED to prevent redrawing all routes!
|
||||
|
||||
// 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 });
|
||||
// Smoothly fit bounds exactly like the initial filtered view
|
||||
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) => {
|
||||
// Switch to map view to show route, not street view directly
|
||||
setViewingStreetView(false);
|
||||
setIframeUrl(null);
|
||||
|
||||
// If clicking the currently selected location, force a map pan/zoom because React state won't trigger the effect
|
||||
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);
|
||||
}
|
||||
|
||||
// Auto-close standard Bootstrap offcanvas on mobile natively
|
||||
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 (Horizontal) --- */}
|
||||
<div className="row g-0 h-100 d-md-none">
|
||||
{/* Thumbnail Map (Left) */}
|
||||
<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%)", // Zoom in slightly and push down to crop out the bottom Google/Map Data logos
|
||||
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>
|
||||
{/* Content (Right) */}
|
||||
<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 (Vertical/Original) --- */}
|
||||
<div className="d-none d-md-block">
|
||||
{/* Thumbnail Map (Top) */}
|
||||
<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>
|
||||
{/* Content (Bottom) */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Oldest date indicator at the bottom */}
|
||||
{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 (Map or Street View) */}
|
||||
<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 }} />
|
||||
|
||||
{/* Overlay controls and mini-map, only shown when in Street View mode */}
|
||||
{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>
|
||||
|
||||
{/* Manual React Backdrop for Mobile Sidebar */}
|
||||
{isMobileSidebarOpen && (
|
||||
<div
|
||||
className="offcanvas-backdrop fade show d-md-none"
|
||||
onClick={() => setIsMobileSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { timingSafeEqual } from "crypto";
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { SESSION_COOKIE, verifySessionToken } from "@/lib/session";
|
||||
|
||||
export function safeCompare(a: string, b: string): boolean {
|
||||
const bufA = Buffer.from(a);
|
||||
const bufB = Buffer.from(b);
|
||||
if (bufA.length !== bufB.length) return false;
|
||||
return timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
export async function requireAdmin(): Promise<
|
||||
{ authorized: true } | { authorized: false; response: NextResponse }
|
||||
> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
|
||||
if (!token || !(await verifySessionToken(token))) {
|
||||
return {
|
||||
authorized: false,
|
||||
response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
|
||||
};
|
||||
}
|
||||
|
||||
return { authorized: true };
|
||||
}
|
||||
@@ -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,31 @@
|
||||
const attempts = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
const MAX_ENTRIES = 10_000;
|
||||
|
||||
function pruneExpired(now: number) {
|
||||
if (attempts.size <= MAX_ENTRIES) return;
|
||||
for (const [key, entry] of attempts) {
|
||||
if (now > entry.resetAt) attempts.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function checkRateLimit(
|
||||
key: string,
|
||||
maxAttempts = 5,
|
||||
windowMs = 15 * 60 * 1000
|
||||
): boolean {
|
||||
const now = Date.now();
|
||||
pruneExpired(now);
|
||||
|
||||
const entry = attempts.get(key);
|
||||
|
||||
if (!entry || now > entry.resetAt) {
|
||||
attempts.set(key, { count: 1, resetAt: now + windowMs });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.count >= maxAttempts) return false;
|
||||
|
||||
entry.count++;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
|
||||
export const SESSION_COOKIE = "admin_session";
|
||||
export const SESSION_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
|
||||
|
||||
function getSecret(): Uint8Array {
|
||||
const secret = process.env.SESSION_SECRET;
|
||||
if (!secret || secret.length < 32) {
|
||||
throw new Error("SESSION_SECRET must be set and at least 32 characters");
|
||||
}
|
||||
return new TextEncoder().encode(secret);
|
||||
}
|
||||
|
||||
export function getSessionCookieOptions(maxAge = SESSION_MAX_AGE) {
|
||||
return {
|
||||
name: SESSION_COOKIE,
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax" as const,
|
||||
maxAge,
|
||||
path: "/",
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSessionToken(): Promise<string> {
|
||||
return new SignJWT({ role: "admin" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${SESSION_MAX_AGE}s`)
|
||||
.sign(getSecret());
|
||||
}
|
||||
|
||||
export async function verifySessionToken(token: string): Promise<boolean> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getSecret());
|
||||
return payload.role === "admin";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const MAX_GPX_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
export const locationSchema = z.object({
|
||||
title: z.string().trim().min(1, "Title is required").max(200),
|
||||
city: z.string().trim().min(1, "City is required").max(200),
|
||||
tags: z.string().max(500).default(""),
|
||||
captureDate: z
|
||||
.string()
|
||||
.refine((d) => !isNaN(Date.parse(d)), "Invalid capture date"),
|
||||
visibility: z.enum(["public", "unlisted"]).default("public"),
|
||||
gpxData: z
|
||||
.string()
|
||||
.max(MAX_GPX_BYTES, `GPX data must be under ${MAX_GPX_BYTES / (1024 * 1024)} MB`)
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type LocationInput = z.infer<typeof locationSchema>;
|
||||
|
||||
export function parseLocationBody(body: unknown) {
|
||||
return locationSchema.safeParse(body);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { verifySessionToken } from "@/lib/session";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const isAdminRoute =
|
||||
request.nextUrl.pathname.startsWith("/admin") ||
|
||||
request.nextUrl.pathname.startsWith("/api/admin");
|
||||
|
||||
if (isAdminRoute) {
|
||||
const adminSession = request.cookies.get("admin_session");
|
||||
const isValid =
|
||||
adminSession?.value &&
|
||||
(await verifySessionToken(adminSession.value));
|
||||
|
||||
if (!isValid) {
|
||||
if (request.nextUrl.pathname.startsWith("/api/")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
}
|
||||
}
|
||||
|
||||
if (request.nextUrl.pathname === "/login") {
|
||||
const adminSession = request.cookies.get("admin_session");
|
||||
if (
|
||||
adminSession?.value &&
|
||||
(await verifySessionToken(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