forked from hangshuo652/aurak
feat: implement QuestionBank CRUD with pagination and template query
- Add pagination support to findAll (page, limit query params) - Add findByTemplateId method to service - Add GET /by-template/:templateId endpoint to controller - Service already includes CRUD for QuestionBank and QuestionBankItem
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Loader, Image as ImageIcon, Box } from 'lucide-react';
|
||||
import { ocrService } from '../services/ocrService';
|
||||
import { noteCategoryService } from '../services/noteCategoryService';
|
||||
import { NoteCategory } from '../types';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
interface CreateNoteFromPDFDialogProps {
|
||||
screenshot: Blob;
|
||||
extractedText: string;
|
||||
onSave: (title: string, content: string, categoryId?: string) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
authToken: string;
|
||||
initialCategoryId?: string;
|
||||
initialPageNumber?: number;
|
||||
}
|
||||
|
||||
export const CreateNoteFromPDFDialog: React.FC<CreateNoteFromPDFDialogProps> = ({
|
||||
screenshot,
|
||||
extractedText,
|
||||
onSave,
|
||||
onCancel,
|
||||
authToken,
|
||||
initialCategoryId,
|
||||
initialPageNumber,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { showToast } = useToast();
|
||||
const defaultTitle = initialPageNumber
|
||||
? `${t('createPDFNote')} - ${t('page')} ${initialPageNumber} - ${new Date().toLocaleString()}`
|
||||
: `${t('createPDFNote')} - ${new Date().toLocaleString()}`;
|
||||
const [title, setTitle] = useState(defaultTitle);
|
||||
const [content, setContent] = useState(extractedText);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | undefined>(initialCategoryId);
|
||||
const [categories, setCategories] = useState<NoteCategory[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [ocrLoading, setOcrLoading] = useState(false);
|
||||
const [screenshotUrl, setScreenshotUrl] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (authToken) {
|
||||
noteCategoryService.getAll(authToken).then(setCategories).catch(console.error);
|
||||
}
|
||||
}, [authToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(screenshot);
|
||||
setScreenshotUrl(url);
|
||||
|
||||
// Trigger OCR if initial text is empty
|
||||
if (!extractedText && authToken) {
|
||||
setOcrLoading(true);
|
||||
ocrService.recognizeText(authToken, screenshot)
|
||||
.then(text => setContent(text))
|
||||
.catch(err => console.error('OCR failed:', err))
|
||||
.finally(() => setOcrLoading(false));
|
||||
}
|
||||
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [screenshot, extractedText, authToken]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(title, content, selectedCategoryId);
|
||||
} catch (error) {
|
||||
console.error('Failed to save note:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg w-full max-w-3xl max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{t('createPDFNote')}</h2>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
disabled={saving}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Screenshot Preview */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<ImageIcon size={16} className="inline mr-1" />
|
||||
{t('screenshotPreview')}
|
||||
</label>
|
||||
<div className="border rounded-lg p-2 bg-gray-50">
|
||||
{screenshotUrl && (
|
||||
<img
|
||||
src={screenshotUrl}
|
||||
alt="PDF Selection"
|
||||
className="max-w-full h-auto rounded"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note Category selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<Box size={16} className="inline mr-1" />
|
||||
{t('personalNotebook') || t('directoryLabel')}
|
||||
</label>
|
||||
<select
|
||||
value={selectedCategoryId || ''}
|
||||
onChange={(e) => setSelectedCategoryId(e.target.value || undefined)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
disabled={saving}
|
||||
>
|
||||
<option value="">{t('uncategorized')}</option>
|
||||
{categories.map(c => {
|
||||
const parent = categories.find(p => p.id === c.parentId)
|
||||
const grandparent = parent ? categories.find(gp => gp.id === parent.parentId) : null
|
||||
const path = [grandparent, parent, c].filter(Boolean).map(cat => cat?.name).join(' > ')
|
||||
return (
|
||||
<option key={c.id} value={c.id}>
|
||||
{'\u00A0'.repeat((c.level - 1) * 2)}{c.name}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Title Input */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('title')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder={t('enterNoteTitle')}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content Textarea */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('contentOCR')}
|
||||
</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
|
||||
rows={10}
|
||||
placeholder={ocrLoading ? t('extractingText') : t('placeholderText')}
|
||||
disabled={saving || ocrLoading}
|
||||
/>
|
||||
{ocrLoading && (
|
||||
<div className="flex items-center gap-2 mt-2 p-2 bg-blue-50/50 rounded-md border border-blue-100/50">
|
||||
<Loader size={12} className="animate-spin" />
|
||||
{t('analyzingImage')}
|
||||
</div>
|
||||
)}
|
||||
{!ocrLoading && !content && (
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('noTextExtracted')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 p-4 border-t bg-gray-50">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
disabled={saving}
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
disabled={saving || !title.trim()}
|
||||
>
|
||||
{saving && <Loader size={16} className="animate-spin" />}
|
||||
{saving ? t('saving') : t('saveNote')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user