flagging
This commit is contained in:
143
apps/web/src/app/api/flag/report/route.ts
Normal file
143
apps/web/src/app/api/flag/report/route.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDb } from "@/lib/db/index";
|
||||
import { flaggedContent, plants, diseases } from "@/lib/db/schema";
|
||||
import { inArray, sql } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* GET /api/flag/report
|
||||
*
|
||||
* Returns all flagged content grouped by content type, with resolved
|
||||
* plant/disease names for readability. Used by the generate-flagged-report script.
|
||||
*
|
||||
* Query params:
|
||||
* minFlags - Optional minimum flag count to include (default: 1)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const minFlags = parseInt(searchParams.get("minFlags") ?? "1", 10);
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Get all flagged entries
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(flaggedContent)
|
||||
.where(sql`flag_count >= ${minFlags}`)
|
||||
.orderBy(flaggedContent.contentType, flaggedContent.flagCount);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({
|
||||
total: 0,
|
||||
groups: {},
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve plant/disease names
|
||||
const plantIds = new Set<string>();
|
||||
const diseaseIds = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.contentType === "plant_image") {
|
||||
plantIds.add(row.contentId);
|
||||
} else {
|
||||
diseaseIds.add(row.contentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch plant names
|
||||
const plantMap = new Map<string, string>();
|
||||
if (plantIds.size > 0) {
|
||||
const plantRows = await db
|
||||
.select({ id: plants.id, name: plants.commonName })
|
||||
.from(plants)
|
||||
.where(inArray(plants.id, [...plantIds]));
|
||||
for (const p of plantRows) {
|
||||
plantMap.set(p.id, p.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch disease names + their plant references
|
||||
const diseaseMap = new Map<string, { name: string; plantId: string }>();
|
||||
if (diseaseIds.size > 0) {
|
||||
const diseaseRows = await db
|
||||
.select({
|
||||
id: diseases.id,
|
||||
name: diseases.name,
|
||||
plantId: diseases.plantId,
|
||||
})
|
||||
.from(diseases)
|
||||
.where(inArray(diseases.id, [...diseaseIds]));
|
||||
for (const d of diseaseRows) {
|
||||
diseaseMap.set(d.id, { name: d.name, plantId: d.plantId });
|
||||
}
|
||||
// Fetch plants for diseases that we don't already have
|
||||
for (const d of diseaseRows) {
|
||||
if (!plantMap.has(d.plantId)) {
|
||||
plantIds.add(d.plantId);
|
||||
}
|
||||
}
|
||||
if (plantIds.size > 0) {
|
||||
const plantRows = await db
|
||||
.select({ id: plants.id, name: plants.commonName })
|
||||
.from(plants)
|
||||
.where(inArray(plants.id, [...plantIds]));
|
||||
for (const p of plantRows) {
|
||||
plantMap.set(p.id, p.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group by content type
|
||||
const groups: Record<string, Array<Record<string, unknown>>> = {};
|
||||
for (const row of rows) {
|
||||
const type = row.contentType;
|
||||
if (!groups[type]) groups[type] = [];
|
||||
|
||||
let label = row.contentId;
|
||||
if (type === "plant_image") {
|
||||
label = plantMap.get(row.contentId) ?? row.contentId;
|
||||
} else {
|
||||
const disease = diseaseMap.get(row.contentId);
|
||||
if (disease) {
|
||||
const plantName = plantMap.get(disease.plantId) ?? disease.plantId;
|
||||
label = `${disease.name} (on ${plantName})`;
|
||||
}
|
||||
}
|
||||
|
||||
groups[type].push({
|
||||
id: row.id,
|
||||
contentType: row.contentType,
|
||||
contentId: row.contentId,
|
||||
fieldName: row.fieldName,
|
||||
label,
|
||||
notes: row.notes,
|
||||
flagCount: row.flagCount,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
total: rows.length,
|
||||
groups,
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
contentType: row.contentType,
|
||||
contentId: row.contentId,
|
||||
fieldName: row.fieldName,
|
||||
notes: row.notes,
|
||||
flagCount: row.flagCount,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[Flag Report] Error fetching flagged content:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error", message: "Failed to fetch flagged content", status: 500 },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
147
apps/web/src/app/api/flag/route.ts
Normal file
147
apps/web/src/app/api/flag/route.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { getDb } from "@/lib/db/index";
|
||||
import { flaggedContent } from "@/lib/db/schema";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
/**
|
||||
* Content types that can be flagged for manual review.
|
||||
*/
|
||||
const VALID_CONTENT_TYPES = [
|
||||
"plant_image",
|
||||
"disease_image",
|
||||
"disease_symptoms",
|
||||
"disease_causes",
|
||||
"disease_treatment",
|
||||
"disease_prevention",
|
||||
] as const;
|
||||
|
||||
type FlagContentType = (typeof VALID_CONTENT_TYPES)[number];
|
||||
|
||||
interface FlagRequestBody {
|
||||
contentType: FlagContentType;
|
||||
contentId: string;
|
||||
fieldName: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/flag
|
||||
*
|
||||
* Flag content for manual review. If the same content_type + content_id + field_name
|
||||
* combination already exists, increments the flag_count. Otherwise creates a new entry.
|
||||
*
|
||||
* Body:
|
||||
* contentType - Type of content being flagged
|
||||
* contentId - The ID of the plant or disease
|
||||
* fieldName - The specific field name (e.g., "image", "symptoms")
|
||||
* notes - Optional notes/reason for flagging
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: FlagRequestBody = await request.json();
|
||||
|
||||
// ── Validate required fields ──
|
||||
|
||||
if (!body.contentType || !VALID_CONTENT_TYPES.includes(body.contentType)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Bad Request",
|
||||
message: `Invalid contentType. Must be one of: ${VALID_CONTENT_TYPES.join(", ")}`,
|
||||
status: 400,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!body.contentId ||
|
||||
typeof body.contentId !== "string" ||
|
||||
body.contentId.trim().length === 0
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bad Request", message: "contentId is required", status: 400 },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!body.fieldName ||
|
||||
typeof body.fieldName !== "string" ||
|
||||
body.fieldName.trim().length === 0
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bad Request", message: "fieldName is required", status: 400 },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// ── Check if this item was already flagged ──
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(flaggedContent)
|
||||
.where(
|
||||
and(
|
||||
eq(flaggedContent.contentType, body.contentType),
|
||||
eq(flaggedContent.contentId, body.contentId),
|
||||
eq(flaggedContent.fieldName, body.fieldName),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Increment flag count and update timestamp
|
||||
const current = existing[0];
|
||||
await db
|
||||
.update(flaggedContent)
|
||||
.set({
|
||||
flagCount: (current.flagCount ?? 0) + 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
...(body.notes ? { notes: body.notes } : {}),
|
||||
})
|
||||
.where(eq(flaggedContent.id, current.id));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
action: "incremented",
|
||||
flagCount: (current.flagCount ?? 0) + 1,
|
||||
message: "Flag count incremented. Thank you for your review input.",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Create new flag entry ──
|
||||
|
||||
const id = uuidv4();
|
||||
await db.insert(flaggedContent).values({
|
||||
id,
|
||||
contentType: body.contentType,
|
||||
contentId: body.contentId,
|
||||
fieldName: body.fieldName,
|
||||
notes: body.notes ?? "",
|
||||
flagCount: 1,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Flag] New flag: type=${body.contentType} id=${body.contentId} field=${body.fieldName}`,
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
action: "created",
|
||||
flagCount: 1,
|
||||
message: "Content flagged for manual review. Thank you!",
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[Flag] Error flagging content:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error", message: "Failed to flag content", status: 500 },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import type { Disease, CausalAgentType, Prevalence, Severity } from "@/lib/types";
|
||||
import ImageLightbox from "@/components/ImageLightbox";
|
||||
import FlagButton from "@/components/FlagButton";
|
||||
|
||||
// ─── Severity badge ───
|
||||
|
||||
@@ -86,7 +87,7 @@ function DiseaseCard({
|
||||
</div>
|
||||
|
||||
{/* Disease image or placeholder */}
|
||||
<div className="mb-4 rounded-lg overflow-hidden border border-zinc-200 dark:border-zinc-700">
|
||||
<div className="mb-2 rounded-lg overflow-hidden border border-zinc-200 dark:border-zinc-700 relative">
|
||||
{disease.imageUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -128,6 +129,16 @@ function DiseaseCard({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Flag button for disease image */}
|
||||
<div className="flex justify-end mb-2">
|
||||
<FlagButton
|
||||
contentType="disease_image"
|
||||
contentId={disease.id}
|
||||
fieldName="image"
|
||||
label="disease image"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed mb-4">
|
||||
{disease.description}
|
||||
@@ -137,9 +148,18 @@ function DiseaseCard({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Symptoms */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-red-600 dark:text-red-400 mb-2 flex items-center gap-1">
|
||||
<span aria-hidden="true">⚠️</span> Symptoms
|
||||
</h4>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-red-600 dark:text-red-400 flex items-center gap-1">
|
||||
<span aria-hidden="true">⚠️</span> Symptoms
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_symptoms"
|
||||
contentId={disease.id}
|
||||
fieldName="symptoms"
|
||||
label="symptoms"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{disease.symptoms.map((symptom, i) => (
|
||||
<li
|
||||
@@ -155,9 +175,18 @@ function DiseaseCard({
|
||||
|
||||
{/* Causes */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-orange-600 dark:text-orange-400 mb-2 flex items-center gap-1">
|
||||
<span aria-hidden="true">🔍</span> Causes
|
||||
</h4>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-orange-600 dark:text-orange-400 flex items-center gap-1">
|
||||
<span aria-hidden="true">🔍</span> Causes
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_causes"
|
||||
contentId={disease.id}
|
||||
fieldName="causes"
|
||||
label="causes"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{disease.causes.map((cause, i) => (
|
||||
<li
|
||||
@@ -173,9 +202,18 @@ function DiseaseCard({
|
||||
|
||||
{/* Treatment Steps */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 mb-2 flex items-center gap-1">
|
||||
<span aria-hidden="true">💊</span> Treatment Steps
|
||||
</h4>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 flex items-center gap-1">
|
||||
<span aria-hidden="true">💊</span> Treatment Steps
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_treatment"
|
||||
contentId={disease.id}
|
||||
fieldName="treatment"
|
||||
label="treatment"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<ol className="space-y-1.5 list-decimal list-inside">
|
||||
{disease.treatment.map((step, i) => (
|
||||
<li key={i} className="text-sm text-zinc-600 dark:text-zinc-300">
|
||||
@@ -187,9 +225,18 @@ function DiseaseCard({
|
||||
|
||||
{/* Prevention Tips */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 mb-2 flex items-center gap-1">
|
||||
<span aria-hidden="true">🛡️</span> Prevention Tips
|
||||
</h4>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 flex items-center gap-1">
|
||||
<span aria-hidden="true">🛡️</span> Prevention Tips
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_prevention"
|
||||
contentId={disease.id}
|
||||
fieldName="prevention"
|
||||
label="prevention tips"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{disease.prevention.map((tip, i) => (
|
||||
<li
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getPlantWithDiseases } from "@/lib/api/diseases-db";
|
||||
import { getPlantDescription } from "@/lib/display-helpers";
|
||||
import DiseaseCards from "./DiseaseCards";
|
||||
import PlantViewTracker from "@/components/PlantViewTracker";
|
||||
import FlagPlantImage from "@/components/FlagPlantImage";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ plantId: string }>;
|
||||
@@ -114,6 +115,7 @@ export default async function PlantDetailPage({ params }: Props) {
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<FlagPlantImage plantId={plantId} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -6,6 +6,7 @@ import ConfidenceBadge, { getConfidenceColors } from "@/components/ConfidenceBad
|
||||
import SymptomChecker from "@/components/SymptomChecker";
|
||||
import TreatmentTimeline, { treatmentStepsWithUrgency } from "@/components/TreatmentTimeline";
|
||||
import LookalikeWarning from "@/components/LookalikeWarning";
|
||||
import FlagButton from "@/components/FlagButton";
|
||||
import { getLookalikeDiseases } from "@/lib/api/diseases";
|
||||
|
||||
/**
|
||||
@@ -45,15 +46,18 @@ export default function DiseaseCard({
|
||||
<article
|
||||
className={`
|
||||
group/card relative rounded-xl border-2 overflow-hidden transition-all duration-200
|
||||
${isPrimary
|
||||
? `${colors.border} ${colors.bg} shadow-md`
|
||||
: "border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-sm hover:shadow-md"
|
||||
${
|
||||
isPrimary
|
||||
? `${colors.border} ${colors.bg} shadow-md`
|
||||
: "border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-sm hover:shadow-md"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Primary diagnosis ribbon */}
|
||||
{isPrimary && (
|
||||
<div className={`${colors.accent} text-white text-xs font-bold uppercase tracking-wider px-4 py-1.5 flex items-center gap-2`}>
|
||||
<div
|
||||
className={`${colors.accent} text-white text-xs font-bold uppercase tracking-wider px-4 py-1.5 flex items-center gap-2`}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
@@ -71,13 +75,16 @@ export default function DiseaseCard({
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Rank / causal agent icon */}
|
||||
<div className={`
|
||||
<div
|
||||
className={`
|
||||
flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold
|
||||
${isPrimary
|
||||
? `${colors.accent} text-white`
|
||||
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400"
|
||||
${
|
||||
isPrimary
|
||||
? `${colors.accent} text-white`
|
||||
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400"
|
||||
}
|
||||
`}>
|
||||
`}
|
||||
>
|
||||
{rank}
|
||||
</div>
|
||||
|
||||
@@ -93,9 +100,7 @@ export default function DiseaseCard({
|
||||
<p className="mt-0.5 text-xs italic text-zinc-500 dark:text-zinc-400">
|
||||
{disease.scientificName}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2">
|
||||
{summary}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2">{summary}</p>
|
||||
</div>
|
||||
|
||||
{/* Expand/collapse chevron */}
|
||||
@@ -105,7 +110,11 @@ export default function DiseaseCard({
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.22 7.22a.75.75 0 011.06 0L10 10.94l3.72-3.72a.75.75 0 111.06 1.06l-4.25 4.25a.75.75 0 01-1.06 0L5.22 8.28a.75.75 0 010-1.06z" clipRule="evenodd" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.22 7.22a.75.75 0 011.06 0L10 10.94l3.72-3.72a.75.75 0 111.06 1.06l-4.25 4.25a.75.75 0 01-1.06 0L5.22 8.28a.75.75 0 010-1.06z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
@@ -133,17 +142,47 @@ export default function DiseaseCard({
|
||||
|
||||
{/* Symptom checker */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Symptom Checker
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_symptoms"
|
||||
contentId={disease.id}
|
||||
fieldName="symptoms"
|
||||
label="symptoms"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<SymptomChecker symptoms={disease.symptoms} />
|
||||
</div>
|
||||
|
||||
{/* Causes */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2 flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-zinc-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Causes & Contributing Factors
|
||||
</h4>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
|
||||
<svg
|
||||
className="h-4 w-4 text-zinc-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Causes & Contributing Factors
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_causes"
|
||||
contentId={disease.id}
|
||||
fieldName="causes"
|
||||
label="causes"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<ul className="space-y-1.5" role="list">
|
||||
{disease.causes.map((cause, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
@@ -156,12 +195,30 @@ export default function DiseaseCard({
|
||||
|
||||
{/* Treatment timeline */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2 flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-leaf-green-600 dark:text-leaf-green-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Treatment Plan
|
||||
</h4>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
|
||||
<svg
|
||||
className="h-4 w-4 text-leaf-green-600 dark:text-leaf-green-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Treatment Plan
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_treatment"
|
||||
contentId={disease.id}
|
||||
fieldName="treatment"
|
||||
label="treatment"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<TreatmentTimeline
|
||||
steps={treatmentStepsWithUrgency(disease.treatment)}
|
||||
severity={disease.severity}
|
||||
@@ -170,12 +227,30 @@ export default function DiseaseCard({
|
||||
|
||||
{/* Prevention tips */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2 flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-leaf-green-600 dark:text-leaf-green-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M6.32 2.577a49.255 49.255 0 0111.36 0c1.497.174 2.57 1.46 2.57 2.93V21a.75.75 0 01-1.085.67L10 18.089l-9.165 3.583A.75.75 0 010 21V5.507c0-1.47 1.073-2.756 2.57-2.93a49.254 49.254 0 0111.36 0zM12 9a2 2 0 11-4 0 2 2 0 014 0zm-2 3a1 1 0 00-1 1v1a1 1 0 001 1h0a1 1 0 001-1v-1a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Prevention Tips
|
||||
</h4>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
|
||||
<svg
|
||||
className="h-4 w-4 text-leaf-green-600 dark:text-leaf-green-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M6.32 2.577a49.255 49.255 0 0111.36 0c1.497.174 2.57 1.46 2.57 2.93V21a.75.75 0 01-1.085.67L10 18.089l-9.165 3.583A.75.75 0 010 21V5.507c0-1.47 1.073-2.756 2.57-2.93a49.254 49.254 0 0111.36 0zM12 9a2 2 0 11-4 0 2 2 0 014 0zm-2 3a1 1 0 00-1 1v1a1 1 0 001 1h0a1 1 0 001-1v-1a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Prevention Tips
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_prevention"
|
||||
contentId={disease.id}
|
||||
fieldName="prevention"
|
||||
label="prevention tips"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<ul className="space-y-1.5" role="list">
|
||||
{disease.prevention.map((tip, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
@@ -187,9 +262,7 @@ export default function DiseaseCard({
|
||||
</div>
|
||||
|
||||
{/* Lookalike warnings */}
|
||||
{lookalikes.length > 0 && (
|
||||
<LookalikeWarning disease={disease} lookalikes={lookalikes} />
|
||||
)}
|
||||
{lookalikes.length > 0 && <LookalikeWarning disease={disease} lookalikes={lookalikes} />}
|
||||
|
||||
{/* Feedback buttons */}
|
||||
<div className="pt-2 border-t border-zinc-200 dark:border-zinc-700">
|
||||
@@ -203,9 +276,10 @@ export default function DiseaseCard({
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium
|
||||
transition-colors
|
||||
${feedback === "yes"
|
||||
? "bg-leaf-green-100 dark:bg-leaf-green-900/50 text-leaf-green-700 dark:text-leaf-green-300 ring-1 ring-leaf-green-300 dark:ring-leaf-green-700"
|
||||
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||
${
|
||||
feedback === "yes"
|
||||
? "bg-leaf-green-100 dark:bg-leaf-green-900/50 text-leaf-green-700 dark:text-leaf-green-300 ring-1 ring-leaf-green-300 dark:ring-leaf-green-700"
|
||||
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||
}
|
||||
`}
|
||||
aria-pressed={feedback === "yes"}
|
||||
@@ -221,9 +295,10 @@ export default function DiseaseCard({
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium
|
||||
transition-colors
|
||||
${feedback === "no"
|
||||
? "bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 ring-1 ring-red-300 dark:ring-red-700"
|
||||
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||
${
|
||||
feedback === "no"
|
||||
? "bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 ring-1 ring-red-300 dark:ring-red-700"
|
||||
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||
}
|
||||
`}
|
||||
aria-pressed={feedback === "no"}
|
||||
|
||||
178
apps/web/src/components/FlagButton.tsx
Normal file
178
apps/web/src/components/FlagButton.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
/**
|
||||
* Content types that can be flagged for manual review.
|
||||
*/
|
||||
export type FlagContentType =
|
||||
| "plant_image"
|
||||
| "disease_image"
|
||||
| "disease_symptoms"
|
||||
| "disease_causes"
|
||||
| "disease_treatment"
|
||||
| "disease_prevention";
|
||||
|
||||
interface FlagButtonProps {
|
||||
/** Type of content being flagged */
|
||||
contentType: FlagContentType;
|
||||
/** The ID of the plant or disease */
|
||||
contentId: string;
|
||||
/** The specific field name (e.g., "image", "symptoms", "causes", "treatment", "prevention") */
|
||||
fieldName: string;
|
||||
/** Optional human-readable label for display (e.g., "This plant image") */
|
||||
label?: string;
|
||||
/** Optional notes/reason pre-filled for flagging */
|
||||
notes?: string;
|
||||
/** Small variant for inline use */
|
||||
small?: boolean;
|
||||
/** Optional class name override */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FlagButton — a small button that lets users flag content for manual review.
|
||||
*
|
||||
* When clicked, it POSTs to /api/flag which either creates or increments
|
||||
* a flag count in the flagged_content table.
|
||||
*
|
||||
* Shows visual feedback: "Flagged!" toast-like state for a few seconds.
|
||||
*/
|
||||
export default function FlagButton({
|
||||
contentType,
|
||||
contentId,
|
||||
fieldName,
|
||||
label,
|
||||
small = false,
|
||||
className = "",
|
||||
}: FlagButtonProps) {
|
||||
const [state, setState] = useState<"idle" | "loading" | "flagged" | "error">("idle");
|
||||
const [flagCount, setFlagCount] = useState(0);
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
|
||||
const handleFlag = useCallback(async () => {
|
||||
if (state === "loading" || state === "flagged") return;
|
||||
|
||||
setState("loading");
|
||||
setErrorMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/flag", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType, contentId, fieldName }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ message: "Failed to flag content" }));
|
||||
throw new Error(data.message || "Failed to flag content");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setFlagCount(data.flagCount ?? 1);
|
||||
setState("flagged");
|
||||
|
||||
// Reset back to idle after a moment so user can flag again if needed
|
||||
setTimeout(() => {
|
||||
setState("idle");
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : "Failed to flag");
|
||||
setState("error");
|
||||
|
||||
setTimeout(() => {
|
||||
setState("idle");
|
||||
setErrorMsg("");
|
||||
}, 3000);
|
||||
}
|
||||
}, [contentType, contentId, fieldName, state]);
|
||||
|
||||
// ─── Button state styles ────────────────────────────────────────────────────
|
||||
|
||||
const baseClasses = small
|
||||
? "inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium transition-all"
|
||||
: "inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-all";
|
||||
|
||||
const idleClasses =
|
||||
"text-zinc-400 dark:text-zinc-500 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-950/30 border border-transparent hover:border-amber-200 dark:hover:border-amber-800";
|
||||
|
||||
const loadingClasses = "text-zinc-300 dark:text-zinc-600 cursor-wait";
|
||||
|
||||
const flaggedClasses =
|
||||
"text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-700";
|
||||
|
||||
const errorClasses =
|
||||
"text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-800";
|
||||
|
||||
const stateClasses =
|
||||
state === "loading"
|
||||
? loadingClasses
|
||||
: state === "flagged"
|
||||
? flaggedClasses
|
||||
: state === "error"
|
||||
? errorClasses
|
||||
: idleClasses;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFlag}
|
||||
disabled={state === "loading"}
|
||||
className={`${baseClasses} ${stateClasses} ${className}`}
|
||||
title={
|
||||
state === "flagged"
|
||||
? `Flagged (${flagCount}×)`
|
||||
: state === "error"
|
||||
? errorMsg
|
||||
: `Flag for manual review${label ? ` — ${label}` : ""}`
|
||||
}
|
||||
aria-label={
|
||||
state === "flagged"
|
||||
? `Flagged for review (${flagCount} times)`
|
||||
: `Flag this ${label || "content"} for manual review`
|
||||
}
|
||||
>
|
||||
{/* Flag icon */}
|
||||
{state === "loading" ? (
|
||||
<svg
|
||||
className={`${small ? "h-3 w-3" : "h-3.5 w-3.5"} animate-spin`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className={`${small ? "h-3 w-3" : "h-3.5 w-3.5"}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M3.5 2.75a.75.75 0 00-1.5 0v14.5a.75.75 0 001.5 0v-4.392l1.657-.348a6.453 6.453 0 014.271.572 7.948 7.948 0 005.965.524l2.078-.64A.75.75 0 0018 12.25v-8.5a.75.75 0 00-.904-.734l-2.38.501a7.25 7.25 0 01-4.186-.363l-.502-.2a8.75 8.75 0 00-5.053-.439l-1.475.31V2.75z" />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Text */}
|
||||
{state === "flagged" ? (
|
||||
<span>Flagged{flagCount > 1 ? ` (${flagCount}×)` : ""}</span>
|
||||
) : state === "error" ? (
|
||||
<span>Error</span>
|
||||
) : (
|
||||
<span>Flag</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
26
apps/web/src/components/FlagPlantImage.tsx
Normal file
26
apps/web/src/components/FlagPlantImage.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import FlagButton from "@/components/FlagButton";
|
||||
|
||||
/**
|
||||
* Client component wrapper to add a flag button for plant images
|
||||
* on the detail page (which is a server component).
|
||||
*/
|
||||
export default function FlagPlantImage({ plantId }: { plantId: string }) {
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-1 right-1 z-10" onClick={handleClick}>
|
||||
<FlagButton
|
||||
contentType="plant_image"
|
||||
contentId={plantId}
|
||||
fieldName="image"
|
||||
label="plant image"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import FlagButton from "@/components/FlagButton";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export interface PlantCardData {
|
||||
id: string;
|
||||
@@ -23,6 +25,11 @@ interface PlantCardProps {
|
||||
* Used on the homepage featured section and browse grid.
|
||||
*/
|
||||
export default function PlantCard({ plant, showDiseaseCount = true }: PlantCardProps) {
|
||||
const handleFlagClick = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/browse/${plant.id}`}
|
||||
@@ -58,6 +65,16 @@ export default function PlantCard({ plant, showDiseaseCount = true }: PlantCardP
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{/* Flag button overlay at bottom-right of image */}
|
||||
<div className="absolute bottom-1.5 right-1.5 z-10" onClick={handleFlagClick}>
|
||||
<FlagButton
|
||||
contentType="plant_image"
|
||||
contentId={plant.id}
|
||||
fieldName="image"
|
||||
label="plant image"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
|
||||
@@ -10,7 +10,14 @@ import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql";
|
||||
import { createClient } from "@libsql/client";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export type { PlantRow, PlantInsert, DiseaseRow, DiseaseInsert } from "./schema";
|
||||
export type {
|
||||
PlantRow,
|
||||
PlantInsert,
|
||||
DiseaseRow,
|
||||
DiseaseInsert,
|
||||
FlaggedContentRow,
|
||||
FlaggedContentInsert,
|
||||
} from "./schema";
|
||||
|
||||
export { schema };
|
||||
|
||||
|
||||
@@ -113,6 +113,51 @@ export const plantViews = sqliteTable(
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Flagged Content Table ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stores user-flagged content for manual review.
|
||||
* content_type: what kind of content is flagged
|
||||
* content_id: the ID of the plant or disease
|
||||
* field_name: specific field being flagged (e.g., "image", "symptoms", "causes", "treatment", "prevention")
|
||||
* flag_count: number of times this item has been flagged
|
||||
*/
|
||||
export const flaggedContent = sqliteTable(
|
||||
"flagged_content",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
contentType: text("content_type", {
|
||||
enum: [
|
||||
"plant_image",
|
||||
"disease_image",
|
||||
"disease_symptoms",
|
||||
"disease_causes",
|
||||
"disease_treatment",
|
||||
"disease_prevention",
|
||||
],
|
||||
}).notNull(),
|
||||
contentId: text("content_id").notNull(),
|
||||
fieldName: text("field_name").notNull(),
|
||||
notes: text("notes").default(""),
|
||||
flagCount: integer("flag_count").notNull().default(1),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
},
|
||||
(table) => ({
|
||||
contentTypeIdx: index("idx_flagged_content_type").on(table.contentType),
|
||||
contentIdIdx: index("idx_flagged_content_id").on(table.contentId),
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Type helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
export type FlaggedContentRow = typeof flaggedContent.$inferSelect;
|
||||
export type FlaggedContentInsert = typeof flaggedContent.$inferInsert;
|
||||
|
||||
// ─── Relation Inference ──────────────────────────────────────────────────────
|
||||
|
||||
export const plantsRelations = {};
|
||||
|
||||
Reference in New Issue
Block a user