mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
Merge branch 'main' into narl.io
This commit is contained in:
commit
613b453833
2 files changed
+89
-49
No files matched your search
+55
-30
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user