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) => {
|
||||
|
||||
22
packages/frontend/components.json
Normal file
22
packages/frontend/components.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -11,25 +11,26 @@
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"axios": "^1.6.5",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.303.0",
|
||||
"plyr-react": "^5.3.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"zustand": "^4.4.7",
|
||||
"axios": "^1.6.5",
|
||||
"clsx": "^2.1.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"lucide-react": "^0.303.0",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"plyr-react": "^5.3.0",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"zod": "^3.22.4"
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.48",
|
||||
@@ -47,4 +48,3 @@
|
||||
"vite": "^5.0.11"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect } from 'react';
|
||||
import Home from './pages/Home';
|
||||
import { Login } from './pages/Login';
|
||||
import { Admin } from './pages/Admin';
|
||||
import Dictionary from './pages/Dictionary';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
|
||||
@@ -33,8 +34,9 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Dictionary */}
|
||||
<Route path="/dictionary" element={<ProtectedRoute><Dictionary /></ProtectedRoute>} />
|
||||
{/* Placeholder routes for other pages */}
|
||||
<Route path="/dictionary" element={<ProtectedRoute><div>Dictionary (Coming Soon)</div></ProtectedRoute>} />
|
||||
<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>} />
|
||||
|
||||
87
packages/frontend/src/components/dictionary/FilterBar.tsx
Normal file
87
packages/frontend/src/components/dictionary/FilterBar.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import { Input } from '../ui/input';
|
||||
import { WordType, CefrLevel, TermFilters } from '../../types/term';
|
||||
|
||||
interface FilterBarProps {
|
||||
filters: TermFilters;
|
||||
onFilterChange: (filters: TermFilters) => void;
|
||||
}
|
||||
|
||||
export function FilterBar({ filters, onFilterChange }: FilterBarProps) {
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onFilterChange({ ...filters, query: e.target.value, page: 1 });
|
||||
};
|
||||
|
||||
const handleWordTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
onFilterChange({
|
||||
...filters,
|
||||
wordType: e.target.value as WordType | '',
|
||||
page: 1
|
||||
});
|
||||
};
|
||||
|
||||
const handleCefrLevelChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
onFilterChange({
|
||||
...filters,
|
||||
cefrLevel: e.target.value as CefrLevel | '',
|
||||
page: 1
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-lg shadow mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-5 w-5" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Pretraži riječi..."
|
||||
value={filters.query || ''}
|
||||
onChange={handleSearchChange}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Word Type Filter */}
|
||||
<div>
|
||||
<select
|
||||
value={filters.wordType || ''}
|
||||
onChange={handleWordTypeChange}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="">Sve vrste riječi</option>
|
||||
<option value={WordType.NOUN}>Imenica</option>
|
||||
<option value={WordType.VERB}>Glagol</option>
|
||||
<option value={WordType.ADJECTIVE}>Pridjev</option>
|
||||
<option value={WordType.ADVERB}>Prilog</option>
|
||||
<option value={WordType.PRONOUN}>Zamjenica</option>
|
||||
<option value={WordType.PREPOSITION}>Prijedlog</option>
|
||||
<option value={WordType.CONJUNCTION}>Veznik</option>
|
||||
<option value={WordType.INTERJECTION}>Uzvik</option>
|
||||
<option value={WordType.PHRASE}>Fraza</option>
|
||||
<option value={WordType.OTHER}>Ostalo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* CEFR Level Filter */}
|
||||
<div>
|
||||
<select
|
||||
value={filters.cefrLevel || ''}
|
||||
onChange={handleCefrLevelChange}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="">Sve razine</option>
|
||||
<option value={CefrLevel.A1}>A1 - Početnik</option>
|
||||
<option value={CefrLevel.A2}>A2 - Osnovno</option>
|
||||
<option value={CefrLevel.B1}>B1 - Srednje</option>
|
||||
<option value={CefrLevel.B2}>B2 - Više srednje</option>
|
||||
<option value={CefrLevel.C1}>C1 - Napredno</option>
|
||||
<option value={CefrLevel.C2}>C2 - Majstorsko</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
106
packages/frontend/src/components/dictionary/WordCard.tsx
Normal file
106
packages/frontend/src/components/dictionary/WordCard.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Info, Plus } from 'lucide-react';
|
||||
import { Term, CefrLevel } from '../../types/term';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
interface WordCardProps {
|
||||
term: Term;
|
||||
onInfo: (term: Term) => void;
|
||||
onAddToSentence?: (term: Term) => void;
|
||||
}
|
||||
|
||||
const cefrColors: Record<CefrLevel, string> = {
|
||||
[CefrLevel.A1]: 'bg-green-500',
|
||||
[CefrLevel.A2]: 'bg-green-400',
|
||||
[CefrLevel.B1]: 'bg-yellow-500',
|
||||
[CefrLevel.B2]: 'bg-yellow-400',
|
||||
[CefrLevel.C1]: 'bg-orange-500',
|
||||
[CefrLevel.C2]: 'bg-red-500',
|
||||
};
|
||||
|
||||
const wordTypeLabels: Record<string, string> = {
|
||||
NOUN: 'Imenica',
|
||||
VERB: 'Glagol',
|
||||
ADJECTIVE: 'Pridjev',
|
||||
ADVERB: 'Prilog',
|
||||
PRONOUN: 'Zamjenica',
|
||||
PREPOSITION: 'Prijedlog',
|
||||
CONJUNCTION: 'Veznik',
|
||||
INTERJECTION: 'Uzvik',
|
||||
PHRASE: 'Fraza',
|
||||
OTHER: 'Ostalo',
|
||||
};
|
||||
|
||||
export function WordCard({ term, onInfo, onAddToSentence }: WordCardProps) {
|
||||
const videoMedia = term.media?.find(m => m.kind === 'VIDEO');
|
||||
const imageMedia = term.media?.find(m => m.kind === 'IMAGE' || m.kind === 'ILLUSTRATION');
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow hover:shadow-lg transition-shadow p-4 flex flex-col">
|
||||
{/* CEFR Level Indicator */}
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<span className={`${cefrColors[term.cefrLevel]} text-white text-xs font-bold px-2 py-1 rounded`}>
|
||||
{term.cefrLevel}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{wordTypeLabels[term.wordType] || term.wordType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Icon/Image */}
|
||||
<div className="flex-1 flex items-center justify-center mb-4 min-h-[120px]">
|
||||
{imageMedia ? (
|
||||
<img
|
||||
src={imageMedia.url}
|
||||
alt={term.wordText}
|
||||
className="max-h-[120px] max-w-full object-contain"
|
||||
/>
|
||||
) : videoMedia ? (
|
||||
<div className="w-full h-[120px] bg-gray-100 rounded flex items-center justify-center">
|
||||
<span className="text-gray-400 text-sm">Video</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-[120px] bg-gray-100 rounded flex items-center justify-center">
|
||||
<span className="text-4xl">📝</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Word Text */}
|
||||
<h3 className="text-lg font-semibold text-center mb-3 text-gray-900">
|
||||
{term.wordText}
|
||||
</h3>
|
||||
|
||||
{/* Short Description */}
|
||||
{term.shortDescription && (
|
||||
<p className="text-sm text-gray-600 text-center mb-4 line-clamp-2">
|
||||
{term.shortDescription}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 mt-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => onInfo(term)}
|
||||
>
|
||||
<Info className="h-4 w-4 mr-1" />
|
||||
Info
|
||||
</Button>
|
||||
{onAddToSentence && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => onAddToSentence(term)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Dodaj
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
150
packages/frontend/src/components/dictionary/WordDetailModal.tsx
Normal file
150
packages/frontend/src/components/dictionary/WordDetailModal.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Term, CefrLevel } from '../../types/term';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
|
||||
interface WordDetailModalProps {
|
||||
term: Term | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAddToSentence?: (term: Term) => void;
|
||||
}
|
||||
|
||||
const cefrColors: Record<CefrLevel, string> = {
|
||||
[CefrLevel.A1]: 'bg-green-500',
|
||||
[CefrLevel.A2]: 'bg-green-400',
|
||||
[CefrLevel.B1]: 'bg-yellow-500',
|
||||
[CefrLevel.B2]: 'bg-yellow-400',
|
||||
[CefrLevel.C1]: 'bg-orange-500',
|
||||
[CefrLevel.C2]: 'bg-red-500',
|
||||
};
|
||||
|
||||
const wordTypeLabels: Record<string, string> = {
|
||||
NOUN: 'Imenica',
|
||||
VERB: 'Glagol',
|
||||
ADJECTIVE: 'Pridjev',
|
||||
ADVERB: 'Prilog',
|
||||
PRONOUN: 'Zamjenica',
|
||||
PREPOSITION: 'Prijedlog',
|
||||
CONJUNCTION: 'Veznik',
|
||||
INTERJECTION: 'Uzvik',
|
||||
PHRASE: 'Fraza',
|
||||
OTHER: 'Ostalo',
|
||||
};
|
||||
|
||||
export function WordDetailModal({ term, open, onClose, onAddToSentence }: WordDetailModalProps) {
|
||||
if (!term) return null;
|
||||
|
||||
const videoMedia = term.media?.find(m => m.kind === 'VIDEO');
|
||||
const imageMedia = term.media?.find(m => m.kind === 'IMAGE' || m.kind === 'ILLUSTRATION');
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between">
|
||||
<span className="text-2xl font-bold">{term.wordText}</span>
|
||||
<span className={`${cefrColors[term.cefrLevel]} text-white text-sm font-bold px-3 py-1 rounded`}>
|
||||
{term.cefrLevel}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Word Type */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">Vrsta riječi</p>
|
||||
<p className="text-base font-medium">{wordTypeLabels[term.wordType] || term.wordType}</p>
|
||||
</div>
|
||||
|
||||
{/* Video Player */}
|
||||
{videoMedia && (
|
||||
<div className="bg-gray-100 rounded-lg overflow-hidden">
|
||||
<video
|
||||
src={videoMedia.url}
|
||||
controls
|
||||
loop
|
||||
className="w-full"
|
||||
style={{ maxHeight: '400px' }}
|
||||
>
|
||||
Vaš preglednik ne podržava video reprodukciju.
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image (if no video) */}
|
||||
{!videoMedia && imageMedia && (
|
||||
<div className="flex justify-center bg-gray-100 rounded-lg p-6">
|
||||
<img
|
||||
src={imageMedia.url}
|
||||
alt={term.wordText}
|
||||
className="max-h-[300px] max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{term.shortDescription && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">Opis</p>
|
||||
<p className="text-base">{term.shortDescription}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Examples */}
|
||||
{term.examples && term.examples.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-2">Primjeri</p>
|
||||
<div className="space-y-2">
|
||||
{term.examples.map((example) => (
|
||||
<div key={example.id} className="bg-gray-50 p-3 rounded">
|
||||
<p className="text-base">{example.exampleText}</p>
|
||||
{example.notes && (
|
||||
<p className="text-sm text-gray-600 mt-1">{example.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{term.tags && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-2">Oznake</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{JSON.parse(term.tags).map((tag: string, index: number) => (
|
||||
<span
|
||||
key={index}
|
||||
className="bg-indigo-100 text-indigo-800 text-xs font-medium px-2.5 py-0.5 rounded"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-4 border-t">
|
||||
<Button variant="outline" onClick={onClose} className="flex-1">
|
||||
Zatvori
|
||||
</Button>
|
||||
{onAddToSentence && (
|
||||
<Button onClick={() => { onAddToSentence(term); onClose(); }} className="flex-1">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Dodaj u rečenicu
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
100
packages/frontend/src/components/dictionary/WordGrid.tsx
Normal file
100
packages/frontend/src/components/dictionary/WordGrid.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Term } from '../../types/term';
|
||||
import { WordCard } from './WordCard';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
interface WordGridProps {
|
||||
terms: Term[];
|
||||
loading?: boolean;
|
||||
onInfo: (term: Term) => void;
|
||||
onAddToSentence?: (term: Term) => void;
|
||||
pagination?: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
onPageChange?: (page: number) => void;
|
||||
}
|
||||
|
||||
export function WordGrid({
|
||||
terms,
|
||||
loading,
|
||||
onInfo,
|
||||
onAddToSentence,
|
||||
pagination,
|
||||
onPageChange,
|
||||
}: WordGridProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Učitavanje...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (terms.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600 text-lg mb-2">Nema pronađenih riječi</p>
|
||||
<p className="text-gray-500 text-sm">Pokušajte promijeniti filtere ili pretragu</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 mb-6">
|
||||
{terms.map((term) => (
|
||||
<WordCard
|
||||
key={term.id}
|
||||
term={term}
|
||||
onInfo={onInfo}
|
||||
onAddToSentence={onAddToSentence}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between bg-white p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-600">
|
||||
Stranica {pagination.page} od {pagination.totalPages}
|
||||
<span className="ml-2">
|
||||
(Ukupno: {pagination.total} {pagination.total === 1 ? 'riječ' : 'riječi'})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange?.(pagination.page - 1)}
|
||||
disabled={pagination.page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Prethodna
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange?.(pagination.page + 1)}
|
||||
disabled={pagination.page === pagination.totalPages}
|
||||
>
|
||||
Sljedeća
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
120
packages/frontend/src/components/ui/dialog.tsx
Normal file
120
packages/frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -5,59 +5,64 @@
|
||||
@layer base {
|
||||
:root {
|
||||
/* Indigo-50 (#eef2ff) - Primary background */
|
||||
--background: 238 100% 97%;
|
||||
--background: 0 0% 100%;
|
||||
/* Slate-900 (#101828) - Primary text */
|
||||
--foreground: 222 47% 11%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222 47% 11%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
/* Indigo-600 (#4f39f6) - Primary accent color */
|
||||
--primary: 248 91% 60%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
/* Indigo-50 (#eef2ff) - Secondary background */
|
||||
--secondary: 238 100% 97%;
|
||||
--secondary-foreground: 248 91% 60%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
/* Slate-100 (#f4f4f6) - Muted background */
|
||||
--muted: 240 5% 96%;
|
||||
--muted: 0 0% 96.1%;
|
||||
/* Slate-600 (#4a5565) - Secondary text */
|
||||
--muted-foreground: 218 15% 35%;
|
||||
--accent: 238 100% 97%;
|
||||
--accent-foreground: 248 91% 60%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
/* Slate-300 (#d0d5e2) - Borders */
|
||||
--border: 225 20% 85%;
|
||||
--input: 225 20% 85%;
|
||||
--ring: 248 91% 60%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* CEFR Level Colors */
|
||||
--cefr-a1-a2: 145 100% 33%; /* Green-600 (#00a63e) */
|
||||
--cefr-b1-b2: 32 100% 51%; /* Orange-500 (#ff8904) */
|
||||
--cefr-c1-c2: 14 100% 57%; /* Red-Orange (#ff5c33) */
|
||||
--cefr-c1-c2: 14 100% 57%; /* Red-Orange (#ff5c33) */ --chart-1: 12 76% 61%; --chart-2: 173 58% 39%; --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
27
packages/frontend/src/lib/termApi.ts
Normal file
27
packages/frontend/src/lib/termApi.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import api from './api';
|
||||
import { Term, TermsResponse, TermFilters } from '../types/term';
|
||||
|
||||
/**
|
||||
* Fetch terms with optional filters and pagination
|
||||
*/
|
||||
export const fetchTerms = async (filters: TermFilters = {}): Promise<TermsResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters.query) params.append('query', filters.query);
|
||||
if (filters.wordType) params.append('wordType', filters.wordType);
|
||||
if (filters.cefrLevel) params.append('cefrLevel', filters.cefrLevel);
|
||||
if (filters.page) params.append('page', filters.page.toString());
|
||||
if (filters.limit) params.append('limit', filters.limit.toString());
|
||||
|
||||
const response = await api.get<TermsResponse>(`/api/terms?${params.toString()}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch a single term by ID
|
||||
*/
|
||||
export const fetchTermById = async (id: string): Promise<Term> => {
|
||||
const response = await api.get<{ term: Term }>(`/api/terms/${id}`);
|
||||
return response.data.term;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
|
||||
99
packages/frontend/src/pages/Dictionary.tsx
Normal file
99
packages/frontend/src/pages/Dictionary.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Layout } from '../components/layout/Layout';
|
||||
import { FilterBar } from '../components/dictionary/FilterBar';
|
||||
import { WordGrid } from '../components/dictionary/WordGrid';
|
||||
import { WordDetailModal } from '../components/dictionary/WordDetailModal';
|
||||
import { Term, TermFilters } from '../types/term';
|
||||
import { fetchTerms } from '../lib/termApi';
|
||||
|
||||
function Dictionary() {
|
||||
const [terms, setTerms] = useState<Term[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedTerm, setSelectedTerm] = useState<Term | null>(null);
|
||||
const [filters, setFilters] = useState<TermFilters>({
|
||||
query: '',
|
||||
wordType: '',
|
||||
cefrLevel: '',
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadTerms();
|
||||
}, [filters]);
|
||||
|
||||
const loadTerms = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetchTerms(filters);
|
||||
setTerms(response.terms);
|
||||
setPagination(response.pagination);
|
||||
} catch (error) {
|
||||
console.error('Failed to load terms:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = (newFilters: TermFilters) => {
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setFilters({ ...filters, page });
|
||||
};
|
||||
|
||||
const handleTermInfo = (term: Term) => {
|
||||
setSelectedTerm(term);
|
||||
};
|
||||
|
||||
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.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Rječnik</h1>
|
||||
<p className="text-gray-600">
|
||||
Pretražite i pregledajte hrvatski znakovni jezik
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<FilterBar filters={filters} onFilterChange={handleFilterChange} />
|
||||
|
||||
{/* Word Grid */}
|
||||
<WordGrid
|
||||
terms={terms}
|
||||
loading={loading}
|
||||
onInfo={handleTermInfo}
|
||||
onAddToSentence={handleAddToSentence}
|
||||
pagination={pagination}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
|
||||
{/* Word Detail Modal */}
|
||||
<WordDetailModal
|
||||
term={selectedTerm}
|
||||
open={!!selectedTerm}
|
||||
onClose={() => setSelectedTerm(null)}
|
||||
onAddToSentence={handleAddToSentence}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dictionary;
|
||||
|
||||
82
packages/frontend/src/types/term.ts
Normal file
82
packages/frontend/src/types/term.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
export enum WordType {
|
||||
NOUN = 'NOUN',
|
||||
VERB = 'VERB',
|
||||
ADJECTIVE = 'ADJECTIVE',
|
||||
ADVERB = 'ADVERB',
|
||||
PRONOUN = 'PRONOUN',
|
||||
PREPOSITION = 'PREPOSITION',
|
||||
CONJUNCTION = 'CONJUNCTION',
|
||||
INTERJECTION = 'INTERJECTION',
|
||||
PHRASE = 'PHRASE',
|
||||
OTHER = 'OTHER',
|
||||
}
|
||||
|
||||
export enum CefrLevel {
|
||||
A1 = 'A1',
|
||||
A2 = 'A2',
|
||||
B1 = 'B1',
|
||||
B2 = 'B2',
|
||||
C1 = 'C1',
|
||||
C2 = 'C2',
|
||||
}
|
||||
|
||||
export enum MediaKind {
|
||||
VIDEO = 'VIDEO',
|
||||
IMAGE = 'IMAGE',
|
||||
ILLUSTRATION = 'ILLUSTRATION',
|
||||
}
|
||||
|
||||
export interface TermMedia {
|
||||
id: string;
|
||||
termId: string;
|
||||
kind: MediaKind;
|
||||
url: string;
|
||||
durationMs?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
checksum?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TermExample {
|
||||
id: string;
|
||||
termId: string;
|
||||
exampleText: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Term {
|
||||
id: string;
|
||||
wordText: string;
|
||||
normalizedText: string;
|
||||
language: string;
|
||||
wordType: WordType;
|
||||
cefrLevel: CefrLevel;
|
||||
shortDescription?: string;
|
||||
tags?: string;
|
||||
iconAssetId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
media?: TermMedia[];
|
||||
examples?: TermExample[];
|
||||
}
|
||||
|
||||
export interface TermsResponse {
|
||||
terms: Term[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TermFilters {
|
||||
query?: string;
|
||||
wordType?: WordType | '';
|
||||
cefrLevel?: CefrLevel | '';
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@@ -3,57 +3,68 @@ export default {
|
||||
darkMode: ['class'],
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
cefr: {
|
||||
'a1-a2': 'hsl(var(--cefr-a1-a2))',
|
||||
'b1-b2': 'hsl(var(--cefr-b1-b2))',
|
||||
'c1-c2': 'hsl(var(--cefr-c1-c2))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'Inter',
|
||||
'system-ui',
|
||||
'sans-serif'
|
||||
]
|
||||
},
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))'
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))'
|
||||
},
|
||||
cefr: {
|
||||
'a1-a2': 'hsl(var(--cefr-a1-a2))',
|
||||
'b1-b2': 'hsl(var(--cefr-b1-b2))',
|
||||
'c1-c2': 'hsl(var(--cefr-c1-c2))'
|
||||
},
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user