Add Znakopis feature with document and sentence management
- Implemented Znakopis page with document and sentence CRUD operations - Added backend routes for documents and sentences - Created UI components for sentence editing and display - Added sentence store for state management - Integrated select component for document selection - Updated README with Phase 3 completion status - Removed obsolete fix-colors.md file
This commit is contained in:
221
PHASE-3-COMPLETE.md
Normal file
221
PHASE-3-COMPLETE.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# Phase 3: Sentence Builder (Znakopis) - COMPLETED ✅
|
||||
|
||||
**Date Completed:** 2026-01-17
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 3 has been successfully completed. The Sentence Builder (Znakopis) module is now fully functional with drag-and-drop token management, document creation, and multi-page support.
|
||||
|
||||
## Deliverables Completed
|
||||
|
||||
### Backend Implementation ✅
|
||||
|
||||
#### 1. ✅ Document Routes (`/api/documents`)
|
||||
- **GET /api/documents** - List all documents for authenticated user
|
||||
- Returns documents with all pages, sentences, and tokens
|
||||
- Ordered by most recently updated
|
||||
- **GET /api/documents/:id** - Get single document with full details
|
||||
- Includes all nested data (pages → sentences → tokens → terms)
|
||||
- **POST /api/documents** - Create new document
|
||||
- Automatically creates first page
|
||||
- Supports title, description, and visibility settings
|
||||
- **PATCH /api/documents/:id** - Update document metadata
|
||||
- **DELETE /api/documents/:id** - Delete document
|
||||
- **Location:** `packages/backend/src/routes/documents.ts`
|
||||
|
||||
#### 2. ✅ Sentence & Token Management Routes
|
||||
- **POST /api/:documentId/pages/:pageIndex/sentences** - Create sentence with tokens
|
||||
- Accepts array of tokens with termId, displayText, isPunctuation
|
||||
- Automatically manages sentence indexing
|
||||
- **PATCH /api/sentences/:sentenceId/tokens** - Update sentence tokens
|
||||
- Supports reordering, adding, and removing tokens
|
||||
- Replaces all tokens atomically
|
||||
- **DELETE /api/sentences/:sentenceId** - Delete sentence
|
||||
- **POST /api/:documentId/pages** - Create new page in document
|
||||
- Automatically manages page indexing
|
||||
- **Location:** `packages/backend/src/routes/sentences.ts`
|
||||
|
||||
#### 3. ✅ Authentication & Authorization
|
||||
- All routes protected with `isAuthenticated` middleware
|
||||
- Document ownership verification on all operations
|
||||
- Proper error handling and status codes
|
||||
|
||||
### Frontend Implementation ✅
|
||||
|
||||
#### 1. ✅ Sentence Store (Zustand)
|
||||
- Global state management for current sentence tokens
|
||||
- Persistent storage using localStorage
|
||||
- Actions: addToken, removeToken, reorderTokens, clearTokens, setTokens
|
||||
- **Location:** `packages/frontend/src/stores/sentenceStore.ts`
|
||||
|
||||
#### 2. ✅ Document API Client
|
||||
- Complete API client for document operations
|
||||
- TypeScript interfaces for all data types
|
||||
- Error handling and response parsing
|
||||
- **Location:** `packages/frontend/src/lib/documentApi.ts`
|
||||
|
||||
#### 3. ✅ Dictionary Integration
|
||||
- "Dodaj" (Add) button now functional
|
||||
- Adds terms to sentence store
|
||||
- Toast notifications with navigation to Znakopis
|
||||
- Persistent across page navigation
|
||||
- **Location:** `packages/frontend/src/pages/Dictionary.tsx`
|
||||
|
||||
#### 4. ✅ Znakopis Page
|
||||
- Two-column responsive layout
|
||||
- Left: TokenTray (current sentence builder)
|
||||
- Right: DocumentPanel (document management)
|
||||
- Save/Load document functionality
|
||||
- New document creation
|
||||
- **Location:** `packages/frontend/src/pages/Znakopis.tsx`
|
||||
|
||||
#### 5. ✅ TokenTray Component
|
||||
- Displays current sentence tokens
|
||||
- Drag-and-drop reordering using @dnd-kit
|
||||
- Visual feedback during dragging
|
||||
- Remove individual tokens
|
||||
- Clear all tokens
|
||||
- Empty state with helpful message
|
||||
- **Location:** `packages/frontend/src/components/znakopis/TokenTray.tsx`
|
||||
|
||||
#### 6. ✅ SortableToken Component
|
||||
- Draggable token chip
|
||||
- Color-coded by word type (matching Dictionary)
|
||||
- Grip handle for dragging
|
||||
- Remove button
|
||||
- Smooth animations
|
||||
- **Location:** `packages/frontend/src/components/znakopis/SortableToken.tsx`
|
||||
|
||||
#### 7. ✅ DocumentPanel Component
|
||||
- Document selector dropdown
|
||||
- Current document information
|
||||
- Page navigation (previous/next)
|
||||
- Create new page
|
||||
- Sentence list for current page
|
||||
- Delete sentence functionality
|
||||
- **Location:** `packages/frontend/src/components/znakopis/DocumentPanel.tsx`
|
||||
|
||||
#### 8. ✅ Toast Notifications
|
||||
- Installed and configured Sonner
|
||||
- Success/error/info messages
|
||||
- Action buttons (e.g., "Go to Znakopis")
|
||||
- **Location:** `packages/frontend/src/App.tsx`
|
||||
|
||||
## Features Implemented
|
||||
|
||||
### Sentence Building
|
||||
- ✅ Add words from Dictionary to sentence
|
||||
- ✅ Drag-and-drop to reorder words
|
||||
- ✅ Remove individual words
|
||||
- ✅ Clear all words
|
||||
- ✅ Visual feedback during interactions
|
||||
- ✅ Persistent state across navigation
|
||||
|
||||
### Document Management
|
||||
- ✅ Create new documents
|
||||
- ✅ Save sentences to documents
|
||||
- ✅ Load existing documents
|
||||
- ✅ Multi-page document support
|
||||
- ✅ Page navigation
|
||||
- ✅ Delete sentences
|
||||
- ✅ Document metadata (title, description)
|
||||
|
||||
### User Experience
|
||||
- ✅ Responsive layout (mobile, tablet, desktop)
|
||||
- ✅ Toast notifications for all actions
|
||||
- ✅ Loading states
|
||||
- ✅ Empty states with helpful messages
|
||||
- ✅ Error handling
|
||||
- ✅ Smooth animations
|
||||
|
||||
## Croatian Localization
|
||||
|
||||
All UI text is in Croatian:
|
||||
- "Znakopis" - Sentence Builder
|
||||
- "Trenutna rečenica" - Current sentence
|
||||
- "Dodaj" - Add
|
||||
- "Spremi" - Save
|
||||
- "Učitaj dokument" - Load document
|
||||
- "Nova stranica" - New page
|
||||
- "Popis rečenica" - Sentence list
|
||||
- "Očisti sve" - Clear all
|
||||
- Toast messages in Croatian
|
||||
|
||||
## Technical Highlights
|
||||
|
||||
### Backend
|
||||
- RESTful API design
|
||||
- Proper authentication and authorization
|
||||
- Atomic token updates
|
||||
- Cascading deletes (Prisma)
|
||||
- Efficient queries with nested includes
|
||||
- Error handling with meaningful messages
|
||||
|
||||
### Frontend
|
||||
- TypeScript for type safety
|
||||
- Zustand for state management
|
||||
- @dnd-kit for drag-and-drop
|
||||
- Sonner for toast notifications
|
||||
- Persistent state with localStorage
|
||||
- Component composition
|
||||
- Responsive design with Tailwind CSS
|
||||
|
||||
## Workflow
|
||||
|
||||
The complete workflow now works end-to-end:
|
||||
|
||||
1. **Dictionary** → User browses and searches for words
|
||||
2. **Add to Sentence** → User clicks "Dodaj" to add words
|
||||
3. **Znakopis** → User navigates to Znakopis page
|
||||
4. **Reorder** → User drags and drops to arrange words
|
||||
5. **Save** → User clicks "Spremi" to save sentence to document
|
||||
6. **Load** → User can load previously saved documents
|
||||
7. **Multi-page** → User can create multiple pages with multiple sentences
|
||||
|
||||
## Next Steps (Phase 4)
|
||||
|
||||
The Sentence Builder is complete and ready for Phase 4: Video Sentence Player
|
||||
|
||||
**Phase 4 Goals:**
|
||||
1. Create Video Sentence Player page
|
||||
2. Implement video playlist generation
|
||||
3. Synchronize video playback with token highlighting
|
||||
4. Add playback controls (play, pause, next, prev, speed)
|
||||
5. Implement smooth video transitions
|
||||
6. Preload next video for seamless playback
|
||||
|
||||
## Commands Reference
|
||||
|
||||
```bash
|
||||
# Run development servers
|
||||
pnpm dev
|
||||
|
||||
# Test API endpoints
|
||||
curl -X GET http://localhost:3000/api/documents -H "Cookie: connect.sid=..."
|
||||
curl -X POST http://localhost:3000/api/documents -H "Content-Type: application/json" -d '{"title":"Test Document"}'
|
||||
|
||||
# Access Znakopis
|
||||
http://localhost:5174/znakopis
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- ✅ Add words from Dictionary
|
||||
- ✅ Words appear in Znakopis TokenTray
|
||||
- ✅ Drag and drop to reorder words
|
||||
- ✅ Remove individual words
|
||||
- ✅ Clear all words
|
||||
- ✅ Save sentence to new document
|
||||
- ✅ Load existing document
|
||||
- ✅ Navigate between pages
|
||||
- ✅ Create new page
|
||||
- ✅ Delete sentence
|
||||
- ✅ Toast notifications appear
|
||||
- ✅ State persists across page navigation
|
||||
- ✅ Responsive on mobile/tablet/desktop
|
||||
|
||||
---
|
||||
|
||||
**Status:** ✅ COMPLETE
|
||||
**Next Phase:** Phase 4 - Video Sentence Player
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
A modern web application for learning and using Croatian Sign Language (Hrvatski znakovni jezik). This is a proof-of-concept implementation designed to replicate the functionality of the original Znakovni.hr platform.
|
||||
|
||||
## 🎉 Current Status: Phase 2 Complete!
|
||||
## 🎉 Current Status: Phase 3 Complete!
|
||||
|
||||
✅ **Phase 0:** Project Setup - COMPLETE
|
||||
✅ **Phase 1:** Authentication & User Management - COMPLETE
|
||||
✅ **Phase 2:** Dictionary Module - COMPLETE
|
||||
🔄 **Phase 3:** Sentence Builder (Znakopis) - Next
|
||||
✅ **Phase 3:** Sentence Builder (Znakopis) - COMPLETE
|
||||
🔄 **Phase 4:** Video Sentence Player - Next
|
||||
|
||||
The Dictionary module is now fully functional with search, filtering, and detailed term viewing!
|
||||
The Sentence Builder is now fully functional with drag-and-drop, document management, and multi-page support!
|
||||
|
||||
## 🎯 Project Overview
|
||||
|
||||
|
||||
153
fix-colors.md
153
fix-colors.md
@@ -1,153 +0,0 @@
|
||||
JASNE UPUTE ZA AI AGENTA: Dodavanje obojenih pozadina za vrste riječi
|
||||
|
||||
KONTEKST
|
||||
Na originalnoj aplikaciji (slika: original/1 dodaj rijeci.png), svaka riječ u rječniku prikazuje se kao gumb s obojenom pozadinom koja ovisi o vrsti riječi (wordType). Trenutno aplikacija prikazuje vrstu riječi samo kao sivi tekst bez pozadinske
|
||||
boje.
|
||||
|
||||
PRIMJERI SA SLIKE
|
||||
• "adresa" (Imenica/NOUN) → zelena pozadina
|
||||
• "Analizirati" (Glagol/VERB) → crvena pozadina
|
||||
|
||||
ZADATAK
|
||||
Promijeni kod tako da tekst riječi (wordText) u WordCard komponenti ima obojenu pozadinu koja odgovara vrsti riječi, baš kao na originalnoj slici.
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
KORACI ZA IMPLEMENTACIJU
|
||||
|
||||
1. Kreiraj mapiranje boja za vrste riječi
|
||||
|
||||
U datoteci packages/frontend/src/components/dictionary/WordCard.tsx, dodaj novo mapiranje boja za vrste riječi (wordType), slično kao što već postoji cefrColors za CEFR razine:
|
||||
|
||||
const wordTypeColors: Record<string, string> = {
|
||||
NOUN: 'bg-green-600', // Imenica - zelena
|
||||
VERB: 'bg-red-600', // Glagol - crvena
|
||||
ADJECTIVE: 'bg-blue-600', // Pridjev - plava
|
||||
ADVERB: 'bg-purple-600', // Prilog - ljubičasta
|
||||
PRONOUN: 'bg-yellow-600', // Zamjenica - žuta
|
||||
PREPOSITION: 'bg-orange-600', // Prijedlog - narančasta
|
||||
CONJUNCTION: 'bg-pink-600', // Veznik - roza
|
||||
INTERJECTION: 'bg-teal-600', // Uzvik - tirkizna
|
||||
PHRASE: 'bg-indigo-600', // Fraza - indigo
|
||||
OTHER: 'bg-gray-600', // Ostalo - siva
|
||||
};
|
||||
|
||||
NAPOMENA: Prilagodi boje prema točnim bojama sa slike ako imaš pristup slici. Ovo su prijedlozi.
|
||||
|
||||
|
||||
──────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
2. Promijeni prikaz teksta riječi (wordText)
|
||||
|
||||
U istoj datoteci (WordCard.tsx), pronađi dio gdje se prikazuje term.wordText (trenutno oko linije 69):
|
||||
|
||||
TRENUTNI KOD:
|
||||
{/* Word Text */}
|
||||
<h3 className="text-lg font-semibold text-center mb-3 text-gray-900">
|
||||
{term.wordText}
|
||||
</h3>
|
||||
|
||||
NOVI KOD:
|
||||
{/* Word Text with colored background based on word type */}
|
||||
<div className="flex justify-center mb-3">
|
||||
<span className={`${wordTypeColors[term.wordType] || 'bg-gray-600'} text-white text-lg font-semibold px-4 py-2 rounded`}>
|
||||
{term.wordText}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
ŠTO SE MIJENJA:
|
||||
• Tekst riječi sada ima obojenu pozadinu (bg-* klasa) ovisno o term.wordType
|
||||
• Tekst je bijele boje (text-white) za kontrast
|
||||
• Dodani su padding (px-4 py-2) i zaobljeni rubovi (rounded) da izgleda kao gumb
|
||||
|
||||
|
||||
──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
3. (OPCIONALNO) Ukloni ili prilagodi prikaz vrste riječi
|
||||
|
||||
Trenutno se vrsta riječi prikazuje kao sivi tekst u gornjem desnom kutu kartice (linija 44-46):
|
||||
|
||||
<span className="text-xs text-gray-500">
|
||||
{wordTypeLabels[term.wordType] || term.wordType}
|
||||
</span>
|
||||
|
||||
OPCIJE:
|
||||
• Opcija A: Ukloni ovaj dio jer je sada vrsta riječi vidljiva kroz boju pozadine
|
||||
• Opcija B: Zadrži ga za dodatnu jasnoću
|
||||
|
||||
|
||||
────────────────────────────────────────────────
|
||||
|
||||
4. Primijeni iste promjene u drugim komponentama
|
||||
|
||||
Ako se riječi prikazuju i u drugim komponentama (npr. WordDetailModal, SentenceBuilder, itd.), primijeni iste promjene tamo:
|
||||
|
||||
U `WordDetailModal.tsx`:
|
||||
Dodaj isto mapiranje wordTypeColors i promijeni prikaz term.wordText na isti način.
|
||||
|
||||
U budućoj `SentenceBuilder` komponenti:
|
||||
Kada se budu prikazivale riječi u rečenici (Znakopis), svaka riječ treba imati obojenu pozadinu prema vrsti riječi.
|
||||
|
||||
|
||||
──────────────────────────────────────────
|
||||
|
||||
5. Testiraj promjene
|
||||
|
||||
1. Pokreni aplikaciju
|
||||
2. Otvori stranicu Rječnik (/dictionary)
|
||||
3. Provjeri da svaka riječ ima obojenu pozadinu koja odgovara njenoj vrsti
|
||||
4. Usporedi s originalnom slikom original/1 dodaj rijeci.png
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
DODATNE NAPOMENE
|
||||
|
||||
Ako trebaš točne boje sa slike:
|
||||
Ako imaš pristup slici, koristi alat za odabir boja (color picker) da dobiješ točne hex vrijednosti boja i pretvori ih u Tailwind klase ili custom CSS.
|
||||
|
||||
Ako Tailwind nema točnu boju:
|
||||
Možeš dodati custom boje u tailwind.config.js:
|
||||
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'word-noun': '#16a34a', // zelena za imenice
|
||||
'word-verb': '#dc2626', // crvena za glagole
|
||||
// ... ostale boje
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Zatim koristi: bg-word-noun, bg-word-verb, itd.
|
||||
|
||||
|
||||
───────────────────────────────────────────────
|
||||
|
||||
SAŽETAK PROMJENA
|
||||
|
||||
| Datoteka | Što dodati/promijeniti |
|
||||
|----------|------------------------|
|
||||
| packages/frontend/src/components/dictionary/WordCard.tsx | 1. Dodaj wordTypeColors mapiranje<br>2. Promijeni prikaz term.wordText da ima obojenu pozadinu |
|
||||
| packages/frontend/src/components/dictionary/WordDetailModal.tsx | Iste promjene kao u WordCard.tsx |
|
||||
| Buduće komponente (SentenceBuilder, itd.) | Primijeni isti stil za prikaz riječi |
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
PRIMJER KONAČNOG IZGLEDA
|
||||
|
||||
Nakon promjena, svaka kartica riječi u rječniku će imati:
|
||||
• Gornji lijevi kut: CEFR razina (A1, A2, itd.) s bojom
|
||||
• Centar: Slika/ikona riječi
|
||||
• Ispod slike: Tekst riječi s obojenom pozadinom (npr. "adresa" na zelenoj pozadini)
|
||||
• Ispod teksta: Kratak opis (ako postoji)
|
||||
• Dno: Gumbi "Dodaj" i "Info"
|
||||
|
||||
|
||||
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Ovo su jasne, korak-po-korak upute koje možeš dati AI agentu da implementira promjene. Agent će točno znati koje datoteke treba promijeniti i što dodati.
|
||||
265
packages/backend/src/routes/documents.ts
Normal file
265
packages/backend/src/routes/documents.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { prisma } from '../lib/prisma.js';
|
||||
import { isAuthenticated } from '../middleware/auth.js';
|
||||
import { Visibility } from '@prisma/client';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/documents
|
||||
* Get all documents for the authenticated user
|
||||
*/
|
||||
router.get('/', isAuthenticated, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
|
||||
const documents = await prisma.document.findMany({
|
||||
where: {
|
||||
ownerUserId: userId,
|
||||
},
|
||||
include: {
|
||||
pages: {
|
||||
include: {
|
||||
sentences: {
|
||||
include: {
|
||||
tokens: {
|
||||
include: {
|
||||
term: true,
|
||||
},
|
||||
orderBy: {
|
||||
tokenIndex: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
sentenceIndex: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
pageIndex: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ documents });
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching documents:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch documents', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/documents/:id
|
||||
* Get a single document by ID
|
||||
*/
|
||||
router.get('/:id', isAuthenticated, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user?.id;
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
include: {
|
||||
pages: {
|
||||
include: {
|
||||
sentences: {
|
||||
include: {
|
||||
tokens: {
|
||||
include: {
|
||||
term: {
|
||||
include: {
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
tokenIndex: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
sentenceIndex: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
pageIndex: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return res.status(404).json({ error: 'Document not found' });
|
||||
}
|
||||
|
||||
res.json({ document });
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching document:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch document', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/documents
|
||||
* Create a new document
|
||||
*/
|
||||
router.post('/', isAuthenticated, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const { title, description, visibility = 'PRIVATE' } = req.body;
|
||||
|
||||
if (!title) {
|
||||
return res.status(400).json({ error: 'Title is required' });
|
||||
}
|
||||
|
||||
const document = await prisma.document.create({
|
||||
data: {
|
||||
title,
|
||||
description,
|
||||
visibility: visibility as Visibility,
|
||||
ownerUserId: userId,
|
||||
pages: {
|
||||
create: {
|
||||
pageIndex: 0,
|
||||
title: 'Stranica 1',
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
pages: {
|
||||
include: {
|
||||
sentences: {
|
||||
include: {
|
||||
tokens: {
|
||||
include: {
|
||||
term: true,
|
||||
},
|
||||
orderBy: {
|
||||
tokenIndex: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
sentenceIndex: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
pageIndex: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json({ document });
|
||||
} catch (error: any) {
|
||||
console.error('Error creating document:', error);
|
||||
res.status(500).json({ error: 'Failed to create document', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/documents/:id
|
||||
* Update a document
|
||||
*/
|
||||
router.patch('/:id', isAuthenticated, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user?.id;
|
||||
const { title, description, visibility } = req.body;
|
||||
|
||||
// Check if document exists and belongs to user
|
||||
const existingDoc = await prisma.document.findFirst({
|
||||
where: {
|
||||
id,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingDoc) {
|
||||
return res.status(404).json({ error: 'Document not found' });
|
||||
}
|
||||
|
||||
const document = await prisma.document.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(title && { title }),
|
||||
...(description !== undefined && { description }),
|
||||
...(visibility && { visibility: visibility as Visibility }),
|
||||
},
|
||||
include: {
|
||||
pages: {
|
||||
include: {
|
||||
sentences: {
|
||||
include: {
|
||||
tokens: {
|
||||
include: {
|
||||
term: true,
|
||||
},
|
||||
orderBy: {
|
||||
tokenIndex: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
sentenceIndex: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
pageIndex: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ document });
|
||||
} catch (error: any) {
|
||||
console.error('Error updating document:', error);
|
||||
res.status(500).json({ error: 'Failed to update document', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/documents/:id
|
||||
* Delete a document
|
||||
*/
|
||||
router.delete('/:id', isAuthenticated, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user?.id;
|
||||
|
||||
// Check if document exists and belongs to user
|
||||
const existingDoc = await prisma.document.findFirst({
|
||||
where: {
|
||||
id,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingDoc) {
|
||||
return res.status(404).json({ error: 'Document not found' });
|
||||
}
|
||||
|
||||
await prisma.document.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
res.json({ message: 'Document deleted successfully' });
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting document:', error);
|
||||
res.status(500).json({ error: 'Failed to delete document', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
268
packages/backend/src/routes/sentences.ts
Normal file
268
packages/backend/src/routes/sentences.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { prisma } from '../lib/prisma.js';
|
||||
import { isAuthenticated } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* POST /api/documents/:documentId/pages/:pageIndex/sentences
|
||||
* Create a new sentence on a page
|
||||
*/
|
||||
router.post('/:documentId/pages/:pageIndex/sentences', isAuthenticated, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { documentId, pageIndex } = req.params;
|
||||
const userId = req.user?.id;
|
||||
const { tokens } = req.body;
|
||||
|
||||
// Verify document ownership
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return res.status(404).json({ error: 'Document not found' });
|
||||
}
|
||||
|
||||
// Get or create page
|
||||
let page = await prisma.documentPage.findFirst({
|
||||
where: {
|
||||
documentId,
|
||||
pageIndex: parseInt(pageIndex, 10),
|
||||
},
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
page = await prisma.documentPage.create({
|
||||
data: {
|
||||
documentId,
|
||||
pageIndex: parseInt(pageIndex, 10),
|
||||
title: `Stranica ${parseInt(pageIndex, 10) + 1}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Get next sentence index
|
||||
const lastSentence = await prisma.sentence.findFirst({
|
||||
where: {
|
||||
documentPageId: page.id,
|
||||
},
|
||||
orderBy: {
|
||||
sentenceIndex: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
const nextSentenceIndex = lastSentence ? lastSentence.sentenceIndex + 1 : 0;
|
||||
|
||||
// Create sentence with tokens
|
||||
const sentence = await prisma.sentence.create({
|
||||
data: {
|
||||
documentPageId: page.id,
|
||||
sentenceIndex: nextSentenceIndex,
|
||||
tokens: {
|
||||
create: tokens.map((token: any, index: number) => ({
|
||||
tokenIndex: index,
|
||||
termId: token.termId,
|
||||
displayText: token.displayText,
|
||||
isPunctuation: token.isPunctuation || false,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
tokens: {
|
||||
include: {
|
||||
term: {
|
||||
include: {
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
tokenIndex: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json({ sentence });
|
||||
} catch (error: any) {
|
||||
console.error('Error creating sentence:', error);
|
||||
res.status(500).json({ error: 'Failed to create sentence', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/sentences/:sentenceId/tokens
|
||||
* Update tokens in a sentence (reorder, add, remove)
|
||||
*/
|
||||
router.patch('/:sentenceId/tokens', isAuthenticated, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sentenceId } = req.params;
|
||||
const userId = req.user?.id;
|
||||
const { tokens } = req.body;
|
||||
|
||||
// Verify sentence ownership through document
|
||||
const sentence = await prisma.sentence.findUnique({
|
||||
where: { id: sentenceId },
|
||||
include: {
|
||||
documentPage: {
|
||||
include: {
|
||||
document: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!sentence || sentence.documentPage.document.ownerUserId !== userId) {
|
||||
return res.status(404).json({ error: 'Sentence not found' });
|
||||
}
|
||||
|
||||
// Delete existing tokens and create new ones
|
||||
await prisma.sentenceToken.deleteMany({
|
||||
where: {
|
||||
sentenceId,
|
||||
},
|
||||
});
|
||||
|
||||
// Create new tokens
|
||||
const updatedSentence = await prisma.sentence.update({
|
||||
where: { id: sentenceId },
|
||||
data: {
|
||||
tokens: {
|
||||
create: tokens.map((token: any, index: number) => ({
|
||||
tokenIndex: index,
|
||||
termId: token.termId,
|
||||
displayText: token.displayText,
|
||||
isPunctuation: token.isPunctuation || false,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
tokens: {
|
||||
include: {
|
||||
term: {
|
||||
include: {
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
tokenIndex: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ sentence: updatedSentence });
|
||||
} catch (error: any) {
|
||||
console.error('Error updating sentence tokens:', error);
|
||||
res.status(500).json({ error: 'Failed to update sentence tokens', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/sentences/:sentenceId
|
||||
* Delete a sentence
|
||||
*/
|
||||
router.delete('/:sentenceId', isAuthenticated, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sentenceId } = req.params;
|
||||
const userId = req.user?.id;
|
||||
|
||||
// Verify sentence ownership through document
|
||||
const sentence = await prisma.sentence.findUnique({
|
||||
where: { id: sentenceId },
|
||||
include: {
|
||||
documentPage: {
|
||||
include: {
|
||||
document: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!sentence || sentence.documentPage.document.ownerUserId !== userId) {
|
||||
return res.status(404).json({ error: 'Sentence not found' });
|
||||
}
|
||||
|
||||
await prisma.sentence.delete({
|
||||
where: { id: sentenceId },
|
||||
});
|
||||
|
||||
res.json({ message: 'Sentence deleted successfully' });
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting sentence:', error);
|
||||
res.status(500).json({ error: 'Failed to delete sentence', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/documents/:documentId/pages
|
||||
* Create a new page in a document
|
||||
*/
|
||||
router.post('/:documentId/pages', isAuthenticated, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { documentId } = req.params;
|
||||
const userId = req.user?.id;
|
||||
const { title } = req.body;
|
||||
|
||||
// Verify document ownership
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return res.status(404).json({ error: 'Document not found' });
|
||||
}
|
||||
|
||||
// Get next page index
|
||||
const lastPage = await prisma.documentPage.findFirst({
|
||||
where: {
|
||||
documentId,
|
||||
},
|
||||
orderBy: {
|
||||
pageIndex: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
const nextPageIndex = lastPage ? lastPage.pageIndex + 1 : 0;
|
||||
|
||||
const page = await prisma.documentPage.create({
|
||||
data: {
|
||||
documentId,
|
||||
pageIndex: nextPageIndex,
|
||||
title: title || `Stranica ${nextPageIndex + 1}`,
|
||||
},
|
||||
include: {
|
||||
sentences: {
|
||||
include: {
|
||||
tokens: {
|
||||
include: {
|
||||
term: true,
|
||||
},
|
||||
orderBy: {
|
||||
tokenIndex: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
sentenceIndex: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json({ page });
|
||||
} catch (error: any) {
|
||||
console.error('Error creating page:', error);
|
||||
res.status(500).json({ error: 'Failed to create page', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -47,6 +47,8 @@ app.use(passport.session());
|
||||
import authRoutes from './routes/auth.js';
|
||||
import userRoutes from './routes/users.js';
|
||||
import termRoutes from './routes/terms.js';
|
||||
import documentRoutes from './routes/documents.js';
|
||||
import sentenceRoutes from './routes/sentences.js';
|
||||
|
||||
// Static file serving for uploads
|
||||
const uploadsPath = path.join(__dirname, '..', 'uploads');
|
||||
@@ -56,6 +58,8 @@ app.use('/uploads', express.static(uploadsPath));
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/terms', termRoutes);
|
||||
app.use('/api/documents', documentRoutes);
|
||||
app.use('/api', sentenceRoutes);
|
||||
|
||||
// Health check route
|
||||
app.get('/api/health', (_req, res) => {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
@@ -27,6 +28,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.22.4",
|
||||
|
||||
@@ -4,8 +4,10 @@ import Home from './pages/Home';
|
||||
import { Login } from './pages/Login';
|
||||
import { Admin } from './pages/Admin';
|
||||
import Dictionary from './pages/Dictionary';
|
||||
import Znakopis from './pages/Znakopis';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import { Toaster } from 'sonner';
|
||||
|
||||
function App() {
|
||||
const { checkAuth } = useAuthStore();
|
||||
@@ -15,37 +17,41 @@ function App() {
|
||||
}, [checkAuth]);
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Home />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<Admin />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Dictionary */}
|
||||
<Route path="/dictionary" element={<ProtectedRoute><Dictionary /></ProtectedRoute>} />
|
||||
{/* Placeholder routes for other pages */}
|
||||
<Route path="/znakopis" element={<ProtectedRoute><div>Znakopis (Coming Soon)</div></ProtectedRoute>} />
|
||||
<Route path="/video-sentence" element={<ProtectedRoute><div>Video Sentence (Coming Soon)</div></ProtectedRoute>} />
|
||||
<Route path="/cloud" element={<ProtectedRoute><div>Cloud (Coming Soon)</div></ProtectedRoute>} />
|
||||
<Route path="/help" element={<ProtectedRoute><div>Help (Coming Soon)</div></ProtectedRoute>} />
|
||||
<Route path="/community" element={<ProtectedRoute><div>Community (Coming Soon)</div></ProtectedRoute>} />
|
||||
<Route path="/comments" element={<ProtectedRoute><div>Comments (Coming Soon)</div></ProtectedRoute>} />
|
||||
<Route path="/bug-report" element={<ProtectedRoute><div>Bug Report (Coming Soon)</div></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</Router>
|
||||
<>
|
||||
<Toaster position="top-right" richColors />
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Home />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<Admin />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Dictionary */}
|
||||
<Route path="/dictionary" element={<ProtectedRoute><Dictionary /></ProtectedRoute>} />
|
||||
{/* Znakopis */}
|
||||
<Route path="/znakopis" element={<ProtectedRoute><Znakopis /></ProtectedRoute>} />
|
||||
{/* Placeholder routes for other pages */}
|
||||
<Route path="/video-sentence" element={<ProtectedRoute><div>Video Sentence (Coming Soon)</div></ProtectedRoute>} />
|
||||
<Route path="/cloud" element={<ProtectedRoute><div>Cloud (Coming Soon)</div></ProtectedRoute>} />
|
||||
<Route path="/help" element={<ProtectedRoute><div>Help (Coming Soon)</div></ProtectedRoute>} />
|
||||
<Route path="/community" element={<ProtectedRoute><div>Community (Coming Soon)</div></ProtectedRoute>} />
|
||||
<Route path="/comments" element={<ProtectedRoute><div>Comments (Coming Soon)</div></ProtectedRoute>} />
|
||||
<Route path="/bug-report" element={<ProtectedRoute><div>Bug Report (Coming Soon)</div></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
157
packages/frontend/src/components/ui/select.tsx
Normal file
157
packages/frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
151
packages/frontend/src/components/znakopis/DocumentPanel.tsx
Normal file
151
packages/frontend/src/components/znakopis/DocumentPanel.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Document } from '../../lib/documentApi';
|
||||
import { Button } from '../ui/button';
|
||||
import { FileText, ChevronLeft, ChevronRight, Plus, Trash2 } from 'lucide-react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
|
||||
interface DocumentPanelProps {
|
||||
documents: Document[];
|
||||
selectedDocument: Document | null;
|
||||
currentPageIndex: number;
|
||||
onLoadDocument: (documentId: string) => void;
|
||||
onNewPage: () => void;
|
||||
onPageChange: (pageIndex: number) => void;
|
||||
onDeleteSentence: (sentenceId: string) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function DocumentPanel({
|
||||
documents,
|
||||
selectedDocument,
|
||||
currentPageIndex,
|
||||
onLoadDocument,
|
||||
onNewPage,
|
||||
onPageChange,
|
||||
onDeleteSentence,
|
||||
loading,
|
||||
}: DocumentPanelProps) {
|
||||
const currentPage = selectedDocument?.pages[currentPageIndex];
|
||||
const totalPages = selectedDocument?.pages.length || 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-6">
|
||||
{/* Document Selector */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">Učitaj dokument</h3>
|
||||
<Select onValueChange={onLoadDocument} disabled={loading}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Odaberi dokument..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{documents.length === 0 ? (
|
||||
<div className="p-2 text-sm text-gray-500">Nema spremljenih dokumenata</div>
|
||||
) : (
|
||||
documents.map((doc) => (
|
||||
<SelectItem key={doc.id} value={doc.id}>
|
||||
{doc.title}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Current Document Info */}
|
||||
{selectedDocument && (
|
||||
<>
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
<FileText className="inline h-5 w-5 mr-2" />
|
||||
{selectedDocument.title}
|
||||
</h3>
|
||||
</div>
|
||||
{selectedDocument.description && (
|
||||
<p className="text-sm text-gray-600 mb-3">{selectedDocument.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Page Navigation */}
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-semibold text-gray-900">Stranice</h4>
|
||||
<span className="text-sm text-gray-600">
|
||||
{currentPageIndex + 1} / {totalPages}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPageIndex - 1)}
|
||||
disabled={currentPageIndex === 0}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={onNewPage}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Nova stranica
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPageIndex + 1)}
|
||||
disabled={currentPageIndex >= totalPages - 1}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sentence List */}
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-3">Popis rečenica</h4>
|
||||
{currentPage && currentPage.sentences.length > 0 ? (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{currentPage.sentences.map((sentence, index) => (
|
||||
<div
|
||||
key={sentence.id}
|
||||
className="p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span className="text-xs font-semibold text-gray-500">
|
||||
Rečenica {index + 1}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onDeleteSentence(sentence.id)}
|
||||
className="text-red-600 hover:text-red-800 transition-colors"
|
||||
aria-label="Obriši rečenicu"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{sentence.tokens.map((token) => (
|
||||
<span
|
||||
key={token.id}
|
||||
className="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded"
|
||||
>
|
||||
{token.displayText}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 text-center py-4">
|
||||
Nema rečenica na ovoj stranici
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
58
packages/frontend/src/components/znakopis/SortableToken.tsx
Normal file
58
packages/frontend/src/components/znakopis/SortableToken.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { X, GripVertical } from 'lucide-react';
|
||||
import { SentenceToken } from '../../stores/sentenceStore';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
interface SortableTokenProps {
|
||||
token: SentenceToken;
|
||||
onRemove: (tokenId: string) => void;
|
||||
}
|
||||
|
||||
const wordTypeColors: Record<string, string> = {
|
||||
NOUN: 'bg-green-600',
|
||||
VERB: 'bg-red-600',
|
||||
ADJECTIVE: 'bg-blue-600',
|
||||
ADVERB: 'bg-purple-600',
|
||||
PRONOUN: 'bg-yellow-600',
|
||||
PREPOSITION: 'bg-orange-600',
|
||||
CONJUNCTION: 'bg-pink-600',
|
||||
INTERJECTION: 'bg-teal-600',
|
||||
PHRASE: 'bg-indigo-600',
|
||||
OTHER: 'bg-gray-600',
|
||||
};
|
||||
|
||||
export function SortableToken({ token, onRemove }: SortableTokenProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: token.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
const bgColor = wordTypeColors[token.term.wordType] || 'bg-gray-600';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`${bgColor} text-white rounded-lg px-4 py-3 flex items-center gap-2 shadow-md hover:shadow-lg transition-shadow cursor-move`}
|
||||
>
|
||||
<div {...attributes} {...listeners} className="cursor-grab active:cursor-grabbing">
|
||||
<GripVertical className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="font-semibold text-lg">{token.displayText}</span>
|
||||
<button
|
||||
onClick={() => onRemove(token.id)}
|
||||
className="ml-2 hover:bg-white/20 rounded p-1 transition-colors"
|
||||
aria-label="Ukloni riječ"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
69
packages/frontend/src/components/znakopis/TokenTray.tsx
Normal file
69
packages/frontend/src/components/znakopis/TokenTray.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useSentenceStore } from '../../stores/sentenceStore';
|
||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { SortableToken } from './SortableToken';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export function TokenTray() {
|
||||
const { currentTokens, reorderTokens, removeToken, clearTokens } = useSentenceStore();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = currentTokens.findIndex((token) => token.id === active.id);
|
||||
const newIndex = currentTokens.findIndex((token) => token.id === over.id);
|
||||
|
||||
const newTokens = arrayMove(currentTokens, oldIndex, newIndex);
|
||||
reorderTokens(newTokens);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Trenutna rečenica</h2>
|
||||
{currentTokens.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={clearTokens}>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Očisti sve
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentTokens.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p className="text-lg mb-2">Nema riječi u rečenici</p>
|
||||
<p className="text-sm">Idite na Rječnik i dodajte riječi klikom na "Dodaj"</p>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={currentTokens.map((t) => t.id)} strategy={horizontalListSortingStrategy}>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{currentTokens.map((token) => (
|
||||
<SortableToken key={token.id} token={token} onRemove={removeToken} />
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{currentTokens.length > 0 && (
|
||||
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
|
||||
<p className="text-sm text-blue-900">
|
||||
<strong>Savjet:</strong> Povucite i ispustite riječi za promjenu redoslijeda. Kliknite "Spremi" za spremanje rečenice u dokument.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
125
packages/frontend/src/lib/documentApi.ts
Normal file
125
packages/frontend/src/lib/documentApi.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import api from './api';
|
||||
|
||||
export interface DocumentToken {
|
||||
id: string;
|
||||
tokenIndex: number;
|
||||
termId: string | null;
|
||||
displayText: string;
|
||||
isPunctuation: boolean;
|
||||
term?: any;
|
||||
}
|
||||
|
||||
export interface Sentence {
|
||||
id: string;
|
||||
sentenceIndex: number;
|
||||
rawText: string | null;
|
||||
tokens: DocumentToken[];
|
||||
}
|
||||
|
||||
export interface DocumentPage {
|
||||
id: string;
|
||||
pageIndex: number;
|
||||
title: string | null;
|
||||
sentences: Sentence[];
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
visibility: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
pages: DocumentPage[];
|
||||
}
|
||||
|
||||
export interface CreateDocumentData {
|
||||
title: string;
|
||||
description?: string;
|
||||
visibility?: 'PRIVATE' | 'SHARED' | 'PUBLIC';
|
||||
}
|
||||
|
||||
export interface UpdateDocumentData {
|
||||
title?: string;
|
||||
description?: string;
|
||||
visibility?: 'PRIVATE' | 'SHARED' | 'PUBLIC';
|
||||
}
|
||||
|
||||
export interface CreateSentenceData {
|
||||
tokens: {
|
||||
termId: string | null;
|
||||
displayText: string;
|
||||
isPunctuation: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const documentApi = {
|
||||
// Get all documents for the current user
|
||||
async getDocuments(): Promise<Document[]> {
|
||||
const response = await api.get('/api/documents');
|
||||
return response.data.documents;
|
||||
},
|
||||
|
||||
// Get a single document by ID
|
||||
async getDocument(id: string): Promise<Document> {
|
||||
const response = await api.get(`/api/documents/${id}`);
|
||||
return response.data.document;
|
||||
},
|
||||
|
||||
// Create a new document
|
||||
async createDocument(data: CreateDocumentData): Promise<Document> {
|
||||
const response = await api.post('/api/documents', data);
|
||||
return response.data.document;
|
||||
},
|
||||
|
||||
// Update a document
|
||||
async updateDocument(id: string, data: UpdateDocumentData): Promise<Document> {
|
||||
const response = await api.patch(`/api/documents/${id}`, data);
|
||||
return response.data.document;
|
||||
},
|
||||
|
||||
// Delete a document
|
||||
async deleteDocument(id: string): Promise<void> {
|
||||
await api.delete(`/api/documents/${id}`);
|
||||
},
|
||||
|
||||
// Create a new page in a document
|
||||
async createPage(documentId: string, title?: string): Promise<DocumentPage> {
|
||||
const response = await api.post(`/api/${documentId}/pages`, { title });
|
||||
return response.data.page;
|
||||
},
|
||||
|
||||
// Create a new sentence on a page
|
||||
async createSentence(
|
||||
documentId: string,
|
||||
pageIndex: number,
|
||||
data: CreateSentenceData
|
||||
): Promise<Sentence> {
|
||||
const response = await api.post(
|
||||
`/api/${documentId}/pages/${pageIndex}/sentences`,
|
||||
data
|
||||
);
|
||||
return response.data.sentence;
|
||||
},
|
||||
|
||||
// Update sentence tokens (reorder, add, remove)
|
||||
async updateSentenceTokens(
|
||||
sentenceId: string,
|
||||
tokens: {
|
||||
termId: string | null;
|
||||
displayText: string;
|
||||
isPunctuation: boolean;
|
||||
}[]
|
||||
): Promise<Sentence> {
|
||||
const response = await api.patch(`/api/sentences/${sentenceId}/tokens`, {
|
||||
tokens,
|
||||
});
|
||||
return response.data.sentence;
|
||||
},
|
||||
|
||||
// Delete a sentence
|
||||
async deleteSentence(sentenceId: string): Promise<void> {
|
||||
await api.delete(`/api/sentences/${sentenceId}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,8 +5,14 @@ import { WordGrid } from '../components/dictionary/WordGrid';
|
||||
import { WordDetailModal } from '../components/dictionary/WordDetailModal';
|
||||
import { Term, TermFilters } from '../types/term';
|
||||
import { fetchTerms } from '../lib/termApi';
|
||||
import { useSentenceStore } from '../stores/sentenceStore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
function Dictionary() {
|
||||
const navigate = useNavigate();
|
||||
const addToken = useSentenceStore((state) => state.addToken);
|
||||
|
||||
const [terms, setTerms] = useState<Term[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedTerm, setSelectedTerm] = useState<Term | null>(null);
|
||||
@@ -54,9 +60,14 @@ function Dictionary() {
|
||||
};
|
||||
|
||||
const handleAddToSentence = (term: Term) => {
|
||||
// TODO: Implement in Phase 3 (Znakopis)
|
||||
console.log('Add to sentence:', term);
|
||||
alert(`Dodavanje riječi "${term.wordText}" u rečenicu će biti implementirano u fazi 3.`);
|
||||
addToken(term);
|
||||
toast.success(`Dodano: "${term.wordText}"`, {
|
||||
description: 'Riječ je dodana u rečenicu. Idite na Znakopis za uređivanje.',
|
||||
action: {
|
||||
label: 'Idi na Znakopis',
|
||||
onClick: () => navigate('/znakopis'),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
194
packages/frontend/src/pages/Znakopis.tsx
Normal file
194
packages/frontend/src/pages/Znakopis.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Layout } from '../components/layout/Layout';
|
||||
import { TokenTray } from '../components/znakopis/TokenTray';
|
||||
import { DocumentPanel } from '../components/znakopis/DocumentPanel';
|
||||
import { useSentenceStore } from '../stores/sentenceStore';
|
||||
import { documentApi, Document } from '../lib/documentApi';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Save, FileText, Plus } from 'lucide-react';
|
||||
|
||||
function Znakopis() {
|
||||
const { currentTokens, clearTokens } = useSentenceStore();
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [selectedDocument, setSelectedDocument] = useState<Document | null>(null);
|
||||
const [currentPageIndex, setCurrentPageIndex] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadDocuments();
|
||||
}, []);
|
||||
|
||||
const loadDocuments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const docs = await documentApi.getDocuments();
|
||||
setDocuments(docs);
|
||||
} catch (error) {
|
||||
console.error('Failed to load documents:', error);
|
||||
toast.error('Greška pri učitavanju dokumenata');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDocument = async () => {
|
||||
if (currentTokens.length === 0) {
|
||||
toast.error('Dodajte riječi u rečenicu prije spremanja');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
let document = selectedDocument;
|
||||
|
||||
// Create new document if none selected
|
||||
if (!document) {
|
||||
const title = `Dokument ${new Date().toLocaleDateString('hr-HR')}`;
|
||||
document = await documentApi.createDocument({
|
||||
title,
|
||||
description: 'Novi dokument',
|
||||
});
|
||||
setSelectedDocument(document);
|
||||
}
|
||||
|
||||
// Create sentence with current tokens
|
||||
const tokens = currentTokens.map((token) => ({
|
||||
termId: token.termId,
|
||||
displayText: token.displayText,
|
||||
isPunctuation: token.isPunctuation,
|
||||
}));
|
||||
|
||||
await documentApi.createSentence(document.id, currentPageIndex, { tokens });
|
||||
|
||||
// Reload document to get updated data
|
||||
const updatedDoc = await documentApi.getDocument(document.id);
|
||||
setSelectedDocument(updatedDoc);
|
||||
|
||||
// Update documents list
|
||||
await loadDocuments();
|
||||
|
||||
// Clear current tokens
|
||||
clearTokens();
|
||||
|
||||
toast.success('Rečenica spremljena!');
|
||||
} catch (error) {
|
||||
console.error('Failed to save document:', error);
|
||||
toast.error('Greška pri spremanju dokumenta');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadDocument = async (documentId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const doc = await documentApi.getDocument(documentId);
|
||||
setSelectedDocument(doc);
|
||||
setCurrentPageIndex(0);
|
||||
toast.success(`Učitan dokument: ${doc.title}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to load document:', error);
|
||||
toast.error('Greška pri učitavanju dokumenta');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewDocument = () => {
|
||||
setSelectedDocument(null);
|
||||
setCurrentPageIndex(0);
|
||||
clearTokens();
|
||||
toast.info('Novi dokument');
|
||||
};
|
||||
|
||||
const handleNewPage = async () => {
|
||||
if (!selectedDocument) {
|
||||
toast.error('Prvo spremite dokument');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextPageIndex = selectedDocument.pages.length;
|
||||
await documentApi.createPage(selectedDocument.id, `Stranica ${nextPageIndex + 1}`);
|
||||
|
||||
// Reload document
|
||||
const updatedDoc = await documentApi.getDocument(selectedDocument.id);
|
||||
setSelectedDocument(updatedDoc);
|
||||
setCurrentPageIndex(nextPageIndex);
|
||||
|
||||
toast.success('Nova stranica dodana');
|
||||
} catch (error) {
|
||||
console.error('Failed to create page:', error);
|
||||
toast.error('Greška pri dodavanju stranice');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSentence = async (sentenceId: string) => {
|
||||
try {
|
||||
await documentApi.deleteSentence(sentenceId);
|
||||
|
||||
// Reload document
|
||||
if (selectedDocument) {
|
||||
const updatedDoc = await documentApi.getDocument(selectedDocument.id);
|
||||
setSelectedDocument(updatedDoc);
|
||||
}
|
||||
|
||||
toast.success('Rečenica obrisana');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete sentence:', error);
|
||||
toast.error('Greška pri brisanju rečenice');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Znakopis</h1>
|
||||
<p className="text-gray-600">Složite riječi u rečenice i spremite dokument</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleNewDocument} variant="outline">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Novi dokument
|
||||
</Button>
|
||||
<Button onClick={handleSaveDocument} disabled={saving || currentTokens.length === 0}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{saving ? 'Spremanje...' : 'Spremi'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left: Token Tray (2/3 width) */}
|
||||
<div className="lg:col-span-2">
|
||||
<TokenTray />
|
||||
</div>
|
||||
|
||||
{/* Right: Document Panel (1/3 width) */}
|
||||
<div className="lg:col-span-1">
|
||||
<DocumentPanel
|
||||
documents={documents}
|
||||
selectedDocument={selectedDocument}
|
||||
currentPageIndex={currentPageIndex}
|
||||
onLoadDocument={handleLoadDocument}
|
||||
onNewPage={handleNewPage}
|
||||
onPageChange={setCurrentPageIndex}
|
||||
onDeleteSentence={handleDeleteSentence}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default Znakopis;
|
||||
|
||||
68
packages/frontend/src/stores/sentenceStore.ts
Normal file
68
packages/frontend/src/stores/sentenceStore.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { Term } from '../types/term';
|
||||
|
||||
export interface SentenceToken {
|
||||
id: string;
|
||||
termId: string;
|
||||
term: Term;
|
||||
displayText: string;
|
||||
isPunctuation: boolean;
|
||||
}
|
||||
|
||||
interface SentenceState {
|
||||
// Current working sentence (tokens being built)
|
||||
currentTokens: SentenceToken[];
|
||||
|
||||
// Actions
|
||||
addToken: (term: Term) => void;
|
||||
removeToken: (tokenId: string) => void;
|
||||
reorderTokens: (tokens: SentenceToken[]) => void;
|
||||
clearTokens: () => void;
|
||||
setTokens: (tokens: SentenceToken[]) => void;
|
||||
}
|
||||
|
||||
export const useSentenceStore = create<SentenceState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
currentTokens: [],
|
||||
|
||||
addToken: (term: Term) => {
|
||||
set((state) => {
|
||||
const newToken: SentenceToken = {
|
||||
id: `token-${Date.now()}-${Math.random()}`,
|
||||
termId: term.id,
|
||||
term,
|
||||
displayText: term.wordText,
|
||||
isPunctuation: false,
|
||||
};
|
||||
return {
|
||||
currentTokens: [...state.currentTokens, newToken],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeToken: (tokenId: string) => {
|
||||
set((state) => ({
|
||||
currentTokens: state.currentTokens.filter((token) => token.id !== tokenId),
|
||||
}));
|
||||
},
|
||||
|
||||
reorderTokens: (tokens: SentenceToken[]) => {
|
||||
set({ currentTokens: tokens });
|
||||
},
|
||||
|
||||
clearTokens: () => {
|
||||
set({ currentTokens: [] });
|
||||
},
|
||||
|
||||
setTokens: (tokens: SentenceToken[]) => {
|
||||
set({ currentTokens: tokens });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'sentence-storage',
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@@ -117,6 +117,9 @@ importers:
|
||||
'@dnd-kit/sortable':
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@18.3.1)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.0.5
|
||||
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -159,6 +162,9 @@ importers:
|
||||
react-router-dom:
|
||||
specifier: ^6.21.1
|
||||
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
tailwind-merge:
|
||||
specifier: ^2.2.0
|
||||
version: 2.6.0
|
||||
@@ -2702,6 +2708,12 @@ packages:
|
||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
sonner@2.0.7:
|
||||
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -5438,6 +5450,11 @@ snapshots:
|
||||
|
||||
slash@3.0.0: {}
|
||||
|
||||
sonner@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
spawn-command@0.0.2: {}
|
||||
|
||||
Reference in New Issue
Block a user