This commit is contained in:
ct
2025-07-17 03:28:02 +08:00
parent b195a9e7c3
commit 62cefe271e
9 changed files with 267 additions and 90 deletions

View File

@@ -4,6 +4,7 @@
use App\Models\BackgroundMedia; use App\Models\BackgroundMedia;
use App\Models\MemeMedia; use App\Models\MemeMedia;
use App\Services\MemeMediaService;
use Artesaos\SEOTools\Facades\JsonLd; use Artesaos\SEOTools\Facades\JsonLd;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@@ -11,6 +12,10 @@
class FrontHomeController extends Controller class FrontHomeController extends Controller
{ {
public function __construct(
private MemeMediaService $memeMediaService
) {}
public function index() public function index()
{ {
if (App::environment('production') && env('COMING_SOON_ENABLED', true)) { if (App::environment('production') && env('COMING_SOON_ENABLED', true)) {
@@ -28,12 +33,16 @@ public function index()
// Get FAQ data // Get FAQ data
$faqData = $this->getFaqData(); $faqData = $this->getFaqData();
// Get popular keywords for search suggestions
$popularKeywords = $this->memeMediaService->getPopularKeywords(10);
// Add FAQ JSON-LD structured data // Add FAQ JSON-LD structured data
$this->addFaqJsonLd($faqData); $this->addFaqJsonLd($faqData);
return Inertia::render('home/home', [ return Inertia::render('home/home', [
'stats' => $stats, 'stats' => $stats,
'faqData' => $faqData, 'faqData' => $faqData,
'popularKeywords' => $popularKeywords,
]); ]);
} }

View File

@@ -2,7 +2,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\MemeMedia; use App\Services\MemeMediaService;
use Artesaos\SEOTools\Facades\SEOMeta; use Artesaos\SEOTools\Facades\SEOMeta;
use Artesaos\SEOTools\Facades\OpenGraph; use Artesaos\SEOTools\Facades\OpenGraph;
use Artesaos\SEOTools\Facades\TwitterCard; use Artesaos\SEOTools\Facades\TwitterCard;
@@ -15,6 +15,10 @@
class FrontMemeController extends Controller class FrontMemeController extends Controller
{ {
public function __construct(
private MemeMediaService $memeMediaService
) {}
public function index(Request $request): Response public function index(Request $request): Response
{ {
return $this->getMemes($request->input('search')); return $this->getMemes($request->input('search'));
@@ -29,23 +33,7 @@ public function search(string $search): Response
private function getMemes(?string $search = null): Response private function getMemes(?string $search = null): Response
{ {
$query = MemeMedia::query() $memes = $this->memeMediaService->searchMemes($search, 24);
->where('is_enabled', true)
->orderBy('id', 'desc');
// Search functionality
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'ilike', "%{$search}%")
->orWhere('description', 'ilike', "%{$search}%")
->orWhereJsonContains('keywords', $search)
->orWhereJsonContains('action_keywords', $search)
->orWhereJsonContains('emotion_keywords', $search)
->orWhereJsonContains('misc_keywords', $search);
});
}
$memes = $query->cursorPaginate(24);
// Set up SEO meta tags // Set up SEO meta tags
$title = $search ? ucfirst($search) . " Memes in MEMEFA.ST" : 'Meme Library - Thousands of Video Meme Templates'; $title = $search ? ucfirst($search) . " Memes in MEMEFA.ST" : 'Meme Library - Thousands of Video Meme Templates';
@@ -89,21 +77,10 @@ private function getMemes(?string $search = null): Response
TwitterCard::setType('summary_large_image'); TwitterCard::setType('summary_large_image');
// Get available types for filter // Get available types for filter
$types = MemeMedia::where('is_enabled', true) $types = $this->memeMediaService->getAvailableTypes();
->distinct()
->pluck('type')
->filter();
// Get popular keywords for filter // Get popular keywords for filter
$popularKeywords = MemeMedia::where('is_enabled', true) $popularKeywords = $this->memeMediaService->getPopularKeywords(20);
->get()
->pluck('keywords')
->flatten()
->countBy()
->sort()
->reverse()
->take(20)
->keys();
return Inertia::render('memes/index', [ return Inertia::render('memes/index', [
'memes' => $memes, 'memes' => $memes,
@@ -118,39 +95,10 @@ private function getMemes(?string $search = null): Response
public function show(string $slug): Response public function show(string $slug): Response
{ {
$meme = MemeMedia::where('slug', $slug) $meme = $this->memeMediaService->findBySlug($slug);
->where('is_enabled', true)
->firstOrFail();
// Get related memes based on similar keywords // Get related memes based on similar keywords
$relatedMemes = MemeMedia::where('is_enabled', true) $relatedMemes = $this->memeMediaService->getRelatedMemes($meme, 6);
->where('id', '!=', $meme->id)
->where(function ($query) use ($meme) {
if ($meme->keywords) {
foreach ($meme->keywords as $keyword) {
$query->orWhereJsonContains('keywords', $keyword)
->orWhereJsonContains('action_keywords', $keyword)
->orWhereJsonContains('emotion_keywords', $keyword)
->orWhereJsonContains('misc_keywords', $keyword);
}
}
})
->limit(6)
->get();
// If we have less than 6 related memes, fill up with random ones
if ($relatedMemes->count() < 6) {
$excludeIds = $relatedMemes->pluck('id')->push($meme->id)->toArray();
$needed = 6 - $relatedMemes->count();
$randomMemes = MemeMedia::where('is_enabled', true)
->whereNotIn('id', $excludeIds)
->inRandomOrder()
->limit($needed)
->get();
$relatedMemes = $relatedMemes->merge($randomMemes);
}
// Set up SEO meta tags for individual meme page // Set up SEO meta tags for individual meme page
$title = "{$meme->name} - Make Video Memes with MEMEFA.ST"; $title = "{$meme->name} - Make Video Memes with MEMEFA.ST";
@@ -184,21 +132,8 @@ public function show(string $slug): Response
public function generateOG(string $ids): HttpResponse public function generateOG(string $ids): HttpResponse
{ {
// Decode the hashids to get the meme media ID // Get the meme media using the service
$memeId = hashids_decode($ids); $meme = $this->memeMediaService->findByHashIds($ids);
if (!$memeId) {
abort(404, 'Meme not found');
}
// Get the meme media
$meme = MemeMedia::where('id', $memeId)
->where('is_enabled', true)
->first();
if (!$meme) {
abort(404, 'Meme not found');
}
// Load the template image // Load the template image
$templatePath = public_path('memefast-og-template.jpg'); $templatePath = public_path('memefast-og-template.jpg');

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Services;
use App\Models\MemeMedia;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Collection as SupportCollection;
use Illuminate\Support\Facades\Cache;
class MemeMediaService
{
/**
* Search memes with optional query and pagination
*/
public function searchMemes(?string $search = null, int $perPage = 24): CursorPaginator
{
$query = $this->buildSearchQuery($search);
return $query->cursorPaginate($perPage);
}
/**
* Get popular keywords for filtering
*/
public function getPopularKeywords(int $limit = 20): SupportCollection
{
$cacheKey = "popular_keywords_limit_{$limit}";
return Cache::remember($cacheKey, 60 * 60 * 24, function () use ($limit) {
return MemeMedia::where('is_enabled', true)
->get()
->pluck('keywords')
->flatten()
->countBy()
->sort()
->reverse()
->take($limit)
->keys();
});
}
/**
* Get available meme types for filtering
*/
public function getAvailableTypes(): SupportCollection
{
return MemeMedia::where('is_enabled', true)
->distinct()
->pluck('type')
->filter();
}
/**
* Find meme by slug
*/
public function findBySlug(string $slug): MemeMedia
{
return MemeMedia::where('slug', $slug)
->where('is_enabled', true)
->firstOrFail();
}
/**
* Find meme by hashids
*/
public function findByHashIds(string $ids): MemeMedia
{
$memeId = hashids_decode($ids);
if (!$memeId) {
throw new ModelNotFoundException('Meme not found');
}
return MemeMedia::where('id', $memeId)
->where('is_enabled', true)
->firstOrFail();
}
/**
* Get related memes based on keywords
*/
public function getRelatedMemes(MemeMedia $meme, int $limit = 6): Collection
{
$relatedMemes = MemeMedia::where('is_enabled', true)
->where('id', '!=', $meme->id)
->where(function ($query) use ($meme) {
if ($meme->keywords) {
foreach ($meme->keywords as $keyword) {
$query->orWhereJsonContains('keywords', $keyword)
->orWhereJsonContains('action_keywords', $keyword)
->orWhereJsonContains('emotion_keywords', $keyword)
->orWhereJsonContains('misc_keywords', $keyword);
}
}
})
->limit($limit)
->get();
// If we have less than the desired limit, fill up with random ones
if ($relatedMemes->count() < $limit) {
$excludeIds = $relatedMemes->pluck('id')->push($meme->id)->toArray();
$needed = $limit - $relatedMemes->count();
$randomMemes = $this->fillWithRandomMemes($relatedMemes, $limit, $excludeIds);
$relatedMemes = $relatedMemes->merge($randomMemes);
}
return $relatedMemes;
}
/**
* Fill collection with random memes up to target count
*/
public function fillWithRandomMemes(Collection $existing, int $targetCount, array $excludeIds): Collection
{
$needed = $targetCount - $existing->count();
if ($needed <= 0) {
return collect();
}
return MemeMedia::where('is_enabled', true)
->whereNotIn('id', $excludeIds)
->inRandomOrder()
->limit($needed)
->get();
}
/**
* Build search query with keyword matching
*/
private function buildSearchQuery(?string $search = null): Builder
{
$query = $this->getEnabledMemesQuery();
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'ilike', "%{$search}%")
->orWhere('description', 'ilike', "%{$search}%")
->orWhereJsonContains('keywords', $search)
->orWhereJsonContains('action_keywords', $search)
->orWhereJsonContains('emotion_keywords', $search)
->orWhereJsonContains('misc_keywords', $search);
});
}
return $query;
}
/**
* Get base query for enabled memes
*/
private function getEnabledMemesQuery(): Builder
{
return MemeMedia::query()
->where('is_enabled', true)
->orderBy('id', 'desc');
}
}

View File

@@ -21,4 +21,4 @@ export function KeywordBadge({ keyword, size = 'default', className = '' }: Keyw
{keyword} {keyword}
</Link> </Link>
); );
} }

View File

@@ -108,7 +108,7 @@ const useResponsiveDimensions = () => {
}; };
const Editor = ({ setInitialMeme, setInitialBackground, setInitialText }) => { const Editor = ({ setInitialMeme, setInitialBackground, setInitialText }) => {
const { init, setInitialMeme: setStoreMeme, setInitialBackground: setStoreBackground, setInitialText: setStoreText } = useMediaStore(); const { init, setInitialMeme: setStoreMeme, setInitialBackground: setStoreBackground, setInitialText: setStoreText, clearInitialState } = useMediaStore();
const { getSetting } = useLocalSettingsStore(); const { getSetting } = useLocalSettingsStore();
const { setSelectedTextElement } = useVideoEditorStore(); const { setSelectedTextElement } = useVideoEditorStore();
const emitter = useMitt(); const emitter = useMitt();
@@ -121,6 +121,9 @@ const Editor = ({ setInitialMeme, setInitialBackground, setInitialText }) => {
const isBelowMinWidth = useViewportDetection(320); const isBelowMinWidth = useViewportDetection(320);
useEffect(() => { useEffect(() => {
// Clear any previous initial state to allow fresh initialization
clearInitialState();
// Set initial values if props are provided // Set initial values if props are provided
if (setInitialMeme) { if (setInitialMeme) {
setInitialMeme(setStoreMeme); setInitialMeme(setStoreMeme);
@@ -134,7 +137,12 @@ const Editor = ({ setInitialMeme, setInitialBackground, setInitialText }) => {
// Initialize (will skip API call if initial values were set) // Initialize (will skip API call if initial values were set)
init(); init();
}, [setInitialMeme, setInitialBackground, setInitialText, setStoreMeme, setStoreBackground, setStoreText, init]);
// Cleanup: Clear initial state when component unmounts
return () => {
clearInitialState();
};
}, [setInitialMeme, setInitialBackground, setInitialText, setStoreMeme, setStoreBackground, setStoreText, init, clearInitialState]);
// Listen for text element selection (but don't auto-open sidebar) // Listen for text element selection (but don't auto-open sidebar)
useEffect(() => { useEffect(() => {

View File

@@ -6,7 +6,7 @@ import Footer from './partials/Footer.jsx';
import Hero from './partials/Hero.jsx'; import Hero from './partials/Hero.jsx';
import MemeLibrarySearch from './partials/MemeLibrarySearch.jsx'; import MemeLibrarySearch from './partials/MemeLibrarySearch.jsx';
const Home = ({ faqData }) => { const Home = ({ faqData, popularKeywords }) => {
const [isClient, setIsClient] = useState(false); const [isClient, setIsClient] = useState(false);
const [Editor, setEditor] = useState(null); const [Editor, setEditor] = useState(null);
@@ -34,7 +34,7 @@ const Home = ({ faqData }) => {
</div> </div>
<div className="space-y-16"> <div className="space-y-16">
<Hero /> <Hero />
<MemeLibrarySearch /> <MemeLibrarySearch popularKeywords={popularKeywords} />
<Features /> <Features />
<FAQDiscord faqData={faqData} /> <FAQDiscord faqData={faqData} />
</div> </div>

View File

@@ -1,11 +1,12 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { KeywordBadge } from '@/components/ui/keyword-badge';
import { router } from '@inertiajs/react'; import { router } from '@inertiajs/react';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
const MemeLibrarySearch = () => { const MemeLibrarySearch = ({ popularKeywords = [] }) => {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
@@ -32,13 +33,21 @@ const MemeLibrarySearch = () => {
} }
}; };
const handleKeywordClick = (keyword) => {
setSearchQuery(keyword);
setIsSearching(true);
router.visit(route('memes.search', { search: keyword }), {
onFinish: () => setIsSearching(false),
});
};
return ( return (
<section className="relative"> <section className="relative">
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8"> <div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
<div className="space-y-6 text-center"> <div className="space-y-6 text-center">
{/* Section heading */} {/* Section heading */}
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-foreground text-3xl font-bold tracking-tight sm:text-4xl">Explore Our Meme Library</h2> <h2 className="text-foreground text-3xl font-bold tracking-tight sm:text-4xl">Find the perfect meme</h2>
<p className="text-muted-foreground mx-auto max-w-2xl text-lg"> <p className="text-muted-foreground mx-auto max-w-2xl text-lg">
Search through our database of popular meme templates and find the perfect one for your video Search through our database of popular meme templates and find the perfect one for your video
</p> </p>
@@ -65,10 +74,27 @@ const MemeLibrarySearch = () => {
</div> </div>
</form> </form>
{/* Popular Keywords */}
{popularKeywords.length > 0 && (
<div className="space-y-3">
<div className="flex flex-wrap justify-center gap-2">
{popularKeywords.map((keyword, index) => (
<KeywordBadge
size="lg"
key={index}
keyword={keyword}
onClick={() => handleKeywordClick(keyword)}
disabled={isSearching}
/>
))}
</div>
</div>
)}
{/* Browse all link */} {/* Browse all link */}
<div className="pt-4"> <div className="pt-4">
<Button variant="outline" onClick={() => router.visit(route('memes.index'))} className="gap-2"> <Button variant="outline" onClick={() => router.visit(route('memes.index'))} className="gap-2">
Browse our Meme Library or Browse our Meme Library
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -89,7 +89,9 @@ export default function MemesIndex({ memes, popularKeywords, filters, dynamicDes
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbSeparator /> <BreadcrumbSeparator />
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbPage>{filters.search ? `${filters.search.charAt(0).toUpperCase() + filters.search.slice(1)} Memes` : 'Meme Library'}</BreadcrumbPage> <BreadcrumbPage>
{filters.search ? `${filters.search.charAt(0).toUpperCase() + filters.search.slice(1)} Memes` : 'Meme Library'}
</BreadcrumbPage>
</BreadcrumbItem> </BreadcrumbItem>
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
@@ -100,10 +102,10 @@ export default function MemesIndex({ memes, popularKeywords, filters, dynamicDes
{filters.search ? `${filters.search.charAt(0).toUpperCase() + filters.search.slice(1)} Memes` : 'Meme Library'} {filters.search ? `${filters.search.charAt(0).toUpperCase() + filters.search.slice(1)} Memes` : 'Meme Library'}
</h1> </h1>
<p className="text-muted-foreground mx-auto max-w-2xl text-xl"> <p className="text-muted-foreground mx-auto max-w-2xl text-xl">
{filters.search {filters.search
? (dynamicDescription || `Discover ${filters.search} meme templates and create viral content for TikTok, Instagram Reels, and YouTube Shorts.`) ? dynamicDescription ||
: 'Thousands of memes ready for TikTok, Reels, Threads and YouTube Shorts. No signup needed - click any meme to start creating!' `Discover ${filters.search} meme templates and create viral content for TikTok, Instagram Reels, and YouTube Shorts.`
} : 'Thousands of memes ready for TikTok, Reels, Threads and YouTube Shorts. No signup needed - click any meme to start creating!'}
</p> </p>
</div> </div>
@@ -204,6 +206,32 @@ export default function MemesIndex({ memes, popularKeywords, filters, dynamicDes
)} )}
</div> </div>
)} )}
{/* Discord Request Section */}
<div className="mt-12 flex flex-col items-center gap-6 text-center">
<div className="flex items-center gap-4">
<div className="text-center">
<h3 className="text-foreground text-xl font-bold">Can't find the meme you're looking for?</h3>
<p className="text-muted-foreground text-sm leading-relaxed">
Request it in our Discord community and we'll add it to the library!
</p>
</div>
</div>
{import.meta.env.VITE_DISCORD_LINK && (
<a
href={import.meta.env.VITE_DISCORD_LINK}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-full bg-[#5865F2] px-8 py-3 text-lg font-semibold text-white shadow-lg transition-all hover:bg-[#4752C4] hover:shadow-xl focus:ring-2 focus:ring-[#5865F2] focus:ring-offset-2 focus:outline-none"
>
Request in Discord
<svg className="h-8 w-8 text-white" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.211.375-.445.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
</a>
)}
</div>
</div> </div>
<Footer /> <Footer />
</div> </div>

View File

@@ -75,6 +75,15 @@ const useMediaStore = create(
set({ currentCaption: text, hasInitialText: true }); set({ currentCaption: text, hasInitialText: true });
}, },
// Clear initial state to allow fresh initialization
clearInitialState: () => {
set({
hasInitialMeme: false,
hasInitialBackground: false,
hasInitialText: false
});
},
// Clear selections // Clear selections
clearSelectedMeme: () => { clearSelectedMeme: () => {
set({ selectedMeme: null }); set({ selectedMeme: null });