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.
|
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 0:** Project Setup - COMPLETE
|
||||||
✅ **Phase 1:** Authentication & User Management - COMPLETE
|
✅ **Phase 1:** Authentication & User Management - COMPLETE
|
||||||
✅ **Phase 2:** Dictionary Module - 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
|
## 🎯 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 authRoutes from './routes/auth.js';
|
||||||
import userRoutes from './routes/users.js';
|
import userRoutes from './routes/users.js';
|
||||||
import termRoutes from './routes/terms.js';
|
import termRoutes from './routes/terms.js';
|
||||||
|
import documentRoutes from './routes/documents.js';
|
||||||
|
import sentenceRoutes from './routes/sentences.js';
|
||||||
|
|
||||||
// Static file serving for uploads
|
// Static file serving for uploads
|
||||||
const uploadsPath = path.join(__dirname, '..', '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/auth', authRoutes);
|
||||||
app.use('/api/users', userRoutes);
|
app.use('/api/users', userRoutes);
|
||||||
app.use('/api/terms', termRoutes);
|
app.use('/api/terms', termRoutes);
|
||||||
|
app.use('/api/documents', documentRoutes);
|
||||||
|
app.use('/api', sentenceRoutes);
|
||||||
|
|
||||||
// Health check route
|
// Health check route
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.1.0",
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
"@radix-ui/react-label": "^2.0.2",
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.21.1",
|
"react-router-dom": "^6.21.1",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^2.2.0",
|
"tailwind-merge": "^2.2.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^3.22.4",
|
"zod": "^3.22.4",
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import Home from './pages/Home';
|
|||||||
import { Login } from './pages/Login';
|
import { Login } from './pages/Login';
|
||||||
import { Admin } from './pages/Admin';
|
import { Admin } from './pages/Admin';
|
||||||
import Dictionary from './pages/Dictionary';
|
import Dictionary from './pages/Dictionary';
|
||||||
|
import Znakopis from './pages/Znakopis';
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||||
import { useAuthStore } from './stores/authStore';
|
import { useAuthStore } from './stores/authStore';
|
||||||
|
import { Toaster } from 'sonner';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { checkAuth } = useAuthStore();
|
const { checkAuth } = useAuthStore();
|
||||||
@@ -15,37 +17,41 @@ function App() {
|
|||||||
}, [checkAuth]);
|
}, [checkAuth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router>
|
<>
|
||||||
<Routes>
|
<Toaster position="top-right" richColors />
|
||||||
<Route path="/login" element={<Login />} />
|
<Router>
|
||||||
<Route
|
<Routes>
|
||||||
path="/"
|
<Route path="/login" element={<Login />} />
|
||||||
element={
|
<Route
|
||||||
<ProtectedRoute>
|
path="/"
|
||||||
<Home />
|
element={
|
||||||
</ProtectedRoute>
|
<ProtectedRoute>
|
||||||
}
|
<Home />
|
||||||
/>
|
</ProtectedRoute>
|
||||||
<Route
|
}
|
||||||
path="/admin"
|
/>
|
||||||
element={
|
<Route
|
||||||
<ProtectedRoute requireAdmin>
|
path="/admin"
|
||||||
<Admin />
|
element={
|
||||||
</ProtectedRoute>
|
<ProtectedRoute requireAdmin>
|
||||||
}
|
<Admin />
|
||||||
/>
|
</ProtectedRoute>
|
||||||
{/* Dictionary */}
|
}
|
||||||
<Route path="/dictionary" element={<ProtectedRoute><Dictionary /></ProtectedRoute>} />
|
/>
|
||||||
{/* Placeholder routes for other pages */}
|
{/* Dictionary */}
|
||||||
<Route path="/znakopis" element={<ProtectedRoute><div>Znakopis (Coming Soon)</div></ProtectedRoute>} />
|
<Route path="/dictionary" element={<ProtectedRoute><Dictionary /></ProtectedRoute>} />
|
||||||
<Route path="/video-sentence" element={<ProtectedRoute><div>Video Sentence (Coming Soon)</div></ProtectedRoute>} />
|
{/* Znakopis */}
|
||||||
<Route path="/cloud" element={<ProtectedRoute><div>Cloud (Coming Soon)</div></ProtectedRoute>} />
|
<Route path="/znakopis" element={<ProtectedRoute><Znakopis /></ProtectedRoute>} />
|
||||||
<Route path="/help" element={<ProtectedRoute><div>Help (Coming Soon)</div></ProtectedRoute>} />
|
{/* Placeholder routes for other pages */}
|
||||||
<Route path="/community" element={<ProtectedRoute><div>Community (Coming Soon)</div></ProtectedRoute>} />
|
<Route path="/video-sentence" element={<ProtectedRoute><div>Video Sentence (Coming Soon)</div></ProtectedRoute>} />
|
||||||
<Route path="/comments" element={<ProtectedRoute><div>Comments (Coming Soon)</div></ProtectedRoute>} />
|
<Route path="/cloud" element={<ProtectedRoute><div>Cloud (Coming Soon)</div></ProtectedRoute>} />
|
||||||
<Route path="/bug-report" element={<ProtectedRoute><div>Bug Report (Coming Soon)</div></ProtectedRoute>} />
|
<Route path="/help" element={<ProtectedRoute><div>Help (Coming Soon)</div></ProtectedRoute>} />
|
||||||
</Routes>
|
<Route path="/community" element={<ProtectedRoute><div>Community (Coming Soon)</div></ProtectedRoute>} />
|
||||||
</Router>
|
<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 { WordDetailModal } from '../components/dictionary/WordDetailModal';
|
||||||
import { Term, TermFilters } from '../types/term';
|
import { Term, TermFilters } from '../types/term';
|
||||||
import { fetchTerms } from '../lib/termApi';
|
import { fetchTerms } from '../lib/termApi';
|
||||||
|
import { useSentenceStore } from '../stores/sentenceStore';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
function Dictionary() {
|
function Dictionary() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const addToken = useSentenceStore((state) => state.addToken);
|
||||||
|
|
||||||
const [terms, setTerms] = useState<Term[]>([]);
|
const [terms, setTerms] = useState<Term[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedTerm, setSelectedTerm] = useState<Term | null>(null);
|
const [selectedTerm, setSelectedTerm] = useState<Term | null>(null);
|
||||||
@@ -54,9 +60,14 @@ function Dictionary() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddToSentence = (term: Term) => {
|
const handleAddToSentence = (term: Term) => {
|
||||||
// TODO: Implement in Phase 3 (Znakopis)
|
addToken(term);
|
||||||
console.log('Add to sentence:', term);
|
toast.success(`Dodano: "${term.wordText}"`, {
|
||||||
alert(`Dodavanje riječi "${term.wordText}" u rečenicu će biti implementirano u fazi 3.`);
|
description: 'Riječ je dodana u rečenicu. Idite na Znakopis za uređivanje.',
|
||||||
|
action: {
|
||||||
|
label: 'Idi na Znakopis',
|
||||||
|
onClick: () => navigate('/znakopis'),
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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':
|
'@dnd-kit/sortable':
|
||||||
specifier: ^8.0.0
|
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)
|
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':
|
'@radix-ui/react-dialog':
|
||||||
specifier: ^1.0.5
|
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)
|
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:
|
react-router-dom:
|
||||||
specifier: ^6.21.1
|
specifier: ^6.21.1
|
||||||
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.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:
|
tailwind-merge:
|
||||||
specifier: ^2.2.0
|
specifier: ^2.2.0
|
||||||
version: 2.6.0
|
version: 2.6.0
|
||||||
@@ -2702,6 +2708,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||||
engines: {node: '>=8'}
|
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:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -5438,6 +5450,11 @@ snapshots:
|
|||||||
|
|
||||||
slash@3.0.0: {}
|
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: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
spawn-command@0.0.2: {}
|
spawn-command@0.0.2: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user