diff --git a/package.json b/package.json index e7ff5cc..4109351 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "heic-convert": "^2.1.0", "maplibre-gl": "5.7.3", "path": "0.12.7", + "piexifjs": "^1.0.6", "quick-lru": "^7.3.0", "vite": "7.1.6" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e74f64..04b79b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: path: specifier: 0.12.7 version: 0.12.7 + piexifjs: + specifier: ^1.0.6 + version: 1.0.6 quick-lru: specifier: ^7.3.0 version: 7.3.0 @@ -1368,6 +1371,9 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + piexifjs@1.0.6: + resolution: {integrity: sha512-0wVyH0cKohzBQ5Gi2V1BuxYpxWfxF3cSqfFXfPIpl5tl9XLS5z4ogqhUCD20AbHi0h9aJkqXNJnkVev6gwh2ag==} + pngjs@6.0.0: resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} engines: {node: '>=12.13.0'} @@ -2978,6 +2984,8 @@ snapshots: picomatch@4.0.3: {} + piexifjs@1.0.6: {} + pngjs@6.0.0: {} postcss-load-config@3.1.4(postcss@8.5.6): diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..7339bb5 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,24 @@ +import type { Handle } from "@sveltejs/kit"; +import { redirect, error } from "@sveltejs/kit"; +import { getSession } from "$lib/server/session"; +import { ADMIN_USER } from "$env/static/private"; + +export const handle: Handle = async ({ event, resolve }) => { + const path = event.url.pathname; + + // Block ALL /admin routes if ADMIN_USER not configured + if (path.startsWith("/admin") && !ADMIN_USER) { + throw error(403, "Admin access is disabled"); + } + + const session = getSession(event.cookies.get("session")); + if ( + path.startsWith("/admin") && + !path.startsWith("/admin/login") && + !session + ) { + throw redirect(303, "/admin/login"); + } + + return resolve(event); +}; diff --git a/src/lib/components/dashboard/PhotoTagger.svelte b/src/lib/components/dashboard/PhotoTagger.svelte new file mode 100644 index 0000000..468f299 --- /dev/null +++ b/src/lib/components/dashboard/PhotoTagger.svelte @@ -0,0 +1,238 @@ + + +{#if loading} +

Loading untagged photos…

+{:else if error} +

{error}

+{:else if photos.length === 0} +

🎉 All photos are tagged!

+{:else} + +{/if} + + diff --git a/src/lib/server/session.ts b/src/lib/server/session.ts new file mode 100644 index 0000000..77064c2 --- /dev/null +++ b/src/lib/server/session.ts @@ -0,0 +1,24 @@ +export const sessions = new Map(); + +export function getSession(id: string | undefined) { + if (!id) return null; + const session = sessions.get(id); + if (!session) return null; + + if (session.expires < Date.now()) { + sessions.delete(id); + return null; + } + return session; +} + +export function createSession() { + const id = crypto.randomUUID(); + const expires = Date.now() + 1000 * 60 * 60; // 1h + sessions.set(id, { expires }); + return { id, expires }; +} + +export function destroySession(id: string) { + sessions.delete(id); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..2a8190c --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,28 @@ +
+ +
+ + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 32fa82a..2da4013 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -183,32 +183,30 @@ }); -
- - - {#if photos?.features?.length} - - {/if} - - {#if selectedPhoto} - - {/if} - -
-
+ + +{#if photos?.features?.length} + +{/if} + +{#if selectedPhoto} + +{/if} + +
diff --git a/src/routes/admin/login/+page.svelte b/src/routes/admin/login/+page.svelte new file mode 100644 index 0000000..dd114db --- /dev/null +++ b/src/routes/admin/login/+page.svelte @@ -0,0 +1,104 @@ + + +
+

Login

+ {#if error} +

{error}

+ {/if} +
+ + + +
+
+ + diff --git a/src/routes/admin/panel/+page.svelte b/src/routes/admin/panel/+page.svelte new file mode 100644 index 0000000..c4e193d --- /dev/null +++ b/src/routes/admin/panel/+page.svelte @@ -0,0 +1,82 @@ + + +
+
+
+ +
+ + +
+ + +
+ {#if activeTab === "tag"} + + {/if} +
+
+ + diff --git a/src/routes/api/login/+server.ts b/src/routes/api/login/+server.ts new file mode 100644 index 0000000..0feced1 --- /dev/null +++ b/src/routes/api/login/+server.ts @@ -0,0 +1,35 @@ +import type { RequestHandler } from "@sveltejs/kit"; +import { ADMIN_USER, ADMIN_PASS } from "$env/static/private"; +import { createSession, getSession, destroySession } from "$lib/server/session"; + +export const POST: RequestHandler = async ({ request, cookies }) => { + const { username, password } = await request.json(); + + if (username === ADMIN_USER && password === ADMIN_PASS) { + const { id, expires } = createSession(); + cookies.set("session", id, { + path: "/", + httpOnly: true, + sameSite: "strict", + secure: true, + expires: new Date(expires), + }); + return new Response("ok"); + } + + return new Response("unauthorized", { status: 401 }); +}; + +export const GET: RequestHandler = async ({ cookies }) => { + const session = getSession(cookies.get("session")); + return new Response(JSON.stringify(session), { + headers: { "Content-Type": "application/json" }, + }); +}; + +export const DELETE: RequestHandler = async ({ cookies }) => { + const id = cookies.get("session"); + if (id) destroySession(id); + cookies.delete("session", { path: "/" }); + return new Response("logged out"); +}; diff --git a/src/routes/api/logout/+server.ts b/src/routes/api/logout/+server.ts new file mode 100644 index 0000000..752ffb0 --- /dev/null +++ b/src/routes/api/logout/+server.ts @@ -0,0 +1,8 @@ +import type { RequestHandler } from '@sveltejs/kit'; + +export const POST: RequestHandler = async ({ cookies }) => { + cookies.delete('session', { path: '/' }); + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json' } + }); +}; diff --git a/src/routes/api/photos/image/[fileId]/+server.ts b/src/routes/api/photos/image/[fileId]/+server.ts index b26881a..ae47c7a 100644 --- a/src/routes/api/photos/image/[fileId]/+server.ts +++ b/src/routes/api/photos/image/[fileId]/+server.ts @@ -86,7 +86,7 @@ export const GET: RequestHandler = async ({ params, url }) => { data: output, contentType: finalType, fileName, - expires: now + 3600 * 1000, + expires: now + 10 * 1000 * 12 * 60 * 60, }); const totalMs = (performance.now() - start).toFixed(1); diff --git a/src/routes/api/photos/image/update/+server.ts b/src/routes/api/photos/image/update/+server.ts new file mode 100644 index 0000000..96c9a20 --- /dev/null +++ b/src/routes/api/photos/image/update/+server.ts @@ -0,0 +1,63 @@ +import { initDrive } from "$lib/server/drive"; +import type { RequestHandler } from "@sveltejs/kit"; +import { Readable } from "node:stream"; +import piexif from "piexifjs"; +import { destroySession } from "$lib/server/session"; + +export const POST: RequestHandler = async ({ request }) => { + const sessionId = cookies.get("session"); + if (sessionId) destroySession(sessionId); + + const { fileId, lat, lon } = await request.json(); + + if (!fileId || lat == null || lon == null) { + return new Response("Missing parameters", { status: 400 }); + } + + const drive = initDrive(); + + try { + // --- 1. Download the image --- + const res = await drive.files.get( + { fileId, alt: "media" }, + { responseType: "arraybuffer" }, + ); + const buffer = Buffer.from(res.data); + const base64 = buffer.toString("binary"); + + // --- 2. Add EXIF metadata --- + const exifObj = piexif.load(base64); + exifObj["GPS"][piexif.GPSIFD.GPSLatitudeRef] = lat >= 0 ? "N" : "S"; + exifObj["GPS"][piexif.GPSIFD.GPSLatitude] = + piexif.GPSHelper.degToDmsRational(Math.abs(lat)); + exifObj["GPS"][piexif.GPSIFD.GPSLongitudeRef] = lon >= 0 ? "E" : "W"; + exifObj["GPS"][piexif.GPSIFD.GPSLongitude] = + piexif.GPSHelper.degToDmsRational(Math.abs(lon)); + + const exifBytes = piexif.dump(exifObj); + const newBase64 = piexif.insert(exifBytes, base64); + const newBuffer = Buffer.from(newBase64, "binary"); + + // --- 3. Convert Buffer → Readable stream --- + const stream = Readable.from(newBuffer); + + // --- 4. Update file in place (keeps same fileId) --- + const updated = await drive.files.update({ + fileId, + media: { + mimeType: "image/jpeg", + body: stream, // ✅ must be a readable stream + }, + fields: "id, name, mimeType, webViewLink, modifiedTime", + }); + + // --- 5. Return response --- + return new Response(JSON.stringify(updated.data), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + console.error("Failed to update photo metadata:", err); + return new Response("Error updating photo", { status: 500 }); + } +}; diff --git a/src/routes/api/photos/untagged/+server.ts b/src/routes/api/photos/untagged/+server.ts new file mode 100644 index 0000000..c8bc081 --- /dev/null +++ b/src/routes/api/photos/untagged/+server.ts @@ -0,0 +1,24 @@ +// src/routes/api/photos/untagged/+server.ts +import { json, type RequestHandler } from '@sveltejs/kit'; +import { initDrive } from '$lib/server/drive'; +import { GOOGLE_FOLDER_ID } from '$env/static/private'; + +export const GET: RequestHandler = async () => { + const drive = initDrive(); + const listRes: any = await drive.files.list({ + q: `'${GOOGLE_FOLDER_ID}' in parents`, + fields: 'files(id,name,createdTime,imageMediaMetadata)', + pageSize: 100 + }); + + const untagged = listRes.data.files.filter((f: any) => !f.imageMediaMetadata?.location); + + return json( + untagged.map((f: any) => ({ + id: f.id, + name: f.name, + takenAt: f.imageMediaMetadata?.time || f.createdTime, + url: `/api/photos/image/${f.id}` // 👈 you already have an endpoint for image streaming + })) + ); +};