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:
2026-01-17 18:50:53 +01:00
parent 2b768d4c13
commit 8bdd4f6368
17 changed files with 1654 additions and 190 deletions

221
PHASE-3-COMPLETE.md Normal file
View 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

View File

@@ -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

View File

@@ -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.

View 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;

View 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;

View File

@@ -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) => {

View File

@@ -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",

View File

@@ -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>
</>
);
}

View 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,
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}`);
},
};

View File

@@ -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 (

View 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;

View 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
View File

@@ -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: {}