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;