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:
@@ -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 $$;
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@
|
|||||||
"when": 1779458573080,
|
"when": 1779458573080,
|
||||||
"tag": "0000_harsh_arachne",
|
"tag": "0000_harsh_arachne",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1779461286348,
|
||||||
|
"tag": "0001_cute_lyja",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -11,9 +11,12 @@
|
|||||||
"db:migrate": "drizzle-kit migrate"
|
"db:migrate": "drizzle-kit migrate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tensorflow/tfjs": "4.22.0",
|
||||||
"@zeavis/shared": "workspace:*",
|
"@zeavis/shared": "workspace:*",
|
||||||
"drizzle-orm": "^0.36.4",
|
"drizzle-orm": "^0.36.4",
|
||||||
"elysia": "^1.1.25",
|
"elysia": "^1.1.25",
|
||||||
|
"jpeg-js": "0.4.4",
|
||||||
|
"pngjs": "7.0.0",
|
||||||
"postgres": "^3.4.5"
|
"postgres": "^3.4.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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', {
|
export const appEvents = pgTable('app_events', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
@@ -30,3 +30,17 @@ export const manualClassifications = pgTable('manual_classifications', {
|
|||||||
location: varchar('location', { length: 160 }).notNull(),
|
location: varchar('location', { length: 160 }).notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
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(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 {
|
export function serviceUnavailable(message: string): Response {
|
||||||
return new Response(JSON.stringify({ error: message }), {
|
return new Response(JSON.stringify({ error: message }), {
|
||||||
status: 503,
|
status: 503,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,12 +1,215 @@
|
|||||||
import { Elysia } from 'elysia';
|
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 { isDiseaseSlug } from '@zeavis/shared';
|
||||||
import { createDbClient } from '../db/client';
|
import { createDbClient } from '../db/client';
|
||||||
import { diseaseCatalog, manualClassifications } from '../db/schema';
|
import { diseaseCatalog, manualClassifications, imageClassifications } from '../db/schema';
|
||||||
import { badRequest, serviceUnavailable } from '../lib/http-errors';
|
import { badRequest, badGateway, serviceUnavailable } from '../lib/http-errors';
|
||||||
import { desc, eq } from 'drizzle-orm';
|
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' })
|
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 () => {
|
.get('/classifications/manual', async () => {
|
||||||
try {
|
try {
|
||||||
const db = createDbClient();
|
const db = createDbClient();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Elysia } from 'elysia';
|
import { Elysia } from 'elysia';
|
||||||
import type { DashboardSummary, RiskLevel } from '@zeavis/shared';
|
import type { DashboardSummary, RiskLevel } from '@zeavis/shared';
|
||||||
import { createDbClient } from '../db/client';
|
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 { serviceUnavailable } from '../lib/http-errors';
|
||||||
import { desc, eq } from 'drizzle-orm';
|
import { desc, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
@@ -10,7 +10,8 @@ export const dashboardRoutes = new Elysia({ prefix: '/api/v1' }).get('/dashboard
|
|||||||
const db = createDbClient();
|
const db = createDbClient();
|
||||||
|
|
||||||
const diseaseCount = await db.select().from(diseaseCatalog);
|
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
|
const latestClassificationRow = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -75,7 +76,7 @@ export const dashboardRoutes = new Elysia({ prefix: '/api/v1' }).get('/dashboard
|
|||||||
|
|
||||||
const summary: DashboardSummary = {
|
const summary: DashboardSummary = {
|
||||||
diseaseCount: diseaseCount.length,
|
diseaseCount: diseaseCount.length,
|
||||||
classificationCount: classificationCount.length,
|
classificationCount: manualClassificationCount.length + imageClassificationCount.length,
|
||||||
latestClassification,
|
latestClassification,
|
||||||
riskDistribution,
|
riskDistribution,
|
||||||
};
|
};
|
||||||
|
|||||||
+8
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
DiseaseCatalogItem,
|
DiseaseCatalogItem,
|
||||||
ManualClassificationRequest,
|
ManualClassificationRequest,
|
||||||
ManualClassificationRecord,
|
ManualClassificationRecord,
|
||||||
|
ImageClassificationRecord,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
} from '@zeavis/shared';
|
} from '@zeavis/shared';
|
||||||
|
|
||||||
@@ -20,7 +21,6 @@ async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T>
|
|||||||
errorMessage = errorData.error;
|
errorMessage = errorData.error;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Response is not JSON, use default error message
|
|
||||||
}
|
}
|
||||||
throw new Error(errorMessage);
|
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> {
|
async getDashboardSummary(): Promise<DashboardSummary> {
|
||||||
return fetchApi('/api/v1/dashboard/summary');
|
return fetchApi('/api/v1/dashboard/summary');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Link } from 'react-router-dom';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { ManualClassificationForm } from '@/components/manual-classification-form';
|
import { ManualClassificationForm } from '@/components/manual-classification-form';
|
||||||
|
import { ImageClassificationForm } from '@/components/image-classification-form';
|
||||||
import { RiskBadge } from '@/components/risk-badge';
|
import { RiskBadge } from '@/components/risk-badge';
|
||||||
import { useUiStore } from '@/store/ui-store';
|
import { useUiStore } from '@/store/ui-store';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
@@ -12,7 +13,7 @@ export function DashboardPage() {
|
|||||||
const { dashboardCompact, toggleDashboardCompact } = useUiStore();
|
const { dashboardCompact, toggleDashboardCompact } = useUiStore();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [diseasesQuery, summaryQuery, classificationsQuery] = useQueries({
|
const [diseasesQuery, summaryQuery, classificationsQuery, imageClassificationsQuery] = useQueries({
|
||||||
queries: [
|
queries: [
|
||||||
{
|
{
|
||||||
queryKey: ['diseases'],
|
queryKey: ['diseases'],
|
||||||
@@ -26,6 +27,10 @@ export function DashboardPage() {
|
|||||||
queryKey: ['manual-classifications'],
|
queryKey: ['manual-classifications'],
|
||||||
queryFn: () => apiClient.getManualClassifications(),
|
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 diseases = diseasesQuery.data || [];
|
||||||
const summary = summaryQuery.data;
|
const summary = summaryQuery.data;
|
||||||
const classifications = classificationsQuery.data || [];
|
const classifications = classificationsQuery.data || [];
|
||||||
|
const imageClassifications = imageClassificationsQuery.data || [];
|
||||||
|
|
||||||
const isLoadingData = diseasesQuery.isLoading || summaryQuery.isLoading;
|
const isLoadingData = diseasesQuery.isLoading || summaryQuery.isLoading;
|
||||||
const hasError = diseasesQuery.error || summaryQuery.error;
|
const hasError = diseasesQuery.error || summaryQuery.error;
|
||||||
@@ -220,6 +236,14 @@ export function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ImageClassificationForm
|
||||||
|
onSubmit={async (file) => {
|
||||||
|
await createImageClassificationMutation.mutateAsync(file);
|
||||||
|
}}
|
||||||
|
isSubmitting={createImageClassificationMutation.isPending}
|
||||||
|
latestResult={imageClassifications[0] ?? null}
|
||||||
|
/>
|
||||||
|
|
||||||
<ManualClassificationForm
|
<ManualClassificationForm
|
||||||
diseases={diseases}
|
diseases={diseases}
|
||||||
onSubmit={async (payload) => {
|
onSubmit={async (payload) => {
|
||||||
@@ -228,38 +252,45 @@ export function DashboardPage() {
|
|||||||
isSubmitting={createClassificationMutation.isPending}
|
isSubmitting={createClassificationMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{classifications.length > 0 && (
|
{imageClassifications.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Leaf className="h-5 w-5" />
|
<Image className="h-5 w-5" />
|
||||||
Riwayat Laporan
|
Riwayat Klasifikasi Gambar
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{classifications.length} laporan pengamatan penyakit
|
{imageClassifications.length} gambar yang diklasifikasi
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{classifications.slice(0, 5).map((classification) => (
|
{imageClassifications.slice(0, 6).map((classification) => (
|
||||||
<div key={classification.id} className="rounded-lg border border-border p-3">
|
<div key={classification.id} className="rounded-lg border border-border overflow-hidden">
|
||||||
<div className="flex items-start justify-between gap-4 mb-2">
|
<img
|
||||||
<div>
|
src={classification.imageUrl}
|
||||||
<h4 className="text-sm font-semibold">
|
alt="Classified corn leaf"
|
||||||
{classification.disease.commonName}
|
className="w-full h-32 object-cover"
|
||||||
</h4>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="p-3 space-y-2">
|
||||||
{classification.disease.label}
|
<div className="flex items-start justify-between gap-2">
|
||||||
</p>
|
<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>
|
</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>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,9 +11,12 @@
|
|||||||
"name": "@zeavis/api",
|
"name": "@zeavis/api",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tensorflow/tfjs": "4.22.0",
|
||||||
"@zeavis/shared": "packages/shared",
|
"@zeavis/shared": "packages/shared",
|
||||||
"drizzle-orm": "^0.36.4",
|
"drizzle-orm": "^0.36.4",
|
||||||
"elysia": "^1.1.25",
|
"elysia": "^1.1.25",
|
||||||
|
"jpeg-js": "0.4.4",
|
||||||
|
"pngjs": "7.0.0",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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=="],
|
"@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/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=="],
|
"@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/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": ["@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/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": ["@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/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=="],
|
"@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/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" } }],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||||
|
|
||||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
"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=="],
|
"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=="],
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"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=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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": ["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=="],
|
"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=="],
|
"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=="],
|
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
@@ -386,12 +463,16 @@
|
|||||||
|
|
||||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
|
||||||
|
|
||||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="],
|
||||||
|
|
||||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"@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=="],
|
"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=="],
|
"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.
|
||||||
@@ -15,6 +15,40 @@ export type ManualClassificationRecord = {
|
|||||||
disease: DiseaseCatalogItem;
|
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 = {
|
export type DashboardSummary = {
|
||||||
diseaseCount: number;
|
diseaseCount: number;
|
||||||
classificationCount: number;
|
classificationCount: number;
|
||||||
|
|||||||
@@ -20,4 +20,7 @@ export type {
|
|||||||
ManualClassificationRequest,
|
ManualClassificationRequest,
|
||||||
ManualClassificationRecord,
|
ManualClassificationRecord,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
|
PredictionProbability,
|
||||||
|
UploaderMetadata,
|
||||||
|
ImageClassificationRecord,
|
||||||
} from './classifications';
|
} from './classifications';
|
||||||
|
|||||||
Reference in New Issue
Block a user