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
+ }))
+ );
+};