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
|
||||
*.tsbuildinfo
|
||||
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";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+1262
-30
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -4,21 +4,30 @@
|
||||
"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",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@prisma/client": "^6.4.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": "^6.4.0",
|
||||
"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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+9
-15
@@ -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}`}>
|
||||
<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,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