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