feat: add interactive like button to media preview tiles
This commit is contained in:
1 parent
b1093b37cd
commit
9c66ea85b8
4 files changed
+65
-67
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user