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) => {

View 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": {}
}

View File

@@ -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"
}
}

View File

@@ -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>} />

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
}

View File

@@ -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%;
}
}

View 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;
};

View File

@@ -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))
}

View 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;

View 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;
}

View File

@@ -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")],
};