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
+65 -67

No files matched your search

+5 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { validateMediaPath } from '@/lib/security/path';
import { validateSession } from '@/lib/auth/session';
import { getSafeContentType } from '@/lib/media/types';
import { getThumbnailPath, getGalleryPath } from '@/lib/processing/images';
import { getVideoPosterPath, getVideoPreviewPath } from '@/lib/processing/videos';
@@ -34,10 +34,13 @@ export async function GET(
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) {
const isAuthenticated = await validateSession(request);
if (!isAuthenticated) {
return new NextResponse('Forbidden', { status: 403 });
}
}
// Determine which file to serve
let filePath: string;
+37 -25
View File
@@ -53,27 +53,16 @@ body {
}
.media-wall {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 150px;
grid-auto-flow: dense;
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 80px 10px 20px;
max-width: 1920px;
margin: 0 auto;
}
@media (min-width: 480px) {
.media-wall {
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: 180px;
}
}
@media (min-width: 768px) {
.media-wall {
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 200px;
gap: 15px;
padding: 80px 15px 20px;
}
@@ -81,20 +70,11 @@ body {
@media (min-width: 1024px) {
.media-wall {
grid-template-columns: repeat(5, 1fr);
grid-auto-rows: 250px;
gap: 20px;
padding: 90px 20px 30px;
}
}
@media (min-width: 1440px) {
.media-wall {
grid-template-columns: repeat(6, 1fr);
grid-auto-rows: 250px;
}
}
.media-tile {
position: relative;
overflow: hidden;
@@ -102,8 +82,7 @@ body {
background: var(--color-border);
cursor: pointer;
transition: transform var(--transition), box-shadow var(--transition);
height: 100%;
width: 100%;
/* The width will be controlled by flex-basis and flex-grow dynamically */
}
.media-tile:hover {
@@ -114,10 +93,13 @@ body {
.media-tile img,
.media-tile video {
position: absolute;
top: 0;
left: 0;
display: block;
width: 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 {
@@ -304,6 +286,36 @@ body {
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 {
opacity: 0.5;
}
+4 -2
View File
@@ -5,15 +5,17 @@ import React, { useState } from 'react';
type LikeButtonProps = {
mediaId: 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 [isLiked, setIsLiked] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleLike = async (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
if (isLoading) return;
setIsLoading(true);
@@ -53,7 +55,7 @@ export default function LikeButton({ mediaId, initialLikes }: LikeButtonProps) {
return (
<button
className={`btn-icon like-btn ${isLiked ? 'liked' : ''}`}
className={`btn-icon like-btn ${isLiked ? 'liked' : ''} ${className}`}
onClick={handleLike}
disabled={isLoading}
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 LikeButton from './LikeButton';
type MediaItem = {
id: number;
relativePath: string;
@@ -38,40 +40,17 @@ export default function MediaTile({ item, index, onClick, votingOpen }: MediaTil
return () => observer.disconnect();
}, []);
let colSpan = 1;
let rowSpan = 1;
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;
}
}
const defaultRatio = 1.5;
const ratio = (item.width && item.height) ? item.width / item.height : defaultRatio;
const targetRowHeight = 250; // We can tweak this base height
// 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 = {
gridColumn: `span ${colSpan}`,
gridRow: `span ${rowSpan}`,
flexGrow: ratio,
flexShrink: ratio,
flexBasis: `${ratio * targetRowHeight}px`,
};
const isVideo = item.mediaType === 'VIDEO';
@@ -86,6 +65,11 @@ export default function MediaTile({ item, index, onClick, votingOpen }: MediaTil
data-index={index}
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 && (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
@@ -105,12 +89,9 @@ export default function MediaTile({ item, index, onClick, votingOpen }: MediaTil
</svg>
</div>
)}
{votingOpen && likesCount > 0 && (
<div className="badge">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<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>
{votingOpen && (
<div className="tile-like-wrapper">
<LikeButton mediaId={item.id} initialLikes={likesCount} className="tile-like-btn" />
</div>
)}
</>