diff --git a/src/officesense_pi/.gitignore b/src/officesense_pi/.gitignore index 62a996d..7ed1777 100644 --- a/src/officesense_pi/.gitignore +++ b/src/officesense_pi/.gitignore @@ -2,4 +2,6 @@ node_modules # Keep environment variables out of version control .env -**/generated/prisma +/generated/prisma + +/generated/prisma diff --git a/src/officesense_pi/prisma.config.ts b/src/officesense_pi/prisma.config.ts deleted file mode 100644 index 831a20f..0000000 --- a/src/officesense_pi/prisma.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -// This file was generated by Prisma, and assumes you have installed the following: -// npm install --save-dev prisma dotenv -import "dotenv/config"; -import { defineConfig } from "prisma/config"; - -export default defineConfig({ - schema: "prisma/schema.prisma", - migrations: { - path: "prisma/migrations", - }, - datasource: { - url: process.env["DATABASE_URL"], - }, -}); diff --git a/src/officesense_pi/src/api/management/components.ts b/src/officesense_pi/src/api/management/components.ts index e2efd62..93ea60a 100644 --- a/src/officesense_pi/src/api/management/components.ts +++ b/src/officesense_pi/src/api/management/components.ts @@ -8,4 +8,6 @@ export const componentLoader = new ComponentLoader() export const Components = { Dashboard: componentLoader.add('Dashboard', path.join(__dirname, './components/Dashboard.tsx'), 'components'), + UploadFace: componentLoader.add('UploadFace', path.join(__dirname, './components/UploadFace.tsx'), 'components'), + FaceEmbeddingField: componentLoader.add('FaceEmbeddingField', path.join(__dirname, './components/FaceEmbeddingField.tsx'), 'components'), } \ No newline at end of file diff --git a/src/officesense_pi/src/api/management/components/FaceEmbeddingField.tsx b/src/officesense_pi/src/api/management/components/FaceEmbeddingField.tsx new file mode 100644 index 0000000..2b8caed --- /dev/null +++ b/src/officesense_pi/src/api/management/components/FaceEmbeddingField.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { BasePropertyProps } from 'adminjs' +import { Label } from '@adminjs/design-system' + +const FaceEmbeddingField: React.FC = ({ record, property }) => { + const has = !!record?.params?.faceEmbedding + return ( +
+ +
+ + {has ? 'SET' : 'UNSET'} + +
+
+ ) +} + +export default FaceEmbeddingField \ No newline at end of file diff --git a/src/officesense_pi/src/api/management/components/UploadFace.tsx b/src/officesense_pi/src/api/management/components/UploadFace.tsx new file mode 100644 index 0000000..3dc6e58 --- /dev/null +++ b/src/officesense_pi/src/api/management/components/UploadFace.tsx @@ -0,0 +1,96 @@ +import React, { useState, useRef } from 'react' +import { Box, H3, Text, Button, MessageBox } from '@adminjs/design-system' +import { ActionProps, useRecord } from 'adminjs' + +const UploadFace: React.FC = ({ record, action }) => { + const [preview, setPreview] = useState(null) + const [base64, setBase64] = useState(null) + const [loading, setLoading] = useState(false) + const [notice, setNotice] = useState<{ message: string, type: 'success' | 'error' } | null>(null) + const inputRef = useRef(null) + + const handleFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = () => { + const result = reader.result as string + setPreview(result) + setBase64(result.split(',')[1]) // strip data:image/jpeg;base64, + } + reader.readAsDataURL(file) + } + + const handleSubmit = async () => { + if (!base64) return + setLoading(true) + setNotice(null) + try { + const res = await fetch( + `/admin/api/resources/User/records/${record?.params.id}/uploadFace`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ base64 }), + } + ) + const data = await res.json() + setNotice(data.notice) + } catch (e: any) { + setNotice({ message: e.message, type: 'error' }) + } finally { + setLoading(false) + } + } + + return ( + +

Upload face image

+ + Upload a JPEG photo of the user to generate and store their face embedding. + + + {notice && ( + + )} + + + + + + + {preview && ( + + Preview + + )} + + +
+ ) +} + +export default UploadFace \ No newline at end of file diff --git a/src/officesense_pi/src/api/management/resources/adminjs.user.resource.ts b/src/officesense_pi/src/api/management/resources/adminjs.user.resource.ts index f846082..0a62b77 100644 --- a/src/officesense_pi/src/api/management/resources/adminjs.user.resource.ts +++ b/src/officesense_pi/src/api/management/resources/adminjs.user.resource.ts @@ -1,5 +1,53 @@ import { prisma } from "../../../lib/prisma.js"; import { getModelByName } from "@adminjs/prisma"; +import { spawn } from "child_process"; +import { Components } from '../components.js' +import { fileURLToPath } from 'url' +import path from 'path' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const execPath = '../../../../bin'; +const binaryPath = path.join(__dirname, execPath, 'extractEmbeddings') + +async function extractEmbedding(base64: string): Promise { + console.log(base64); + + return new Promise((resolve, reject) => { + const proc = spawn(binaryPath, [], { + cwd: path.join(__dirname, execPath) + }) + let stdout = '' + let stderr = '' + + proc.stdout.on('data', (d) => stdout += d) + proc.stderr.on('data', (d) => stderr += d) + + proc.stdin.on('error', (err) => { + // ignore EPIPE here, we'll catch it in close handler + }) + + proc.on('error', (err) => { + reject(new Error(`Failed to start binary: ${err.message}`)) + }) + + proc.on('close', (code) => { + console.log('extractEmbeddings exited with code', code) + console.log('stdout:', stdout) + console.log('stderr:', stderr) + if (code !== 0) return reject(new Error(stderr || `exited with code ${code}`)) + try { + const result = JSON.parse(stdout) + if (!result.success) return reject(new Error(result.error)) + resolve(JSON.stringify(result.descriptor)) + } catch (e) { + reject(new Error(`Failed to parse output: ${stdout}`)) + } + }) + + proc.stdin.write(base64) + proc.stdin.end() + }) +} export default { resource: { @@ -10,14 +58,56 @@ export default { titleProperty: "id", navigation: { icon: "User" }, listProperties: ["id", "firstName", "lastName"], - showProperties: ["id", "firstName", "lastName"], + showProperties: ["id", "firstName", "lastName", "faceEmbedding"], editProperties: ["firstName", "lastName"], filterProperties: ["firstName", "lastName"], properties: { faceEmbedding: { - isVisible: false, - type: "string" + isVisible: { list: false, show: true, edit: false, filter: false }, + type: "string", + components: { + list: Components.FaceEmbeddingField, + show: Components.FaceEmbeddingField, + }, }, - } + }, + actions: { + uploadFace: { + actionType: 'record', + icon: 'Camera', + label: 'Upload face', + showInDrawer: true, + handler: async (request: any, response: any, context: any) => { + const { record, currentAdmin } = context + if (request.method === 'post') { + const { base64 } = request.payload + if (!base64) { + return { + record: record.toJSON(currentAdmin), + notice: { message: 'No image provided', type: 'error' }, + } + } + try { + const embedding = await extractEmbedding(base64) + await prisma.user.update({ + where: { id: record.params.id }, + data: { faceEmbedding: embedding }, + }) + return { + record: record.toJSON(currentAdmin), + notice: { message: 'Face embedding saved!', type: 'success' }, + } + } catch (e: any) { + return { + record: record.toJSON(currentAdmin), + notice: { message: `Failed: ${e.message}`, type: 'error' }, + } + } + } + return { record: record.toJSON(currentAdmin) } + }, + component: Components.UploadFace, + }, + }, }, } \ No newline at end of file