feat: add interactive like button to media preview tiles

This commit is contained in:
Jannik committed 2026-09-24 04:39:41 +02:00
1 parent b1093b37cd
commit 9c66ea85b8
4 files changed
+66 -68

No files matched your search

+6 -3
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { validateMediaPath } from '@/lib/security/path'; import { validateSession } from '@/lib/auth/session';
import { getSafeContentType } from '@/lib/media/types'; import { getSafeContentType } from '@/lib/media/types';
import { getThumbnailPath, getGalleryPath } from '@/lib/processing/images'; import { getThumbnailPath, getGalleryPath } from '@/lib/processing/images';
import { getVideoPosterPath, getVideoPreviewPath } from '@/lib/processing/videos'; import { getVideoPosterPath, getVideoPreviewPath } from '@/lib/processing/videos';
@@ -34,9 +34,12 @@ export async function GET(
return new NextResponse('Not found', { status: 404 }); return new NextResponse('Not found', { status: 404 });
} }
// CRITICAL: Unpublished media returns 403 — no metadata leak // CRITICAL: Unpublished media returns 403 for public, but allows admin
if (!mediaItem.published) { if (!mediaItem.published) {
return new NextResponse('Forbidden', { status: 403 }); const isAuthenticated = await validateSession(request);
if (!isAuthenticated) {
return new NextResponse('Forbidden', { status: 403 });
}
} }
// Determine which file to serve // Determine which file to serve
+37 -25
View File
@@ -53,27 +53,16 @@ body {
} }
.media-wall { .media-wall {
display: grid; display: flex;
grid-template-columns: repeat(2, 1fr); flex-wrap: wrap;
grid-auto-rows: 150px;
grid-auto-flow: dense;
gap: 10px; gap: 10px;
padding: 80px 10px 20px; padding: 80px 10px 20px;
max-width: 1920px; max-width: 1920px;
margin: 0 auto; margin: 0 auto;
} }
@media (min-width: 480px) {
.media-wall {
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: 180px;
}
}
@media (min-width: 768px) { @media (min-width: 768px) {
.media-wall { .media-wall {
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 200px;
gap: 15px; gap: 15px;
padding: 80px 15px 20px; padding: 80px 15px 20px;
} }
@@ -81,20 +70,11 @@ body {
@media (min-width: 1024px) { @media (min-width: 1024px) {
.media-wall { .media-wall {
grid-template-columns: repeat(5, 1fr);
grid-auto-rows: 250px;
gap: 20px; gap: 20px;
padding: 90px 20px 30px; padding: 90px 20px 30px;
} }
} }
@media (min-width: 1440px) {
.media-wall {
grid-template-columns: repeat(6, 1fr);
grid-auto-rows: 250px;
}
}
.media-tile { .media-tile {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -102,8 +82,7 @@ body {
background: var(--color-border); background: var(--color-border);
cursor: pointer; cursor: pointer;
transition: transform var(--transition), box-shadow var(--transition); transition: transform var(--transition), box-shadow var(--transition);
height: 100%; /* The width will be controlled by flex-basis and flex-grow dynamically */
width: 100%;
} }
.media-tile:hover { .media-tile:hover {
@@ -114,10 +93,13 @@ body {
.media-tile img, .media-tile img,
.media-tile video { .media-tile video {
position: absolute;
top: 0;
left: 0;
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover; /* We still use cover, but the container's aspect ratio perfectly matches the image, so no cropping occurs! */
} }
.media-tile .play-icon { .media-tile .play-icon {
@@ -304,6 +286,36 @@ body {
gap: 4px; gap: 4px;
} }
.tile-like-wrapper {
position: absolute;
bottom: 8px;
right: 8px;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.tile-like-btn {
width: auto;
height: auto;
min-width: 40px;
min-height: 40px;
background: rgba(0, 0, 0, 0.6);
padding: 6px;
border-radius: 20px;
}
.tile-like-btn svg {
width: 16px;
height: 16px;
}
.tile-like-btn .like-count {
font-size: 0.75rem;
margin-top: 2px;
}
.unpublished { .unpublished {
opacity: 0.5; opacity: 0.5;
} }
+4 -2
View File
@@ -5,15 +5,17 @@ import React, { useState } from 'react';
type LikeButtonProps = { type LikeButtonProps = {
mediaId: number; mediaId: number;
initialLikes: number; initialLikes: number;
className?: string;
}; };
export default function LikeButton({ mediaId, initialLikes }: LikeButtonProps) { export default function LikeButton({ mediaId, initialLikes, className = '' }: LikeButtonProps) {
const [likes, setLikes] = useState(initialLikes); const [likes, setLikes] = useState(initialLikes);
const [isLiked, setIsLiked] = useState(false); const [isLiked, setIsLiked] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const handleLike = async (e: React.MouseEvent) => { const handleLike = async (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault();
if (isLoading) return; if (isLoading) return;
setIsLoading(true); setIsLoading(true);
@@ -53,7 +55,7 @@ export default function LikeButton({ mediaId, initialLikes }: LikeButtonProps) {
return ( return (
<button <button
className={`btn-icon like-btn ${isLiked ? 'liked' : ''}`} className={`btn-icon like-btn ${isLiked ? 'liked' : ''} ${className}`}
onClick={handleLike} onClick={handleLike}
disabled={isLoading} disabled={isLoading}
title={isLiked ? "Gefällt mir nicht mehr" : "Gefällt mir"} title={isLiked ? "Gefällt mir nicht mehr" : "Gefällt mir"}
+19 -38
View File
@@ -2,6 +2,8 @@
import React, { useRef, useEffect, useState } from 'react'; import React, { useRef, useEffect, useState } from 'react';
import LikeButton from './LikeButton';
type MediaItem = { type MediaItem = {
id: number; id: number;
relativePath: string; relativePath: string;
@@ -38,40 +40,17 @@ export default function MediaTile({ item, index, onClick, votingOpen }: MediaTil
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
let colSpan = 1; const defaultRatio = 1.5;
let rowSpan = 1; const ratio = (item.width && item.height) ? item.width / item.height : defaultRatio;
const targetRowHeight = 250; // We can tweak this base height
if (item.width && item.height) {
const ratio = item.width / item.height;
if (ratio > 1.2) {
// Landscape
colSpan = 2;
rowSpan = 1;
} else if (ratio < 0.8) {
// Portrait
colSpan = 1;
rowSpan = 2;
}
}
// Randomizer for variety based on ID so it stays consistent
const isRandomLarge = item.id % 7 === 0;
if (isRandomLarge) {
if (colSpan === 2 && rowSpan === 1) {
colSpan = 2;
rowSpan = 2;
} else if (colSpan === 1 && rowSpan === 2) {
colSpan = 2;
rowSpan = 2;
} else {
colSpan = 2;
rowSpan = 2;
}
}
// By using flexGrow and flexBasis proportional to the aspect ratio,
// Flexbox will distribute the items so that ALL items in the same row
// end up with the EXACT same height!
const style: React.CSSProperties = { const style: React.CSSProperties = {
gridColumn: `span ${colSpan}`, flexGrow: ratio,
gridRow: `span ${rowSpan}`, flexShrink: ratio,
flexBasis: `${ratio * targetRowHeight}px`,
}; };
const isVideo = item.mediaType === 'VIDEO'; const isVideo = item.mediaType === 'VIDEO';
@@ -86,6 +65,11 @@ export default function MediaTile({ item, index, onClick, votingOpen }: MediaTil
data-index={index} data-index={index}
title={item.filename} title={item.filename}
> >
{/*
This padding block forces the container to strictly maintain its aspect ratio.
Since flexbox assigns the width, the height is automatically derived from it.
*/}
<div style={{ display: 'block', paddingTop: `${(1 / ratio) * 100}%` }} />
{isVisible && ( {isVisible && (
<> <>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
@@ -105,12 +89,9 @@ export default function MediaTile({ item, index, onClick, votingOpen }: MediaTil
</svg> </svg>
</div> </div>
)} )}
{votingOpen && likesCount > 0 && ( {votingOpen && (
<div className="badge"> <div className="tile-like-wrapper">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none"> <LikeButton mediaId={item.id} initialLikes={likesCount} className="tile-like-btn" />
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
<span>{likesCount}</span>
</div> </div>
)} )}
</> </>