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

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