Implement realtime UI for libraries

This commit is contained in:
grimsi
2025-05-21 20:44:00 +02:00
parent 54e8c31f9e
commit ad2fee0c3f
10 changed files with 149 additions and 114 deletions
+3
View File
@@ -9,12 +9,15 @@ import {AuthProvider} from "Frontend/util/auth";
import {IconContext, X} from "@phosphor-icons/react";
import client from "Frontend/generated/connect-client.default";
import {ErrorHandlingMiddleware} from "Frontend/util/middleware";
import {initializeLibraryState} from "Frontend/state/LibraryState";
export default function App() {
const navigate = useNavigate();
client.middlewares = [ErrorHandlingMiddleware];
initializeLibraryState();
return (
<HeroUIProvider className="size-full" navigate={navigate} useHref={useHref}>
<NextThemesProvider attribute="class" themes={themeNames()} defaultTheme="gameyfin-violet-dark">
@@ -1,4 +1,4 @@
import React, {useEffect, useState} from "react";
import React, {useEffect} from "react";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section";
@@ -10,38 +10,19 @@ import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOver
import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal";
import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto";
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import {useSnapshot} from "valtio/react";
import {initializeLibraryState, libraryState} from "Frontend/state/LibraryState";
function LibraryManagementLayout({getConfig, formik}: any) {
const [libraries, setLibraries] = useState<LibraryDto[]>([]);
const libraryCreationModal = useDisclosure();
const state = useSnapshot(libraryState);
useEffect(() => {
LibraryEndpoint.getAllLibraries().then((response) => {
if (response === undefined) return;
let sortedLibraries: LibraryDto[] = response
.filter(l => !!l)
.sort((a: LibraryDto, b: LibraryDto) => {
if (a.name === undefined || b.name === undefined) return 0;
return a.name.localeCompare(b.name);
});
setLibraries(sortedLibraries);
});
initializeLibraryState();
}, []);
async function updateLibrary(library: LibraryUpdateDto) {
let updatedLibrary = await LibraryEndpoint.updateLibrary(library);
if (updatedLibrary === undefined) return;
setLibraries((prevLibraries) => {
const index = prevLibraries.findIndex((l) => l.id === updatedLibrary.id);
if (index !== -1) {
const updatedLibraries = [...prevLibraries];
updatedLibraries[index] = updatedLibrary;
return updatedLibraries;
}
return [...prevLibraries, updatedLibrary];
});
await LibraryEndpoint.updateLibrary(library);
addToast({
title: "Library updated",
description: `Library ${library.name} has been updated.`,
@@ -51,9 +32,6 @@ function LibraryManagementLayout({getConfig, formik}: any) {
async function removeLibrary(library: LibraryDto) {
await LibraryEndpoint.removeLibrary(library.id);
setLibraries((prevLibraries) => {
return prevLibraries.filter((l) => l.id !== library.id);
});
addToast({
title: "Library removed",
description: `Library ${library.name} has been removed.`,
@@ -87,10 +65,11 @@ function LibraryManagementLayout({getConfig, formik}: any) {
</Tooltip>
</div>
<Divider className="mb-4"/>
{libraries.length > 0 ?
{state.sorted.length > 0 ?
// Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px
<div id="library-cards" className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
{libraries.map((library) =>
{state.sorted.map((library) =>
// @ts-ignore
<LibraryOverviewCard library={library} updateLibrary={updateLibrary}
removeLibrary={removeLibrary} key={library.name}/>
)}
@@ -99,8 +78,8 @@ function LibraryManagementLayout({getConfig, formik}: any) {
}
<LibraryCreationModal
libraries={libraries}
setLibraries={setLibraries}
// @ts-ignore
libraries={state.sorted}
isOpen={libraryCreationModal.isOpen}
onOpenChange={libraryCreationModal.onOpenChange}
/>
@@ -20,16 +20,15 @@ import {
TreasureChest,
Trophy
} from "@phosphor-icons/react";
import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto";
import ScanType from "Frontend/generated/de/grimsi/gameyfin/libraries/enums/ScanType";
import {randomGamesFromLibrary} from "Frontend/util/utils";
import {useNavigate} from "react-router";
export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: {
library: LibraryDto,
updateLibrary: (library: LibraryUpdateDto) => Promise<void>,
removeLibrary: (library: LibraryDto) => Promise<void>
}) {
interface LibraryOverviewCardProps {
library: LibraryDto;
}
export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
const MAX_COVER_COUNT = 5;
const navigate = useNavigate();
const [randomGames, setRandomGames] = useState<GameDto[]>([]);
@@ -42,7 +41,6 @@ export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: {
async function triggerScan() {
await LibraryEndpoint.triggerScan(ScanType.QUICK, [library]);
await updateLibrary({id: library.id});
}
return (
@@ -16,14 +16,13 @@ interface LibraryCreationModalProps {
export default function LibraryCreationModal({
libraries,
setLibraries,
isOpen,
onOpenChange
}: LibraryCreationModalProps) {
async function createLibrary(library: LibraryDto) {
try {
const newLibrary = await LibraryEndpoint.createLibrary(library as LibraryDto);
setLibraries([...libraries, newLibrary]);
await LibraryEndpoint.createLibrary(library as LibraryDto);
} catch (e) {
addToast({
title: "Error creating library",
@@ -0,0 +1,57 @@
import {Subscription} from "@vaadin/hilla-frontend";
import {proxy} from "valtio/index";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import LibraryEvent from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryEvent";
type LibraryState = {
subscription?: Subscription<LibraryEvent>;
isLoaded: boolean;
state: Record<string, LibraryDto>;
libraries: LibraryDto[];
sorted: LibraryDto[];
};
export const libraryState = proxy<LibraryState>({
get isLoaded() {
return this.subscription != null;
},
state: {},
get libraries() {
return Object.values<LibraryDto>(this.state);
},
get sorted() {
return Object.values<LibraryDto>(this.state).sort((a, b) => {
if (a.name === undefined || b.name === undefined) return 0;
return a.name.localeCompare(b.name);
});
}
});
/** Subscribe to and process state updates from backend **/
export async function initializeLibraryState() {
if (libraryState.isLoaded) return libraryState;
// Fetch initial library list
const initialEntries = await LibraryEndpoint.getAll();
initialEntries.forEach((library: LibraryDto) => {
libraryState.state[library.id] = library;
});
// Subscribe to real-time updates
libraryState.subscription = LibraryEndpoint.subscribe().onNext((libraryEvent) => {
switch (libraryEvent.type) {
case "created":
case "updated":
//@ts-ignore
libraryState.state[libraryEvent.library.id] = libraryEvent.library;
break;
case "deleted":
//@ts-ignore
delete libraryState.state[libraryEvent.libraryId];
break;
}
});
return libraryState;
}
+14 -16
View File
@@ -1,30 +1,28 @@
import {useEffect, useState} from "react";
import {GameEndpoint, LibraryEndpoint} from "Frontend/generated/endpoints";
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import {GameEndpoint} from "Frontend/generated/endpoints";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import {randomGamesFromLibrary} from "Frontend/util/utils";
import {CoverRow} from "Frontend/components/general/CoverRow";
import {useSnapshot} from "valtio/react";
import {libraryState} from "Frontend/state/LibraryState";
export default function HomeView() {
const [recentlyAddedGames, setRecentlyAddedGames] = useState<GameDto[]>([]);
const [libraries, setLibraries] = useState<LibraryDto[]>([]);
const [libraryIdToGames, setLibraryIdToGames] = useState<Map<number, GameDto[]>>(new Map());
const state = useSnapshot(libraryState);
useEffect(() => {
LibraryEndpoint.getAllLibraries().then(libraries => {
setLibraries(libraries);
const gamePromises = state.libraries.map((library) =>
//@ts-ignore
randomGamesFromLibrary(library).then((games) => [library.id, games] as [number, GameDto[]])
);
const gamePromises = libraries.map((library) =>
randomGamesFromLibrary(library).then((games) => [library.id, games] as [number, GameDto[]])
);
Promise.all(gamePromises).then((results) => {
const libraryGamesMap = new Map<number, GameDto[]>();
results.forEach(([libraryId, games]) => {
libraryGamesMap.set(libraryId, games);
});
setLibraryIdToGames(libraryGamesMap);
Promise.all(gamePromises).then((results) => {
const libraryGamesMap = new Map<number, GameDto[]>();
results.forEach(([libraryId, games]) => {
libraryGamesMap.set(libraryId, games);
});
setLibraryIdToGames(libraryGamesMap);
});
// TODO: see https://github.com/vaadin/hilla/issues/3470
@@ -38,7 +36,7 @@ export default function HomeView() {
<div className="flex flex-col gap-2">
<CoverRow title="Recently added" games={recentlyAddedGames}
onPressShowMore={() => alert("show more of 'Recently added'")}/>
{libraries.map((library) => (
{state.libraries.map((library) => (
<CoverRow key={library.id} title={library.name}
games={libraryIdToGames.get(library.id) || []}
onPressShowMore={() => alert(`show more of library '${library.name}'`)}
@@ -1,45 +1,44 @@
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import {useNavigate, useParams} from "react-router";
import React, {useEffect, useState} from "react";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import React, {useEffect} from "react";
import LibraryHeader from "Frontend/components/general/covers/LibraryHeader";
import {Button, Tab, Tabs} from "@heroui/react";
import {ArrowLeft} from "@phosphor-icons/react";
import LibraryManagementDetails from "Frontend/components/general/library/LibraryManagementDetails";
import LibraryManagementGames from "Frontend/components/general/library/LibraryManagementGames";
import {useSnapshot} from "valtio/react";
import {initializeLibraryState, libraryState} from "Frontend/state/LibraryState";
export default function LibraryManagementView() {
const {libraryId} = useParams();
const navigate = useNavigate();
const [library, setLibrary] = useState<LibraryDto>();
const [games, setGames] = useState<GameDto[]>([]);
const state = useSnapshot(libraryState);
useEffect(() => {
if (!libraryId) return;
LibraryEndpoint.getById(parseInt(libraryId)).then((library: LibraryDto) => {
setLibrary(library);
});
LibraryEndpoint.getGamesInLibrary(parseInt(libraryId)).then((games) => {
setGames(games);
initializeLibraryState().then((state) => {
if (!libraryId || !state.state[libraryId]) {
navigate("/administration/libraries");
}
});
}, []);
return library && <div className="flex flex-col gap-4">
return libraryId && state.state[libraryId] && <div className="flex flex-col gap-4">
<div className="flex flex-row gap-4 items-center">
<Button isIconOnly variant="light" onPress={() => navigate("/administration/libraries")}>
<ArrowLeft/>
</Button>
<h1 className="text-2xl font-bold">Manage library</h1>
</div>
<LibraryHeader library={library} className="h-32"/>
{/* @ts-ignore */}
<LibraryHeader library={state.state[libraryId]} className="h-32"/>
<Tabs color="primary" fullWidth>
<Tab title="Details">
<LibraryManagementDetails library={library}/>
{/* @ts-ignore */}
<LibraryManagementDetails library={state.state[libraryId]}/>
</Tab>
<Tab title="Games">
<LibraryManagementGames library={library}/>
{/* @ts-ignore */}
<LibraryManagementGames library={state.state[libraryId]}/>
</Tab>
<Tab title="Unmatched paths">
<p>Unmatched paths</p>
@@ -2,50 +2,37 @@ package de.grimsi.gameyfin.libraries
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.games.dto.GameDto
import de.grimsi.gameyfin.libraries.dto.LibraryDto
import de.grimsi.gameyfin.libraries.dto.LibraryEvent
import de.grimsi.gameyfin.libraries.dto.LibraryUpdateDto
import de.grimsi.gameyfin.libraries.enums.ScanType
import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed
import reactor.core.publisher.Flux
@Endpoint
@PermitAll
class LibraryEndpoint(
private val libraryService: LibraryService
) {
fun getAllLibraries(): Collection<LibraryDto> {
return libraryService.getAllLibraries()
fun subscribe(): Flux<LibraryEvent> {
return libraryService.subscribe()
}
fun getById(libraryId: Long): LibraryDto = libraryService.getById(libraryId)
fun getAll() = libraryService.getAll()
fun getGamesInLibrary(libraryId: Long): Collection<GameDto> {
return libraryService.getGamesInLibrary(libraryId)
}
fun getGamesInLibrary(libraryId: Long) = libraryService.getGamesInLibrary(libraryId)
@RolesAllowed(Role.Names.ADMIN)
fun triggerScan(scanType: ScanType = ScanType.QUICK, libraries: Collection<LibraryDto>?) {
return libraryService.triggerScan(scanType, libraries)
}
fun triggerScan(scanType: ScanType = ScanType.QUICK, libraries: Collection<LibraryDto>?) =
libraryService.triggerScan(scanType, libraries)
@RolesAllowed(Role.Names.ADMIN)
fun createLibrary(library: LibraryDto): LibraryDto {
return libraryService.create(library)
}
fun createLibrary(library: LibraryDto) = libraryService.create(library)
@RolesAllowed(Role.Names.ADMIN)
fun updateLibrary(library: LibraryUpdateDto): LibraryDto {
return libraryService.update(library)
}
fun updateLibrary(library: LibraryUpdateDto) = libraryService.update(library)
@RolesAllowed(Role.Names.ADMIN)
fun removeLibrary(libraryId: Long) {
return libraryService.deleteLibrary(libraryId)
}
@RolesAllowed(Role.Names.ADMIN)
fun removeLibraries() {
return libraryService.deleteAllLibraries()
}
fun removeLibrary(libraryId: Long) = libraryService.deleteLibrary(libraryId)
}
@@ -5,15 +5,14 @@ import de.grimsi.gameyfin.games.GameService
import de.grimsi.gameyfin.games.dto.GameDto
import de.grimsi.gameyfin.games.entities.Game
import de.grimsi.gameyfin.games.toDto
import de.grimsi.gameyfin.libraries.dto.DirectoryMappingDto
import de.grimsi.gameyfin.libraries.dto.LibraryDto
import de.grimsi.gameyfin.libraries.dto.LibraryStatsDto
import de.grimsi.gameyfin.libraries.dto.LibraryUpdateDto
import de.grimsi.gameyfin.libraries.dto.*
import de.grimsi.gameyfin.libraries.enums.ScanType
import de.grimsi.gameyfin.media.ImageService
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks
import java.util.concurrent.Callable
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
@@ -33,15 +32,27 @@ class LibraryService(
private val executor = Executors.newVirtualThreadPerTaskExecutor()
}
private val libraryEvents = Sinks.many().multicast().onBackpressureBuffer<LibraryEvent>(1024, false)
fun subscribe(): Flux<LibraryEvent> {
log.debug { "New subscription for libraryUpdates" }
return libraryEvents.asFlux()
.doOnSubscribe { log.debug { "Subscriber added to libraryUpdates [${libraryEvents.currentSubscriberCount()}]" } }
.doFinally {
log.debug { "Subscriber removed from libraryUpdates with signal type $it [${libraryEvents.currentSubscriberCount()}]" }
}
}
/**
* Creates or updates a library in the repository.
*
* @param library: The library to create or update.
* @return The created or updated LibraryDto object.
*/
fun create(library: LibraryDto): LibraryDto {
fun create(library: LibraryDto) {
val entity = libraryRepository.save(toEntity(library))
return toDto(entity)
val libraryDto = toDto(entity)
libraryEvents.tryEmitNext(LibraryEvent.Created(libraryDto))
}
/**
@@ -51,7 +62,7 @@ class LibraryService(
* @return The updated LibraryDto.
* @throws IllegalArgumentException if the library ID is null or the library is not found.
*/
fun update(libraryDto: LibraryUpdateDto): LibraryDto {
fun update(libraryDto: LibraryUpdateDto) {
val existingLibrary = libraryRepository.findByIdOrNull(libraryDto.id)
?: throw IllegalArgumentException("Library with ID $libraryDto.id not found")
@@ -64,13 +75,14 @@ class LibraryService(
}
val updatedLibrary = libraryRepository.save(existingLibrary)
return toDto(updatedLibrary)
val updatedLibraryDto = toDto(updatedLibrary)
libraryEvents.tryEmitNext(LibraryEvent.Updated(updatedLibraryDto))
}
/**
* Retrieves all libraries from the repository.
*/
fun getAllLibraries(): Collection<LibraryDto> {
fun getAll(): List<LibraryDto> {
val entities = libraryRepository.findAll()
return entities.map { toDto(it) }
}
@@ -95,13 +107,7 @@ class LibraryService(
*/
fun deleteLibrary(libraryId: Long) {
libraryRepository.deleteById(libraryId)
}
/**
* Deletes all libraries from the repository.
*/
fun deleteAllLibraries() {
libraryRepository.deleteAll()
libraryEvents.tryEmitNext(LibraryEvent.Deleted(libraryId))
}
/**
@@ -0,0 +1,9 @@
package de.grimsi.gameyfin.libraries.dto
sealed class LibraryEvent {
abstract val type: String
data class Created(val library: LibraryDto, override val type: String = "created") : LibraryEvent()
data class Updated(val library: LibraryDto, override val type: String = "updated") : LibraryEvent()
data class Deleted(val libraryId: Long, override val type: String = "deleted") : LibraryEvent()
}