feat: implement backend image classification with TensorFlow.js model

- Extend shared types for image classification, including PredictionProbability, UploaderMetadata, and ImageClassificationRecord.
- Create image_classifications table in the database with necessary fields and foreign key constraints.
- Implement disease mappers to convert database rows to shared disease records.
- Develop uploader client to handle image uploads to external service.
- Create image model service to load and classify images using TensorFlow.js.
- Add API routes for image classification, including GET for history and POST for new classifications.
- Implement frontend components for image classification form and display results.
- Update dashboard to integrate image classification functionality and display results.
- Document implementation plan for backend image classification.
This commit is contained in:
Asep Haryana Saputra
2026-05-22 15:13:22 +00:00
parent 229db82a08
commit 15c6be9e84
19 changed files with 1750 additions and 33 deletions
+17
View File
@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS "image_classifications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"predicted_disease_slug" varchar(80) NOT NULL,
"confidence" real NOT NULL,
"probabilities" jsonb NOT NULL,
"image_url" text NOT NULL,
"original_file_name" varchar(240) NOT NULL,
"uploader_public_id" varchar(160) NOT NULL,
"uploader_payload" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "image_classifications" ADD CONSTRAINT "image_classifications_predicted_disease_slug_disease_catalog_slug_fk" FOREIGN KEY ("predicted_disease_slug") REFERENCES "public"."disease_catalog"("slug") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
+281
View File
@@ -0,0 +1,281 @@
{
"id": "16700e2c-ff8f-4864-a71a-6f4b98a1313a",
"prevId": "71e2eeba-4f8f-48bc-8bcf-63201334aa65",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.app_events": {
"name": "app_events",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "varchar(120)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.disease_catalog": {
"name": "disease_catalog",
"schema": "",
"columns": {
"slug": {
"name": "slug",
"type": "varchar(80)",
"primaryKey": true,
"notNull": true
},
"label": {
"name": "label",
"type": "varchar(80)",
"primaryKey": false,
"notNull": true
},
"common_name": {
"name": "common_name",
"type": "varchar(120)",
"primaryKey": false,
"notNull": true
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
},
"symptoms": {
"name": "symptoms",
"type": "text[]",
"primaryKey": false,
"notNull": true
},
"recommendations": {
"name": "recommendations",
"type": "text[]",
"primaryKey": false,
"notNull": true
},
"risk_level": {
"name": "risk_level",
"type": "varchar(20)",
"primaryKey": false,
"notNull": true
},
"accent_color": {
"name": "accent_color",
"type": "varchar(40)",
"primaryKey": false,
"notNull": true
},
"display_order": {
"name": "display_order",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.image_classifications": {
"name": "image_classifications",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"predicted_disease_slug": {
"name": "predicted_disease_slug",
"type": "varchar(80)",
"primaryKey": false,
"notNull": true
},
"confidence": {
"name": "confidence",
"type": "real",
"primaryKey": false,
"notNull": true
},
"probabilities": {
"name": "probabilities",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"original_file_name": {
"name": "original_file_name",
"type": "varchar(240)",
"primaryKey": false,
"notNull": true
},
"uploader_public_id": {
"name": "uploader_public_id",
"type": "varchar(160)",
"primaryKey": false,
"notNull": true
},
"uploader_payload": {
"name": "uploader_payload",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"image_classifications_predicted_disease_slug_disease_catalog_slug_fk": {
"name": "image_classifications_predicted_disease_slug_disease_catalog_slug_fk",
"tableFrom": "image_classifications",
"tableTo": "disease_catalog",
"columnsFrom": [
"predicted_disease_slug"
],
"columnsTo": [
"slug"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.manual_classifications": {
"name": "manual_classifications",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"disease_slug": {
"name": "disease_slug",
"type": "varchar(80)",
"primaryKey": false,
"notNull": true
},
"observation": {
"name": "observation",
"type": "text",
"primaryKey": false,
"notNull": true
},
"location": {
"name": "location",
"type": "varchar(160)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"manual_classifications_disease_slug_disease_catalog_slug_fk": {
"name": "manual_classifications_disease_slug_disease_catalog_slug_fk",
"tableFrom": "manual_classifications",
"tableTo": "disease_catalog",
"columnsFrom": [
"disease_slug"
],
"columnsTo": [
"slug"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -8,6 +8,13 @@
"when": 1779458573080,
"tag": "0000_harsh_arachne",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1779461286348,
"tag": "0001_cute_lyja",
"breakpoints": true
}
]
}
+3
View File
@@ -11,9 +11,12 @@
"db:migrate": "drizzle-kit migrate"
},
"dependencies": {
"@tensorflow/tfjs": "4.22.0",
"@zeavis/shared": "workspace:*",
"drizzle-orm": "^0.36.4",
"elysia": "^1.1.25",
"jpeg-js": "0.4.4",
"pngjs": "7.0.0",
"postgres": "^3.4.5"
},
"devDependencies": {
+15 -1
View File
@@ -1,4 +1,4 @@
import { pgTable, timestamp, uuid, varchar, text, integer } from 'drizzle-orm/pg-core';
import { pgTable, timestamp, uuid, varchar, text, integer, jsonb, real } from 'drizzle-orm/pg-core';
export const appEvents = pgTable('app_events', {
id: uuid('id').primaryKey().defaultRandom(),
@@ -30,3 +30,17 @@ export const manualClassifications = pgTable('manual_classifications', {
location: varchar('location', { length: 160 }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export const imageClassifications = pgTable('image_classifications', {
id: uuid('id').primaryKey().defaultRandom(),
predictedDiseaseSlug: varchar('predicted_disease_slug', { length: 80 })
.notNull()
.references(() => diseaseCatalog.slug),
confidence: real('confidence').notNull(),
probabilities: jsonb('probabilities').notNull(),
imageUrl: text('image_url').notNull(),
originalFileName: varchar('original_file_name', { length: 240 }).notNull(),
uploaderPublicId: varchar('uploader_public_id', { length: 160 }).notNull(),
uploaderPayload: jsonb('uploader_payload').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
+17
View File
@@ -0,0 +1,17 @@
import type { DiseaseCatalogItem, DiseaseSlug, DiseaseLabel, RiskLevel } from '@zeavis/shared';
import type { diseaseCatalog } from '../db/schema';
export function toDisease(row: typeof diseaseCatalog.$inferSelect): DiseaseCatalogItem {
return {
slug: row.slug as DiseaseSlug,
label: row.label as DiseaseLabel,
commonName: row.commonName,
summary: row.summary,
description: row.description,
symptoms: row.symptoms,
recommendations: row.recommendations,
riskLevel: row.riskLevel as RiskLevel,
accentColor: row.accentColor,
displayOrder: row.displayOrder,
};
}
+7
View File
@@ -12,6 +12,13 @@ export function notFound(message: string): Response {
});
}
export function badGateway(message: string): Response {
return new Response(JSON.stringify({ error: message }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
export function serviceUnavailable(message: string): Response {
return new Response(JSON.stringify({ error: message }), {
status: 503,
+134
View File
@@ -0,0 +1,134 @@
import * as tf from '@tensorflow/tfjs';
import * as jpeg from 'jpeg-js';
import { PNG } from 'pngjs';
import { existsSync } from 'fs';
import { resolve } from 'path';
import type { DiseaseSlug, DiseaseLabel, PredictionProbability } from '@zeavis/shared';
const DISEASE_CLASSES: Array<{ slug: DiseaseSlug; label: DiseaseLabel }> = [
{ slug: 'bercak-daun', label: 'Bercak Daun' },
{ slug: 'daun-sehat', label: 'Daun Sehat' },
{ slug: 'karat-daun', label: 'Karat Daun' },
{ slug: 'hawar-daun', label: 'Hawar Daun' },
];
function resolveModelPath() {
const candidates = [
resolve(process.cwd(), 'Machine_Learning/model/tfjs_model/model.json'),
resolve(process.cwd(), '../../Machine_Learning/model/tfjs_model/model.json'),
];
const modelPath = candidates.find((candidate) => existsSync(candidate));
if (!modelPath) {
throw new Error('TFJS model file was not found');
}
return modelPath;
}
let modelPromise: Promise<tf.GraphModel> | null = null;
async function loadModel(): Promise<tf.GraphModel> {
if (modelPromise) {
return modelPromise;
}
modelPromise = (async () => {
try {
const fileUrl = `file://${resolveModelPath()}`;
return await tf.loadGraphModel(fileUrl);
} catch (error) {
throw new Error(`Failed to load TFJS model: ${error instanceof Error ? error.message : String(error)}`);
}
})();
return modelPromise;
}
export type ClassificationResult = {
predictedDiseaseSlug: DiseaseSlug;
confidence: number;
probabilities: PredictionProbability[];
};
export async function classifyImage(file: File): Promise<ClassificationResult> {
if (file.type !== 'image/jpeg' && file.type !== 'image/png') {
throw new Error('File must be JPEG or PNG');
}
const buffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(buffer);
let imageData: { data: Uint8Array; width: number; height: number };
if (file.type === 'image/jpeg') {
const decoded = jpeg.decode(uint8Array, { useTArray: true });
imageData = {
data: decoded.data,
width: decoded.width,
height: decoded.height,
};
} else {
const png = new PNG();
await new Promise<void>((resolve, reject) => {
png.parse(Buffer.from(uint8Array), (err: Error | null) => {
if (err) reject(err);
else resolve();
});
});
imageData = {
data: png.data,
width: png.width,
height: png.height,
};
}
const imageTensor = tf.tidy(() => {
const rgb = new Uint8Array(imageData.width * imageData.height * 3);
for (let source = 0, target = 0; source < imageData.data.length; source += 4, target += 3) {
rgb[target] = imageData.data[source];
rgb[target + 1] = imageData.data[source + 1];
rgb[target + 2] = imageData.data[source + 2];
}
return tf
.tensor3d(rgb, [imageData.height, imageData.width, 3], 'int32')
.resizeBilinear([224, 224])
.toFloat()
.expandDims(0);
});
try {
const model = await loadModel();
const predictions = model.predict(imageTensor) as tf.Tensor;
try {
const scoresArray = await predictions.data();
let maxScore = -Infinity;
let maxIndex = 0;
for (let i = 0; i < scoresArray.length; i++) {
if (scoresArray[i] > maxScore) {
maxScore = scoresArray[i];
maxIndex = i;
}
}
const probabilities: PredictionProbability[] = DISEASE_CLASSES.map((disease, index) => ({
diseaseSlug: disease.slug,
label: disease.label,
confidence: Math.max(0, Math.min(1, scoresArray[index])),
})).sort((a, b) => b.confidence - a.confidence);
return {
predictedDiseaseSlug: DISEASE_CLASSES[maxIndex].slug,
confidence: Math.max(0, Math.min(1, maxScore)),
probabilities,
};
} finally {
predictions.dispose();
}
} finally {
imageTensor.dispose();
}
}
+28
View File
@@ -0,0 +1,28 @@
import type { UploaderMetadata } from '@zeavis/shared';
export async function uploadImageToStorage(file: File): Promise<UploaderMetadata> {
const formData = new FormData();
formData.append('file', file);
formData.append('fileName', file.name);
const response = await fetch('https://upload.asepharyana.tech/api/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`Upload failed with status ${response.status}`);
}
const data = (await response.json()) as Record<string, unknown>;
if (!data.download_url) {
throw new Error('Upload response missing download_url');
}
if (!data.public_id) {
throw new Error('Upload response missing public_id');
}
return data as UploaderMetadata;
}
+206 -3
View File
@@ -1,12 +1,215 @@
import { Elysia } from 'elysia';
import type { ManualClassificationRequest, ManualClassificationRecord } from '@zeavis/shared';
import type {
DiseaseCatalogItem,
DiseaseSlug,
ImageClassificationRecord,
ManualClassificationRequest,
ManualClassificationRecord,
PredictionProbability,
UploaderMetadata,
} from '@zeavis/shared';
import { isDiseaseSlug } from '@zeavis/shared';
import { createDbClient } from '../db/client';
import { diseaseCatalog, manualClassifications } from '../db/schema';
import { badRequest, serviceUnavailable } from '../lib/http-errors';
import { diseaseCatalog, manualClassifications, imageClassifications } from '../db/schema';
import { badRequest, badGateway, serviceUnavailable } from '../lib/http-errors';
import { desc, eq } from 'drizzle-orm';
import { classifyImage } from '../lib/image-model';
import { uploadImageToStorage } from '../lib/uploader-client';
import { toDisease } from '../lib/disease-mappers';
function toImageClassificationRecord(row: {
id: string;
predictedDiseaseSlug: string;
confidence: number;
probabilities: unknown;
imageUrl: string;
originalFileName: string;
uploaderPublicId: string;
uploaderPayload: unknown;
createdAt: Date;
disease: {
slug: string;
label: string;
commonName: string;
summary: string;
description: string;
symptoms: string[];
recommendations: string[];
riskLevel: string;
accentColor: string;
displayOrder: number;
};
}): ImageClassificationRecord {
return {
id: row.id,
predictedDiseaseSlug: row.predictedDiseaseSlug as DiseaseSlug,
confidence: row.confidence,
probabilities: row.probabilities as PredictionProbability[],
imageUrl: row.imageUrl,
originalFileName: row.originalFileName,
uploaderPublicId: row.uploaderPublicId,
uploader: row.uploaderPayload as UploaderMetadata,
createdAt: row.createdAt.toISOString(),
disease: toDisease(row.disease as typeof diseaseCatalog.$inferSelect),
};
}
export const classificationRoutes = new Elysia({ prefix: '/api/v1' })
.get('/classifications/image', async () => {
try {
const db = createDbClient();
const rows = await db
.select({
id: imageClassifications.id,
predictedDiseaseSlug: imageClassifications.predictedDiseaseSlug,
confidence: imageClassifications.confidence,
probabilities: imageClassifications.probabilities,
imageUrl: imageClassifications.imageUrl,
originalFileName: imageClassifications.originalFileName,
uploaderPublicId: imageClassifications.uploaderPublicId,
uploaderPayload: imageClassifications.uploaderPayload,
createdAt: imageClassifications.createdAt,
disease: {
slug: diseaseCatalog.slug,
label: diseaseCatalog.label,
commonName: diseaseCatalog.commonName,
summary: diseaseCatalog.summary,
description: diseaseCatalog.description,
symptoms: diseaseCatalog.symptoms,
recommendations: diseaseCatalog.recommendations,
riskLevel: diseaseCatalog.riskLevel,
accentColor: diseaseCatalog.accentColor,
displayOrder: diseaseCatalog.displayOrder,
},
})
.from(imageClassifications)
.innerJoin(diseaseCatalog, eq(imageClassifications.predictedDiseaseSlug, diseaseCatalog.slug))
.orderBy(desc(imageClassifications.createdAt))
.limit(20);
const records: ImageClassificationRecord[] = rows.map(toImageClassificationRecord);
return records;
} catch (error) {
return serviceUnavailable('Database unavailable');
}
})
.post('/classifications/image', async ({ body }) => {
let file: File | undefined;
if (body instanceof FormData) {
const formFile = body.get('file');
if (formFile instanceof File) {
file = formFile;
}
} else if (typeof body === 'object' && body !== null) {
const bodyObj = body as Record<string, unknown>;
if (bodyObj.file instanceof File) {
file = bodyObj.file;
} else if (Array.isArray(bodyObj.file) && bodyObj.file.length > 0 && bodyObj.file[0] instanceof File) {
file = bodyObj.file[0];
}
}
if (!file) {
return badRequest('Missing required field: file');
}
if (file.size === 0) {
return badRequest('File is empty');
}
if (file.type !== 'image/jpeg' && file.type !== 'image/png') {
return badRequest('File must be JPEG or PNG image');
}
try {
let uploaderMetadata: UploaderMetadata;
try {
uploaderMetadata = await uploadImageToStorage(file);
} catch (error) {
return badGateway(
`Upload service error: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
let classificationResult;
try {
classificationResult = await classifyImage(file);
} catch (error) {
return serviceUnavailable(
`Model service error: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
const db = createDbClient();
let diseaseRow;
try {
const diseaseRows = await db
.select()
.from(diseaseCatalog)
.where(eq(diseaseCatalog.slug, classificationResult.predictedDiseaseSlug))
.limit(1);
if (diseaseRows.length === 0) {
return serviceUnavailable(
`Predicted disease "${classificationResult.predictedDiseaseSlug}" not found in catalog`
);
}
diseaseRow = diseaseRows[0];
} catch (error) {
return serviceUnavailable('Database unavailable');
}
let inserted;
try {
const result = await db
.insert(imageClassifications)
.values({
predictedDiseaseSlug: classificationResult.predictedDiseaseSlug,
confidence: classificationResult.confidence,
probabilities: classificationResult.probabilities,
imageUrl: uploaderMetadata.download_url,
originalFileName: file.name,
uploaderPublicId: uploaderMetadata.public_id,
uploaderPayload: uploaderMetadata,
})
.returning();
inserted = result[0];
} catch (error) {
return serviceUnavailable('Database unavailable');
}
const record: ImageClassificationRecord = toImageClassificationRecord({
id: inserted.id,
predictedDiseaseSlug: inserted.predictedDiseaseSlug,
confidence: inserted.confidence,
probabilities: inserted.probabilities,
imageUrl: inserted.imageUrl,
originalFileName: inserted.originalFileName,
uploaderPublicId: inserted.uploaderPublicId,
uploaderPayload: inserted.uploaderPayload,
createdAt: inserted.createdAt,
disease: {
slug: diseaseRow.slug,
label: diseaseRow.label,
commonName: diseaseRow.commonName,
summary: diseaseRow.summary,
description: diseaseRow.description,
symptoms: diseaseRow.symptoms,
recommendations: diseaseRow.recommendations,
riskLevel: diseaseRow.riskLevel,
accentColor: diseaseRow.accentColor,
displayOrder: diseaseRow.displayOrder,
},
});
return record;
} catch (error) {
return serviceUnavailable('Internal server error');
}
})
.get('/classifications/manual', async () => {
try {
const db = createDbClient();
+4 -3
View File
@@ -1,7 +1,7 @@
import { Elysia } from 'elysia';
import type { DashboardSummary, RiskLevel } from '@zeavis/shared';
import { createDbClient } from '../db/client';
import { diseaseCatalog, manualClassifications } from '../db/schema';
import { diseaseCatalog, imageClassifications, manualClassifications } from '../db/schema';
import { serviceUnavailable } from '../lib/http-errors';
import { desc, eq } from 'drizzle-orm';
@@ -10,7 +10,8 @@ export const dashboardRoutes = new Elysia({ prefix: '/api/v1' }).get('/dashboard
const db = createDbClient();
const diseaseCount = await db.select().from(diseaseCatalog);
const classificationCount = await db.select().from(manualClassifications);
const manualClassificationCount = await db.select().from(manualClassifications);
const imageClassificationCount = await db.select().from(imageClassifications);
const latestClassificationRow = await db
.select({
@@ -75,7 +76,7 @@ export const dashboardRoutes = new Elysia({ prefix: '/api/v1' }).get('/dashboard
const summary: DashboardSummary = {
diseaseCount: diseaseCount.length,
classificationCount: classificationCount.length,
classificationCount: manualClassificationCount.length + imageClassificationCount.length,
latestClassification,
riskDistribution,
};
+8
View File
@@ -0,0 +1,8 @@
declare module 'pngjs' {
export class PNG {
width: number;
height: number;
data: Buffer;
parse(data: Buffer, callback: (err: Error | null) => void): void;
}
}
@@ -0,0 +1,155 @@
import { useRef, useState } from 'react';
import type { ImageClassificationRecord } from '@zeavis/shared';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { RiskBadge } from '@/components/risk-badge';
export interface ImageClassificationFormProps {
onSubmit: (file: File) => Promise<void>;
isSubmitting: boolean;
latestResult: ImageClassificationRecord | null;
}
export function ImageClassificationForm({
onSubmit,
isSubmitting,
latestResult,
}: ImageClassificationFormProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (!file.type.startsWith('image/')) {
setError('Silakan pilih file gambar');
setSelectedFile(null);
return;
}
setError(null);
setSelectedFile(file);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!selectedFile) {
setError('Silakan pilih file gambar');
return;
}
try {
await onSubmit(selectedFile);
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Terjadi kesalahan saat mengunggah gambar');
}
};
const isFormValid = selectedFile !== null;
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Klasifikasi Gambar</CardTitle>
<CardDescription>Unggah foto daun jagung untuk klasifikasi otomatis</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-800">{error}</div>
)}
<div>
<label htmlFor="image-file" className="block text-sm font-medium">
Pilih Gambar
</label>
<input
ref={fileInputRef}
id="image-file"
type="file"
accept="image/*"
onChange={handleFileChange}
disabled={isSubmitting}
className="mt-1 block w-full text-sm file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground hover:file:bg-primary/90 disabled:opacity-50"
/>
{selectedFile && (
<p className="mt-2 text-sm text-muted-foreground">
File dipilih: {selectedFile.name}
</p>
)}
</div>
<Button
type="submit"
disabled={!isFormValid || isSubmitting}
className="w-full"
>
{isSubmitting ? 'Mengunggah...' : 'Unggah dan Klasifikasi'}
</Button>
</form>
</CardContent>
</Card>
{latestResult && (
<Card>
<CardHeader>
<CardTitle>Hasil Klasifikasi Terbaru</CardTitle>
<CardDescription>Prediksi penyakit dari gambar terakhir</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border border-border overflow-hidden">
<img
src={latestResult.imageUrl}
alt="Uploaded corn leaf"
className="w-full h-48 object-cover"
/>
</div>
<div className="space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<h4 className="font-semibold">{latestResult.disease.commonName}</h4>
<p className="text-sm text-muted-foreground">{latestResult.disease.label}</p>
</div>
<RiskBadge level={latestResult.disease.riskLevel} />
</div>
<div className="rounded-md bg-muted p-3">
<p className="text-sm font-medium">
Kepercayaan: {(latestResult.confidence * 100).toFixed(1)}%
</p>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Rekomendasi awal:</p>
<ul className="space-y-1 text-sm text-muted-foreground">
{latestResult.disease.recommendations.slice(0, 3).map((recommendation) => (
<li key={recommendation}> {recommendation}</li>
))}
</ul>
</div>
<p className="text-xs text-muted-foreground">
{new Date(latestResult.createdAt).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
}
+15 -1
View File
@@ -3,6 +3,7 @@ import type {
DiseaseCatalogItem,
ManualClassificationRequest,
ManualClassificationRecord,
ImageClassificationRecord,
DashboardSummary,
} from '@zeavis/shared';
@@ -20,7 +21,6 @@ async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T>
errorMessage = errorData.error;
}
} catch {
// Response is not JSON, use default error message
}
throw new Error(errorMessage);
}
@@ -53,6 +53,20 @@ export const apiClient = {
});
},
async getImageClassifications(): Promise<ImageClassificationRecord[]> {
return fetchApi('/api/v1/classifications/image');
},
async createImageClassification(file: File): Promise<ImageClassificationRecord> {
const formData = new FormData();
formData.append('file', file);
return fetchApi('/api/v1/classifications/image', {
method: 'POST',
body: formData,
});
},
async getDashboardSummary(): Promise<DashboardSummary> {
return fetchApi('/api/v1/dashboard/summary');
},
+55 -24
View File
@@ -1,9 +1,10 @@
import { useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { BookOpen, History, Leaf, LayoutDashboard, TrendingUp } from 'lucide-react';
import { BookOpen, History, Leaf, LayoutDashboard, TrendingUp, Image } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ManualClassificationForm } from '@/components/manual-classification-form';
import { ImageClassificationForm } from '@/components/image-classification-form';
import { RiskBadge } from '@/components/risk-badge';
import { useUiStore } from '@/store/ui-store';
import { apiClient } from '@/lib/api-client';
@@ -12,7 +13,7 @@ export function DashboardPage() {
const { dashboardCompact, toggleDashboardCompact } = useUiStore();
const queryClient = useQueryClient();
const [diseasesQuery, summaryQuery, classificationsQuery] = useQueries({
const [diseasesQuery, summaryQuery, classificationsQuery, imageClassificationsQuery] = useQueries({
queries: [
{
queryKey: ['diseases'],
@@ -26,6 +27,10 @@ export function DashboardPage() {
queryKey: ['manual-classifications'],
queryFn: () => apiClient.getManualClassifications(),
},
{
queryKey: ['image-classifications'],
queryFn: () => apiClient.getImageClassifications(),
},
],
});
@@ -39,9 +44,20 @@ export function DashboardPage() {
},
});
const createImageClassificationMutation = useMutation({
mutationFn: async (file: File) => {
return await apiClient.createImageClassification(file);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['image-classifications'] });
queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] });
},
});
const diseases = diseasesQuery.data || [];
const summary = summaryQuery.data;
const classifications = classificationsQuery.data || [];
const imageClassifications = imageClassificationsQuery.data || [];
const isLoadingData = diseasesQuery.isLoading || summaryQuery.isLoading;
const hasError = diseasesQuery.error || summaryQuery.error;
@@ -220,6 +236,14 @@ export function DashboardPage() {
</Card>
)}
<ImageClassificationForm
onSubmit={async (file) => {
await createImageClassificationMutation.mutateAsync(file);
}}
isSubmitting={createImageClassificationMutation.isPending}
latestResult={imageClassifications[0] ?? null}
/>
<ManualClassificationForm
diseases={diseases}
onSubmit={async (payload) => {
@@ -228,38 +252,45 @@ export function DashboardPage() {
isSubmitting={createClassificationMutation.isPending}
/>
{classifications.length > 0 && (
{imageClassifications.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Leaf className="h-5 w-5" />
Riwayat Laporan
<Image className="h-5 w-5" />
Riwayat Klasifikasi Gambar
</CardTitle>
<CardDescription>
{classifications.length} laporan pengamatan penyakit
{imageClassifications.length} gambar yang diklasifikasi
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{classifications.slice(0, 5).map((classification) => (
<div key={classification.id} className="rounded-lg border border-border p-3">
<div className="flex items-start justify-between gap-4 mb-2">
<div>
<h4 className="text-sm font-semibold">
{classification.disease.commonName}
</h4>
<p className="text-xs text-muted-foreground">
{classification.disease.label}
</p>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{imageClassifications.slice(0, 6).map((classification) => (
<div key={classification.id} className="rounded-lg border border-border overflow-hidden">
<img
src={classification.imageUrl}
alt="Classified corn leaf"
className="w-full h-32 object-cover"
/>
<div className="p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<h4 className="text-sm font-semibold">
{classification.disease.commonName}
</h4>
<p className="text-xs text-muted-foreground">
{classification.disease.label}
</p>
</div>
<RiskBadge level={classification.disease.riskLevel} className="text-xs" />
</div>
<RiskBadge level={classification.disease.riskLevel} />
<div className="text-xs text-muted-foreground">
Kepercayaan: {(classification.confidence * 100).toFixed(1)}%
</div>
<p className="text-xs text-muted-foreground">
{new Date(classification.createdAt).toLocaleDateString('id-ID')}
</p>
</div>
<p className="text-xs text-muted-foreground mb-1">
{classification.observation}
</p>
<p className="text-xs text-muted-foreground">
{classification.location} {new Date(classification.createdAt).toLocaleDateString('id-ID')}
</p>
</div>
))}
</div>
+128 -1
View File
@@ -11,9 +11,12 @@
"name": "@zeavis/api",
"version": "0.1.0",
"dependencies": {
"@tensorflow/tfjs": "4.22.0",
"@zeavis/shared": "packages/shared",
"drizzle-orm": "^0.36.4",
"elysia": "^1.1.25",
"jpeg-js": "0.4.4",
"pngjs": "7.0.0",
"postgres": "^3.4.5",
},
"devDependencies": {
@@ -254,6 +257,20 @@
"@tanstack/react-query": ["@tanstack/react-query@5.100.11", "", { "dependencies": { "@tanstack/query-core": "5.100.11" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-J0f9s5x3LE1450nNNfYx+e/n0DMa0uOBdFJUy5r0RvmsXd4nB/n0rbHtHI1vYXhikNFan+wf51p6Tmp4c8ucrg=="],
"@tensorflow/tfjs": ["@tensorflow/tfjs@4.22.0", "", { "dependencies": { "@tensorflow/tfjs-backend-cpu": "4.22.0", "@tensorflow/tfjs-backend-webgl": "4.22.0", "@tensorflow/tfjs-converter": "4.22.0", "@tensorflow/tfjs-core": "4.22.0", "@tensorflow/tfjs-data": "4.22.0", "@tensorflow/tfjs-layers": "4.22.0", "argparse": "^1.0.10", "chalk": "^4.1.0", "core-js": "3.29.1", "regenerator-runtime": "^0.13.5", "yargs": "^16.0.3" }, "bin": { "tfjs-custom-module": "dist/tools/custom_module/cli.js" } }, "sha512-0TrIrXs6/b7FLhLVNmfh8Sah6JgjBPH4mZ8JGb7NU6WW+cx00qK5BcAZxw7NCzxj6N8MRAIfHq+oNbPUNG5VAg=="],
"@tensorflow/tfjs-backend-cpu": ["@tensorflow/tfjs-backend-cpu@4.22.0", "", { "dependencies": { "@types/seedrandom": "^2.4.28", "seedrandom": "^3.0.5" }, "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw=="],
"@tensorflow/tfjs-backend-webgl": ["@tensorflow/tfjs-backend-webgl@4.22.0", "", { "dependencies": { "@tensorflow/tfjs-backend-cpu": "4.22.0", "@types/offscreencanvas": "~2019.3.0", "@types/seedrandom": "^2.4.28", "seedrandom": "^3.0.5" }, "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg=="],
"@tensorflow/tfjs-converter": ["@tensorflow/tfjs-converter@4.22.0", "", { "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ=="],
"@tensorflow/tfjs-core": ["@tensorflow/tfjs-core@4.22.0", "", { "dependencies": { "@types/long": "^4.0.1", "@types/offscreencanvas": "~2019.7.0", "@types/seedrandom": "^2.4.28", "@webgpu/types": "0.1.38", "long": "4.0.0", "node-fetch": "~2.6.1", "seedrandom": "^3.0.5" } }, "sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A=="],
"@tensorflow/tfjs-data": ["@tensorflow/tfjs-data@4.22.0", "", { "dependencies": { "@types/node-fetch": "^2.1.2", "node-fetch": "~2.6.1", "string_decoder": "^1.3.0" }, "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0", "seedrandom": "^3.0.5" } }, "sha512-dYmF3LihQIGvtgJrt382hSRH4S0QuAp2w1hXJI2+kOaEqo5HnUPG0k5KA6va+S1yUhx7UBToUKCBHeLHFQRV4w=="],
"@tensorflow/tfjs-layers": ["@tensorflow/tfjs-layers@4.22.0", "", { "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-lybPj4ZNj9iIAPUj7a8ZW1hg8KQGfqWLlCZDi9eM/oNKCCAgchiyzx8OrYoWmRrB+AM6VNEeIT+2gZKg5ReihA=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
@@ -270,28 +287,46 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/long": ["@types/long@4.0.2", "", {}, "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="],
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"@types/offscreencanvas": ["@types/offscreencanvas@2019.3.0", "", {}, "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/react": ["@types/react@18.3.29", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg=="],
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
"@types/seedrandom": ["@types/seedrandom@2.4.34", "", {}, "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@zeavis/api": ["@zeavis/api@workspace:apps/api", { "dependencies": { "@zeavis/shared": "packages/shared", "drizzle-orm": "^0.36.4", "elysia": "^1.1.25", "postgres": "^3.4.5" }, "devDependencies": { "@types/bun": "latest", "drizzle-kit": "^0.27.1", "typescript": "^5.6.3" } }],
"@webgpu/types": ["@webgpu/types@0.1.38", "", {}, "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA=="],
"@zeavis/api": ["@zeavis/api@workspace:apps/api", { "dependencies": { "@tensorflow/tfjs": "4.22.0", "@zeavis/shared": "packages/shared", "drizzle-orm": "^0.36.4", "elysia": "^1.1.25", "jpeg-js": "0.4.4", "pngjs": "7.0.0", "postgres": "^3.4.5" }, "devDependencies": { "@types/bun": "latest", "drizzle-kit": "^0.27.1", "typescript": "^5.6.3" } }],
"@zeavis/shared": ["@zeavis/shared@workspace:packages/shared", { "devDependencies": { "typescript": "^5.6.3" } }],
"@zeavis/web": ["@zeavis/web@workspace:apps/web", { "dependencies": { "@radix-ui/react-slot": "^1.1.0", "@tanstack/react-query": "^5.59.16", "@vitejs/plugin-react": "^4.3.3", "@zeavis/shared": "packages/shared", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.28.0", "tailwind-merge": "^2.5.4", "zustand": "^5.0.1" }, "devDependencies": { "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "autoprefixer": "^10.4.20", "postcss": "^8.4.49", "tailwindcss": "^3.4.15", "typescript": "^5.6.3", "vite": "^6.0.1" } }],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"autoprefixer": ["autoprefixer@10.5.0", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.31", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q=="],
@@ -306,28 +341,44 @@
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"core-js": ["core-js@3.29.1", "", {}, "sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
@@ -338,12 +389,22 @@
"drizzle-orm": ["drizzle-orm@0.36.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=3", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/react", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "react", "sql.js", "sqlite3"] }, "sha512-1OZY3PXD7BR00Gl61UUOFihslDldfH4NFRH2MbP54Yxi0G/PKn4HfO65JYZ7c16DeP3SpM3Aw+VXVG9j6CRSXA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.361", "", {}, "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA=="],
"elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
@@ -364,6 +425,8 @@
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@@ -372,10 +435,24 @@
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -386,12 +463,16 @@
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
@@ -402,24 +483,34 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"long": ["long@4.0.0", "", {}, "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.468.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"node-fetch": ["node-fetch@2.6.13", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA=="],
"node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@@ -440,6 +531,8 @@
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
@@ -472,6 +565,10 @@
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
@@ -482,8 +579,12 @@
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"seedrandom": ["seedrandom@3.0.5", "", {}, "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -492,10 +593,20 @@
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="],
@@ -512,6 +623,8 @@
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
@@ -526,12 +639,26 @@
"vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
"yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
"zustand": ["zustand@5.0.13", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@tensorflow/tfjs-core/@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -0,0 +1,633 @@
# Backend Image Classification Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add real backend image classification using the exported TensorFlow.js model and external uploader service.
**Architecture:** The web app sends an image to the Elysia API. The API uploads the original image to `https://upload.asepharyana.tech/api/upload`, runs the local TFJS graph model from `Machine_Learning/model/tfjs_model/model.json`, maps probabilities to ZeaVis Edu labels, stores the result in PostgreSQL, and returns disease education metadata. Shared types define the API contract for uploader metadata, prediction probabilities, and image classification history.
**Tech Stack:** Bun workspaces, TypeScript, Elysia, Drizzle ORM, PostgreSQL, TensorFlow.js graph model, React, TanStack Query, Tailwind CSS.
---
## File structure
- Modify `apps/api/package.json`: add runtime dependencies for TensorFlow.js/image decoding if compatible with Bun.
- Modify `packages/shared/src/classifications.ts`: add image classification, probability, and uploader metadata types.
- Modify `packages/shared/src/index.ts`: export new image classification types.
- Modify `apps/api/src/db/schema.ts`: add `imageClassifications` table.
- Create/modify Drizzle migration under `apps/api/drizzle/`: create `image_classifications` table.
- Create `apps/api/src/lib/disease-mappers.ts`: map DB disease rows to shared disease records.
- Create `apps/api/src/lib/uploader-client.ts`: upload image files to external uploader.
- Create `apps/api/src/lib/image-model.ts`: load/cached TFJS graph model and classify image buffers.
- Modify `apps/api/src/routes/classifications.ts`: add image classification POST/GET routes.
- Modify `apps/api/src/routes/dashboard.ts`: optionally include image latest/count only if shared summary is extended.
- Modify `apps/web/src/lib/api-client.ts`: add image classification API methods.
- Create `apps/web/src/components/image-classification-form.tsx`: upload form and latest result display.
- Modify `apps/web/src/pages/dashboard-page.tsx`: show image classification form and image prediction history.
---
### Task 1: Shared image classification contract
**Files:**
- Modify: `packages/shared/src/classifications.ts`
- Modify: `packages/shared/src/index.ts`
- [ ] **Step 1: Extend shared classification types**
Modify `packages/shared/src/classifications.ts` to:
```ts
import type { DiseaseCatalogItem, DiseaseSlug, RiskLevel } from './diseases';
export type ManualClassificationRequest = {
diseaseSlug: DiseaseSlug;
observation: string;
location: string;
};
export type ManualClassificationRecord = {
id: string;
diseaseSlug: DiseaseSlug;
observation: string;
location: string;
createdAt: string;
disease: DiseaseCatalogItem;
};
export type PredictionProbability = {
diseaseSlug: DiseaseSlug;
label: DiseaseCatalogItem['label'];
confidence: number;
};
export type UploaderMetadata = {
public_id: string;
file_name: string;
mime_type: string;
size_bytes: number;
file_type: string;
uploader_id?: number;
created_at: string;
telegram_file_id?: string;
telegram_file_unique_id?: string;
storage_chat_id?: number;
storage_message_id?: number;
download_url: string;
};
export type ImageClassificationRecord = {
id: string;
predictedDiseaseSlug: DiseaseSlug;
confidence: number;
probabilities: PredictionProbability[];
imageUrl: string;
originalFileName: string;
uploaderPublicId: string;
uploader: UploaderMetadata;
createdAt: string;
disease: DiseaseCatalogItem;
};
export type DashboardSummary = {
diseaseCount: number;
classificationCount: number;
latestClassification: ManualClassificationRecord | null;
riskDistribution: Record<RiskLevel, number>;
};
```
- [ ] **Step 2: Export new types**
Modify `packages/shared/src/index.ts` classification exports to include:
```ts
export type {
DashboardSummary,
ImageClassificationRecord,
ManualClassificationRecord,
ManualClassificationRequest,
PredictionProbability,
UploaderMetadata,
} from './classifications';
```
- [ ] **Step 3: Verify shared typecheck**
Run: `bun run --cwd packages/shared typecheck`
Expected: TypeScript exits with code 0.
---
### Task 2: Backend dependencies, DB schema, and migrations
**Files:**
- Modify: `apps/api/package.json`
- Modify: `apps/api/src/db/schema.ts`
- Create/modify: `apps/api/drizzle/*.sql`
- [ ] **Step 1: Add backend inference dependencies**
Run: `bun add --cwd apps/api @tensorflow/tfjs jpeg-js pngjs`
Expected: `apps/api/package.json` and `bun.lockb`/lockfile update. Use pure `@tensorflow/tfjs` first because it avoids native Node bindings and is more likely to run under Bun.
- [ ] **Step 2: Add image classification table to schema**
Modify `apps/api/src/db/schema.ts` to import `jsonb` and `real`, then add:
```ts
export const imageClassifications = pgTable('image_classifications', {
id: uuid('id').primaryKey().defaultRandom(),
predictedDiseaseSlug: varchar('predicted_disease_slug', { length: 80 })
.notNull()
.references(() => diseaseCatalog.slug),
confidence: real('confidence').notNull(),
probabilities: jsonb('probabilities').notNull(),
imageUrl: text('image_url').notNull(),
originalFileName: varchar('original_file_name', { length: 240 }).notNull(),
uploaderPublicId: varchar('uploader_public_id', { length: 160 }).notNull(),
uploaderPayload: jsonb('uploader_payload').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
```
- [ ] **Step 3: Generate migration**
Run: `bun run --cwd apps/api db:generate`
Expected: a new SQL migration creates `image_classifications` with FK to `disease_catalog`.
- [ ] **Step 4: Verify API typecheck**
Run: `bun run --cwd apps/api typecheck`
Expected: TypeScript exits with code 0.
---
### Task 3: Backend mapper, uploader client, and TFJS model service
**Files:**
- Create: `apps/api/src/lib/disease-mappers.ts`
- Create: `apps/api/src/lib/uploader-client.ts`
- Create: `apps/api/src/lib/image-model.ts`
- Modify: `apps/api/src/lib/http-errors.ts`
- [ ] **Step 1: Add bad gateway helper**
Modify `apps/api/src/lib/http-errors.ts` to add:
```ts
export function badGateway(message: string) {
return new Response(JSON.stringify({ error: message }), {
status: 502,
headers: { 'content-type': 'application/json' },
});
}
```
- [ ] **Step 2: Add disease row mapper**
Write `apps/api/src/lib/disease-mappers.ts`:
```ts
import type { DiseaseCatalogItem, DiseaseLabel, DiseaseSlug, RiskLevel } from '@zeavis/shared';
import { diseaseCatalog } from '../db/schema';
export function toDisease(row: typeof diseaseCatalog.$inferSelect): DiseaseCatalogItem {
return {
slug: row.slug as DiseaseSlug,
label: row.label as DiseaseLabel,
commonName: row.commonName,
summary: row.summary,
description: row.description,
symptoms: row.symptoms,
recommendations: row.recommendations,
riskLevel: row.riskLevel as RiskLevel,
accentColor: row.accentColor,
displayOrder: row.displayOrder,
};
}
```
- [ ] **Step 3: Add uploader client**
Write `apps/api/src/lib/uploader-client.ts`:
```ts
import type { UploaderMetadata } from '@zeavis/shared';
const uploaderUrl = 'https://upload.asepharyana.tech/api/upload';
export async function uploadImageToStorage(file: File): Promise<UploaderMetadata> {
const formData = new FormData();
formData.append('file', file, file.name);
formData.append('fileName', file.name);
const response = await fetch(uploaderUrl, {
method: 'POST',
body: formData,
});
const payload = (await response.json().catch(() => null)) as Partial<UploaderMetadata> & { error?: string } | null;
if (!response.ok || !payload?.download_url || !payload.public_id) {
throw new Error(payload?.error ?? 'Upload service failed');
}
return payload as UploaderMetadata;
}
```
- [ ] **Step 4: Add TFJS image model service**
Write `apps/api/src/lib/image-model.ts`:
```ts
import type { DiseaseLabel, DiseaseSlug, PredictionProbability } from '@zeavis/shared';
import * as tf from '@tensorflow/tfjs';
import jpeg from 'jpeg-js';
import { PNG } from 'pngjs';
const labels: Array<{ diseaseSlug: DiseaseSlug; label: DiseaseLabel }> = [
{ diseaseSlug: 'bercak-daun', label: 'Bercak Daun' },
{ diseaseSlug: 'hawar-daun', label: 'Hawar Daun' },
{ diseaseSlug: 'karat-daun', label: 'Karat Daun' },
{ diseaseSlug: 'daun-sehat', label: 'Daun Sehat' },
];
let modelPromise: Promise<tf.GraphModel> | undefined;
function modelUrl() {
return `file://${process.cwd()}/../../Machine_Learning/model/tfjs_model/model.json`;
}
async function loadModel() {
modelPromise ??= tf.loadGraphModel(modelUrl());
return modelPromise;
}
function decodeImage(buffer: ArrayBuffer, mimeType: string) {
const bytes = new Uint8Array(buffer);
if (mimeType === 'image/png') {
const png = PNG.sync.read(Buffer.from(bytes));
return { width: png.width, height: png.height, data: png.data };
}
const jpegImage = jpeg.decode(Buffer.from(bytes), { useTArray: true });
return { width: jpegImage.width, height: jpegImage.height, data: jpegImage.data };
}
function imageToTensor(image: { width: number; height: number; data: Uint8Array | Buffer }) {
const rgb = new Uint8Array(image.width * image.height * 3);
for (let source = 0, target = 0; source < image.data.length; source += 4, target += 3) {
rgb[target] = image.data[source];
rgb[target + 1] = image.data[source + 1];
rgb[target + 2] = image.data[source + 2];
}
return tf.tidy(() =>
tf.tensor3d(rgb, [image.height, image.width, 3], 'int32')
.resizeBilinear([224, 224])
.toFloat()
.div(255)
.expandDims(0),
);
}
export async function classifyImage(file: File) {
if (!['image/jpeg', 'image/png'].includes(file.type)) {
throw new Error('Unsupported image type');
}
const model = await loadModel();
const decoded = decodeImage(await file.arrayBuffer(), file.type);
const input = imageToTensor(decoded);
try {
const output = model.predict(input) as tf.Tensor;
const scores = Array.from(await output.data());
output.dispose();
const probabilities: PredictionProbability[] = scores.map((confidence, index) => ({
...labels[index],
confidence,
}));
probabilities.sort((a, b) => b.confidence - a.confidence);
return {
predictedDiseaseSlug: probabilities[0].diseaseSlug,
confidence: probabilities[0].confidence,
probabilities,
};
} finally {
input.dispose();
}
}
```
- [ ] **Step 5: Verify API typecheck**
Run: `bun run --cwd apps/api typecheck`
Expected: TypeScript exits with code 0. If imports for `jpeg-js` or `pngjs` lack types, add minimal `.d.ts` declarations in `apps/api/src/types/image-decoders.d.ts` and include them through tsconfig include.
---
### Task 4: Backend image classification routes
**Files:**
- Modify: `apps/api/src/routes/classifications.ts`
- [ ] **Step 1: Add image row conversion helpers**
In `apps/api/src/routes/classifications.ts`, import `ImageClassificationRecord`, `PredictionProbability`, `UploaderMetadata`, `imageClassifications`, `badGateway`, `toDisease`, `classifyImage`, and `uploadImageToStorage`. Add a helper:
```ts
function toImageRecord(row: {
id: string;
predictedDiseaseSlug: string;
confidence: number;
probabilities: unknown;
imageUrl: string;
originalFileName: string;
uploaderPublicId: string;
uploaderPayload: unknown;
createdAt: Date;
disease: typeof diseaseCatalog.$inferSelect;
}): ImageClassificationRecord {
return {
id: row.id,
predictedDiseaseSlug: row.predictedDiseaseSlug as ImageClassificationRecord['predictedDiseaseSlug'],
confidence: row.confidence,
probabilities: row.probabilities as PredictionProbability[],
imageUrl: row.imageUrl,
originalFileName: row.originalFileName,
uploaderPublicId: row.uploaderPublicId,
uploader: row.uploaderPayload as UploaderMetadata,
createdAt: row.createdAt.toISOString(),
disease: toDisease(row.disease),
};
}
```
- [ ] **Step 2: Add GET image history route**
Add to `classificationRoutes` before the manual POST route:
```ts
.get('/classifications/image', async () => {
try {
const db = createDbClient();
const rows = await db
.select({
id: imageClassifications.id,
predictedDiseaseSlug: imageClassifications.predictedDiseaseSlug,
confidence: imageClassifications.confidence,
probabilities: imageClassifications.probabilities,
imageUrl: imageClassifications.imageUrl,
originalFileName: imageClassifications.originalFileName,
uploaderPublicId: imageClassifications.uploaderPublicId,
uploaderPayload: imageClassifications.uploaderPayload,
createdAt: imageClassifications.createdAt,
disease: diseaseCatalog,
})
.from(imageClassifications)
.innerJoin(diseaseCatalog, eq(imageClassifications.predictedDiseaseSlug, diseaseCatalog.slug))
.orderBy(desc(imageClassifications.createdAt))
.limit(20);
return rows.map(toImageRecord);
} catch {
return serviceUnavailable('Database unavailable');
}
})
```
- [ ] **Step 3: Add POST image classification route**
Add to `classificationRoutes`:
```ts
.post('/classifications/image', async ({ body }) => {
const payload = body as { file?: File } | undefined;
const file = payload?.file;
if (!(file instanceof File) || file.size === 0) {
return badRequest('Image file is required');
}
if (!file.type.startsWith('image/')) {
return badRequest('Only image files are supported');
}
let uploader: UploaderMetadata;
try {
uploader = await uploadImageToStorage(file);
} catch {
return badGateway('Image uploader is unavailable');
}
let prediction: Awaited<ReturnType<typeof classifyImage>>;
try {
prediction = await classifyImage(file);
} catch {
return serviceUnavailable('Image classification model is unavailable');
}
try {
const db = createDbClient();
const [disease] = await db
.select()
.from(diseaseCatalog)
.where(eq(diseaseCatalog.slug, prediction.predictedDiseaseSlug))
.limit(1);
if (!disease) {
return serviceUnavailable('Predicted disease is not available in the catalog');
}
const [created] = await db
.insert(imageClassifications)
.values({
predictedDiseaseSlug: prediction.predictedDiseaseSlug,
confidence: prediction.confidence,
probabilities: prediction.probabilities,
imageUrl: uploader.download_url,
originalFileName: file.name || uploader.file_name,
uploaderPublicId: uploader.public_id,
uploaderPayload: uploader,
})
.returning();
return toImageRecord({ ...created, disease });
} catch {
return serviceUnavailable('Database unavailable');
}
})
```
- [ ] **Step 4: Verify API typecheck**
Run: `bun run --cwd apps/api typecheck`
Expected: TypeScript exits with code 0.
---
### Task 5: Web client, upload form, and dashboard integration
**Files:**
- Modify: `apps/web/src/lib/api-client.ts`
- Create: `apps/web/src/components/image-classification-form.tsx`
- Modify: `apps/web/src/pages/dashboard-page.tsx`
- [ ] **Step 1: Extend API client**
Modify `apps/web/src/lib/api-client.ts` imports to include `ImageClassificationRecord`. Add methods:
```ts
getImageClassifications: () => request<ImageClassificationRecord[]>('/api/v1/classifications/image'),
createImageClassification: (file: File) => {
const body = new FormData();
body.append('file', file, file.name);
return request<ImageClassificationRecord>('/api/v1/classifications/image', { method: 'POST', body, headers: {} });
},
```
Adjust `request` so it only sets `content-type: application/json` when `init?.body` is not a `FormData`.
- [ ] **Step 2: Add image classification form**
Write `apps/web/src/components/image-classification-form.tsx`:
```tsx
import type { ImageClassificationRecord } from '@zeavis/shared';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { RiskBadge } from '@/components/risk-badge';
export function ImageClassificationForm({ onSubmit, isSubmitting, latestResult }: { onSubmit: (file: File) => Promise<void>; isSubmitting: boolean; latestResult: ImageClassificationRecord | null }) {
const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);
return (
<Card>
<CardHeader>
<CardTitle>Klasifikasi Gambar</CardTitle>
<CardDescription>Unggah foto daun jagung untuk prediksi model ZeaVis Edu.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form
className="space-y-4"
onSubmit={async (event) => {
event.preventDefault();
setError(null);
if (!file) {
setError('Pilih gambar daun terlebih dahulu');
return;
}
await onSubmit(file);
setFile(null);
}}
>
{error && <div className="rounded-md bg-red-50 p-3 text-sm text-red-800">{error}</div>}
<input
accept="image/*"
type="file"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
className="block w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
/>
<Button disabled={!file || isSubmitting} type="submit">
{isSubmitting ? 'Mengklasifikasi...' : 'Klasifikasi gambar'}
</Button>
</form>
{latestResult && (
<div className="rounded-2xl border p-4">
<img src={latestResult.imageUrl} alt={latestResult.originalFileName} className="mb-4 max-h-64 w-full rounded-xl object-cover" />
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm text-muted-foreground">Hasil prediksi</p>
<h3 className="text-xl font-semibold">{latestResult.disease.label}</h3>
<p className="text-sm text-muted-foreground">Confidence {(latestResult.confidence * 100).toFixed(1)}%</p>
</div>
<RiskBadge level={latestResult.disease.riskLevel} />
</div>
<ul className="mt-4 space-y-2 text-sm text-muted-foreground">
{latestResult.disease.recommendations.slice(0, 3).map((recommendation) => (
<li key={recommendation}> {recommendation}</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
);
}
```
- [ ] **Step 3: Wire dashboard image queries**
Modify `apps/web/src/pages/dashboard-page.tsx`:
- Add `ImageClassificationForm` import.
- Add a fourth `useQueries` entry with `queryKey: ['image-classifications']` and `queryFn: () => apiClient.getImageClassifications()`.
- Add `const imageClassifications = imageClassificationsQuery.data || [];`.
- Add `useMutation` for `apiClient.createImageClassification(file)` and invalidate `image-classifications` on success.
- Render `ImageClassificationForm` above `ManualClassificationForm` with `latestResult={imageClassifications[0] ?? null}`.
- Add a history `Card` for image classifications showing thumbnail, label, confidence, and date.
- [ ] **Step 4: Verify web typecheck**
Run: `bun run --cwd apps/web typecheck`
Expected: TypeScript exits with code 0.
---
### Task 6: Full verification and manual behavior check
**Files:**
- Modify only if verification reveals a concrete bug.
- [ ] **Step 1: Run full typecheck and build**
Run: `bun run typecheck && bun run build`
Expected: all workspace typecheck and build tasks pass.
- [ ] **Step 2: Start API and web**
Run API: `bun run --cwd apps/api start`
Run web: `bun run --cwd apps/web dev`
Expected: API logs `ZeaVis Edu API running...`; Vite serves the app.
- [ ] **Step 3: Check API without database**
Run: `curl -s -w '\n%{http_code}' http://localhost:3000/api/v1/classifications/image`
Expected without `DATABASE_URL`: JSON `{ "error": "Database unavailable" }` with status `503`, not a crash.
- [ ] **Step 4: Check upload route validation**
Run: `curl -s -X POST -w '\n%{http_code}' http://localhost:3000/api/v1/classifications/image`
Expected: JSON error with status `400`.
- [ ] **Step 5: Browser check dashboard**
Open `/dashboard`. Confirm the image classification form renders. If no database is configured, confirm structured error state renders without crashing. If database is configured and migrated, submit a JPEG/PNG corn leaf image and verify the result card displays image, label, confidence, and recommendations.
---
## Self-review notes
- Spec coverage: backend TFJS inference, uploader integration, DB persistence, API routes, shared types, frontend upload/result/history, and verification are covered.
- Placeholder scan: no TBD/TODO/fill-later placeholders remain; every file and route has explicit behavior.
- Type consistency: `ImageClassificationRecord`, `PredictionProbability`, and `UploaderMetadata` are defined once in shared and used consistently across API and web.
+34
View File
@@ -15,6 +15,40 @@ export type ManualClassificationRecord = {
disease: DiseaseCatalogItem;
};
export type PredictionProbability = {
diseaseSlug: DiseaseSlug;
label: DiseaseCatalogItem['label'];
confidence: number;
};
export type UploaderMetadata = {
public_id: string;
file_name: string;
mime_type: string;
size_bytes: number;
file_type: string;
uploader_id?: number;
created_at: string;
telegram_file_id?: string;
telegram_file_unique_id?: string;
storage_chat_id?: number;
storage_message_id?: number;
download_url: string;
};
export type ImageClassificationRecord = {
id: string;
predictedDiseaseSlug: DiseaseSlug;
confidence: number;
probabilities: PredictionProbability[];
imageUrl: string;
originalFileName: string;
uploaderPublicId: string;
uploader: UploaderMetadata;
createdAt: string;
disease: DiseaseCatalogItem;
};
export type DashboardSummary = {
diseaseCount: number;
classificationCount: number;
+3
View File
@@ -20,4 +20,7 @@ export type {
ManualClassificationRequest,
ManualClassificationRecord,
DashboardSummary,
PredictionProbability,
UploaderMetadata,
ImageClassificationRecord,
} from './classifications';