Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: camera pick #61

Merged
merged 1 commit into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,6 @@ docs/gen

# API codegen files
*.gen.*
gen
gen

.certs
10 changes: 9 additions & 1 deletion docs/pdf_download.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,34 @@
# PDF Documentation Generation

## Overview

This workflow generates PDF documentation for the frontend using **typedoc** and the converting tool **md-to-pdf**.

## Workflow Steps

### Checkout Repository

Clones the repository to the workflow runner.

### Install dependencies

Installs all the needed dependencies using the command `yarn install`

### Generate docs to markdown

Generate docs in markdown format using `yarn run typedoc`

### Convert md to pdf files

Convert md files to pdf using a custom script with `yarn run convert-md-to-pdf`

### Upload PDF Artifact

Uploads the generated PDF files as an artifact using [actions/upload-artifact@v3]

## How to Download the generated PDF file

1. After the workflow completes, navigate to the "Actions" tab of your repository.
2. Select the latest workflow run for "Generate documentation for the frontend".
3. Click on the "Artifacts" dropdown menu.
4. Choose "documentations" to download the generated PDF documentations.
4. Choose "documentations" to download the generated PDF documentations.
11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --host 0.0.0.0",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
Expand All @@ -24,6 +24,8 @@
"@yudiel/react-qr-scanner": "^2.0.4",
"autoprefixer": "^10.4.19",
"axios": "^1.7.2",
"compromise": "^14.14.0",
"formik": "^2.4.6",
"i18next": "^23.11.4",
"i18next-chained-backend": "^4.6.2",
"i18next-http-backend": "^2.5.2",
Expand All @@ -34,6 +36,7 @@
"react-daisyui": "^5.0.0",
"react-dom": "^18.2.0",
"react-feather": "^2.0.10",
"react-html5-camera-photo": "^1.5.11",
"react-i18next": "^14.1.1",
"react-qr-code": "^2.0.15",
"react-redux": "^9.1.2",
Expand All @@ -43,7 +46,9 @@
"redux-persist": "^6.0.0",
"redux-toolkit": "^1.1.2",
"tailwindcss": "^3.4.3",
"theme-change": "^2.5.0"
"tesseract.js": "^5.1.1",
"theme-change": "^2.5.0",
"yup": "^1.4.0"
},
"devDependencies": {
"@commitlint/cli": "^19.3.0",
Expand All @@ -55,6 +60,7 @@
"@types/i18next-browser-languagedetector": "^3.0.0",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@types/react-html5-camera-photo": "^1.5.3",
"@types/redux-logger": "^3.0.13",
"@typescript-eslint/eslint-plugin": "^7.7.1",
"@typescript-eslint/parser": "^7.7.1",
Expand Down Expand Up @@ -85,6 +91,7 @@
"typescript": "^5.4.5",
"vite": "^5.2.0",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-mkcert": "^1.17.5",
"vite-tsconfig-paths": "^4.3.2"
},
"lint-staged": {
Expand Down
71 changes: 71 additions & 0 deletions src/components/camera-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useCallback, useRef, useState } from 'react';
import { TakePicture } from '@components/take-picture.tsx';
import { useAiLoadingState, useExtractDataFromText } from '@store';
import { Button, Progress } from 'react-daisyui';
import { Camera, X } from 'react-feather';

export interface CameraInputProps {
name: string;
id?: string;
}

export function CameraInput(props: CameraInputProps) {
const ref = useRef<HTMLDialogElement | null>(null);
const openModal = useCallback(() => ref.current?.showModal(), [ref]);
const extractedData = useExtractDataFromText();
const aiLoading = useAiLoadingState();
const [shouldTake, setShouldTake] = useState(false);
const [dataUri, setDataUri] = useState<string | null>(null);

const onPicture = useCallback((dataUri: string) => {
setDataUri(dataUri);
setShouldTake(false);
}, []);

return (
<>
<button className="btn" onClick={openModal}>
open modal
</button>
<dialog
id={props.id}
ref={ref}
className="modal modal-bottom sm:modal-middle"
>
<div className="modal-box flex flex-col gap-2 items-center">
<form className="w-full" method="dialog">
<h2>{props.name}</h2>
{/* if there is a button in form, it will close the modal */}
<Button
color="ghost"
shape="circle"
className="absolute right-2 top-2 z-10"
>
<X />
</Button>
</form>

{shouldTake && <TakePicture onTakePhoto={onPicture} />}
{dataUri && !shouldTake && (
<div className="rounded-lg relative overflow-hidden">
<img src={dataUri} alt="image" />
</div>
)}
{!shouldTake && (
<Button shape="circle" onClick={() => setShouldTake(true)}>
<Camera />
</Button>
)}
{extractedData && !shouldTake && (
<pre className="bg-base-200 rounded-xl overflow-scroll h-[100px] w-full">
{JSON.stringify(extractedData, null, 2)}
</pre>
)}
{dataUri && aiLoading > 0 && aiLoading < 3 && (
<Progress className="w-full" value={aiLoading} max={3} />
)}
</div>
</dialog>
</>
);
}
37 changes: 37 additions & 0 deletions src/components/take-picture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Camera, { CameraProps } from 'react-html5-camera-photo';
import { useAddErrorNotification, useExtractText } from '@store';
import 'react-html5-camera-photo/build/css/index.css';
import { useCallback } from 'react';

interface TakePictureProps {
onTakePhoto: Required<CameraProps>['onTakePhoto'];
}

export function TakePicture({ onTakePhoto }: TakePictureProps) {
const extractText = useExtractText();
const addErrorNotification = useAddErrorNotification();
const handleTakePhoto: Required<CameraProps>['onTakePhoto'] = useCallback(
(dataUri) => {
extractText(dataUri);
onTakePhoto(dataUri);
},
[extractText, onTakePhoto]
);

const onCameraError: Required<CameraProps>['onCameraError'] = useCallback(
(error) => {
addErrorNotification(error.message);
},
[addErrorNotification]
);

return (
<div>
<Camera
onCameraError={onCameraError}
onTakePhoto={handleTakePhoto}
isMaxResolution={true}
/>
</div>
);
}
1 change: 1 addition & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import './index.scss';
import 'barcode-detector/side-effects';
import { isElectron, setupLogging } from '@shared';
import * as Sentry from '@sentry/react';
import { i18nFn } from '@i18n';
Expand Down
5 changes: 4 additions & 1 deletion src/screens/scan.screen.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { Header } from '../components/header.tsx';
import { CameraInput } from '@components/camera-input.tsx';

/**
* Scan screen
Expand All @@ -9,7 +10,9 @@ export const Component: React.FC = () => {
return (
<>
<Header title="Scan" back=".." />
<div className="bg-base-100">TODO: Form & Camera will be here</div>
<div className="bg-base-100">
<CameraInput name="image" />
</div>
</>
);
};
38 changes: 36 additions & 2 deletions src/store/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { useCallback } from 'react';
import { fetchConfigUrl } from './thunks';
import { extractBarcode, extractText, fetchConfigUrl } from './thunks';
import { useAppDispatch, useAppSelector } from './types';
import { selectConfigUrl } from '@store/selectors.ts';
import {
selectAiData,
selectAiLoadingState,
selectConfigUrl,
} from '@store/selectors.ts';
import { addNotification } from '@store/slices';

export function useFetchConfigUrl() {
const dispatch = useAppDispatch();
Expand All @@ -13,3 +18,32 @@ export function useFetchConfigUrl() {
export function useConfigData() {
return useAppSelector(selectConfigUrl);
}

export function useExtractText() {
const dispatch = useAppDispatch();
return useCallback(
(imgUri: string) => {
dispatch(extractText({ imgUri }));
dispatch(extractBarcode({ imgUri }));
},
[dispatch]
);
}

export function useExtractDataFromText() {
return useAppSelector(selectAiData);
}

export function useAiLoadingState() {
return useAppSelector(selectAiLoadingState);
}

export function useAddErrorNotification() {
const dispatch = useAppDispatch();
return useCallback(
(msg: string) => {
dispatch(addNotification(msg));
},
[dispatch]
);
}
23 changes: 23 additions & 0 deletions src/store/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,30 @@ export const selectNotification = createSelector(
(ro: RootState) => ro.notification.messages,
(p) => p.filter(({ message }) => message.length > 0)
);

export const selectConfigUrl = createSelector(
(ro: RootState) => ro.config,
({ url, loading }) => (loading ? undefined : JSON.stringify({ url }))
);

export const selectAiData = createSelector(
(ro: RootState) => ro.ai,
({ loadingData, loadingBarCodes, data, barCodes }) =>
loadingData || loadingBarCodes
? undefined
: {
data,
barCodes,
}
);

export const selectAiLoadingState = createSelector(
(ro: RootState) => ro.ai,
({ loadingData, loadingBarCodes, loadingText }) => {
let total = 0;
if (!loadingData) total++;
if (!loadingBarCodes) total++;
if (!loadingText) total++;
return total;
}
);
71 changes: 71 additions & 0 deletions src/store/slices/ai.slice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { createSlice } from '@reduxjs/toolkit';
import { PURGE } from 'redux-persist';
import {
extractBarcode,
extractDataFromText,
extractText,
} from '@store/thunks';

export interface AIState {
text?: string;
data?: Record<string, unknown>;
barCodes?: string[];
loadingText: boolean;
loadingData: boolean;
loadingBarCodes: boolean;
}

const initialState = {
text: undefined,
data: undefined,
barCodes: undefined,
loadingText: true,
loadingData: true,
loadingBarCodes: true,
} satisfies AIState as AIState;

const aiSlice = createSlice({
name: 'ai',
initialState,
reducers: {},
extraReducers: (builder) => {
// Add reducers for additional action types here, and handle loading state as needed
builder
.addCase(extractText.fulfilled, (state, action) => {
// Add user to the state array
state.text = action.payload;
state.loadingText = false;
})
.addCase(extractText.pending, (state) => {
// Add user to the state array
state.loadingText = true;
})
.addCase(extractBarcode.fulfilled, (state, action) => {
// Add user to the state array
state.barCodes = action.payload;
state.loadingBarCodes = false;
})
.addCase(extractBarcode.pending, (state) => {
// Add user to the state array
state.loadingBarCodes = true;
})
.addCase(extractDataFromText.fulfilled, (state, action) => {
// Add user to the state array
state.data = action.payload;
state.loadingData = false;
})
.addCase(extractDataFromText.pending, (state) => {
// Add user to the state array
state.loadingData = true;
})
.addCase(PURGE, (state) => {
state.data = initialState.data;
state.text = initialState.text;
state.loadingData = initialState.loadingData;
state.loadingText = initialState.loadingText;
state.loadingBarCodes = initialState.loadingBarCodes;
});
},
});

export const reducerAI = aiSlice.reducer;
1 change: 1 addition & 0 deletions src/store/slices/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './ai.slice';
export * from './config.slice';
export * from './notification.slice';
Loading