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:
@@ -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!');
|
||||
}
|
||||
|
||||
|
||||
134
packages/backend/src/routes/terms.ts
Normal file
134
packages/backend/src/routes/terms.ts
Normal 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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user