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

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