Fix bugs and add features to Znakopis document management
- Fix API endpoint for creating pages (documents/:id/pages) - Fix sentence deletion functionality - Add CreateDocumentDialog component for better UX - Improve document and sentence management UI - Update seed data and backend routes - Clean up documentation files (remove videos.md, videosentence.md) - Add comprehensive bug tracking in fixbugsaddfeatures.md
This commit is contained in:
277
fixbugsaddfeatures.md
Normal file
277
fixbugsaddfeatures.md
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
# Znakopis Page - Bug Fixes and Feature Additions
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This document outlines the bugs to fix and features to add to the Znakopis page for document and sentence management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 BUG FIXES
|
||||||
|
|
||||||
|
### Bug 1: Sentence Deletion Error
|
||||||
|
**Issue:** When loading a document and trying to delete a sentence, an error occurs and the sentence is not deleted.
|
||||||
|
|
||||||
|
**Root Cause:** The API endpoint path is incorrect in the frontend.
|
||||||
|
- Frontend calls: `/api/sentences/${sentenceId}` (via `documentApi.deleteSentence()`)
|
||||||
|
- Backend expects: `/api/sentences/:sentenceId`
|
||||||
|
- Backend route is registered at: `app.use('/api', sentenceRoutes)` in `server.ts`
|
||||||
|
- This means the actual endpoint is: `/api/sentences/:sentenceId` ✅
|
||||||
|
|
||||||
|
**Investigation Needed:**
|
||||||
|
1. Check browser console for the exact error message
|
||||||
|
2. Verify the sentence ID is being passed correctly
|
||||||
|
3. Check if authentication is working properly
|
||||||
|
4. Verify the backend route is handling the DELETE request correctly
|
||||||
|
|
||||||
|
**Files to Check:**
|
||||||
|
- `packages/frontend/src/lib/documentApi.ts` - Line 121-123 (deleteSentence method)
|
||||||
|
- `packages/backend/src/routes/sentences.ts` - Line 169-199 (DELETE endpoint)
|
||||||
|
- `packages/frontend/src/pages/Znakopis.tsx` - Line 129-144 (handleDeleteSentence)
|
||||||
|
- `packages/frontend/src/components/znakopis/DocumentPanel.tsx` - Line 119-125 (delete button)
|
||||||
|
|
||||||
|
**Fix Steps:**
|
||||||
|
1. Add error logging to identify the exact issue
|
||||||
|
2. Verify the sentence ID format (should be UUID string)
|
||||||
|
3. Ensure the DELETE request includes authentication credentials
|
||||||
|
4. Test the endpoint directly to confirm it works
|
||||||
|
5. Add better error handling and user feedback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bug 2: Incorrect API Endpoint for Creating Pages
|
||||||
|
**Issue:** The frontend uses wrong endpoint path for creating pages.
|
||||||
|
|
||||||
|
**Current State:**
|
||||||
|
- Frontend calls: `/api/${documentId}/pages` (Line 88 in documentApi.ts)
|
||||||
|
- Backend expects: `/api/documents/:documentId/pages` (Line 205 in sentences.ts)
|
||||||
|
|
||||||
|
**Fix Required:**
|
||||||
|
- Update `packages/frontend/src/lib/documentApi.ts` line 88
|
||||||
|
- Change from: `api.post(\`/api/${documentId}/pages\`, { title })`
|
||||||
|
- Change to: `api.post(\`/api/documents/${documentId}/pages\`, { title })`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bug 3: Incorrect API Endpoint for Creating Sentences
|
||||||
|
**Issue:** The frontend uses wrong endpoint path for creating sentences.
|
||||||
|
|
||||||
|
**Current State:**
|
||||||
|
- Frontend calls: `/api/${documentId}/pages/${pageIndex}/sentences` (Line 99 in documentApi.ts)
|
||||||
|
- Backend expects: `/api/documents/:documentId/pages/:pageIndex/sentences` (Line 11 in sentences.ts)
|
||||||
|
|
||||||
|
**Fix Required:**
|
||||||
|
- Update `packages/frontend/src/lib/documentApi.ts` line 99
|
||||||
|
- Change from: `api.post(\`/api/${documentId}/pages/${pageIndex}/sentences\`, data)`
|
||||||
|
- Change to: `api.post(\`/api/documents/${documentId}/pages/${pageIndex}/sentences\`, data)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ NEW FEATURES
|
||||||
|
|
||||||
|
### Feature 1: Delete Document Functionality
|
||||||
|
**Description:** Add ability to delete documents from the Znakopis page.
|
||||||
|
|
||||||
|
**Backend:** Already implemented ✅
|
||||||
|
- Endpoint: `DELETE /api/documents/:id` (Line 236 in documents.ts)
|
||||||
|
- Method: `documentApi.deleteDocument(id)` already exists (Line 82 in documentApi.ts)
|
||||||
|
|
||||||
|
**Frontend Changes Needed:**
|
||||||
|
|
||||||
|
1. **Update DocumentPanel Component** (`packages/frontend/src/components/znakopis/DocumentPanel.tsx`):
|
||||||
|
- Add `onDeleteDocument` prop to interface (Line 6-15)
|
||||||
|
- Add delete button in the document info section (around Line 56-65)
|
||||||
|
- Use Trash2 icon from lucide-react (already imported)
|
||||||
|
- Add confirmation dialog before deletion
|
||||||
|
|
||||||
|
2. **Update Znakopis Page** (`packages/frontend/src/pages/Znakopis.tsx`):
|
||||||
|
- Add `handleDeleteDocument` function (similar to handleDeleteSentence)
|
||||||
|
- Pass handler to DocumentPanel component
|
||||||
|
- After deletion:
|
||||||
|
- Clear selectedDocument state
|
||||||
|
- Reload documents list
|
||||||
|
- Show success toast
|
||||||
|
- Handle errors with error toast
|
||||||
|
|
||||||
|
3. **UI/UX Considerations:**
|
||||||
|
- Add confirmation dialog: "Jeste li sigurni da želite obrisati ovaj dokument?"
|
||||||
|
- Show document title in confirmation
|
||||||
|
- Disable delete button while deleting (loading state)
|
||||||
|
- Clear current tokens if deleting the selected document
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Feature 2: Edit Document Name
|
||||||
|
**Description:** Allow users to edit document title and description.
|
||||||
|
|
||||||
|
**Backend:** Already implemented ✅
|
||||||
|
- Endpoint: `PATCH /api/documents/:id` (Line 174 in documents.ts)
|
||||||
|
- Method: `documentApi.updateDocument(id, data)` already exists (Line 76 in documentApi.ts)
|
||||||
|
|
||||||
|
**Frontend Changes Needed:**
|
||||||
|
|
||||||
|
1. **Update DocumentPanel Component** (`packages/frontend/src/components/znakopis/DocumentPanel.tsx`):
|
||||||
|
- Add `onUpdateDocument` prop to interface
|
||||||
|
- Add edit mode state for document title and description
|
||||||
|
- Add edit button (Pencil icon from lucide-react)
|
||||||
|
- When in edit mode:
|
||||||
|
- Show input field for title
|
||||||
|
- Show textarea for description
|
||||||
|
- Show Save and Cancel buttons
|
||||||
|
- When not in edit mode:
|
||||||
|
- Show title and description as text
|
||||||
|
- Show edit button
|
||||||
|
|
||||||
|
2. **Update Znakopis Page** (`packages/frontend/src/pages/Znakopis.tsx`):
|
||||||
|
- Add `handleUpdateDocument` function
|
||||||
|
- Accept documentId, title, and description
|
||||||
|
- Call `documentApi.updateDocument()`
|
||||||
|
- Reload document after update
|
||||||
|
- Update documents list
|
||||||
|
- Show success/error toast
|
||||||
|
|
||||||
|
3. **UI/UX Considerations:**
|
||||||
|
- Inline editing for better UX
|
||||||
|
- Validate title is not empty
|
||||||
|
- Show loading state while saving
|
||||||
|
- Revert changes on cancel
|
||||||
|
- Auto-focus title input when entering edit mode
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Feature 3: Name Document on Creation
|
||||||
|
**Description:** Allow users to provide a custom name when creating a new document instead of auto-generated name.
|
||||||
|
|
||||||
|
**Current Behavior:**
|
||||||
|
- Auto-generates name: `Dokument ${new Date().toLocaleDateString('hr-HR')}`
|
||||||
|
- Creates with description: "Novi dokument"
|
||||||
|
|
||||||
|
**Proposed Changes:**
|
||||||
|
|
||||||
|
1. **Add Document Creation Dialog:**
|
||||||
|
- Create new component: `CreateDocumentDialog.tsx`
|
||||||
|
- Use Dialog component from UI library
|
||||||
|
- Include:
|
||||||
|
- Title input field (required)
|
||||||
|
- Description textarea (optional)
|
||||||
|
- Create and Cancel buttons
|
||||||
|
|
||||||
|
2. **Update Znakopis Page** (`packages/frontend/src/pages/Znakopis.tsx`):
|
||||||
|
- Add state for dialog open/closed
|
||||||
|
- Modify "Novi dokument" button to open dialog
|
||||||
|
- Update `handleSaveDocument`:
|
||||||
|
- If no document selected, check if user wants to create new or use dialog
|
||||||
|
- Option 1: Always show dialog for new documents
|
||||||
|
- Option 2: Show dialog only when clicking "Novi dokument", auto-create when saving first sentence
|
||||||
|
- Pass document data from dialog to createDocument API
|
||||||
|
|
||||||
|
3. **Alternative Approach (Simpler):**
|
||||||
|
- Add inline form in DocumentPanel when no document is selected
|
||||||
|
- Show title and description inputs
|
||||||
|
- First save creates document with provided info
|
||||||
|
- If fields empty, use default values
|
||||||
|
|
||||||
|
4. **UI/UX Considerations:**
|
||||||
|
- Default title could be: "Novi dokument" (user can change)
|
||||||
|
- Placeholder for description: "Dodajte opis dokumenta..."
|
||||||
|
- Validate title is not empty
|
||||||
|
- Show character count for title (max 255)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 IMPLEMENTATION CHECKLIST
|
||||||
|
|
||||||
|
### Phase 1: Bug Fixes (Priority: HIGH)
|
||||||
|
- [ ] Fix API endpoint for creating pages (documentApi.ts line 88)
|
||||||
|
- [ ] Fix API endpoint for creating sentences (documentApi.ts line 99)
|
||||||
|
- [ ] Debug and fix sentence deletion error
|
||||||
|
- [ ] Add console logging to identify error
|
||||||
|
- [ ] Verify authentication is working
|
||||||
|
- [ ] Test endpoint directly
|
||||||
|
- [ ] Fix any issues found
|
||||||
|
- [ ] Add better error handling
|
||||||
|
|
||||||
|
### Phase 2: Delete Document Feature (Priority: HIGH)
|
||||||
|
- [ ] Add onDeleteDocument prop to DocumentPanel interface
|
||||||
|
- [ ] Add delete button to DocumentPanel UI
|
||||||
|
- [ ] Implement confirmation dialog
|
||||||
|
- [ ] Add handleDeleteDocument function in Znakopis page
|
||||||
|
- [ ] Test deletion flow
|
||||||
|
- [ ] Verify state updates correctly after deletion
|
||||||
|
|
||||||
|
### Phase 3: Edit Document Name (Priority: MEDIUM)
|
||||||
|
- [ ] Add onUpdateDocument prop to DocumentPanel interface
|
||||||
|
- [ ] Add edit mode state to DocumentPanel
|
||||||
|
- [ ] Implement inline editing UI (title and description)
|
||||||
|
- [ ] Add edit/save/cancel buttons
|
||||||
|
- [ ] Add handleUpdateDocument function in Znakopis page
|
||||||
|
- [ ] Add validation for title
|
||||||
|
- [ ] Test edit flow
|
||||||
|
- [ ] Verify state updates correctly after edit
|
||||||
|
|
||||||
|
### Phase 4: Name Document on Creation (Priority: MEDIUM)
|
||||||
|
- [ ] Decide on approach (dialog vs inline form)
|
||||||
|
- [ ] Create CreateDocumentDialog component (if using dialog approach)
|
||||||
|
- [ ] Update "Novi dokument" button behavior
|
||||||
|
- [ ] Add form validation
|
||||||
|
- [ ] Update handleSaveDocument or create separate handler
|
||||||
|
- [ ] Test document creation with custom name
|
||||||
|
- [ ] Ensure backward compatibility (auto-name if not provided)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 TESTING REQUIREMENTS
|
||||||
|
|
||||||
|
### For Each Bug Fix:
|
||||||
|
1. Test the specific scenario that was failing
|
||||||
|
2. Verify error messages are clear and helpful
|
||||||
|
3. Test edge cases (empty data, invalid IDs, etc.)
|
||||||
|
4. Verify authentication is required
|
||||||
|
|
||||||
|
### For Each Feature:
|
||||||
|
1. Test happy path (normal usage)
|
||||||
|
2. Test validation (empty fields, too long text, etc.)
|
||||||
|
3. Test error handling (network errors, server errors)
|
||||||
|
4. Test UI states (loading, success, error)
|
||||||
|
5. Test on different screen sizes (responsive design)
|
||||||
|
6. Verify Croatian language text is correct
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 NOTES FOR IMPLEMENTATION
|
||||||
|
|
||||||
|
1. **Consistency:** Follow existing patterns in the codebase
|
||||||
|
2. **Error Handling:** Always show user-friendly error messages in Croatian
|
||||||
|
3. **Loading States:** Show loading indicators for async operations
|
||||||
|
4. **Confirmation Dialogs:** Use for destructive actions (delete)
|
||||||
|
5. **Toast Messages:** Use Sonner toast for feedback (already imported)
|
||||||
|
6. **Icons:** Use lucide-react icons (already in use)
|
||||||
|
7. **Styling:** Use existing Tailwind classes for consistency
|
||||||
|
8. **TypeScript:** Ensure all types are properly defined
|
||||||
|
9. **API Calls:** Always handle errors with try-catch
|
||||||
|
10. **State Management:** Update all relevant state after API calls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 FILES TO MODIFY
|
||||||
|
|
||||||
|
### Bug Fixes:
|
||||||
|
1. `packages/frontend/src/lib/documentApi.ts` - Fix API endpoints
|
||||||
|
2. `packages/frontend/src/pages/Znakopis.tsx` - Improve error handling
|
||||||
|
3. `packages/backend/src/routes/sentences.ts` - Verify DELETE endpoint (if needed)
|
||||||
|
|
||||||
|
### Features:
|
||||||
|
1. `packages/frontend/src/components/znakopis/DocumentPanel.tsx` - Add delete and edit UI
|
||||||
|
2. `packages/frontend/src/pages/Znakopis.tsx` - Add handlers for delete and edit
|
||||||
|
3. `packages/frontend/src/components/znakopis/CreateDocumentDialog.tsx` - New file (if using dialog approach)
|
||||||
|
4. `packages/frontend/src/components/ui/dialog.tsx` - May need to create if not exists
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ IMPORTANT CONSIDERATIONS
|
||||||
|
|
||||||
|
1. **Data Loss Prevention:** Always confirm before deleting documents
|
||||||
|
2. **Concurrent Edits:** Consider what happens if document is edited while viewing
|
||||||
|
3. **Permissions:** Verify user owns document before allowing edit/delete
|
||||||
|
4. **Validation:** Ensure title is not empty and within length limits
|
||||||
|
5. **Accessibility:** Ensure all interactive elements are keyboard accessible
|
||||||
|
6. **Mobile:** Test on mobile devices for touch interactions
|
||||||
|
|
||||||
@@ -7,10 +7,12 @@ async function main() {
|
|||||||
console.log('🌱 Starting database seed...');
|
console.log('🌱 Starting database seed...');
|
||||||
|
|
||||||
// Create admin user
|
// Create admin user
|
||||||
const adminPasswordHash = await bcrypt.hash('admin123', 10);
|
const adminPasswordHash = await bcrypt.hash('novinet01', 10);
|
||||||
const admin = await prisma.user.upsert({
|
const admin = await prisma.user.upsert({
|
||||||
where: { email: 'admin@znakovni.hr' },
|
where: { email: 'admin@znakovni.hr' },
|
||||||
update: {},
|
update: {
|
||||||
|
passwordHash: adminPasswordHash, // Update password if user exists
|
||||||
|
},
|
||||||
create: {
|
create: {
|
||||||
email: 'admin@znakovni.hr',
|
email: 'admin@znakovni.hr',
|
||||||
displayName: 'Administrator',
|
displayName: 'Administrator',
|
||||||
@@ -23,7 +25,7 @@ async function main() {
|
|||||||
|
|
||||||
console.log('✅ Created admin user:', admin.email);
|
console.log('✅ Created admin user:', admin.email);
|
||||||
console.log(' Email: admin@znakovni.hr');
|
console.log(' Email: admin@znakovni.hr');
|
||||||
console.log(' Password: admin123');
|
console.log(' Password: novinet01');
|
||||||
|
|
||||||
// Create a demo regular user
|
// Create a demo regular user
|
||||||
const demoPasswordHash = await bcrypt.hash('demo123', 10);
|
const demoPasswordHash = await bcrypt.hash('demo123', 10);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const router = Router();
|
|||||||
* POST /api/documents/:documentId/pages/:pageIndex/sentences
|
* POST /api/documents/:documentId/pages/:pageIndex/sentences
|
||||||
* Create a new sentence on a page
|
* Create a new sentence on a page
|
||||||
*/
|
*/
|
||||||
router.post('/:documentId/pages/:pageIndex/sentences', isAuthenticated, async (req: Request, res: Response) => {
|
router.post('/documents/:documentId/pages/:pageIndex/sentences', isAuthenticated, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { documentId, pageIndex } = req.params;
|
const { documentId, pageIndex } = req.params;
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.id;
|
||||||
@@ -97,7 +97,7 @@ router.post('/:documentId/pages/:pageIndex/sentences', isAuthenticated, async (r
|
|||||||
* PATCH /api/sentences/:sentenceId/tokens
|
* PATCH /api/sentences/:sentenceId/tokens
|
||||||
* Update tokens in a sentence (reorder, add, remove)
|
* Update tokens in a sentence (reorder, add, remove)
|
||||||
*/
|
*/
|
||||||
router.patch('/:sentenceId/tokens', isAuthenticated, async (req: Request, res: Response) => {
|
router.patch('/sentences/:sentenceId/tokens', isAuthenticated, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { sentenceId } = req.params;
|
const { sentenceId } = req.params;
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.id;
|
||||||
@@ -166,7 +166,7 @@ router.patch('/:sentenceId/tokens', isAuthenticated, async (req: Request, res: R
|
|||||||
* DELETE /api/sentences/:sentenceId
|
* DELETE /api/sentences/:sentenceId
|
||||||
* Delete a sentence
|
* Delete a sentence
|
||||||
*/
|
*/
|
||||||
router.delete('/:sentenceId', isAuthenticated, async (req: Request, res: Response) => {
|
router.delete('/sentences/:sentenceId', isAuthenticated, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { sentenceId } = req.params;
|
const { sentenceId } = req.params;
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.id;
|
||||||
@@ -202,7 +202,7 @@ router.delete('/:sentenceId', isAuthenticated, async (req: Request, res: Respons
|
|||||||
* POST /api/documents/:documentId/pages
|
* POST /api/documents/:documentId/pages
|
||||||
* Create a new page in a document
|
* Create a new page in a document
|
||||||
*/
|
*/
|
||||||
router.post('/:documentId/pages', isAuthenticated, async (req: Request, res: Response) => {
|
router.post('/documents/:documentId/pages', isAuthenticated, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { documentId } = req.params;
|
const { documentId } = req.params;
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.id;
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Input } from '../ui/input';
|
||||||
|
|
||||||
|
interface CreateDocumentDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onCreate: (title: string, description: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateDocumentDialog({ open, onOpenChange, onCreate }: CreateDocumentDialogProps) {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
onCreate(title, description);
|
||||||
|
// Reset form
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
// Reset form
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Novi dokument</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Unesite naziv i opis novog dokumenta.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-700 mb-1 block">
|
||||||
|
Naslov <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Novi dokument"
|
||||||
|
maxLength={255}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{title.length}/255 znakova</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-700 mb-1 block">
|
||||||
|
Opis
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Dodajte opis dokumenta..."
|
||||||
|
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
|
Odustani
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreate} disabled={!title.trim()}>
|
||||||
|
Kreiraj
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Document } from '../../lib/documentApi';
|
import { Document, Sentence } from '../../lib/documentApi';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { FileText, ChevronLeft, ChevronRight, Plus, Trash2 } from 'lucide-react';
|
import { FileText, ChevronLeft, ChevronRight, Plus, Trash2, Pencil, Check, X, Upload } from 'lucide-react';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||||
|
import { Input } from '../ui/input';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface DocumentPanelProps {
|
interface DocumentPanelProps {
|
||||||
documents: Document[];
|
documents: Document[];
|
||||||
@@ -11,6 +14,9 @@ interface DocumentPanelProps {
|
|||||||
onNewPage: () => void;
|
onNewPage: () => void;
|
||||||
onPageChange: (pageIndex: number) => void;
|
onPageChange: (pageIndex: number) => void;
|
||||||
onDeleteSentence: (sentenceId: string) => void;
|
onDeleteSentence: (sentenceId: string) => void;
|
||||||
|
onDeleteDocument: (documentId: string) => void;
|
||||||
|
onUpdateDocument: (documentId: string, title: string, description: string) => void;
|
||||||
|
onLoadSentence: (sentence: Sentence) => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,11 +28,55 @@ export function DocumentPanel({
|
|||||||
onNewPage,
|
onNewPage,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onDeleteSentence,
|
onDeleteSentence,
|
||||||
|
onDeleteDocument,
|
||||||
|
onUpdateDocument,
|
||||||
|
onLoadSentence,
|
||||||
loading,
|
loading,
|
||||||
}: DocumentPanelProps) {
|
}: DocumentPanelProps) {
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editTitle, setEditTitle] = useState('');
|
||||||
|
const [editDescription, setEditDescription] = useState('');
|
||||||
const currentPage = selectedDocument?.pages[currentPageIndex];
|
const currentPage = selectedDocument?.pages[currentPageIndex];
|
||||||
const totalPages = selectedDocument?.pages.length || 0;
|
const totalPages = selectedDocument?.pages.length || 0;
|
||||||
|
|
||||||
|
const handleDeleteClick = () => {
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!selectedDocument) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await onDeleteDocument(selectedDocument.id);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditClick = () => {
|
||||||
|
if (!selectedDocument) return;
|
||||||
|
setEditTitle(selectedDocument.title);
|
||||||
|
setEditDescription(selectedDocument.description || '');
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async () => {
|
||||||
|
if (!selectedDocument || !editTitle.trim()) return;
|
||||||
|
|
||||||
|
await onUpdateDocument(selectedDocument.id, editTitle, editDescription);
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditTitle('');
|
||||||
|
setEditDescription('');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow p-6 space-y-6">
|
<div className="bg-white rounded-lg shadow p-6 space-y-6">
|
||||||
{/* Document Selector */}
|
{/* Document Selector */}
|
||||||
@@ -54,15 +104,75 @@ export function DocumentPanel({
|
|||||||
{selectedDocument && (
|
{selectedDocument && (
|
||||||
<>
|
<>
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-700 mb-1 block">Naslov</label>
|
||||||
|
<Input
|
||||||
|
value={editTitle}
|
||||||
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
|
placeholder="Naslov dokumenta"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-700 mb-1 block">Opis</label>
|
||||||
|
<textarea
|
||||||
|
value={editDescription}
|
||||||
|
onChange={(e) => setEditDescription(e.target.value)}
|
||||||
|
placeholder="Dodajte opis dokumenta..."
|
||||||
|
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveEdit}
|
||||||
|
disabled={!editTitle.trim()}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4 mr-1" />
|
||||||
|
Spremi
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
Odustani
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
<FileText className="inline h-5 w-5 mr-2" />
|
<FileText className="inline h-5 w-5 mr-2" />
|
||||||
{selectedDocument.title}
|
{selectedDocument.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleEditClick}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
className="text-red-600 hover:text-red-800 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedDocument.description && (
|
{selectedDocument.description && (
|
||||||
<p className="text-sm text-gray-600 mb-3">{selectedDocument.description}</p>
|
<p className="text-sm text-gray-600 mb-3">{selectedDocument.description}</p>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page Navigation */}
|
{/* Page Navigation */}
|
||||||
@@ -116,6 +226,15 @@ export function DocumentPanel({
|
|||||||
<span className="text-xs font-semibold text-gray-500">
|
<span className="text-xs font-semibold text-gray-500">
|
||||||
Rečenica {index + 1}
|
Rečenica {index + 1}
|
||||||
</span>
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onLoadSentence(sentence)}
|
||||||
|
className="text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
aria-label="Učitaj rečenicu"
|
||||||
|
title="Učitaj rečenicu u gornju traku"
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onDeleteSentence(sentence.id)}
|
onClick={() => onDeleteSentence(sentence.id)}
|
||||||
className="text-red-600 hover:text-red-800 transition-colors"
|
className="text-red-600 hover:text-red-800 transition-colors"
|
||||||
@@ -124,6 +243,7 @@ export function DocumentPanel({
|
|||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{sentence.tokens.map((token) => (
|
{sentence.tokens.map((token) => (
|
||||||
<span
|
<span
|
||||||
@@ -145,6 +265,35 @@ export function DocumentPanel({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Obriši dokument</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Jeste li sigurni da želite obrisati dokument "{selectedDocument?.title}"?
|
||||||
|
Ova radnja se ne može poništiti.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteDialogOpen(false)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
Odustani
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? 'Brisanje...' : 'Obriši'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export const documentApi = {
|
|||||||
|
|
||||||
// Create a new page in a document
|
// Create a new page in a document
|
||||||
async createPage(documentId: string, title?: string): Promise<DocumentPage> {
|
async createPage(documentId: string, title?: string): Promise<DocumentPage> {
|
||||||
const response = await api.post(`/api/${documentId}/pages`, { title });
|
const response = await api.post(`/api/documents/${documentId}/pages`, { title });
|
||||||
return response.data.page;
|
return response.data.page;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ export const documentApi = {
|
|||||||
data: CreateSentenceData
|
data: CreateSentenceData
|
||||||
): Promise<Sentence> {
|
): Promise<Sentence> {
|
||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
`/api/${documentId}/pages/${pageIndex}/sentences`,
|
`/api/documents/${documentId}/pages/${pageIndex}/sentences`,
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
return response.data.sentence;
|
return response.data.sentence;
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function Login() {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
placeholder="admin@znakovni.hr"
|
placeholder=""
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,13 +85,6 @@ export function Login() {
|
|||||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Demo Credentials */}
|
|
||||||
<div className="mt-4 rounded-md bg-indigo-50 p-4 text-sm text-indigo-800">
|
|
||||||
<p className="font-semibold">Demo Credentials:</p>
|
|
||||||
<p className="mt-1">Admin: admin@znakovni.hr / admin123</p>
|
|
||||||
<p>User: demo@znakovni.hr / demo123</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,14 +22,21 @@ function VideoSentence() {
|
|||||||
const nextItem = currentTokenIndex < playlist.length - 1 ? playlist[currentTokenIndex + 1] : null;
|
const nextItem = currentTokenIndex < playlist.length - 1 ? playlist[currentTokenIndex + 1] : null;
|
||||||
const nextVideoUrl = nextItem ? getVideoUrl(nextItem.videoUrl) : null;
|
const nextVideoUrl = nextItem ? getVideoUrl(nextItem.videoUrl) : null;
|
||||||
|
|
||||||
// Reset to first token when tokens change
|
// Reset to first token when tokens change and auto-start playback
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentTokens.length > 0) {
|
if (currentTokens.length > 0) {
|
||||||
setCurrentTokenIndex(0);
|
setCurrentTokenIndex(0);
|
||||||
setIsPlaying(false);
|
setIsPlaying(true); // Auto-start playback
|
||||||
}
|
}
|
||||||
}, [currentTokens]);
|
}, [currentTokens]);
|
||||||
|
|
||||||
|
// Auto-start playback on page load if tokens exist
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentTokens.length > 0 && currentTokenIndex === 0) {
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
}, []); // Run only once on mount
|
||||||
|
|
||||||
// Auto-advance to next video when no video is available
|
// Auto-advance to next video when no video is available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentItem && !currentItem.videoUrl && currentTokens.length > 0) {
|
if (currentItem && !currentItem.videoUrl && currentTokens.length > 0) {
|
||||||
|
|||||||
@@ -2,19 +2,21 @@ import { useState, useEffect } from 'react';
|
|||||||
import { Layout } from '../components/layout/Layout';
|
import { Layout } from '../components/layout/Layout';
|
||||||
import { TokenTray } from '../components/znakopis/TokenTray';
|
import { TokenTray } from '../components/znakopis/TokenTray';
|
||||||
import { DocumentPanel } from '../components/znakopis/DocumentPanel';
|
import { DocumentPanel } from '../components/znakopis/DocumentPanel';
|
||||||
import { useSentenceStore } from '../stores/sentenceStore';
|
import { CreateDocumentDialog } from '../components/znakopis/CreateDocumentDialog';
|
||||||
import { documentApi, Document } from '../lib/documentApi';
|
import { useSentenceStore, SentenceToken } from '../stores/sentenceStore';
|
||||||
|
import { documentApi, Document, Sentence } from '../lib/documentApi';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '../components/ui/button';
|
import { Button } from '../components/ui/button';
|
||||||
import { Save, FileText } from 'lucide-react';
|
import { Save, FileText } from 'lucide-react';
|
||||||
|
|
||||||
function Znakopis() {
|
function Znakopis() {
|
||||||
const { currentTokens, clearTokens } = useSentenceStore();
|
const { currentTokens, clearTokens, setTokens } = useSentenceStore();
|
||||||
const [documents, setDocuments] = useState<Document[]>([]);
|
const [documents, setDocuments] = useState<Document[]>([]);
|
||||||
const [selectedDocument, setSelectedDocument] = useState<Document | null>(null);
|
const [selectedDocument, setSelectedDocument] = useState<Document | null>(null);
|
||||||
const [currentPageIndex, setCurrentPageIndex] = useState(0);
|
const [currentPageIndex, setCurrentPageIndex] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDocuments();
|
loadDocuments();
|
||||||
@@ -98,10 +100,29 @@ function Znakopis() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleNewDocument = () => {
|
const handleNewDocument = () => {
|
||||||
setSelectedDocument(null);
|
setCreateDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateDocument = async (title: string, description: string) => {
|
||||||
|
try {
|
||||||
|
const document = await documentApi.createDocument({
|
||||||
|
title,
|
||||||
|
description: description || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedDocument(document);
|
||||||
setCurrentPageIndex(0);
|
setCurrentPageIndex(0);
|
||||||
clearTokens();
|
clearTokens();
|
||||||
toast.info('Novi dokument');
|
|
||||||
|
// Reload documents list
|
||||||
|
await loadDocuments();
|
||||||
|
|
||||||
|
setCreateDialogOpen(false);
|
||||||
|
toast.success(`Dokument "${title}" kreiran`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create document:', error);
|
||||||
|
toast.error('Greška pri kreiranju dokumenta');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNewPage = async () => {
|
const handleNewPage = async () => {
|
||||||
@@ -143,6 +164,67 @@ function Znakopis() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLoadSentence = (sentence: Sentence) => {
|
||||||
|
try {
|
||||||
|
// Convert DocumentTokens to SentenceTokens
|
||||||
|
// Filter out tokens without termId or term (punctuation-only tokens)
|
||||||
|
const sentenceTokens: SentenceToken[] = sentence.tokens
|
||||||
|
.filter((token) => token.termId && token.term)
|
||||||
|
.map((token) => ({
|
||||||
|
id: `token-${token.id}`,
|
||||||
|
termId: token.termId!,
|
||||||
|
term: token.term,
|
||||||
|
displayText: token.displayText,
|
||||||
|
isPunctuation: token.isPunctuation,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Load tokens into the store
|
||||||
|
setTokens(sentenceTokens);
|
||||||
|
|
||||||
|
toast.success('Rečenica učitana u gornju traku');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load sentence:', error);
|
||||||
|
toast.error('Greška pri učitavanju rečenice');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteDocument = async (documentId: string) => {
|
||||||
|
try {
|
||||||
|
await documentApi.deleteDocument(documentId);
|
||||||
|
|
||||||
|
// Clear selected document
|
||||||
|
setSelectedDocument(null);
|
||||||
|
setCurrentPageIndex(0);
|
||||||
|
clearTokens();
|
||||||
|
|
||||||
|
// Reload documents list
|
||||||
|
await loadDocuments();
|
||||||
|
|
||||||
|
toast.success('Dokument obrisan');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete document:', error);
|
||||||
|
toast.error('Greška pri brisanju dokumenta');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateDocument = async (documentId: string, title: string, description: string) => {
|
||||||
|
try {
|
||||||
|
await documentApi.updateDocument(documentId, { title, description });
|
||||||
|
|
||||||
|
// Reload document
|
||||||
|
const updatedDoc = await documentApi.getDocument(documentId);
|
||||||
|
setSelectedDocument(updatedDoc);
|
||||||
|
|
||||||
|
// Reload documents list
|
||||||
|
await loadDocuments();
|
||||||
|
|
||||||
|
toast.success('Dokument ažuriran');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update document:', error);
|
||||||
|
toast.error('Greška pri ažuriranju dokumenta');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -181,10 +263,20 @@ function Znakopis() {
|
|||||||
onNewPage={handleNewPage}
|
onNewPage={handleNewPage}
|
||||||
onPageChange={setCurrentPageIndex}
|
onPageChange={setCurrentPageIndex}
|
||||||
onDeleteSentence={handleDeleteSentence}
|
onDeleteSentence={handleDeleteSentence}
|
||||||
|
onDeleteDocument={handleDeleteDocument}
|
||||||
|
onUpdateDocument={handleUpdateDocument}
|
||||||
|
onLoadSentence={handleLoadSentence}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Create Document Dialog */}
|
||||||
|
<CreateDocumentDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onOpenChange={setCreateDialogOpen}
|
||||||
|
onCreate={handleCreateDocument}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
216
videos.md
216
videos.md
@@ -1,216 +0,0 @@
|
|||||||
# Video Upload Feature for Admin Panel
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
This document provides AI instructions for implementing video upload functionality for Croatian Sign Language words in the admin panel. Admins should be able to upload videos for existing words and create new words with videos.
|
|
||||||
|
|
||||||
## Context from main-plan.md
|
|
||||||
According to the main plan (lines 60-70, 366-382):
|
|
||||||
- Each Term has associated TermMedia records
|
|
||||||
- TermMedia stores video URLs, duration, dimensions, and checksums
|
|
||||||
- Videos are stored in `/uploads/videos/` directory
|
|
||||||
- Video format: MP4 (H.264) for maximum compatibility
|
|
||||||
- File uploads use Multer middleware (already in dependencies)
|
|
||||||
- Static file serving is already configured at `/uploads`
|
|
||||||
|
|
||||||
## Current State
|
|
||||||
### Backend
|
|
||||||
- ✅ Prisma schema has `Term` and `TermMedia` models
|
|
||||||
- ✅ Static file serving configured for `/uploads` directory
|
|
||||||
- ✅ Multer is in package.json dependencies
|
|
||||||
- ✅ Terms routes exist at `packages/backend/src/routes/terms.ts` (currently read-only)
|
|
||||||
- ✅ Admin authentication middleware exists (`isAuthenticated`, `isAdmin`)
|
|
||||||
- ❌ No upload routes or middleware configured yet
|
|
||||||
- ❌ No admin endpoints for creating/editing terms with media
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- ✅ Admin panel exists at `packages/frontend/src/pages/Admin.tsx` (user management)
|
|
||||||
- ✅ Dictionary page displays terms with videos
|
|
||||||
- ✅ WordDetailModal shows videos with autoplay
|
|
||||||
- ❌ No admin UI for managing terms/videos
|
|
||||||
|
|
||||||
## Implementation Tasks
|
|
||||||
|
|
||||||
### Backend Tasks
|
|
||||||
|
|
||||||
#### 1. Create Upload Middleware
|
|
||||||
**File:** `packages/backend/src/middleware/upload.ts`
|
|
||||||
|
|
||||||
Create Multer configuration for video uploads:
|
|
||||||
- Accept video files (mp4, webm, mov)
|
|
||||||
- Store in `uploads/videos/` directory
|
|
||||||
- Generate unique filenames with timestamp
|
|
||||||
- Validate file size (max 100MB from env)
|
|
||||||
- Validate MIME types
|
|
||||||
- Handle errors gracefully
|
|
||||||
|
|
||||||
#### 2. Add Admin Term Routes
|
|
||||||
**File:** `packages/backend/src/routes/terms.ts`
|
|
||||||
|
|
||||||
Add the following admin-only endpoints:
|
|
||||||
|
|
||||||
**POST /api/terms** (Admin only)
|
|
||||||
- Create new term with metadata
|
|
||||||
- Fields: wordText, wordType, cefrLevel, shortDescription, tags, iconAssetId
|
|
||||||
- Return created term with ID
|
|
||||||
|
|
||||||
**PUT /api/terms/:id** (Admin only)
|
|
||||||
- Update existing term metadata
|
|
||||||
- Validate term exists
|
|
||||||
- Return updated term
|
|
||||||
|
|
||||||
**DELETE /api/terms/:id** (Admin only)
|
|
||||||
- Delete term and cascade to TermMedia
|
|
||||||
- Optionally delete associated video files from disk
|
|
||||||
- Return success message
|
|
||||||
|
|
||||||
**POST /api/terms/:id/media** (Admin only)
|
|
||||||
- Upload video file using Multer middleware
|
|
||||||
- Create TermMedia record linked to term
|
|
||||||
- Extract video metadata (duration, dimensions) if possible
|
|
||||||
- Store relative URL path (e.g., `/uploads/videos/filename.mp4`)
|
|
||||||
- Return created TermMedia record
|
|
||||||
|
|
||||||
**DELETE /api/terms/:id/media/:mediaId** (Admin only)
|
|
||||||
- Delete TermMedia record
|
|
||||||
- Optionally delete video file from disk
|
|
||||||
- Return success message
|
|
||||||
|
|
||||||
#### 3. Update Server Routes
|
|
||||||
**File:** `packages/backend/src/server.ts`
|
|
||||||
|
|
||||||
Ensure upload routes are registered (if creating separate upload routes file).
|
|
||||||
|
|
||||||
### Frontend Tasks
|
|
||||||
|
|
||||||
#### 1. Create Admin Terms Management Page
|
|
||||||
**File:** `packages/frontend/src/pages/AdminTerms.tsx`
|
|
||||||
|
|
||||||
Create a new admin page for managing terms:
|
|
||||||
- List all terms in a table with search/filter
|
|
||||||
- Show term details: word, type, CEFR level, video status
|
|
||||||
- Actions: Edit, Delete, Upload Video
|
|
||||||
- "Create New Term" button
|
|
||||||
- Pagination for large datasets
|
|
||||||
|
|
||||||
#### 2. Create Term Form Component
|
|
||||||
**File:** `packages/frontend/src/components/admin/TermForm.tsx`
|
|
||||||
|
|
||||||
Form for creating/editing terms:
|
|
||||||
- Input: Word Text (required)
|
|
||||||
- Select: Word Type (NOUN, VERB, ADJECTIVE, etc.)
|
|
||||||
- Select: CEFR Level (A1, A2, B1, B2, C1, C2)
|
|
||||||
- Textarea: Short Description
|
|
||||||
- Input: Tags (comma-separated or tag input)
|
|
||||||
- Input: Icon Asset ID (optional)
|
|
||||||
- Submit button
|
|
||||||
- Cancel button
|
|
||||||
- Validation with error messages
|
|
||||||
|
|
||||||
#### 3. Create Video Upload Component
|
|
||||||
**File:** `packages/frontend/src/components/admin/VideoUpload.tsx`
|
|
||||||
|
|
||||||
Video upload interface:
|
|
||||||
- File input accepting video files (.mp4, .webm, .mov)
|
|
||||||
- Drag-and-drop zone
|
|
||||||
- Upload progress indicator
|
|
||||||
- Preview uploaded video
|
|
||||||
- Display existing video if present
|
|
||||||
- Delete video button
|
|
||||||
- File size validation (client-side)
|
|
||||||
- Success/error notifications
|
|
||||||
|
|
||||||
#### 4. Update Admin Navigation
|
|
||||||
**File:** `packages/frontend/src/components/layout/Sidebar.tsx`
|
|
||||||
|
|
||||||
Add navigation link to Admin Terms page:
|
|
||||||
- Show only for admin users
|
|
||||||
- Label: "Upravljanje riječima" (Word Management)
|
|
||||||
- Icon: Book or Video icon
|
|
||||||
- Place near existing "User Management" link
|
|
||||||
|
|
||||||
#### 5. Update Routing
|
|
||||||
**File:** `packages/frontend/src/App.tsx`
|
|
||||||
|
|
||||||
Add route for admin terms page:
|
|
||||||
- Path: `/admin/terms`
|
|
||||||
- Protected route (admin only)
|
|
||||||
- Component: AdminTerms
|
|
||||||
|
|
||||||
## API Endpoints Summary
|
|
||||||
|
|
||||||
### New Backend Endpoints
|
|
||||||
```
|
|
||||||
POST /api/terms - Create term (admin)
|
|
||||||
PUT /api/terms/:id - Update term (admin)
|
|
||||||
DELETE /api/terms/:id - Delete term (admin)
|
|
||||||
POST /api/terms/:id/media - Upload video (admin)
|
|
||||||
DELETE /api/terms/:id/media/:mediaId - Delete video (admin)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Existing Endpoints (Read-only)
|
|
||||||
```
|
|
||||||
GET /api/terms - List terms (public)
|
|
||||||
GET /api/terms/:id - Get term details (public)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Database Schema Reference
|
|
||||||
|
|
||||||
### Term Model
|
|
||||||
```prisma
|
|
||||||
model Term {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
wordText String @map("word_text")
|
|
||||||
normalizedText String @map("normalized_text")
|
|
||||||
language String @default("hr")
|
|
||||||
wordType WordType @map("word_type")
|
|
||||||
cefrLevel CefrLevel @map("cefr_level")
|
|
||||||
shortDescription String? @map("short_description")
|
|
||||||
tags String? // JSON array
|
|
||||||
iconAssetId String? @map("icon_asset_id")
|
|
||||||
media TermMedia[]
|
|
||||||
// ... relations
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### TermMedia Model
|
|
||||||
```prisma
|
|
||||||
model TermMedia {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
termId String @map("term_id")
|
|
||||||
kind MediaKind // VIDEO, IMAGE, ILLUSTRATION
|
|
||||||
url String
|
|
||||||
durationMs Int? @map("duration_ms")
|
|
||||||
width Int?
|
|
||||||
height Int?
|
|
||||||
checksum String?
|
|
||||||
term Term @relation(...)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
1. **File Storage**: Videos stored in `packages/backend/uploads/videos/`
|
|
||||||
2. **URL Format**: Store relative URLs like `/uploads/videos/dobar-dan-1234567890.mp4`
|
|
||||||
3. **Normalized Text**: Auto-generate from wordText (lowercase, remove diacritics)
|
|
||||||
4. **Video Metadata**: Extract duration/dimensions server-side if possible (use ffprobe or similar)
|
|
||||||
5. **Error Handling**: Validate file types, sizes, and handle upload failures gracefully
|
|
||||||
6. **Security**: Ensure only admins can upload/delete, validate file types to prevent malicious uploads
|
|
||||||
7. **Croatian Labels**: Use Croatian text in UI (Riječ, Dodaj video, Obriši, etc.)
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Admin can create new term without video
|
|
||||||
- [ ] Admin can create new term with video upload
|
|
||||||
- [ ] Admin can edit existing term metadata
|
|
||||||
- [ ] Admin can upload video to existing term
|
|
||||||
- [ ] Admin can replace existing video
|
|
||||||
- [ ] Admin can delete video from term
|
|
||||||
- [ ] Admin can delete entire term
|
|
||||||
- [ ] Video appears in Dictionary (Riječi) page
|
|
||||||
- [ ] Video plays in WordDetailModal
|
|
||||||
- [ ] Non-admin users cannot access admin endpoints
|
|
||||||
- [ ] File size limits are enforced
|
|
||||||
- [ ] Invalid file types are rejected
|
|
||||||
- [ ] Upload progress is shown
|
|
||||||
- [ ] Success/error messages display correctly
|
|
||||||
|
|
||||||
608
videosentence.md
608
videosentence.md
@@ -1,608 +0,0 @@
|
|||||||
# Video Rečenica (Video Sentence Player) - Implementation Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
The Video Rečenica feature allows users to watch sequential playback of sign language videos for complete sentences. Each word's video plays in order with synchronized highlighting of the current token.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Data Flow
|
|
||||||
```
|
|
||||||
User navigates to Video Rečenica
|
|
||||||
↓
|
|
||||||
Loads sentence from:
|
|
||||||
- sentenceStore (current working sentence from Znakopis)
|
|
||||||
- OR selected document/page from Oblak
|
|
||||||
↓
|
|
||||||
For each token in sentence:
|
|
||||||
- Get term data (already includes media from database)
|
|
||||||
- Extract video URL from term.media array
|
|
||||||
- Build playlist of videos
|
|
||||||
↓
|
|
||||||
Sequential video playback:
|
|
||||||
- Play video for token[0]
|
|
||||||
- Highlight token[0] in sentence list
|
|
||||||
- On video end → advance to token[1]
|
|
||||||
- Repeat until all tokens played
|
|
||||||
↓
|
|
||||||
User controls: play/pause, next/prev, speed, loop, autoplay
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation Tasks
|
|
||||||
|
|
||||||
### 1. Backend API (Optional - Can use existing data)
|
|
||||||
**Note:** The existing document API already returns all necessary data (tokens with terms and media). No new backend endpoint is strictly required, but you may optionally create a playlist helper endpoint.
|
|
||||||
|
|
||||||
**Optional Endpoint:** `GET /api/playlists/generate`
|
|
||||||
- Query params: `sentenceId` or `documentId` + `pageIndex` + `sentenceIndex`
|
|
||||||
- Returns: Array of playlist items
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
playlist: [
|
|
||||||
{
|
|
||||||
tokenId: string,
|
|
||||||
termId: string,
|
|
||||||
displayText: string,
|
|
||||||
videoUrl: string,
|
|
||||||
durationMs: number
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Alternative:** Use existing document API and transform data on frontend.
|
|
||||||
|
|
||||||
### 2. Frontend Components
|
|
||||||
|
|
||||||
#### A. Create `VideoSentence.tsx` Page
|
|
||||||
**Location:** `packages/frontend/src/pages/VideoSentence.tsx`
|
|
||||||
|
|
||||||
**Layout:**
|
|
||||||
- Two-column grid: 60% video player (left) + 40% sentence panel (right)
|
|
||||||
- Responsive: stack vertically on mobile
|
|
||||||
|
|
||||||
**State Management:**
|
|
||||||
```typescript
|
|
||||||
- currentTokens: SentenceToken[] (from sentenceStore or loaded document)
|
|
||||||
- currentTokenIndex: number (which token is playing)
|
|
||||||
- isPlaying: boolean
|
|
||||||
- playbackSpeed: number (0.5, 0.75, 1.0, 1.25, 1.5, 2.0)
|
|
||||||
- loopMode: 'none' | 'sentence' | 'word'
|
|
||||||
- autoplay: boolean
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Features:**
|
|
||||||
1. Load sentence from sentenceStore on mount
|
|
||||||
2. Option to load from saved documents (document selector)
|
|
||||||
3. Build video playlist from tokens
|
|
||||||
4. Handle video playback state
|
|
||||||
5. Sync highlighting with current video
|
|
||||||
|
|
||||||
#### B. Create `VideoPlayer.tsx` Component
|
|
||||||
**Location:** `packages/frontend/src/components/video-sentence/VideoPlayer.tsx`
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
```typescript
|
|
||||||
interface VideoPlayerProps {
|
|
||||||
videoUrl: string | null;
|
|
||||||
isPlaying: boolean;
|
|
||||||
playbackSpeed: number;
|
|
||||||
onVideoEnd: () => void;
|
|
||||||
onPlayPause: () => void;
|
|
||||||
onNext: () => void;
|
|
||||||
onPrevious: () => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Large video element with controls
|
|
||||||
- Custom control bar (play/pause, next/prev, speed selector)
|
|
||||||
- Preload next video for smooth transitions
|
|
||||||
- Handle video errors gracefully (show placeholder if no video)
|
|
||||||
- Display current word being signed
|
|
||||||
- Fullscreen support (optional)
|
|
||||||
|
|
||||||
**Implementation Notes:**
|
|
||||||
- Use HTML5 `<video>` element
|
|
||||||
- Set `playbackRate` property for speed control
|
|
||||||
- Listen to `ended` event to advance to next token
|
|
||||||
- Use `ref` to control video programmatically
|
|
||||||
- Preload strategy: Create hidden video element for next video
|
|
||||||
|
|
||||||
#### C. Create `SentencePanel.tsx` Component
|
|
||||||
**Location:** `packages/frontend/src/components/video-sentence/SentencePanel.tsx`
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
```typescript
|
|
||||||
interface SentencePanelProps {
|
|
||||||
tokens: SentenceToken[];
|
|
||||||
currentTokenIndex: number;
|
|
||||||
onTokenClick: (index: number) => void;
|
|
||||||
selectedDocument: Document | null;
|
|
||||||
currentPageIndex: number;
|
|
||||||
onLoadDocument: (documentId: string) => void;
|
|
||||||
onPageChange: (pageIndex: number) => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Display all tokens in sentence
|
|
||||||
- Highlight current token (e.g., bold, colored background, border)
|
|
||||||
- Allow clicking tokens to jump to that position
|
|
||||||
- Show document selector (reuse from Znakopis)
|
|
||||||
- Show page navigation if multi-page document
|
|
||||||
- Display sentence list if multiple sentences on page
|
|
||||||
|
|
||||||
**Visual Design:**
|
|
||||||
- Token list: horizontal wrap or vertical list
|
|
||||||
- Current token: distinct visual treatment (e.g., orange background, bold)
|
|
||||||
- Clickable tokens: hover effect
|
|
||||||
- Empty state: "Nema učitane rečenice" with instructions
|
|
||||||
|
|
||||||
#### D. Create `PlaybackControls.tsx` Component
|
|
||||||
**Location:** `packages/frontend/src/components/video-sentence/PlaybackControls.tsx`
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
```typescript
|
|
||||||
interface PlaybackControlsProps {
|
|
||||||
isPlaying: boolean;
|
|
||||||
playbackSpeed: number;
|
|
||||||
loopMode: 'none' | 'sentence' | 'word';
|
|
||||||
autoplay: boolean;
|
|
||||||
onPlayPause: () => void;
|
|
||||||
onNext: () => void;
|
|
||||||
onPrevious: () => void;
|
|
||||||
onSpeedChange: (speed: number) => void;
|
|
||||||
onLoopModeChange: (mode: 'none' | 'sentence' | 'word') => void;
|
|
||||||
onAutoplayToggle: () => void;
|
|
||||||
canGoNext: boolean;
|
|
||||||
canGoPrevious: boolean;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Play/Pause button (large, prominent)
|
|
||||||
- Previous/Next buttons
|
|
||||||
- Speed selector dropdown (0.5x, 0.75x, 1x, 1.25x, 1.5x, 2x)
|
|
||||||
- Loop mode toggle (none, loop sentence, loop word)
|
|
||||||
- Autoplay toggle
|
|
||||||
- Disable next/previous when at boundaries
|
|
||||||
|
|
||||||
**Icons:** Use lucide-react icons (Play, Pause, SkipForward, SkipBack, Repeat, Gauge)
|
|
||||||
|
|
||||||
### 3. Routing
|
|
||||||
|
|
||||||
**Update:** `packages/frontend/src/App.tsx`
|
|
||||||
|
|
||||||
Replace placeholder route:
|
|
||||||
```typescript
|
|
||||||
<Route path="/video-sentence" element={<ProtectedRoute><VideoSentence /></ProtectedRoute>} />
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Video Playlist Logic
|
|
||||||
|
|
||||||
**Helper Function:** Create utility to build playlist from tokens
|
|
||||||
|
|
||||||
**Location:** `packages/frontend/src/lib/videoPlaylist.ts`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { SentenceToken } from '../stores/sentenceStore';
|
|
||||||
|
|
||||||
export interface PlaylistItem {
|
|
||||||
tokenId: string;
|
|
||||||
termId: string;
|
|
||||||
displayText: string;
|
|
||||||
videoUrl: string | null;
|
|
||||||
durationMs: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildPlaylist(tokens: SentenceToken[]): PlaylistItem[] {
|
|
||||||
return tokens.map(token => {
|
|
||||||
// Get first video media for the term
|
|
||||||
const videoMedia = token.term.media?.find(m => m.mediaType === 'VIDEO');
|
|
||||||
|
|
||||||
return {
|
|
||||||
tokenId: token.id,
|
|
||||||
termId: token.termId,
|
|
||||||
displayText: token.displayText,
|
|
||||||
videoUrl: videoMedia?.url || null,
|
|
||||||
durationMs: videoMedia?.durationMs || null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getVideoUrl(relativeUrl: string | null): string | null {
|
|
||||||
if (!relativeUrl) return null;
|
|
||||||
|
|
||||||
// If already absolute URL, return as-is
|
|
||||||
if (relativeUrl.startsWith('http')) return relativeUrl;
|
|
||||||
|
|
||||||
// Construct full URL using backend base URL
|
|
||||||
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
|
||||||
return `${baseUrl}${relativeUrl}`;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. State Management
|
|
||||||
|
|
||||||
**Option A:** Use local component state (recommended for simplicity)
|
|
||||||
- Keep playback state in VideoSentence component
|
|
||||||
- Pass down via props
|
|
||||||
|
|
||||||
**Option B:** Create videoPlayerStore (if needed for cross-component state)
|
|
||||||
```typescript
|
|
||||||
// packages/frontend/src/stores/videoPlayerStore.ts
|
|
||||||
interface VideoPlayerState {
|
|
||||||
currentTokenIndex: number;
|
|
||||||
isPlaying: boolean;
|
|
||||||
playbackSpeed: number;
|
|
||||||
loopMode: 'none' | 'sentence' | 'word';
|
|
||||||
autoplay: boolean;
|
|
||||||
// actions...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Key Implementation Details
|
|
||||||
|
|
||||||
#### Video Playback Flow
|
|
||||||
```typescript
|
|
||||||
// In VideoSentence.tsx
|
|
||||||
const handleVideoEnd = () => {
|
|
||||||
if (loopMode === 'word') {
|
|
||||||
// Replay current video
|
|
||||||
videoRef.current?.play();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentTokenIndex < tokens.length - 1) {
|
|
||||||
// Advance to next token
|
|
||||||
setCurrentTokenIndex(prev => prev + 1);
|
|
||||||
} else {
|
|
||||||
// End of sentence
|
|
||||||
if (loopMode === 'sentence') {
|
|
||||||
// Restart from beginning
|
|
||||||
setCurrentTokenIndex(0);
|
|
||||||
} else {
|
|
||||||
// Stop playback
|
|
||||||
setIsPlaying(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auto-play next video when index changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (isPlaying && videoRef.current) {
|
|
||||||
videoRef.current.play();
|
|
||||||
}
|
|
||||||
}, [currentTokenIndex, isPlaying]);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Preloading Next Video
|
|
||||||
```typescript
|
|
||||||
// Create hidden video element for next video
|
|
||||||
const [nextVideoUrl, setNextVideoUrl] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentTokenIndex < playlist.length - 1) {
|
|
||||||
const nextItem = playlist[currentTokenIndex + 1];
|
|
||||||
setNextVideoUrl(getVideoUrl(nextItem.videoUrl));
|
|
||||||
}
|
|
||||||
}, [currentTokenIndex, playlist]);
|
|
||||||
|
|
||||||
// In render:
|
|
||||||
{nextVideoUrl && (
|
|
||||||
<video
|
|
||||||
src={nextVideoUrl}
|
|
||||||
preload="auto"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#### Error Handling
|
|
||||||
```typescript
|
|
||||||
// Handle missing videos gracefully
|
|
||||||
const currentVideo = playlist[currentTokenIndex];
|
|
||||||
|
|
||||||
if (!currentVideo.videoUrl) {
|
|
||||||
// Show placeholder: "Video nije dostupan za ovu riječ"
|
|
||||||
// Display word text prominently
|
|
||||||
// Auto-advance after 2 seconds
|
|
||||||
setTimeout(() => handleVideoEnd(), 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle video load errors
|
|
||||||
<video
|
|
||||||
onError={(e) => {
|
|
||||||
console.error('Video load error:', e);
|
|
||||||
toast.error(`Greška pri učitavanju videa za "${currentVideo.displayText}"`);
|
|
||||||
// Auto-advance to next video
|
|
||||||
handleVideoEnd();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. UI/UX Considerations
|
|
||||||
|
|
||||||
#### Empty State
|
|
||||||
When no sentence is loaded:
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ │
|
|
||||||
│ 📹 Video Rečenica │
|
|
||||||
│ │
|
|
||||||
│ Nema učitane rečenice │
|
|
||||||
│ │
|
|
||||||
│ Kako započeti: │
|
|
||||||
│ 1. Dodajte riječi u Rječniku │
|
|
||||||
│ 2. Uredite rečenicu u Znakopisu │
|
|
||||||
│ 3. Vratite se ovdje za reprodukciju│
|
|
||||||
│ │
|
|
||||||
│ ILI │
|
|
||||||
│ │
|
|
||||||
│ [Učitaj spremljeni dokument] │
|
|
||||||
│ │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Active Playback Layout
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────────────┐
|
|
||||||
│ Video Rečenica │
|
|
||||||
├────────────────────────────┬─────────────────────────────────┤
|
|
||||||
│ │ Dokument: Moja rečenica │
|
|
||||||
│ │ Stranica: 1 / 2 │
|
|
||||||
│ │ │
|
|
||||||
│ ┌──────────────────────┐ │ Rečenica 1: │
|
|
||||||
│ │ │ │ ┌───────────────────────────┐ │
|
|
||||||
│ │ │ │ │ Ja volim školu │ │
|
|
||||||
│ │ [VIDEO PLAYER] │ │ │ [ACTIVE] [normal] [normal]│ │
|
|
||||||
│ │ │ │ └───────────────────────────┘ │
|
|
||||||
│ │ Currently: "Ja" │ │ │
|
|
||||||
│ │ │ │ Rečenica 2: │
|
|
||||||
│ └──────────────────────┘ │ ┌───────────────────────────┐ │
|
|
||||||
│ │ │ Učitelj uči učenike │ │
|
|
||||||
│ ┌──────────────────────┐ │ │ [normal] [normal] [normal]│ │
|
|
||||||
│ │ [◀] [▶] [⏸] [🔁] [⚙]│ │ └───────────────────────────┘ │
|
|
||||||
│ │ Speed: 1.0x │ │ │
|
|
||||||
│ └──────────────────────┘ │ [Učitaj drugi dokument ▼] │
|
|
||||||
│ │ [◀ Stranica] [Stranica ▶] │
|
|
||||||
└────────────────────────────┴─────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Token Highlighting Styles
|
|
||||||
```css
|
|
||||||
/* Normal token */
|
|
||||||
.token {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #f3f4f6;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Active/playing token */
|
|
||||||
.token-active {
|
|
||||||
background: #fb923c; /* orange-400 */
|
|
||||||
color: white;
|
|
||||||
font-weight: 600;
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 4px 6px rgba(251, 146, 60, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover state */
|
|
||||||
.token:hover {
|
|
||||||
background: #e5e7eb;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. Testing Checklist
|
|
||||||
|
|
||||||
**Basic Functionality:**
|
|
||||||
- [ ] Load sentence from sentenceStore
|
|
||||||
- [ ] Load sentence from saved document
|
|
||||||
- [ ] Play button starts video playback
|
|
||||||
- [ ] Pause button pauses video
|
|
||||||
- [ ] Videos play sequentially in correct order
|
|
||||||
- [ ] Current token is highlighted during playback
|
|
||||||
- [ ] Highlighting updates when video changes
|
|
||||||
|
|
||||||
**Navigation:**
|
|
||||||
- [ ] Next button skips to next video
|
|
||||||
- [ ] Previous button goes to previous video
|
|
||||||
- [ ] Clicking a token jumps to that video
|
|
||||||
- [ ] Next/Previous disabled at boundaries
|
|
||||||
|
|
||||||
**Playback Controls:**
|
|
||||||
- [ ] Speed control changes playback rate (0.5x - 2x)
|
|
||||||
- [ ] Loop sentence restarts from beginning after last video
|
|
||||||
- [ ] Loop word replays current video continuously
|
|
||||||
- [ ] Autoplay automatically starts playback when sentence loads
|
|
||||||
|
|
||||||
**Multi-sentence/Multi-page:**
|
|
||||||
- [ ] Can switch between sentences on same page
|
|
||||||
- [ ] Can navigate between pages
|
|
||||||
- [ ] Playback state resets when changing sentence/page
|
|
||||||
- [ ] Document selector loads different documents
|
|
||||||
|
|
||||||
**Error Handling:**
|
|
||||||
- [ ] Missing video shows placeholder
|
|
||||||
- [ ] Video load error shows toast and auto-advances
|
|
||||||
- [ ] Empty sentence shows helpful message
|
|
||||||
- [ ] No crash when term has no media
|
|
||||||
|
|
||||||
**Performance:**
|
|
||||||
- [ ] Next video preloads for smooth transition
|
|
||||||
- [ ] No lag when switching videos
|
|
||||||
- [ ] Responsive on mobile devices
|
|
||||||
|
|
||||||
### 9. File Structure Summary
|
|
||||||
|
|
||||||
```
|
|
||||||
packages/frontend/src/
|
|
||||||
├── pages/
|
|
||||||
│ └── VideoSentence.tsx # Main page component
|
|
||||||
├── components/
|
|
||||||
│ └── video-sentence/
|
|
||||||
│ ├── VideoPlayer.tsx # Video player with controls
|
|
||||||
│ ├── SentencePanel.tsx # Sentence list with highlighting
|
|
||||||
│ └── PlaybackControls.tsx # Playback control buttons
|
|
||||||
├── lib/
|
|
||||||
│ └── videoPlaylist.ts # Playlist utilities
|
|
||||||
└── stores/
|
|
||||||
└── videoPlayerStore.ts # (Optional) Playback state store
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10. Implementation Order
|
|
||||||
|
|
||||||
1. **Phase 1: Basic Structure**
|
|
||||||
- Create VideoSentence.tsx page with layout
|
|
||||||
- Add route in App.tsx
|
|
||||||
- Create basic VideoPlayer component
|
|
||||||
- Create basic SentencePanel component
|
|
||||||
- Test with sentenceStore data
|
|
||||||
|
|
||||||
2. **Phase 2: Playback Logic**
|
|
||||||
- Implement video playback state management
|
|
||||||
- Add sequential playback (auto-advance on video end)
|
|
||||||
- Add token highlighting sync
|
|
||||||
- Create videoPlaylist.ts utilities
|
|
||||||
|
|
||||||
3. **Phase 3: Controls**
|
|
||||||
- Create PlaybackControls component
|
|
||||||
- Implement play/pause, next/previous
|
|
||||||
- Add speed control
|
|
||||||
- Add loop modes
|
|
||||||
|
|
||||||
4. **Phase 4: Document Integration**
|
|
||||||
- Add document selector to SentencePanel
|
|
||||||
- Implement loading from saved documents
|
|
||||||
- Add page navigation
|
|
||||||
- Add sentence selection (if multiple sentences)
|
|
||||||
|
|
||||||
5. **Phase 5: Polish**
|
|
||||||
- Add video preloading
|
|
||||||
- Improve error handling
|
|
||||||
- Add empty states
|
|
||||||
- Responsive design
|
|
||||||
- Performance optimization
|
|
||||||
|
|
||||||
### 11. Code Examples
|
|
||||||
|
|
||||||
#### VideoSentence.tsx (Skeleton)
|
|
||||||
```typescript
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Layout } from '../components/layout/Layout';
|
|
||||||
import { VideoPlayer } from '../components/video-sentence/VideoPlayer';
|
|
||||||
import { SentencePanel } from '../components/video-sentence/SentencePanel';
|
|
||||||
import { PlaybackControls } from '../components/video-sentence/PlaybackControls';
|
|
||||||
import { useSentenceStore } from '../stores/sentenceStore';
|
|
||||||
import { buildPlaylist, getVideoUrl } from '../lib/videoPlaylist';
|
|
||||||
|
|
||||||
function VideoSentence() {
|
|
||||||
const { currentTokens } = useSentenceStore();
|
|
||||||
const [currentTokenIndex, setCurrentTokenIndex] = useState(0);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [playbackSpeed, setPlaybackSpeed] = useState(1.0);
|
|
||||||
const [loopMode, setLoopMode] = useState<'none' | 'sentence' | 'word'>('none');
|
|
||||||
|
|
||||||
const playlist = buildPlaylist(currentTokens);
|
|
||||||
const currentItem = playlist[currentTokenIndex];
|
|
||||||
const videoUrl = currentItem ? getVideoUrl(currentItem.videoUrl) : null;
|
|
||||||
|
|
||||||
const handleVideoEnd = () => {
|
|
||||||
// Implementation from section 6
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNext = () => {
|
|
||||||
if (currentTokenIndex < playlist.length - 1) {
|
|
||||||
setCurrentTokenIndex(prev => prev + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrevious = () => {
|
|
||||||
if (currentTokenIndex > 0) {
|
|
||||||
setCurrentTokenIndex(prev => prev - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h1 className="text-3xl font-bold">Video Rečenica</h1>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
|
||||||
{/* Video Player - 60% */}
|
|
||||||
<div className="lg:col-span-3">
|
|
||||||
<VideoPlayer
|
|
||||||
videoUrl={videoUrl}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
playbackSpeed={playbackSpeed}
|
|
||||||
onVideoEnd={handleVideoEnd}
|
|
||||||
onPlayPause={() => setIsPlaying(!isPlaying)}
|
|
||||||
onNext={handleNext}
|
|
||||||
onPrevious={handlePrevious}
|
|
||||||
/>
|
|
||||||
<PlaybackControls
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
playbackSpeed={playbackSpeed}
|
|
||||||
loopMode={loopMode}
|
|
||||||
onPlayPause={() => setIsPlaying(!isPlaying)}
|
|
||||||
onNext={handleNext}
|
|
||||||
onPrevious={handlePrevious}
|
|
||||||
onSpeedChange={setPlaybackSpeed}
|
|
||||||
onLoopModeChange={setLoopMode}
|
|
||||||
canGoNext={currentTokenIndex < playlist.length - 1}
|
|
||||||
canGoPrevious={currentTokenIndex > 0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sentence Panel - 40% */}
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<SentencePanel
|
|
||||||
tokens={currentTokens}
|
|
||||||
currentTokenIndex={currentTokenIndex}
|
|
||||||
onTokenClick={setCurrentTokenIndex}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default VideoSentence;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 12. Croatian Translations
|
|
||||||
|
|
||||||
- **Video Rečenica** - Video Sentence
|
|
||||||
- **Reprodukcija** - Playback
|
|
||||||
- **Brzina** - Speed
|
|
||||||
- **Ponavljanje** - Loop/Repeat
|
|
||||||
- **Automatska reprodukcija** - Autoplay
|
|
||||||
- **Nema učitane rečenice** - No sentence loaded
|
|
||||||
- **Trenutno** - Currently
|
|
||||||
- **Prethodno** - Previous
|
|
||||||
- **Sljedeće** - Next
|
|
||||||
- **Pauza** - Pause
|
|
||||||
- **Reproduciraj** - Play
|
|
||||||
- **Video nije dostupan** - Video not available
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
This implementation guide provides a complete roadmap for building the Video Rečenica feature. The key aspects are:
|
|
||||||
|
|
||||||
1. **Sequential video playback** - Videos play one after another automatically
|
|
||||||
2. **Synchronized highlighting** - Current token is visually highlighted
|
|
||||||
3. **Playback controls** - Play/pause, next/prev, speed, loop modes
|
|
||||||
4. **Document integration** - Load from sentenceStore or saved documents
|
|
||||||
5. **Error handling** - Graceful handling of missing videos
|
|
||||||
6. **Smooth UX** - Preloading, responsive design, clear visual feedback
|
|
||||||
|
|
||||||
Start with Phase 1 (basic structure) and progressively add features. Test thoroughly at each phase before moving to the next.
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user