Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions code/src/app/ops/ai-prompts/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"use client";

import { useEffect, useState } from "react";
import { adminFetch } from "../lib/api";
import { ADMIN_API } from "../lib/constants";
import { Loader2, RefreshCw } from "lucide-react";
import { toast } from "sonner";

interface AiPrompt {
key: string;
description: string;
template: string;
}

export default function AiPromptsPage() {
const [prompts, setPrompts] = useState<AiPrompt[]>([]);
const [loading, setLoading] = useState(true);

async function load() {
setLoading(true);
try {
const res = await adminFetch(ADMIN_API.aiPrompts);
if (!res.ok) throw new Error(`${res.status}`);
const json = await res.json();
setPrompts(json.data ?? []);
} catch (err) {
toast.error(`Failed to load prompts: ${err}`);
} finally {
setLoading(false);
}
}

useEffect(() => { load(); }, []);

return (
<div className="p-6 max-w-2xl space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold text-zinc-100">AI Prompts</h1>
<button
onClick={load}
disabled={loading}
className="text-zinc-500 hover:text-zinc-300 transition"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
</button>
</div>

{loading ? (
<div className="flex items-center gap-2 text-zinc-500 text-sm py-8">
<Loader2 className="w-4 h-4 animate-spin" /> Loading…
</div>
) : (
prompts.map((prompt) => (
<div key={prompt.key} className="space-y-2">
<p className="text-sm font-medium text-zinc-300">{prompt.description}</p>
<pre className="text-sm text-zinc-200 whitespace-pre-wrap leading-relaxed bg-zinc-800 rounded-lg px-4 py-3 border border-zinc-700">
{prompt.template}
</pre>
</div>
))
)}
</div>
);
}
2 changes: 2 additions & 0 deletions code/src/app/ops/bulletins/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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";
import AiPromptBox from "../components/AiPromptBox";

function todayISO(): string {
return new Date().toISOString().slice(0, 10);
Expand Down Expand Up @@ -119,6 +120,7 @@ export default function BulletinsPage() {

return (
<div className="px-4 py-6 max-w-7xl mx-auto">
<AiPromptBox promptKey="WEATHER_SUMMARY" />
{/* Header + filters */}
<div className="flex flex-wrap items-center gap-3 mb-5">
<h1 className="text-lg font-semibold text-zinc-200 mr-auto">
Expand Down
39 changes: 39 additions & 0 deletions code/src/app/ops/components/AiPromptBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"use client";

import { useEffect, useState } from "react";
import { adminFetch } from "../lib/api";
import { ADMIN_API } from "../lib/constants";

/**
* Collapsible inline view of a single Groq prompt template.
* Pass the key ("WEATHER_SUMMARY" or "QUIZ_EXPLANATION") and it fetches + renders the template.
*/
export default function AiPromptBox({ promptKey }: { promptKey: string }) {
const [template, setTemplate] = useState<string | null>(null);

useEffect(() => {
adminFetch(ADMIN_API.aiPrompts)
.then((r) => r.json())
.then((json) => {
const match = (json.data ?? []).find(
(p: { key: string; template: string }) => p.key === promptKey,
);
if (match) setTemplate(match.template);
})
.catch(() => {});
}, [promptKey]);

if (!template) return null;

return (
<details className="mb-5 group">
<summary className="cursor-pointer text-sm text-zinc-400 hover:text-zinc-200 transition select-none list-none flex items-center gap-1.5">
<span className="group-open:rotate-90 inline-block transition-transform">▶</span>
Current AI prompt
</summary>
<pre className="mt-2 text-sm text-zinc-200 whitespace-pre-wrap leading-relaxed bg-zinc-800 rounded-md px-3 py-2.5 border border-zinc-700">
{template}
</pre>
</details>
);
}
4 changes: 4 additions & 0 deletions code/src/app/ops/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,8 @@ export const ADMIN_API = {

/** Send a push notification to every user with an active device token */
notification: `${BASE_URL}/admin/notification`,
// ---- AI prompts (read-only view of GroqClient templates) ----

/** Read the Groq prompt templates currently in use */
aiPrompts: `${BASE_URL}/admin/ai-prompts`,
};
70 changes: 65 additions & 5 deletions code/src/app/ops/notification/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ import { Input } from "@/components/ui/input";
import { Loader2, Send } from "lucide-react";
import { toast } from "sonner";

type NotificationType = "BROADCAST" | "CHAT" | "ISSUE_DETAIL";

const TYPE_LABELS: Record<NotificationType, string> = {
BROADCAST: "Broadcast",
CHAT: "Chat",
ISSUE_DETAIL: "Issue Detail",
};

const TYPE_DESCRIPTIONS: Record<NotificationType, string> = {
BROADCAST: "Opens the map screen on tap.",
CHAT: "Opens the community chat screen on tap.",
ISSUE_DETAIL: "Opens a specific issue detail page on tap.",
};

async function errorMessage(res: Response): Promise<string> {
try {
const body = await res.json();
Expand All @@ -18,32 +32,42 @@ async function errorMessage(res: Response): Promise<string> {
}

export default function NotificationPage() {
const [type, setType] = useState<NotificationType>("BROADCAST");
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [issueId, setIssueId] = useState("");
const [sending, setSending] = useState(false);

async function handleSend(e: React.FormEvent) {
e.preventDefault();
if (!title.trim() || !body.trim()) return;
if (!window.confirm("Send this notification to every user with the app installed?")) return;
if (type === "ISSUE_DETAIL" && !issueId.trim()) return;

const confirmMsg =
type === "ISSUE_DETAIL"
? `Send issue #${issueId} notification to every user?`
: "Send this notification to every user with the app installed?";
if (!window.confirm(confirmMsg)) return;

const payload: Record<string, string> = { title, body };
if (type === "ISSUE_DETAIL") payload.issue_id = issueId.trim();

setSending(true);
try {
const res = await adminFetch(ADMIN_API.notification, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
notification: { type: "BROADCAST", payload: { title, body } },
}),
body: JSON.stringify({ notification: { type, payload } }),
});
if (!res.ok) throw new Error(await errorMessage(res));
const json = await res.json();
const delivered = json.data?.notification_delivered ?? 0;
toast.success(`Sent to ${delivered} device${delivered === 1 ? "" : "s"}`);
setTitle("");
setBody("");
setIssueId("");
} catch (err) {
toast.error(`Failed to send broadcast: ${err}`);
toast.error(`Failed to send notification: ${err}`);
} finally {
setSending(false);
}
Expand All @@ -62,6 +86,42 @@ export default function NotificationPage() {
onSubmit={handleSend}
className="space-y-3 bg-zinc-900 border border-zinc-800 rounded-xl p-5"
>
<div>
<label className="text-xs text-zinc-400 mb-1 block">Type *</label>
<div className="flex gap-2">
{(Object.keys(TYPE_LABELS) as NotificationType[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setType(t)}
className={`px-3 py-1.5 rounded-md text-xs font-medium border transition-colors ${
type === t
? "bg-emerald-700 border-emerald-600 text-white"
: "bg-zinc-800 border-zinc-700 text-zinc-400 hover:text-zinc-200"
}`}
>
{TYPE_LABELS[t]}
</button>
))}
</div>
<p className="text-xs text-zinc-500 mt-1.5">{TYPE_DESCRIPTIONS[type]}</p>
</div>

{type === "ISSUE_DETAIL" && (
<div>
<label className="text-xs text-zinc-400 mb-1 block">Issue ID *</label>
<Input
required
type="number"
min="1"
value={issueId}
onChange={(e) => setIssueId(e.target.value)}
placeholder="e.g. 42"
className="bg-zinc-800 border-zinc-700 text-zinc-200"
/>
</div>
)}

<div>
<label className="text-xs text-zinc-400 mb-1 block">Title *</label>
<Input
Expand Down
33 changes: 25 additions & 8 deletions code/src/app/ops/quizzes/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import {
CircleDashed,
} from "lucide-react";
import { toast } from "sonner";
import AiPromptBox from "../components/AiPromptBox";

const QUESTION_MAX = 120;
const OPTION_MAX = 60;

function todayISO(): string {
return new Date().toISOString().slice(0, 10);
Expand Down Expand Up @@ -219,6 +223,7 @@ export default function QuizzesPage() {

return (
<div className="px-4 py-6 max-w-7xl mx-auto">
<AiPromptBox promptKey="QUIZ_EXPLANATION" />
{/* Header */}
<div className="flex flex-wrap items-center gap-3 mb-5">
<div className="mr-auto">
Expand Down Expand Up @@ -255,10 +260,16 @@ export default function QuizzesPage() {
</h2>
<form onSubmit={handleSave} className="space-y-3">
<div>
<label className="text-xs text-zinc-400 mb-1 block">Question *</label>
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-zinc-400">Question *</label>
<span className={`text-xs ${form.question.length > QUESTION_MAX * 0.85 ? "text-amber-400" : "text-zinc-500"}`}>
{form.question.length}/{QUESTION_MAX}
</span>
</div>
<textarea
required
rows={2}
maxLength={QUESTION_MAX}
value={form.question}
onChange={(e) => setForm((p) => ({ ...p, question: e.target.value }))}
placeholder="e.g. Which lake is Bengaluru's largest?"
Expand All @@ -276,13 +287,19 @@ export default function QuizzesPage() {
onChange={() => setForm((p) => ({ ...p, answer_option_index: i + 1 }))}
className="accent-emerald-600"
/>
<Input
required
value={option}
onChange={(e) => setOption(i, e.target.value)}
placeholder={`Option ${i + 1}`}
className="h-8 text-sm bg-zinc-800 border-zinc-700 text-zinc-200"
/>
<div className="flex-1 relative">
<Input
required
maxLength={OPTION_MAX}
value={option}
onChange={(e) => setOption(i, e.target.value)}
placeholder={`Option ${i + 1}`}
className="h-8 text-sm bg-zinc-800 border-zinc-700 text-zinc-200 pr-10"
/>
<span className={`absolute right-2 top-1/2 -translate-y-1/2 text-[10px] pointer-events-none ${option.length > OPTION_MAX * 0.85 ? "text-amber-400" : "text-zinc-600"}`}>
{option.length}/{OPTION_MAX}
</span>
</div>
</div>
))}
<p className="text-xs text-zinc-500">Select the radio next to the correct option.</p>
Expand Down
Loading