diff --git a/app/Http/Controllers/FrontMediaController.php b/app/Http/Controllers/FrontMediaController.php index 5f07797..8e9312c 100644 --- a/app/Http/Controllers/FrontMediaController.php +++ b/app/Http/Controllers/FrontMediaController.php @@ -55,4 +55,83 @@ public function background(Request $request) ], ]); } + + public function searchMemes(Request $request) + { + $query = $request->input('query', ''); + $limit = $request->input('limit', 30); + + if (empty($query)) { + // Return random memes if no search query + $memes = MemeMedia::where('type', 'video')->where('sub_type', 'overlay')->take($limit)->inRandomOrder()->get(); + } else { + // PostgreSQL full-text search with ranking + $memes = MemeMedia::where('type', 'video') + ->where('sub_type', 'overlay') + ->where(function ($q) use ($query) { + // Search in name and description using ILIKE for partial matches + $q->where('name', 'ILIKE', "%{$query}%") + ->orWhere('description', 'ILIKE', "%{$query}%") + // Search in JSON arrays using PostgreSQL JSON operators + ->orWhereRaw("keywords::text ILIKE ?", ["%{$query}%"]) + ->orWhereRaw("action_keywords::text ILIKE ?", ["%{$query}%"]) + ->orWhereRaw("emotion_keywords::text ILIKE ?", ["%{$query}%"]) + ->orWhereRaw("misc_keywords::text ILIKE ?", ["%{$query}%"]); + }) + ->orderByRaw(" + CASE + WHEN name ILIKE ? THEN 1 + WHEN description ILIKE ? THEN 2 + WHEN keywords::text ILIKE ? THEN 3 + WHEN action_keywords::text ILIKE ? THEN 4 + WHEN emotion_keywords::text ILIKE ? THEN 5 + WHEN misc_keywords::text ILIKE ? THEN 6 + ELSE 7 + END, name ASC + ", ["%{$query}%", "%{$query}%", "%{$query}%", "%{$query}%", "%{$query}%", "%{$query}%"]) + ->take($limit) + ->get(); + } + + return response()->json([ + 'success' => [ + 'data' => [ + 'memes' => $memes, + 'query' => $query, + ], + ], + ]); + } + + public function searchBackgrounds(Request $request) + { + $query = $request->input('query', ''); + $limit = $request->input('limit', 30); + + if (empty($query)) { + // Return random backgrounds if no search query + $backgrounds = BackgroundMedia::where('status', 'completed')->take($limit)->inRandomOrder()->get(); + } else { + // Search in prompt field using ILIKE for partial matches + $backgrounds = BackgroundMedia::where('status', 'completed') + ->where('prompt', 'ILIKE', "%{$query}%") + ->orderByRaw(" + CASE + WHEN prompt ILIKE ? THEN 1 + ELSE 2 + END, prompt ASC + ", ["%{$query}%"]) + ->take($limit) + ->get(); + } + + return response()->json([ + 'success' => [ + 'data' => [ + 'backgrounds' => $backgrounds, + 'query' => $query, + ], + ], + ]); + } } diff --git a/resources/css/app.css b/resources/css/app.css index dbde794..5ec21c1 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -432,3 +432,17 @@ @keyframes shine { .shiny-text.disabled { animation: none; } + +/* Shimmer animation for skeleton loading */ +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +@utility animate-shimmer { + animation: shimmer 2s ease-in-out infinite; +} diff --git a/resources/js/modules/editor/partials/edit-sidebar.jsx b/resources/js/modules/editor/partials/edit-sidebar.jsx index dcb1d61..e421d3d 100644 --- a/resources/js/modules/editor/partials/edit-sidebar.jsx +++ b/resources/js/modules/editor/partials/edit-sidebar.jsx @@ -1,10 +1,11 @@ import { Button } from '@/components/ui/button'; +import { GridSkeleton } from '@/components/ui/grid-skeleton'; +import { Input } from '@/components/ui/input'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; -import { Spinner } from '@/components/ui/spinner'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { cn } from '@/lib/utils'; import useMediaStore from '@/stores/MediaStore'; -import { Edit3 } from 'lucide-react'; +import { Edit3, Search, X } from 'lucide-react'; import { useEffect, useState } from 'react'; export default function EditSidebar({ isOpen, onClose }) { @@ -17,6 +18,8 @@ export default function EditSidebar({ isOpen, onClose }) { selectedBackground, fetchMemes, fetchBackgrounds, + searchMemes, + searchBackgrounds, selectMeme, selectBackground, clearSelectedMeme, @@ -25,24 +28,120 @@ export default function EditSidebar({ isOpen, onClose }) { // Track the current active tab const [activeTab, setActiveTab] = useState('memes'); + + // Track search queries + const [searchQueries, setSearchQueries] = useState({ + memes: '', + backgrounds: '' + }); + + // Track if data has been loaded for each tab (to prevent infinite loading on empty results) + const [dataLoaded, setDataLoaded] = useState({ + memes: false, + backgrounds: false + }); // Fetch data when sidebar opens for the current active tab useEffect(() => { if (isOpen) { - if (activeTab === 'memes' && memes.length === 0 && !isFetchingMemes) { - fetchMemes(); - } else if (activeTab === 'backgrounds' && backgrounds.length === 0 && !isFetchingBackgrounds) { - fetchBackgrounds(); + if (activeTab === 'memes' && !dataLoaded.memes && !isFetchingMemes) { + const query = searchQueries.memes; + if (query) { + searchMemes(query).finally(() => { + setDataLoaded(prev => ({ ...prev, memes: true })); + }); + } else { + fetchMemes().finally(() => { + setDataLoaded(prev => ({ ...prev, memes: true })); + }); + } + } else if (activeTab === 'backgrounds' && !dataLoaded.backgrounds && !isFetchingBackgrounds) { + const query = searchQueries.backgrounds; + if (query) { + searchBackgrounds(query).finally(() => { + setDataLoaded(prev => ({ ...prev, backgrounds: true })); + }); + } else { + fetchBackgrounds().finally(() => { + setDataLoaded(prev => ({ ...prev, backgrounds: true })); + }); + } } } - }, [isOpen, activeTab, memes.length, backgrounds.length, isFetchingMemes, isFetchingBackgrounds]); + }, [isOpen, activeTab, dataLoaded.memes, dataLoaded.backgrounds, isFetchingMemes, isFetchingBackgrounds]); const handleTabChange = (value) => { setActiveTab(value); - if (value === 'memes' && memes.length === 0 && !isFetchingMemes) { - fetchMemes(); - } else if (value === 'backgrounds' && backgrounds.length === 0 && !isFetchingBackgrounds) { - fetchBackgrounds(); + if (value === 'memes' && !dataLoaded.memes && !isFetchingMemes) { + const query = searchQueries.memes; + if (query) { + searchMemes(query).finally(() => { + setDataLoaded(prev => ({ ...prev, memes: true })); + }); + } else { + fetchMemes().finally(() => { + setDataLoaded(prev => ({ ...prev, memes: true })); + }); + } + } else if (value === 'backgrounds' && !dataLoaded.backgrounds && !isFetchingBackgrounds) { + const query = searchQueries.backgrounds; + if (query) { + searchBackgrounds(query).finally(() => { + setDataLoaded(prev => ({ ...prev, backgrounds: true })); + }); + } else { + fetchBackgrounds().finally(() => { + setDataLoaded(prev => ({ ...prev, backgrounds: true })); + }); + } + } + }; + + // Handle search input changes + const handleSearchChange = (value) => { + setSearchQueries(prev => ({ + ...prev, + [activeTab]: value + })); + }; + + // Handle search submission + const handleSearch = () => { + const query = searchQueries[activeTab]; + if (activeTab === 'memes') { + searchMemes(query).finally(() => { + setDataLoaded(prev => ({ ...prev, memes: true })); + }); + } else if (activeTab === 'backgrounds') { + searchBackgrounds(query).finally(() => { + setDataLoaded(prev => ({ ...prev, backgrounds: true })); + }); + } + }; + + // Handle clearing search + const handleClearSearch = () => { + setSearchQueries(prev => ({ + ...prev, + [activeTab]: '' + })); + + // Reset data loaded state and fetch fresh data without search + if (activeTab === 'memes') { + fetchMemes().finally(() => { + setDataLoaded(prev => ({ ...prev, memes: true })); + }); + } else if (activeTab === 'backgrounds') { + fetchBackgrounds().finally(() => { + setDataLoaded(prev => ({ ...prev, backgrounds: true })); + }); + } + }; + + // Handle Enter key in search input + const handleSearchKeyDown = (e) => { + if (e.key === 'Enter') { + handleSearch(); } }; @@ -114,9 +213,39 @@ export default function EditSidebar({ isOpen, onClose }) { Background + {/* Search Bar */} +
+
+ + handleSearchChange(e.target.value)} + onKeyDown={handleSearchKeyDown} + className="pl-10 pr-10" + /> + {searchQueries[activeTab] && ( + + )} +
+ +
+ - {isFetchingBackgrounds && } - {!isFetchingBackgrounds && backgrounds.length === 0 &&
No backgrounds available.
} + {isFetchingBackgrounds && } + {!isFetchingBackgrounds && backgrounds.length === 0 &&
No backgrounds found.
} {!isFetchingBackgrounds && backgrounds.length > 0 && ( <>
@@ -139,18 +268,13 @@ export default function EditSidebar({ isOpen, onClose }) { ))}
-
- -
)}
- {isFetchingMemes && } - {!isFetchingMemes && memes.length === 0 &&
No memes available.
} + {isFetchingMemes && } + {!isFetchingMemes && memes.length === 0 &&
No memes found.
} {!isFetchingMemes && memes.length > 0 && ( <>
@@ -167,11 +291,6 @@ export default function EditSidebar({ isOpen, onClose }) { ))}
-
- -
)}
diff --git a/resources/js/stores/MediaStore.js b/resources/js/stores/MediaStore.js index a419d93..3a728f1 100644 --- a/resources/js/stores/MediaStore.js +++ b/resources/js/stores/MediaStore.js @@ -206,6 +206,56 @@ const useMediaStore = create( } }, + // Search memes + searchMemes: async (query = '') => { + set({ isFetchingMemes: true }); + try { + const response = await axiosInstance.post(route('api.app.search.memes'), { query }); + + if (response?.data?.success?.data?.memes) { + set({ + memes: response.data.success.data.memes, + isFetchingMemes: false, + }); + return response.data.success.data.memes; + } else { + throw 'Invalid API response'; + } + } catch (error) { + console.error('Error searching memes:', error); + set({ isFetchingMemes: false }); + if (error?.response?.data?.error?.message?.length > 0) { + toast.error(error.response.data.error.message); + } + throw error; + } + }, + + // Search backgrounds + searchBackgrounds: async (query = '') => { + set({ isFetchingBackgrounds: true }); + try { + const response = await axiosInstance.post(route('api.app.search.background'), { query }); + + if (response?.data?.success?.data?.backgrounds) { + set({ + backgrounds: response.data.success.data.backgrounds, + isFetchingBackgrounds: false, + }); + return response.data.success.data.backgrounds; + } else { + throw 'Invalid API response'; + } + } catch (error) { + console.error('Error searching backgrounds:', error); + set({ isFetchingBackgrounds: false }); + if (error?.response?.data?.error?.message?.length > 0) { + toast.error(error.response.data.error.message); + } + throw error; + } + }, + // Reset store to default state restoreMemeStateToDefault: () => { console.log('restoreMemeStateToDefault'); diff --git a/routes/api.php b/routes/api.php index 2720246..8d7b742 100644 --- a/routes/api.php +++ b/routes/api.php @@ -49,6 +49,8 @@ Route::post('init', [FrontMediaController::class, 'init'])->name('api.app.init'); Route::post('memes', [FrontMediaController::class, 'memes'])->name('api.app.memes'); + Route::post('search/memes', [FrontMediaController::class, 'searchMemes'])->name('api.app.search.memes'); Route::post('background', [FrontMediaController::class, 'background'])->name('api.app.background'); + Route::post('search/background', [FrontMediaController::class, 'searchBackgrounds'])->name('api.app.search.background'); });