Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
Robbie Davis
28c968ac4d Adding masonry cover images to login page
- Added new public api: /api/Image/random-series-cover
- Updated login bg color to black
- If no cover images, use the default library background (for new installs)
- Added a cover masonry component
- updated the splash container
2025-04-15 13:11:47 -04:00
10 changed files with 322 additions and 15 deletions

View file

@ -344,4 +344,22 @@ public class ImageController : BaseApiController
return PhysicalFile(path, MimeTypeMap.GetMimeType(format), _directoryService.FileSystem.Path.GetFileName(path));
}
/// <summary>
/// Returns cover image for a random Series
/// </summary>
/// <returns></returns>
[HttpGet("random-series-cover")]
public async Task<ActionResult> GetRandomSeriesCoverImage()
{
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.SeriesRepository.GetRandomSeriesCoverImageAsync());
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest("No cover image found");
var format = _directoryService.FileSystem.Path.GetExtension(path);
Response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
Response.Headers.Add("Pragma", "no-cache");
Response.Headers.Add("Expires", "0");
return PhysicalFile(path, MimeTypeMap.GetMimeType(format), _directoryService.FileSystem.Path.GetFileName(path));
}
}

View file

@ -116,6 +116,7 @@ public interface ISeriesRepository
/// <returns></returns>
Task AddSeriesModifiers(int userId, IList<SeriesDto> series);
Task<string?> GetSeriesCoverImageAsync(int seriesId);
Task<string?> GetRandomSeriesCoverImageAsync();
Task<PagedList<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto? filter);
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter);
Task<PagedList<SeriesDto>> GetRecentlyAddedV2(int userId, UserParams userParams, FilterV2Dto filter);
@ -183,6 +184,8 @@ public class SeriesRepository : ISeriesRepository
private readonly Regex _yearRegex = new Regex(@"\d{4}", RegexOptions.Compiled,
Services.Tasks.Scanner.Parser.Parser.RegexTimeout);
private static readonly Random _random = new Random();
public SeriesRepository(DataContext context, IMapper mapper, UserManager<AppUser> userManager)
{
_context = context;
@ -780,6 +783,19 @@ public class SeriesRepository : ISeriesRepository
.SingleOrDefaultAsync();
}
public async Task<string?> GetRandomSeriesCoverImageAsync()
{
var count = await _context.Series.CountAsync();
if (count == 0) return null;
var skip = _random.Next(0, count);
return await _context.Series
.Skip(skip)
.Select(s => s.CoverImage)
.FirstOrDefaultAsync();
}
/// <summary>
/// Returns a list of Series that were added, ordered by Created desc

View file

@ -109,6 +109,13 @@ export class ImageService {
return `${this.baseUrl}image/cover-upload?filename=${encodeURIComponent(filename)}&apiKey=${this.encodedKey}`;
}
getRandomSeriesCoverImage() {
const timestamp = new Date().getTime();
const random = Math.random().toString(36).substring(7);
return `${this.baseUrl}image/random-series-cover?t=${timestamp}&r=${random}`;
}
updateErroredWebLinkImage(event: any) {
event.target.src = this.errorWebLinkImage;
}

View file

@ -0,0 +1,12 @@
<div class="masonry-container" [class.no-images]="noImagesFound">
<div class="masonry-grid">
<div *ngFor="let image of coverImages" class="masonry-item">
<img [ngSrc]="image" width="250" height="357" alt="Series cover" priority (error)="imageService.updateErroredWebLinkImage($event)">
</div>
</div>
<div class="masonry-grid duplicate">
<div *ngFor="let image of duplicateImages" class="masonry-item">
<img [ngSrc]="image" width="250" height="357" alt="Series cover" priority (error)="imageService.updateErroredWebLinkImage($event)">
</div>
</div>
</div>

View file

@ -0,0 +1,108 @@
.masonry-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100vh;
overflow: hidden;
z-index: 0;
background-color: var(--login-background-color);
&.no-images {
display: none;
}
}
.masonry-grid {
display: grid;
grid-template-columns: repeat(auto-fit, 250px);
grid-auto-rows: minmax(0, 370px);
grid-gap: 10px;
padding: 2px;
width: 100%;
animation: scrollMasonry 90s linear infinite;
animation-play-state: running;
will-change: transform;
backface-visibility: hidden;
transform: translateZ(0);
justify-content: space-evenly;
&.duplicate {
position: absolute;
top: 0;
left: 0;
animation: scrollMasonryDuplicate 90s linear infinite;
}
@media (max-width: 1600px) {
grid-template-columns: repeat(auto-fit, 250px);
}
@media (max-width: 1200px) {
grid-template-columns: repeat(auto-fit, 250px);
}
@media (max-width: 900px) {
grid-template-columns: repeat(auto-fit, 250px);
}
@media (max-width: 600px) {
grid-template-columns: repeat(auto-fit, 250px);
}
}
.masonry-item {
position: relative;
overflow: hidden;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease;
opacity: 0;
animation: fadeIn 0.5s ease forwards;
background-color: var(--elevation-layer11-dark);
height: auto;
aspect-ratio: 0.70;
margin-bottom: 10px;
&:hover {
transform: scale(1.02);
animation-play-state: paused;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
mix-blend-mode: overlay;
}
}
@keyframes scrollMasonry {
0% {
transform: translate3d(0, 0, 0);
}
100% {
transform: translate3d(0, -100%, 0);
}
}
@keyframes scrollMasonryDuplicate {
0% {
transform: translate3d(0, 100%, 0);
}
100% {
transform: translate3d(0, 0, 0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View file

@ -0,0 +1,89 @@
import { ChangeDetectionStrategy, Component, OnInit, OnDestroy, HostListener, Output, EventEmitter } from '@angular/core';
import { CommonModule, NgOptimizedImage } from '@angular/common';
import { ImageService } from 'src/app/_services/image.service';
import { BehaviorSubject } from 'rxjs';
@Component({
selector: 'app-cover-masonry',
templateUrl: './cover-masonry.component.html',
styleUrls: ['./cover-masonry.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, NgOptimizedImage]
})
export class CoverMasonryComponent implements OnInit, OnDestroy {
@Output() hasValidImages = new EventEmitter<boolean>();
coverImages: string[] = [];
duplicateImages: string[] = [];
noImagesFound = false;
private readonly batchSize = 40;
private loading = false;
private scrollContainer: HTMLElement | null = null;
private loadedImages = 0;
constructor(public imageService: ImageService) {}
ngOnInit() {
this.loadRandomCovers();
this.scrollContainer = document.querySelector('.masonry-container');
}
ngOnDestroy() {
this.scrollContainer = null;
}
@HostListener('window:scroll', ['$event'])
onScroll() {
if (this.loading || !this.scrollContainer) return;
const container = this.scrollContainer;
const scrollPosition = window.scrollY + window.innerHeight;
const containerBottom = container.offsetTop + container.offsetHeight;
// Load more when we're within 200px of the bottom
if (scrollPosition > containerBottom - 200) {
this.loadRandomCovers();
}
}
private loadRandomCovers() {
if (this.loading) return;
this.loading = true;
this.loadedImages = 0;
const newImages: string[] = [];
for (let i = 0; i < this.batchSize; i++) {
const url = this.imageService.getRandomSeriesCoverImage();
if (url) {
// Add a random query parameter to prevent caching
const uniqueUrl = `${url}?t=${Date.now()}`;
newImages.push(uniqueUrl);
}
}
// Check if images load successfully
newImages.forEach(url => {
const img = new Image();
img.onload = () => {
this.loadedImages++;
if (this.loadedImages === newImages.length) {
this.coverImages = [...this.coverImages, ...newImages];
this.duplicateImages = [...this.coverImages];
this.noImagesFound = this.coverImages.length === 0;
this.hasValidImages.emit(true);
}
};
img.onerror = () => {
this.loadedImages++;
if (this.loadedImages === newImages.length) {
this.noImagesFound = this.coverImages.length === 0;
this.hasValidImages.emit(this.coverImages.length > 0);
}
};
img.src = url;
});
this.loading = false;
}
}

View file

@ -1,5 +1,8 @@
<div class="mx-auto container login text-center"
[ngStyle]="{'height': (navService.navbarVisible$ | async) ? 'calc(var(--vh, 1vh) * 100 - var(--nav-offset))' : 'calc(var(--vh, 1vh) * 100)'}">
<div class="mx-auto login text-center"
[ngStyle]="{'height': (navService.navbarVisible$ | async) ? 'calc(var(--vh, 1vh) * 100 - var(--nav-offset))' : 'calc(var(--vh, 1vh) * 100)'}"
[class.no-images]="hasValidCoverImages">
<app-cover-masonry (hasValidImages)="onCoverImagesValid($event)"></app-cover-masonry>
<div class="row align-items-center row-cols-1 logo-container mb-3 justify-content-center">
<div class="col col-md-4 col-sm-12 col-xs-12 align-self-center p-0">

View file

@ -3,29 +3,52 @@
}
.login {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
height: calc(var(--vh, 1vh) * 100 - 57px);
min-height: 289px;
position: relative;
width: 100vw;
max-width: 100vw;
background: var(--login-background-color);
overflow: hidden;
background-color: var(--bs-body-bg);
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 0;
margin: 0;
&::before {
&.no-images::before {
content: "";
background-image: var(--login-background-url);
background-size: var(--login-background-size);
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
opacity: var(--login-background-opacity);
z-index: 0;
width: 100%;
}
&.no-cover-images {
background-color: var(--login-background-color);
&::before {
opacity: var(--login-background-opacity);
}
}
.logo-container {
position: relative;
z-index: 1;
}
.login-container {
position: relative;
z-index: 1;
}
h2 {
font-family: "Spartan", sans-serif;
font-size: 1.5rem;
@ -60,6 +83,8 @@
border-width: var(--login-card-border-width);
border-style: var(--login-card-border-style);
border-color: var(--login-card-border-color);
position: relative;
z-index: 1;
&:focus {
border: 2px solid white;

View file

@ -1,17 +1,31 @@
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
import {ChangeDetectionStrategy, Component, inject, OnInit} from '@angular/core';
import {AsyncPipe, NgStyle} from "@angular/common";
import {NavService} from "../../../_services/nav.service";
import {CoverMasonryComponent} from "../cover-masonry/cover-masonry.component";
import {ThemeService} from "../../../_services/theme.service";
@Component({
selector: 'app-splash-container',
templateUrl: './splash-container.component.html',
styleUrls: ['./splash-container.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
NgStyle,
AsyncPipe
AsyncPipe,
CoverMasonryComponent
]
})
export class SplashContainerComponent {
export class SplashContainerComponent implements OnInit {
protected readonly navService = inject(NavService);
protected readonly themeService = inject(ThemeService);
hasValidCoverImages = false;
ngOnInit() {
this.themeService.getThemes().subscribe();
}
onCoverImagesValid(hasImages: boolean) {
this.hasValidCoverImages = !hasImages;
}
}

View file

@ -2950,6 +2950,21 @@
}
}
},
"/api/Image/random-series-cover": {
"get": {
"tags": [
"Image"
],
"summary": "Returns cover image for a random Series",
"parameters": [
],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/api/Library/create": {
"post": {
"tags": [