Merge branch 'main' into narl.io

This commit is contained in:
nvrl committed 2026-02-17 20:28:32 +01:00
commit 613b453833
2 files changed
+89 -49

No files matched your search

+55 -30
View File
@@ -39,16 +39,65 @@ class FlavorViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
def get_queryset(self):
return Flavor.objects.select_related('category').annotate(average_rating=Avg('ratings__score')).order_by('-average_rating').distinct()
qs = Flavor.objects.select_related('category').annotate(average_rating=Avg('ratings__score')).order_by('-average_rating').distinct()
# Handle category keywords in search query for main results too
search_query = self.request.query_params.get('search', '').lower().strip()
if search_query:
cat_keywords = {
'iced tea': 'iced-tea',
'eistee': 'iced-tea',
'energy': 'energy',
'hydration': 'hydration',
'milkshake': 'milkshake'
}
for keyword, slug in cat_keywords.items():
if keyword in search_query:
qs = qs.filter(category__slug=slug)
break # Only one category at a time
return qs
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
def search(self, request):
# Global unpaginated search for autocomplete
results = []
query = request.query_params.get('q', '').lower().strip()
if not query:
return Response([])
# Start with all flavors
flavors = Flavor.objects.select_related('category').distinct()
# Intelligent category filtering based on keywords
cat_keywords = {
'iced tea': 'iced-tea',
'eistee': 'iced-tea',
'energy': 'energy',
'hydration': 'hydration',
'milkshake': 'milkshake'
}
active_cat_slug = None
remaining_query = query
for keyword, slug in cat_keywords.items():
if keyword in query:
active_cat_slug = slug
remaining_query = remaining_query.replace(keyword, '').strip()
break
# 1. Flavors
flavors = Flavor.objects.select_related('category').order_by('name').distinct()
for f in flavors:
if active_cat_slug:
flavors = flavors.filter(category__slug=active_cat_slug)
# Filter by remaining query words in name or description
if remaining_query:
words = remaining_query.split()
for word in words:
flavors = flavors.filter(
Q(name__icontains=word) | Q(description__icontains=word)
)
# Limit results for performance
results = []
for f in flavors.order_by('name')[:15]:
results.append({
'id': f.id,
'name': f.name,
@@ -57,30 +106,6 @@ class FlavorViewSet(viewsets.ModelViewSet):
'image_url': request.build_absolute_uri(f.image.url) if f.image else f.image_url,
'slug': None
})
# 2. Categories
categories = Category.objects.all().order_by('name')
for c in categories:
results.append({
'id': c.id,
'name': c.name,
'type': 'category',
'subtitle': 'Category',
'image_url': None,
'slug': c.slug
})
# 3. Users
users = User.objects.filter(is_active=True).order_by('username')
for u in users:
results.append({
'id': u.id,
'name': u.username,
'type': 'user',
'subtitle': 'Community Member',
'image_url': request.build_absolute_uri(u.avatar.url) if u.avatar else None,
'slug': u.username
})
return Response(results)
+34 -19
View File
@@ -79,28 +79,37 @@ const GlobalSearch = () => {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [options, setOptions] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const location = useLocation();
// Debounced search
useEffect(() => {
const fetchSearchOptions = async () => {
if (!query.trim()) {
setOptions([]);
return;
}
const timer = setTimeout(async () => {
setLoading(true);
try {
// Fetch unified results: flavors, categories, and users
const res = await api.get('flavors/search/');
// Filter to only flavors
const results = (Array.isArray(res.data) ? res.data : (res.data.results || []))
.filter((item: any) => item.type === 'flavor');
const res = await api.get(`flavors/search/?q=${encodeURIComponent(query)}`);
const results = Array.isArray(res.data) ? res.data : (res.data.results || []);
setOptions(results);
} catch (err) {
console.error('Failed to fetch search options');
console.error('Search failed');
} finally {
setLoading(false);
}
};
fetchSearchOptions();
}, []);
}, 300);
return () => clearTimeout(timer);
}, [query]);
useEffect(() => {
const params = new URLSearchParams(location.search);
setQuery(params.get('q') || '');
const q = params.get('q');
if (q) setQuery(q);
}, [location.search]);
const handleSearch = (e: React.FormEvent) => {
@@ -118,7 +127,9 @@ const GlobalSearch = () => {
fullWidth
freeSolo
size="small"
loading={loading}
options={options}
filterOptions={(x) => x} // Disable local filtering as we do it on server
getOptionLabel={(option) => {
if (typeof option === 'string') return option;
return option.name || '';
@@ -155,6 +166,12 @@ const GlobalSearch = () => {
<SearchIcon sx={{ color: 'inherit', opacity: 0.7 }} />
</InputAdornment>
),
endAdornment: (
<React.Fragment>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
/>
)}
@@ -162,15 +179,13 @@ const GlobalSearch = () => {
const { key, ...optionProps } = props as any;
return (
<Box component="li" key={key} {...optionProps} sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{option.image_url ? (
<Avatar src={option.image_url} sx={{ width: 32, height: 32, borderRadius: 1 }} />
) : (
<Avatar sx={{ width: 32, height: 32, borderRadius: 1, bgcolor: 'primary.main', fontSize: '0.8rem' }}>
{option.name.charAt(0).toUpperCase()}
</Avatar>
)}
<Box
component="img"
src={option.image_url || undefined}
sx={{ width: 32, height: 32, borderRadius: 1, objectFit: 'contain', bgcolor: 'action.hover', p: 0.5 }}
/>
<Box>
<Typography variant="body2">{option.name}</Typography>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{option.name}</Typography>
<Typography variant="caption" color="text.secondary">{option.subtitle}</Typography>
</Box>
</Box>