diff --git a/code/src/app/auth/callback/page.tsx b/code/src/app/auth/callback/page.tsx index 62b2c3e..3b0a7ac 100644 --- a/code/src/app/auth/callback/page.tsx +++ b/code/src/app/auth/callback/page.tsx @@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation"; import { Suspense } from "react"; import Link from "next/link"; import { BASE_URL } from "../../constants/api"; -import { saveTokens } from "../../ops/lib/auth"; +import { saveTokens, getOrCreateDeviceId } from "../../ops/lib/auth"; import { Loader2, AlertCircle } from "lucide-react"; const REDIRECT_URI = @@ -30,7 +30,7 @@ function CallbackHandler() { async function exchangeCode(authCode: string) { try { - const callbackUrl = `${BASE_URL}/auth/google/callback?code=${encodeURIComponent(authCode)}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}`; + const callbackUrl = `${BASE_URL}/auth/google/callback?code=${encodeURIComponent(authCode)}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&platform=WEB&device_id=${encodeURIComponent(getOrCreateDeviceId())}`; const res = await fetch(callbackUrl); if (!res.ok) { const text = await res.text(); diff --git a/code/src/app/ops/bulletins/page.tsx b/code/src/app/ops/bulletins/page.tsx new file mode 100644 index 0000000..584eec1 --- /dev/null +++ b/code/src/app/ops/bulletins/page.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { adminFetch } from "../lib/api"; +import { ADMIN_API } from "../lib/constants"; +import { AdminBulletin, AdminLocalityOption } from "../lib/types"; +import { Button } from "@/components/ui/button"; +import { Loader2, RefreshCw, Newspaper, Pencil, Play, Check, X } from "lucide-react"; +import { toast } from "sonner"; + +function todayISO(): string { + return new Date().toISOString().slice(0, 10); +} + +function fmt(value: number | null | undefined, suffix = ""): string { + return value === null || value === undefined ? "—" : `${Math.round(value)}${suffix}`; +} + +export default function BulletinsPage() { + const [bulletins, setBulletins] = useState([]); + const [localities, setLocalities] = useState([]); + const [loading, setLoading] = useState(true); + const [generating, setGenerating] = useState(false); + + // filters + const [filterLocality, setFilterLocality] = useState(""); + const [filterDate, setFilterDate] = useState(todayISO()); + + // inline summary editing + const [editingId, setEditingId] = useState(null); + const [summaryDraft, setSummaryDraft] = useState(""); + const [savingSummary, setSavingSummary] = useState(false); + + const loadBulletins = useCallback( + async (signal?: AbortSignal) => { + setLoading(true); + try { + const url = ADMIN_API.bulletins( + filterLocality ? Number(filterLocality) : null, + filterDate || null, + ); + const res = await adminFetch(url, { signal }); + if (!res.ok) throw new Error(`${res.status}`); + const json = await res.json(); + setBulletins(json.data ?? []); + } catch (err: unknown) { + if (err instanceof DOMException && err.name === "AbortError") return; + toast.error(`Failed to load bulletins: ${err}`); + } finally { + if (!signal?.aborted) setLoading(false); + } + }, + [filterLocality, filterDate], + ); + + useEffect(() => { + const controller = new AbortController(); + loadBulletins(controller.signal); + return () => controller.abort(); + }, [loadBulletins]); + + useEffect(() => { + (async () => { + try { + const res = await adminFetch(ADMIN_API.quizLocalities); + if (!res.ok) throw new Error(`${res.status}`); + const json = await res.json(); + setLocalities(json.data ?? []); + } catch (err) { + toast.error(`Failed to load localities: ${err}`); + } + })(); + }, []); + + /** Manual run of the daily weather generation (idempotent per locality+day). */ + async function handleGenerate() { + setGenerating(true); + try { + const res = await adminFetch(ADMIN_API.generateBulletins, { method: "POST" }); + if (!res.ok) throw new Error(`${res.status}`); + const json = await res.json(); + const r = json.data; + toast.success( + `Generation done: ${r?.generated ?? 0} generated, ${r?.skipped ?? 0} skipped, ${r?.failed ?? 0} failed`, + ); + loadBulletins(); + } catch (err) { + toast.error(`Generation failed: ${err}`); + } finally { + setGenerating(false); + } + } + + function openSummaryEdit(bulletin: AdminBulletin) { + setEditingId(bulletin.id); + setSummaryDraft(bulletin.summary ?? ""); + } + + async function handleSaveSummary(bulletinId: number) { + setSavingSummary(true); + try { + const res = await adminFetch(ADMIN_API.editBulletinSummary(bulletinId), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ summary: summaryDraft }), + }); + if (!res.ok) throw new Error(`${res.status}`); + toast.success("Summary updated (feed post synced)"); + setBulletins((prev) => + prev.map((b) => (b.id === bulletinId ? { ...b, summary: summaryDraft } : b)), + ); + setEditingId(null); + } catch (err) { + toast.error(`Failed to update summary: ${err}`); + } finally { + setSavingSummary(false); + } + } + + return ( +
+ {/* Header + filters */} +
+

+ Bulletins ({bulletins.length}) +

+ + + + setFilterDate(e.target.value)} + className="h-8 rounded-md border border-zinc-700 bg-zinc-800 px-3 text-sm text-zinc-200 focus:outline-none focus:ring-1 focus:ring-zinc-500" + /> + + + + +
+ + {loading ? ( +
+ +

Loading bulletins…

+
+ ) : bulletins.length === 0 ? ( +
+ +

No bulletins for this filter

+

+ The weather job runs daily at 8 AM for saved-user localities — or use Generate Now. +

+
+ ) : ( +
+ + + + + + + + + + + + + + {bulletins.map((b) => ( + + + + + + + +
LocalityDateMin / MaxHumidityRainQuizAI Summary
+ {b.locality_name} +

{b.hashtag}

+
{b.date} + {fmt(b.weather?.min_temp, "°")} / {fmt(b.weather?.max_temp, "°")} + + {fmt(b.weather?.humidity, "%")} + + {fmt(b.weather?.rain_probability, "%")} + + {b.quiz_id ? ( + #{b.quiz_id} + ) : ( + missing + )} + + {editingId === b.id ? ( +
+