Add dictionary feature with term management and UI components

- Implement backend API for term CRUD operations
- Add frontend dictionary page with search and filtering
- Integrate shadcn/ui components (Dialog)
- Create term management UI with add/edit/delete functionality
- Update database seed with initial terms
- Add API client for term operations
- Complete Phase 2 of development plan
This commit is contained in:
2026-01-17 18:15:01 +01:00
parent bbf143a3b4
commit c6d6c18466
21 changed files with 1757 additions and 176 deletions

View File

@@ -44,7 +44,110 @@ async function main() {
console.log(' Email: demo@znakovni.hr');
console.log(' Password: demo123');
// Add sample terms here in future phases
// Create sample terms
console.log('🌱 Creating sample terms...');
const sampleTerms = [
{
wordText: 'Dobar dan',
normalizedText: 'dobar dan',
wordType: 'PHRASE',
cefrLevel: 'A1',
shortDescription: 'Pozdrav koji se koristi tijekom dana',
tags: JSON.stringify(['pozdrav', 'osnovno']),
},
{
wordText: 'Hvala',
normalizedText: 'hvala',
wordType: 'INTERJECTION',
cefrLevel: 'A1',
shortDescription: 'Izraz zahvalnosti',
tags: JSON.stringify(['zahvala', 'osnovno']),
},
{
wordText: 'Molim',
normalizedText: 'molim',
wordType: 'INTERJECTION',
cefrLevel: 'A1',
shortDescription: 'Izraz ljubaznosti pri traženju nečega',
tags: JSON.stringify(['ljubaznost', 'osnovno']),
},
{
wordText: 'Kuća',
normalizedText: 'kuca',
wordType: 'NOUN',
cefrLevel: 'A1',
shortDescription: 'Zgrada u kojoj ljudi žive',
tags: JSON.stringify(['dom', 'zgrada']),
},
{
wordText: 'Škola',
normalizedText: 'skola',
wordType: 'NOUN',
cefrLevel: 'A1',
shortDescription: 'Ustanova za obrazovanje',
tags: JSON.stringify(['obrazovanje', 'ustanova']),
},
{
wordText: 'Učiti',
normalizedText: 'uciti',
wordType: 'VERB',
cefrLevel: 'A1',
shortDescription: 'Stjecati znanje ili vještine',
tags: JSON.stringify(['obrazovanje', 'aktivnost']),
},
{
wordText: 'Jesti',
normalizedText: 'jesti',
wordType: 'VERB',
cefrLevel: 'A1',
shortDescription: 'Uzimati hranu',
tags: JSON.stringify(['hrana', 'aktivnost']),
},
{
wordText: 'Piti',
normalizedText: 'piti',
wordType: 'VERB',
cefrLevel: 'A1',
shortDescription: 'Uzimati tekućinu',
tags: JSON.stringify(['piće', 'aktivnost']),
},
{
wordText: 'Voda',
normalizedText: 'voda',
wordType: 'NOUN',
cefrLevel: 'A1',
shortDescription: 'Bezbojna tekućina neophodna za život',
tags: JSON.stringify(['piće', 'osnovno']),
},
{
wordText: 'Lijep',
normalizedText: 'lijep',
wordType: 'ADJECTIVE',
cefrLevel: 'A1',
shortDescription: 'Privlačan, ugodan za gledanje',
tags: JSON.stringify(['opis', 'pozitivno']),
},
];
for (const termData of sampleTerms) {
// Check if term already exists
const existing = await prisma.term.findFirst({
where: {
normalizedText: termData.normalizedText,
},
});
if (!existing) {
const term = await prisma.term.create({
data: termData,
});
console.log(` ✅ Created term: ${term.wordText}`);
} else {
console.log(` ⏭️ Term already exists: ${termData.wordText}`);
}
}
console.log('✅ Seed completed successfully!');
}

View File

@@ -0,0 +1,134 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../lib/prisma.js';
import { WordType, CefrLevel } from '@prisma/client';
const router = Router();
/**
* GET /api/terms
* Get all terms with optional filtering, search, and pagination
* Query params:
* - query: search text (searches wordText and normalizedText)
* - wordType: filter by word type (NOUN, VERB, etc.)
* - cefrLevel: filter by CEFR level (A1, A2, B1, B2, C1, C2)
* - page: page number (default: 1)
* - limit: items per page (default: 20)
*/
router.get('/', async (req: Request, res: Response) => {
try {
const {
query = '',
wordType,
cefrLevel,
page = '1',
limit = '20',
} = req.query;
const pageNum = parseInt(page as string, 10);
const limitNum = parseInt(limit as string, 10);
const skip = (pageNum - 1) * limitNum;
// Build where clause
const where: any = {};
// Add search filter
if (query && typeof query === 'string' && query.trim()) {
where.OR = [
{ wordText: { contains: query.trim() } },
{ normalizedText: { contains: query.trim() } },
];
}
// Add wordType filter
if (wordType && typeof wordType === 'string') {
const validWordTypes = Object.values(WordType);
if (validWordTypes.includes(wordType as WordType)) {
where.wordType = wordType as WordType;
}
}
// Add cefrLevel filter
if (cefrLevel && typeof cefrLevel === 'string') {
const validCefrLevels = Object.values(CefrLevel);
if (validCefrLevels.includes(cefrLevel as CefrLevel)) {
where.cefrLevel = cefrLevel as CefrLevel;
}
}
// Get total count for pagination
const total = await prisma.term.count({ where });
// Get terms with media
const terms = await prisma.term.findMany({
where,
include: {
media: {
orderBy: {
createdAt: 'asc',
},
},
examples: {
orderBy: {
createdAt: 'asc',
},
},
},
orderBy: {
wordText: 'asc',
},
skip,
take: limitNum,
});
res.json({
terms,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum),
},
});
} catch (error: any) {
console.error('Error fetching terms:', error);
res.status(500).json({ error: 'Failed to fetch terms', message: error.message });
}
});
/**
* GET /api/terms/:id
* Get a single term by ID with all related data
*/
router.get('/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const term = await prisma.term.findUnique({
where: { id },
include: {
media: {
orderBy: {
createdAt: 'asc',
},
},
examples: {
orderBy: {
createdAt: 'asc',
},
},
},
});
if (!term) {
return res.status(404).json({ error: 'Term not found' });
}
res.json({ term });
} catch (error: any) {
console.error('Error fetching term:', error);
res.status(500).json({ error: 'Failed to fetch term', message: error.message });
}
});
export default router;

View File

@@ -4,11 +4,16 @@ import helmet from 'helmet';
import dotenv from 'dotenv';
import session from 'express-session';
import passport from './lib/passport.js';
import path from 'path';
import { fileURLToPath } from 'url';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3000;
const PORT = parseInt(process.env.PORT || '3000', 10);
// Middleware
app.use(helmet());
@@ -41,10 +46,16 @@ app.use(passport.session());
// Import routes
import authRoutes from './routes/auth.js';
import userRoutes from './routes/users.js';
import termRoutes from './routes/terms.js';
// Static file serving for uploads
const uploadsPath = path.join(__dirname, '..', 'uploads');
app.use('/uploads', express.static(uploadsPath));
// API routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/terms', termRoutes);
// Health check route
app.get('/api/health', (_req, res) => {