5 Commits

Author SHA1 Message Date
c4e6be4452 chore: bump version to 1.7.1
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider frontend (push) Successful in 1m44s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Déploiement en Production (push) Failing after 5s
2026-01-28 20:49:22 +01:00
18288cf8f3 chore(docker): enforce --force flag for pnpm install across all Dockerfiles 2026-01-28 20:49:16 +01:00
3ffc5b6fde chore: bump version to 1.7.0
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m40s
CI/CD Pipeline / Valider frontend (push) Successful in 1m45s
CI/CD Pipeline / Valider documentation (push) Successful in 1m49s
CI/CD Pipeline / Déploiement en Production (push) Failing after 6s
2026-01-28 20:40:45 +01:00
5413774cf4 chore: update pnpm-lock.yaml to reflect lockfile v6, update dependencies versions, and remove redundant nested dependency details 2026-01-28 20:37:39 +01:00
e342eacc69 style: add utility class for scrollbar hiding and align content correctly in meme page layout 2026-01-28 20:35:55 +01:00
13 changed files with 9826 additions and 11364 deletions

View File

@@ -15,13 +15,13 @@ COPY documentation/package.json ./documentation/
# Utilisation du cache pour pnpm et installation figée # Utilisation du cache pour pnpm et installation figée
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile pnpm install --frozen-lockfile --force
COPY . . COPY . .
# Deuxième passe avec cache pour les scripts/liens # Deuxième passe avec cache pour les scripts/liens
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile pnpm install --frozen-lockfile --force
RUN pnpm run --filter @memegoat/backend build RUN pnpm run --filter @memegoat/backend build
RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/backend", "name": "@memegoat/backend",
"version": "1.6.0", "version": "1.7.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,

View File

@@ -14,13 +14,13 @@ COPY documentation/package.json ./documentation/
# Montage du cache pnpm # Montage du cache pnpm
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile pnpm install --frozen-lockfile --force
COPY . . COPY . .
# Deuxième passe avec cache pour les scripts/liens # Deuxième passe avec cache pour les scripts/liens
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile pnpm install --frozen-lockfile --force
# Build avec cache Next.js # Build avec cache Next.js
RUN --mount=type=cache,id=next-docs-cache,target=/usr/src/app/documentation/.next/cache \ RUN --mount=type=cache,id=next-docs-cache,target=/usr/src/app/documentation/.next/cache \

View File

@@ -14,13 +14,13 @@ COPY documentation/package.json ./documentation/
# Montage du cache pnpm # Montage du cache pnpm
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile pnpm install --frozen-lockfile --force
COPY . . COPY . .
# Deuxième passe avec cache pour les scripts/liens # Deuxième passe avec cache pour les scripts/liens
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile pnpm install --frozen-lockfile --force
# Build avec cache Next.js # Build avec cache Next.js
RUN --mount=type=cache,id=next-cache,target=/usr/src/app/frontend/.next/cache \ RUN --mount=type=cache,id=next-cache,target=/usr/src/app/frontend/.next/cache \

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/frontend", "name": "@memegoat/frontend",
"version": "1.6.0", "version": "1.7.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",

View File

@@ -50,7 +50,7 @@ export default async function MemePage({
Retour au flux Retour au flux
</Link> </Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<ContentCard content={content} /> <ContentCard content={content} />
</div> </div>

View File

@@ -74,6 +74,16 @@
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
} }
@layer utilities {
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
:root { :root {
--radius: 0.5rem; --radius: 0.5rem;
--background: oklch(0.9821 0 0); --background: oklch(0.9821 0 0);

View File

@@ -16,8 +16,10 @@ import {
TrendingUp, TrendingUp,
User as UserIcon, User as UserIcon,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
import * as React from "react"; import * as React from "react";
import { ModeToggle } from "@/components/mode-toggle"; import { ModeToggle } from "@/components/mode-toggle";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -47,6 +49,8 @@ import {
SidebarMenuSub, SidebarMenuSub,
SidebarMenuSubButton, SidebarMenuSubButton,
SidebarMenuSubItem, SidebarMenuSubItem,
SidebarRail,
SidebarTrigger,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
import { CategoryService } from "@/services/category.service"; import { CategoryService } from "@/services/category.service";
@@ -74,19 +78,43 @@ export function AppSidebar() {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { user, logout, isAuthenticated } = useAuth(); const { user, logout, isAuthenticated } = useAuth();
const { resolvedTheme } = useTheme();
const [categories, setCategories] = React.useState<Category[]>([]); const [categories, setCategories] = React.useState<Category[]>([]);
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
setMounted(true);
CategoryService.getAll().then(setCategories).catch(console.error); CategoryService.getAll().then(setCategories).catch(console.error);
}, []); }, []);
const logoSrc = React.useMemo(() => {
if (!mounted) return "/memegoat-color.svg";
return resolvedTheme === "dark"
? "/memegoat-white.svg"
: "/memegoat-black.svg";
}, [resolvedTheme, mounted]);
return ( return (
<Sidebar collapsible="icon"> <Sidebar collapsible="icon">
<SidebarHeader className="flex items-center justify-center py-4"> <SidebarHeader className="flex flex-row items-center justify-between py-4 group-data-[collapsible=icon]:justify-center">
<Link href="/" className="flex items-center gap-2 font-bold text-xl"> <Link
<div className="bg-primary text-primary-foreground p-1 rounded">🐐</div> href="/"
<span className="group-data-[collapsible=icon]:hidden">MemeGoat</span> className="flex items-center gap-2 font-bold text-xl overflow-hidden"
>
<div className="p-1 rounded shrink-0">
<Image
src={logoSrc}
alt="MemeGoat Logo"
width={32}
height={32}
className="w-8 h-8"
/>
</div>
<span className="group-data-[collapsible=icon]:hidden whitespace-nowrap">
MemeGoat
</span>
</Link> </Link>
<SidebarTrigger className="hidden md:flex group-data-[collapsible=icon]:hidden" />
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
@@ -305,6 +333,7 @@ export function AppSidebar() {
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarFooter> </SidebarFooter>
<SidebarRail />
</Sidebar> </Sidebar>
); );
} }

View File

@@ -54,9 +54,23 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
const isVideo = !content.mimeType.startsWith("image/"); const isVideo = !content.mimeType.startsWith("image/");
const isThisVideoActive = activeVideoId === content.id; const isThisVideoActive = activeVideoId === content.id;
const isMuted = isGlobalMuted || (isVideo && !isThisVideoActive); const isMuted = isGlobalMuted || (isVideo && !isThisVideoActive);
const videoRef = React.useRef<HTMLVideoElement>(null);
const aspectRatio = React.useEffect(() => {
content.width && content.height ? content.width / content.height : 1; if (videoRef.current) {
if (isThisVideoActive) {
const playPromise = videoRef.current.play();
if (playPromise !== undefined) {
playPromise.catch((_error) => {
// L'auto-lecture peut échouer si l'utilisateur n'a pas interagi avec la page
// On peut tenter de mettre en sourdine pour forcer la lecture si nécessaire
});
}
} else {
videoRef.current.pause();
}
}
}, [isThisVideoActive]);
React.useEffect(() => { React.useEffect(() => {
setIsLiked(content.isLiked || false); setIsLiked(content.isLiked || false);
@@ -178,29 +192,30 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
</DropdownMenu> </DropdownMenu>
</div> </div>
</CardHeader> </CardHeader>
<CardContent <CardContent className="p-0 relative bg-zinc-200 dark:bg-zinc-900 flex items-center justify-center overflow-hidden aspect-square w-full">
className="p-0 relative bg-zinc-200 dark:bg-zinc-900 flex items-center justify-center overflow-hidden" <Link
style={{ aspectRatio: `${aspectRatio}` }} href={`/meme/${content.slug}`}
className="w-full h-full block relative"
> >
<Link href={`/meme/${content.slug}`} className="w-full h-full relative">
{content.mimeType.startsWith("image/") ? ( {content.mimeType.startsWith("image/") ? (
<Image <Image
src={content.url} src={content.url}
alt={content.title} alt={content.title}
fill width={content.width || 1000}
className="object-cover" height={content.height || 1000}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" className="w-full h-full object-contain"
priority={false} priority={false}
/> />
) : ( ) : (
<video <video
ref={videoRef}
src={content.url} src={content.url}
controls={false} controls={false}
autoPlay autoPlay={isThisVideoActive}
muted={isMuted} muted={isMuted}
loop loop
playsInline playsInline
className="w-full h-full object-cover" className="w-full h-full object-contain"
/> />
)} )}
</Link> </Link>

View File

@@ -4,6 +4,7 @@ import * as React from "react";
import { useInfiniteScroll } from "@/app/(dashboard)/_hooks/use-infinite-scroll"; import { useInfiniteScroll } from "@/app/(dashboard)/_hooks/use-infinite-scroll";
import { ContentCard } from "@/components/content-card"; import { ContentCard } from "@/components/content-card";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useAudio } from "@/providers/audio-provider";
import type { Content, PaginatedResponse } from "@/types/content"; import type { Content, PaginatedResponse } from "@/types/content";
interface ContentListProps { interface ContentListProps {
@@ -15,10 +16,48 @@ interface ContentListProps {
} }
export function ContentList({ fetchFn, title }: ContentListProps) { export function ContentList({ fetchFn, title }: ContentListProps) {
const { setActiveVideo } = useAudio();
const [contents, setContents] = React.useState<Content[]>([]); const [contents, setContents] = React.useState<Content[]>([]);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [offset, setOffset] = React.useState(0); const [offset, setOffset] = React.useState(0);
const [hasMore, setHasMore] = React.useState(true); const [hasMore, setHasMore] = React.useState(true);
const containerRef = React.useRef<HTMLDivElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: On a besoin de contents pour ré-attacher l'observer quand la liste change
React.useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
// On cherche l'entrée avec le plus grand ratio d'intersection parmi celles qui dépassent le seuil
let bestEntry: IntersectionObserverEntry | null = null;
let maxRatio = 0;
for (const entry of entries) {
if (entry.isIntersecting && entry.intersectionRatio > maxRatio) {
maxRatio = entry.intersectionRatio;
bestEntry = entry;
}
}
if (bestEntry && maxRatio >= 0.6) {
const contentId = bestEntry.target.getAttribute("data-content-id");
if (contentId) {
setActiveVideo(contentId);
}
}
},
{
threshold: [0, 0.2, 0.4, 0.6, 0.8, 1.0], // Plusieurs seuils pour une détection plus fine du "meilleur"
root: containerRef.current,
},
);
const elements = containerRef.current?.querySelectorAll("[data-content-id]");
elements?.forEach((el) => {
observer.observe(el);
});
return () => observer.disconnect();
}, [setActiveVideo, contents]);
const fetchInitial = React.useCallback(async () => { const fetchInitial = React.useCallback(async () => {
setLoading(true); setLoading(true);
@@ -51,7 +90,11 @@ export function ContentList({ fetchFn, title }: ContentListProps) {
offset: offset + 10, offset: offset + 10,
}); });
setContents((prev) => [...prev, ...response.data]); setContents((prev) => {
const newIds = new Set(response.data.map((item) => item.id));
const filteredPrev = prev.filter((item) => !newIds.has(item.id));
return [...filteredPrev, ...response.data];
});
setOffset((prev) => prev + 10); setOffset((prev) => prev + 10);
setHasMore(response.data.length === 10); setHasMore(response.data.length === 10);
} catch (error) { } catch (error) {
@@ -68,11 +111,18 @@ export function ContentList({ fetchFn, title }: ContentListProps) {
}); });
return ( return (
<div className="max-w-7xl mx-auto py-8 px-4 space-y-8"> <div
{title && <h1 className="text-2xl font-bold">{title}</h1>} ref={containerRef}
<div className="columns-1 sm:columns-2 lg:columns-3 xl:columns-4 gap-6"> className="mx-auto px-4 h-screen flex flex-col justify-start items-center overflow-y-auto snap-y snap-mandatory no-scrollbar"
>
{title && <h1 className="text-2xl font-bold py-8">{title}</h1>}
<div className="max-w-xl flex flex-col justify-start items-center">
{contents.map((content) => ( {contents.map((content) => (
<div key={content.id} className="break-inside-avoid mb-6"> <div
key={content.id}
data-content-id={content.id}
className="w-full snap-start snap-always py-4"
>
<ContentCard content={content} onUpdate={fetchInitial} /> <ContentCard content={content} onUpdate={fetchInitial} />
</div> </div>
))} ))}

View File

@@ -1,12 +1,18 @@
"use client"; "use client";
import { Filter, Search } from "lucide-react"; import { ChevronLeft, ChevronRight, Filter, Search } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import * as React from "react"; import * as React from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CategoryService } from "@/services/category.service"; import { CategoryService } from "@/services/category.service";
import { TagService } from "@/services/tag.service"; import { TagService } from "@/services/tag.service";
import type { Category, Tag } from "@/types/content"; import type { Category, Tag } from "@/types/content";
@@ -16,10 +22,24 @@ export function SearchSidebar() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const pathname = usePathname(); const pathname = usePathname();
const [isCollapsed, setIsCollapsed] = React.useState(true);
const [categories, setCategories] = React.useState<Category[]>([]); const [categories, setCategories] = React.useState<Category[]>([]);
const [popularTags, setPopularTags] = React.useState<Tag[]>([]); const [popularTags, setPopularTags] = React.useState<Tag[]>([]);
const [query, setQuery] = React.useState(searchParams.get("query") || ""); const [query, setQuery] = React.useState(searchParams.get("query") || "");
// Ouvrir automatiquement si des filtres sont actifs
React.useEffect(() => {
const hasFilters =
searchParams.has("query") ||
searchParams.has("category") ||
searchParams.has("tag") ||
searchParams.get("sort") !== "trend";
if (hasFilters) {
setIsCollapsed(false);
}
}, [searchParams]);
React.useEffect(() => { React.useEffect(() => {
CategoryService.getAll().then(setCategories).catch(console.error); CategoryService.getAll().then(setCategories).catch(console.error);
TagService.getAll({ limit: 10, sort: "popular" }) TagService.getAll({ limit: 10, sort: "popular" })
@@ -51,7 +71,54 @@ export function SearchSidebar() {
const currentCategory = searchParams.get("category"); const currentCategory = searchParams.get("category");
return ( return (
<aside className="hidden lg:flex flex-col w-80 border-l bg-background"> <aside
className={`hidden lg:flex flex-col border-l bg-background transition-all duration-300 relative ${
isCollapsed ? "w-12" : "w-80"
}`}
>
<Button
variant="outline"
size="icon"
className="absolute -left-4 top-20 h-8 w-8 rounded-full bg-background shadow-md z-50 hover:bg-accent"
onClick={() => setIsCollapsed(!isCollapsed)}
>
{isCollapsed ? (
<ChevronLeft className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
{isCollapsed ? (
<div className="flex flex-col items-center py-4 gap-4 overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setIsCollapsed(false)}
>
<Search className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Rechercher</TooltipContent>
</Tooltip>
<Separator />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setIsCollapsed(false)}
>
<Filter className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Filtres</TooltipContent>
</Tooltip>
</div>
) : (
<>
<div className="p-4 border-b"> <div className="p-4 border-b">
<h2 className="font-semibold mb-4">Rechercher</h2> <h2 className="font-semibold mb-4">Rechercher</h2>
<form onSubmit={handleSearch} className="relative"> <form onSubmit={handleSearch} className="relative">
@@ -124,7 +191,9 @@ export function SearchSidebar() {
{popularTags.map((tag) => ( {popularTags.map((tag) => (
<Badge <Badge
key={tag.id} key={tag.id}
variant={searchParams.get("tag") === tag.name ? "default" : "outline"} variant={
searchParams.get("tag") === tag.name ? "default" : "outline"
}
className="cursor-pointer hover:bg-secondary" className="cursor-pointer hover:bg-secondary"
onClick={() => onClick={() =>
updateSearch( updateSearch(
@@ -143,6 +212,8 @@ export function SearchSidebar() {
</div> </div>
</div> </div>
</ScrollArea> </ScrollArea>
</>
)}
</aside> </aside>
); );
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/source", "name": "@memegoat/source",
"version": "1.6.0", "version": "1.7.1",
"description": "", "description": "",
"scripts": { "scripts": {
"version:get": "cmake -P version.cmake GET", "version:get": "cmake -P version.cmake GET",

20791
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff