Merge pull request #1162 from booklore-app/develop

Merge develop into master for the release
This commit is contained in:
Aditya Chandel
2025-09-15 11:30:03 -06:00
committed by GitHub
35 changed files with 642 additions and 516 deletions
@@ -297,29 +297,68 @@ public class BookService {
@Transactional
public void updateReadProgress(ReadProgressRequest request) {
BookEntity book = bookRepository.findById(request.getBookId()).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(request.getBookId()));
BookEntity book = bookRepository.findById(request.getBookId())
.orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(request.getBookId()));
BookLoreUser user = authenticationService.getAuthenticatedUser();
UserBookProgressEntity userBookProgress = userBookProgressRepository.findByUserIdAndBookId(user.getId(), book.getId()).orElse(new UserBookProgressEntity());
userBookProgress.setUser(userRepository.findById(user.getId()).orElseThrow(() -> new UsernameNotFoundException("User not found")));
userBookProgress.setBook(book);
userBookProgress.setLastReadTime(Instant.now());
if (book.getBookType() == BookFileType.EPUB && request.getEpubProgress() != null) {
userBookProgress.setEpubProgress(request.getEpubProgress().getCfi());
userBookProgress.setEpubProgressPercent(request.getEpubProgress().getPercentage());
} else if (book.getBookType() == BookFileType.PDF && request.getPdfProgress() != null) {
userBookProgress.setPdfProgress(request.getPdfProgress().getPage());
userBookProgress.setPdfProgressPercent(request.getPdfProgress().getPercentage());
} else if (book.getBookType() == BookFileType.CBX && request.getCbxProgress() != null) {
userBookProgress.setCbxProgress(request.getCbxProgress().getPage());
userBookProgress.setCbxProgressPercent(request.getCbxProgress().getPercentage());
UserBookProgressEntity progress = userBookProgressRepository
.findByUserIdAndBookId(user.getId(), book.getId())
.orElseGet(UserBookProgressEntity::new);
BookLoreUserEntity userEntity = userRepository.findById(user.getId())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
progress.setUser(userEntity);
progress.setBook(book);
progress.setLastReadTime(Instant.now());
Float percentage = null;
switch (book.getBookType()) {
case EPUB -> {
if (request.getEpubProgress() != null) {
progress.setEpubProgress(request.getEpubProgress().getCfi());
percentage = request.getEpubProgress().getPercentage();
}
}
case PDF -> {
if (request.getPdfProgress() != null) {
progress.setPdfProgress(request.getPdfProgress().getPage());
percentage = request.getPdfProgress().getPercentage();
}
}
case CBX -> {
if (request.getCbxProgress() != null) {
progress.setCbxProgress(request.getCbxProgress().getPage());
percentage = request.getCbxProgress().getPercentage();
}
}
}
if (percentage != null) {
progress.setReadStatus(getStatus(percentage));
setProgressPercent(progress, book.getBookType(), percentage);
}
// Update dateFinished if provided
if (request.getDateFinished() != null) {
userBookProgress.setDateFinished(request.getDateFinished());
progress.setDateFinished(request.getDateFinished());
}
userBookProgressRepository.save(userBookProgress);
userBookProgressRepository.save(progress);
}
private void setProgressPercent(UserBookProgressEntity progress, BookFileType type, Float percentage) {
switch (type) {
case EPUB -> progress.setEpubProgressPercent(percentage);
case PDF -> progress.setPdfProgressPercent(percentage);
case CBX -> progress.setCbxProgressPercent(percentage);
}
}
private ReadStatus getStatus(Float percentage) {
if (percentage >= 99.5f) return ReadStatus.READ;
if (percentage > 0.5f) return ReadStatus.READING;
return ReadStatus.UNREAD;
}
@Transactional
@@ -70,7 +70,7 @@ public class MonitoredFileOperationService {
if (!sourceDir.equals(targetDir) && Files.exists(targetDir) && monitoringRegistrationService.isPathMonitored(targetDir)) {
monitoringRegistrationService.unregisterSpecificPath(targetDir);
unregisteredPaths.add(targetDir);
log.info("Temporarily unregistered target directory to prevent monitoring conflicts: {}", targetDir);
log.debug("Temporarily unregistered target directory to prevent monitoring conflicts: {}", targetDir);
}
log.debug("Protected {} directory paths from monitoring during file operation", unregisteredPaths.size());
@@ -169,10 +169,11 @@ public class BookMetadataService {
private BookMetadata updateCover(Long bookId, BiConsumer<MetadataWriter, BookEntity> writerAction) {
BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
bookEntity.getMetadata().setCoverUpdatedOn(Instant.now());
MetadataPersistenceSettings settings = appSettingService.getAppSettings().getMetadataPersistenceSettings();
boolean saveToOriginalFile = settings.isSaveToOriginalFile();
boolean convertCbrCb7ToCbz = settings.isConvertCbrCb7ToCbz();
if (saveToOriginalFile && (bookEntity.getBookType() != BookFileType.CBX || convertCbrCb7ToCbz)) {
if (saveToOriginalFile && (bookEntity.getBookType() != BookFileType.CBX || convertCbrCb7ToCbz)) {
metadataWriterFactory.getWriter(bookEntity.getBookType())
.ifPresent(writer -> writerAction.accept(writer, bookEntity));
}
@@ -404,7 +404,7 @@ public class MetadataRefreshService {
protected void setOtherUnspecifiedMetadata(Map<MetadataProvider, BookMetadata> metadataMap, BookMetadata metadataCombined, MetadataProvider provider) {
if (metadataMap.containsKey(provider)) {
BookMetadata metadata = metadataMap.get(provider);
metadataCombined.setSubtitle(metadata.getSubtitle() != null ? metadata.getSubtitle() : metadata.getTitle());
metadataCombined.setSubtitle(metadata.getSubtitle() != null ? metadata.getSubtitle() : metadataCombined.getSubtitle());
metadataCombined.setPublisher(metadata.getPublisher() != null ? metadata.getPublisher() : metadataCombined.getPublisher());
metadataCombined.setPublishedDate(metadata.getPublishedDate() != null ? metadata.getPublishedDate() : metadataCombined.getPublishedDate());
metadataCombined.setIsbn10(metadata.getIsbn10() != null ? metadata.getIsbn10() : metadataCombined.getIsbn10());
@@ -126,6 +126,13 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
private Document buildSecureDocument(InputStream is) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
try {
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
} catch (Exception ex) {
log.debug("XML factory secure feature not supported: {}", ex.getMessage());
}
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
factory.setExpandEntityReferences(false);
@@ -282,17 +289,23 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
// CBZ path
if (lowerName.endsWith(".cbz")) {
try (ZipFile zipFile = new ZipFile(file)) {
// Try front cover via ComicInfo
ZipEntry coverEntry = findFrontCoverEntry(zipFile);
if (coverEntry != null) {
try (InputStream is = zipFile.getInputStream(coverEntry)) {
return is.readAllBytes();
byte[] bytes = is.readAllBytes();
if (canDecode(bytes)) return bytes;
}
} else {
// Fallback: first image after sorting alphabetically
ZipEntry firstImage = findFirstAlphabeticalImageEntry(zipFile);
if (firstImage != null) {
try (InputStream is2 = zipFile.getInputStream(firstImage)) {
return is2.readAllBytes();
}
// Fallback: iterate images alphabetically until a decodable one is found
ZipEntry firstImage = findFirstAlphabeticalImageEntry(zipFile);
if (firstImage != null) {
// Build a sorted list and iterate for decodable formats
java.util.List<ZipEntry> images = listZipImageEntries(zipFile);
for (ZipEntry e : images) {
try (InputStream is = zipFile.getInputStream(e)) {
byte[] bytes = is.readAllBytes();
if (canDecode(bytes)) return bytes;
}
}
}
@@ -316,13 +329,22 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
if (imageName != null) {
SevenZArchiveEntry byName = findSevenZEntryByName(sevenZ, imageName);
if (byName != null) {
return readSevenZEntryBytes(sevenZ, byName);
byte[] bytes = readSevenZEntryBytes(sevenZ, byName);
if (canDecode(bytes)) return bytes;
}
try {
int index = Integer.parseInt(imageName);
SevenZArchiveEntry byIndex = findSevenZImageEntryByIndex(sevenZ, index);
if (byIndex != null) {
return readSevenZEntryBytes(sevenZ, byIndex);
byte[] bytes = readSevenZEntryBytes(sevenZ, byIndex);
if (canDecode(bytes)) return bytes;
}
if (index > 0) {
SevenZArchiveEntry offByOne = findSevenZImageEntryByIndex(sevenZ, index - 1);
if (offByOne != null) {
byte[] bytes = readSevenZEntryBytes(sevenZ, offByOne);
if (canDecode(bytes)) return bytes;
}
}
} catch (NumberFormatException ignore) {
// continue to fallback
@@ -332,10 +354,14 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
}
}
// Fallback: first image alphabetically
// Fallback: iterate images alphabetically until a decodable one is found
SevenZArchiveEntry first = findFirstAlphabeticalSevenZImageEntry(sevenZ);
if (first != null) {
return readSevenZEntryBytes(sevenZ, first);
java.util.List<SevenZArchiveEntry> images = listSevenZImageEntries(sevenZ);
for (SevenZArchiveEntry e : images) {
byte[] bytes = readSevenZEntryBytes(sevenZ, e);
if (canDecode(bytes)) return bytes;
}
}
} catch (Exception e) {
log.warn("Failed to extract cover image from CB7", e);
@@ -359,13 +385,22 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
if (imageName != null) {
FileHeader byName = findRarHeaderByName(archive, imageName);
if (byName != null) {
return readRarEntryBytes(archive, byName);
byte[] bytes = readRarEntryBytes(archive, byName);
if (canDecode(bytes)) return bytes;
}
try {
int index = Integer.parseInt(imageName);
FileHeader byIndex = findRarImageHeaderByIndex(archive, index);
if (byIndex != null) {
return readRarEntryBytes(archive, byIndex);
byte[] bytes = readRarEntryBytes(archive, byIndex);
if (canDecode(bytes)) return bytes;
}
if (index > 0) {
FileHeader offByOne = findRarImageHeaderByIndex(archive, index - 1);
if (offByOne != null) {
byte[] bytes = readRarEntryBytes(archive, offByOne);
if (canDecode(bytes)) return bytes;
}
}
} catch (NumberFormatException ignore) {
// ignore and continue fallback
@@ -375,10 +410,14 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
}
}
// Fallback: first image in alphabetical order
// Fallback: iterate images alphabetically until a decodable one is found
FileHeader firstImage = findFirstAlphabeticalImageHeader(archive);
if (firstImage != null) {
return readRarEntryBytes(archive, firstImage);
java.util.List<FileHeader> images = listRarImageHeaders(archive);
for (FileHeader fh : images) {
byte[] bytes = readRarEntryBytes(archive, fh);
if (canDecode(bytes)) return bytes;
}
}
} catch (Exception e) {
log.warn("Failed to extract cover image from CBR", e);
@@ -390,6 +429,16 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
return generatePlaceholderCover(250, 350);
}
private boolean canDecode(byte[] bytes) {
if (bytes == null || bytes.length == 0) return false;
try (ByteArrayInputStream bais = new ByteArrayInputStream(bytes)) {
BufferedImage img = ImageIO.read(bais);
return img != null;
} catch (IOException e) {
return false;
}
}
private ZipEntry findFrontCoverEntry(ZipFile zipFile) {
ZipEntry comicInfoEntry = findComicInfoEntry(zipFile);
if (comicInfoEntry != null) {
@@ -401,12 +450,27 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
if (byName != null) {
return byName;
}
// also try base-name match for archives with directories or odd encodings
String imageBase = baseName(imageName);
java.util.Enumeration<? extends ZipEntry> it = zipFile.entries();
while (it.hasMoreElements()) {
ZipEntry e = it.nextElement();
if (!e.isDirectory() && isImageEntry(e.getName())) {
if (baseName(e.getName()).equalsIgnoreCase(imageBase)) {
return e;
}
}
}
try {
int index = Integer.parseInt(imageName);
ZipEntry byIndex = findImageEntryByIndex(zipFile, index);
if (byIndex != null) {
return byIndex;
}
if (index > 0) {
ZipEntry offByOne = findImageEntryByIndex(zipFile, index - 1);
if (offByOne != null) return offByOne;
}
} catch (NumberFormatException ignore) {
// ignore
}
@@ -415,8 +479,10 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
log.warn("Failed to parse ComicInfo.xml for cover", e);
}
}
ZipEntry firstImage = findFirstAlphabeticalImageEntry(zipFile);
return firstImage;
// Heuristic filenames before generic fallback
ZipEntry heuristic = findHeuristicCover(zipFile);
if (heuristic != null) return heuristic;
return findFirstAlphabeticalImageEntry(zipFile);
}
private ZipEntry findImageEntryByIndex(ZipFile zipFile, int index) {
@@ -457,6 +523,7 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
}
private boolean isImageEntry(String name) {
if (!isContentEntry(name)) return false;
String lower = name.toLowerCase();
return (
lower.endsWith(".jpg") ||
@@ -468,6 +535,16 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
);
}
private boolean isContentEntry(String name) {
if (name == null) return false;
String norm = name.replace('\\', '/');
if (norm.startsWith("__MACOSX/") || norm.contains("/__MACOSX/")) return false;
String base = baseName(norm);
if (base.startsWith(".")) return false;
if (base.equalsIgnoreCase(".ds_store")) return false;
return true;
}
private byte[] generatePlaceholderCover(int width, int height) {
BufferedImage image = new BufferedImage(
width,
@@ -574,10 +651,7 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
}
}
if (images.isEmpty()) return null;
images.sort(Comparator.comparing(
fh -> fh.getFileName() == null ? "" : fh.getFileName(),
String.CASE_INSENSITIVE_ORDER
));
images.sort((a, b) -> naturalCompare(a.getFileName(), b.getFileName()));
return images.get(0);
}
@@ -591,10 +665,7 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
}
}
if (images.isEmpty()) return null;
images.sort(Comparator.comparing(
entry -> entry.getName() == null ? "" : entry.getName(),
String.CASE_INSENSITIVE_ORDER
));
images.sort((a, b) -> naturalCompare(a.getName(), b.getName()));
return images.get(0);
}
@@ -642,10 +713,7 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
}
}
if (images.isEmpty()) return null;
images.sort(Comparator.comparing(
entry -> entry.getName() == null ? "" : entry.getName(),
String.CASE_INSENSITIVE_ORDER
));
images.sort((a, b) -> naturalCompare(a.getName(), b.getName()));
return images.get(0);
}
@@ -657,4 +725,76 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
return baos.toByteArray();
}
}
}
private java.util.List<ZipEntry> listZipImageEntries(ZipFile zipFile) {
java.util.List<ZipEntry> images = new java.util.ArrayList<>();
java.util.Enumeration<? extends ZipEntry> en = zipFile.entries();
while (en.hasMoreElements()) {
ZipEntry e = en.nextElement();
if (!e.isDirectory() && isImageEntry(e.getName())) images.add(e);
}
images.sort((a, b) -> naturalCompare(a.getName(), b.getName()));
// Heuristic preferred names first
images.sort((a, b) -> Boolean.compare(!likelyCoverName(baseName(a.getName())), !likelyCoverName(baseName(b.getName()))));
return images;
}
private java.util.List<SevenZArchiveEntry> listSevenZImageEntries(SevenZFile sevenZ) throws IOException {
java.util.List<SevenZArchiveEntry> images = new java.util.ArrayList<>();
for (SevenZArchiveEntry e : sevenZ.getEntries()) {
if (!e.isDirectory() && isImageEntry(e.getName())) images.add(e);
}
images.sort((a, b) -> naturalCompare(a.getName(), b.getName()));
images.sort((a, b) -> Boolean.compare(!likelyCoverName(baseName(a.getName())), !likelyCoverName(baseName(b.getName()))));
return images;
}
private java.util.List<FileHeader> listRarImageHeaders(Archive archive) {
java.util.List<FileHeader> images = new java.util.ArrayList<>();
for (FileHeader fh : archive.getFileHeaders()) {
if (fh != null && !fh.isDirectory() && isImageEntry(fh.getFileName())) images.add(fh);
}
images.sort((a, b) -> naturalCompare(a.getFileName(), b.getFileName()));
images.sort((a, b) -> Boolean.compare(!likelyCoverName(baseName(a.getFileName())), !likelyCoverName(baseName(b.getFileName()))));
return images;
}
private boolean likelyCoverName(String base) {
if (base == null) return false;
String n = base.toLowerCase();
return n.startsWith("cover") || n.equals("folder") || n.startsWith("front");
}
private int naturalCompare(String a, String b) {
if (a == null) return b == null ? 0 : -1;
if (b == null) return 1;
String s1 = a.toLowerCase();
String s2 = b.toLowerCase();
int i = 0, j = 0, n1 = s1.length(), n2 = s2.length();
while (i < n1 && j < n2) {
char c1 = s1.charAt(i);
char c2 = s2.charAt(j);
if (Character.isDigit(c1) && Character.isDigit(c2)) {
int i1 = i; while (i1 < n1 && Character.isDigit(s1.charAt(i1))) i1++;
int j1 = j; while (j1 < n2 && Character.isDigit(s2.charAt(j1))) j1++;
String num1 = s1.substring(i, i1).replaceFirst("^0+", "");
String num2 = s2.substring(j, j1).replaceFirst("^0+", "");
int cmp = Integer.compare(num1.isEmpty() ? 0 : Integer.parseInt(num1), num2.isEmpty() ? 0 : Integer.parseInt(num2));
if (cmp != 0) return cmp;
i = i1; j = j1;
} else {
if (c1 != c2) return Character.compare(c1, c2);
i++; j++;
}
}
return Integer.compare(n1 - i, n2 - j);
}
private ZipEntry findHeuristicCover(ZipFile zipFile) {
java.util.List<ZipEntry> images = listZipImageEntries(zipFile);
for (ZipEntry e : images) {
if (likelyCoverName(baseName(e.getName()))) return e;
}
return null;
}
}
@@ -250,17 +250,23 @@ public class AmazonBookParser implements BookParser {
private String getTitle(Document doc) {
Element titleElement = doc.getElementById("productTitle");
if (titleElement != null) {
return titleElement.text();
String fullTitle = titleElement.text();
return fullTitle.split(":", 2)[0].trim();
}
log.warn("Failed to parse title: Element not found.");
return null;
}
private String getSubtitle(Document doc) {
Element subtitleElement = doc.getElementById("productSubtitle");
if (subtitleElement != null) {
return subtitleElement.text();
Element titleElement = doc.getElementById("productTitle");
if (titleElement != null) {
String fullTitle = titleElement.text();
String[] parts = fullTitle.split(":", 2);
if (parts.length > 1) {
return parts[1].trim();
}
}
log.warn("Failed to parse subtitle: Element not found.");
return null;
}
@@ -224,7 +224,8 @@ public class GoodReadsParser implements BookParser {
private void extractBookDetails(JSONObject apolloStateJson, LinkedHashSet<String> keySet, BookMetadata.BookMetadataBuilder builder) {
JSONObject bookJson = getValidBookJson(apolloStateJson, keySet, "Book:kca:");
if (bookJson != null) {
builder.title(handleStringNull(bookJson.optString("title")))
builder.title(handleStringNull(extractTitleFromFull(bookJson.optString("title"))))
.subtitle(handleStringNull(extractSubtitleFromFull(bookJson.optString("title"))))
.description(handleStringNull(bookJson.optString("description")))
.thumbnailUrl(handleStringNull(bookJson.optString("imageUrl")))
.categories(extractGenres(bookJson));
@@ -263,12 +264,22 @@ public class GoodReadsParser implements BookParser {
builder.goodreadsRating(parseDouble(statsJson.optString("averageRating")))
.goodreadsReviewCount(parseInteger(statsJson.optString("ratingsCount")));
}
JSONObject detailsJson = workJson.optJSONObject("details");
}
}
}
private String extractTitleFromFull(String fullTitle) {
if (fullTitle == null) return null;
String[] parts = fullTitle.split(":", 2);
return parts[0].trim();
}
private String extractSubtitleFromFull(String fullTitle) {
if (fullTitle == null) return null;
String[] parts = fullTitle.split(":", 2);
return parts.length > 1 ? parts[1].trim() : null;
}
private Double parseDouble(String value) {
try {
return value != null ? Double.parseDouble(value) : null;
@@ -5,7 +5,9 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
@@ -19,6 +21,9 @@ import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import javax.imageio.ImageIO;
import java.awt.Color;
import static org.junit.jupiter.api.Assertions.*;
class CbxMetadataExtractorTest {
@@ -90,9 +95,9 @@ class CbxMetadataExtractorTest {
" </Pages>" +
"</ComicInfo>";
byte[] img1 = new byte[]{11};
byte[] img2 = new byte[]{22, 22}; // expect this one
byte[] img3 = new byte[]{33, 33, 33};
byte[] img1 = createTestImage(Color.RED);
byte[] img2 = createTestImage(Color.GREEN); // expect this one
byte[] img3 = createTestImage(Color.BLUE);
File cbz = createCbz("with_cover.cbz", new LinkedHashMap<>() {{
put("ComicInfo.xml", xml.getBytes(StandardCharsets.UTF_8));
@@ -108,9 +113,9 @@ class CbxMetadataExtractorTest {
@Test
void extractCover_fromCbz_fallbackAlphabeticalFirst() throws Exception {
// No ComicInfo.xml, images intentionally added in unsorted order
byte[] aPng = new byte[]{1,1}; // alphabetically first (A.png)
byte[] bJpg = new byte[]{2};
byte[] cJpeg = new byte[]{3,3,3};
byte[] aPng = createTestImage(Color.YELLOW); // alphabetically first (A.png)
byte[] bJpg = createTestImage(Color.MAGENTA);
byte[] cJpeg = createTestImage(Color.CYAN);
File cbz = createCbz("fallback.cbz", new LinkedHashMap<>() {{
put("z/pageC.jpeg", cJpeg);
@@ -150,4 +155,17 @@ class CbxMetadataExtractorTest {
}
return out.toFile();
}
private byte[] createTestImage(Color color) throws IOException {
BufferedImage image = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
for (int x = 0; x < 10; x++) {
for (int y = 0; y < 10; y++) {
image.setRGB(x, y, color.getRGB());
}
}
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
ImageIO.write(image, "jpg", baos);
return baos.toByteArray();
}
}
}
+4 -13
View File
@@ -1,23 +1,14 @@
@if (loading) {
<div class="splash-screen">
<div class="splash-content">
<img src="assets/favicon.svg" alt="Booklore logo" class="logo"/>
<img src="assets/favicon.svg" alt="Booklore logo" class="logo" />
<h1>Loading Booklore…</h1>
<p>Please wait while we get things ready.</p>
<div class="loader"></div>
</div>
</div>
} @else {
<div class="app-background"
[style.background-image]="showBackground() ? backgroundStyle() : 'none'">
<div class="app-overlay"
[style.backdrop-filter]="showBackground() ? blurStyle() : 'none'"
[style.-webkit-backdrop-filter]="showBackground() ? blurStyle() : 'none'"
[style.display]="showBackground() ? 'block' : 'none'"></div>
<div class="app-content">
<p-confirmDialog></p-confirmDialog>
<p-toast></p-toast>
<router-outlet></router-outlet>
</div>
</div>
<p-confirmDialog />
<p-toast></p-toast>
<router-outlet></router-outlet>
}
-22
View File
@@ -1,25 +1,3 @@
.app-background {
min-height: 100vh;
position: relative;
background-size: cover;
background-position: center center;
background-attachment: fixed;
background-repeat: no-repeat;
}
.app-overlay {
position: absolute;
inset: 0;
background: rgba(20, 20, 20, 0.4);
z-index: 0;
}
.app-content {
position: relative;
z-index: 1;
min-height: 100vh;
}
.splash-screen {
display: flex;
align-items: center;
-22
View File
@@ -37,7 +37,6 @@ export class AppComponent implements OnInit, OnDestroy {
private taskEventService = inject(TaskEventService);
private duplicateFileService = inject(DuplicateFileService);
private appConfigService = inject(AppConfigService); // Keep it here to ensure the service is initialized
private readonly configService = inject(AppConfigService);
ngOnInit(): void {
this.authInit.initialized$.subscribe(ready => {
@@ -102,25 +101,4 @@ export class AppComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.subscriptions.forEach(sub => sub.unsubscribe());
}
readonly backgroundStyle = computed(() => {
const state = this.configService.appState();
const backgroundImage = state.backgroundImage;
if (!backgroundImage) {
return 'none';
}
return `url('${backgroundImage}')`;
});
readonly blurStyle = computed(() => {
const state = this.configService.appState();
const blur = state.backgroundBlur ?? AppConfigService.DEFAULT_BACKGROUND_BLUR;
return `blur(${blur}px)`;
});
readonly showBackground = computed(() => {
const state = this.configService.appState();
return state.showBackground ?? true;
});
}
@@ -1,7 +1,8 @@
<div class="book-card"
[class.selected]="isSelected"
(mouseover)="isHovered = true"
(mouseout)="isHovered = false">
(mouseout)="isHovered = false"
(click)="onCardClick($event)">
<div class="cover-container" [ngClass]="{ 'shimmer': !isImageLoaded, 'center-info-btn': readButtonHidden }">
<div
@@ -71,10 +72,17 @@
<div [hidden]="bottomBarHidden">
<div class="book-title-container flex items-center">
@if (shouldShowStatusIcon()) {
<div class="read-status-indicator"
[ngClass]="getReadStatusClass()"
[pTooltip]="'Status: ' + getReadStatusTooltip()"
tooltipPosition="bottom">
<i [class]="getReadStatusIcon()" style="font-size: 0.9rem"></i>
</div>
}
<h4 class="book-title m-0 pl-2"
tooltipPosition="bottom"
tooltipStyleClass="text-xs text-center"
[pTooltip]="displayTitle">
[pTooltip]="'Title: ' + displayTitle">
{{ displayTitle }}
</h4>
<p-tieredmenu #menu [model]="items" [popup]="true" appendTo="body"></p-tieredmenu>
@@ -105,13 +105,14 @@
.select-checkbox {
position: absolute;
top: 4px;
right: 4px;
top: 8px;
right: 8px;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
z-index: 2;
}
.select-checkbox .p-checkbox {
vertical-align: top;
}
@@ -174,6 +175,42 @@
box-shadow: 0 0 4px rgba(0, 0, 0, 0.15);
}
.read-status-indicator {
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-left: 0.5rem;
}
.status-read {
color: #22c55e;
}
.status-reading {
color: #3b82f6;
}
.status-re-reading {
color: #06b6d4;
}
.status-partially-read {
color: #f59e0b;
}
.status-paused {
color: #eab308;
}
.status-abandoned {
color: #ef4444;
}
.status-wont-read {
color: #ec4899;
}
::ng-deep .progress-incomplete .p-progressbar-value {
background-color: #3b82f6;
}
@@ -25,6 +25,7 @@ import {BookMetadataCenterComponent} from '../../../../metadata/book-metadata-ce
import {take, takeUntil} from 'rxjs/operators';
import {readStatusLabels} from '../book-filter/book-filter.component';
import {ResetProgressTypes} from '../../../../shared/constants/reset-progress-type';
import {ReadStatusHelper} from '../../../helpers/read-status.helper';
@Component({
selector: 'app-book-card',
@@ -66,6 +67,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
private userPermissions: any;
private metadataCenterViewMode: 'route' | 'dialog' = 'route';
private destroy$ = new Subject<void>();
protected readStatusHelper = inject(ReadStatusHelper);
ngOnInit(): void {
this.userService.userState$
@@ -696,25 +698,58 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
this.lastMouseEvent = event;
}
toggleSelection(event: CheckboxChangeEvent): void {
if (this.isCheckboxEnabled) {
this.isSelected = event.checked;
onCardClick(event: MouseEvent): void {
if (!event.ctrlKey) {
return;
}
this.toggleCardSelection(!this.isSelected)
}
toggleCardSelection(selected: boolean):void {
if (!this.isCheckboxEnabled) {
return;
}
this.isSelected = selected;
const shiftKey = this.lastMouseEvent?.shiftKey ?? false;
this.checkboxClick.emit({
index: this.index,
bookId: this.book.id,
selected: event.checked,
selected: selected,
shiftKey: shiftKey,
});
if (this.onBookSelect) {
this.onBookSelect(this.book.id, event.checked);
this.onBookSelect(this.book.id, selected);
}
this.lastMouseEvent = null;
}
}
toggleSelection(event: CheckboxChangeEvent): void {
this.toggleCardSelection(event.checked);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
getReadStatusIcon(): string {
return this.readStatusHelper.getReadStatusIcon(this.book.readStatus);
}
getReadStatusClass(): string {
return this.readStatusHelper.getReadStatusClass(this.book.readStatus);
}
getReadStatusTooltip(): string {
return this.readStatusHelper.getReadStatusTooltip(this.book.readStatus);
}
shouldShowStatusIcon(): boolean {
return this.readStatusHelper.shouldShowStatusIcon(this.book.readStatus);
}
}
@@ -2,10 +2,6 @@
--p-accordion-header-padding: 0.6rem 1rem;
}
.filter-row{
.filter-row {
align-items: flex-start;
}
p-badge.filter-value-badge {
border-radius: 6px !important; /* Makes the badge square */
}
@@ -53,7 +53,18 @@
</td>
@for (col of visibleColumns; track col.field) {
@if (col.field === 'amazonRating' || col.field === 'goodreadsRating' || col.field === 'hardcoverRating') {
@if (col.field === 'readStatus') {
<td class="text-center min-w-[4rem] max-w-[4rem]">
@if (shouldShowStatusIcon(book.readStatus)) {
<div class="read-status-indicator"
[ngClass]="getReadStatusClass(book.readStatus)"
[pTooltip]="'Status: ' + getReadStatusTooltip(book.readStatus)"
tooltipPosition="top">
<i [class]="getReadStatusIcon(book.readStatus)"></i>
</div>
}
</td>
} @else if (col.field === 'amazonRating' || col.field === 'goodreadsRating' || col.field === 'hardcoverRating') {
<td class="min-w-[10rem] max-w-[10rem] overflow-hidden truncate">
<span class="flex items-center gap-1">
<p-rating
@@ -1 +1,38 @@
.read-status-indicator {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
font-weight: bold;
width: 20px;
height: 20px;
margin: 0 auto;
}
.status-read {
color: #22c55e;
}
.status-reading {
color: #3b82f6;
}
.status-re-reading {
color: #06b6d4;
}
.status-partially-read {
color: #f59e0b;
}
.status-paused {
color: #eab308;
}
.status-abandoned {
color: #ef4444;
}
.status-wont-read {
color: #ec4899;
}
@@ -1,10 +1,10 @@
import {Component, EventEmitter, inject, Input, OnChanges, OnDestroy, OnInit, Output} from '@angular/core';
import {TableModule} from 'primeng/table';
import {DatePipe} from '@angular/common';
import {DatePipe, NgClass} from '@angular/common';
import {Rating} from 'primeng/rating';
import {FormsModule} from '@angular/forms';
import {TooltipModule} from "primeng/tooltip";
import {Book, BookMetadata} from '../../../model/book.model';
import {Book, BookMetadata, ReadStatus} from '../../../model/book.model';
import {SortOption} from '../../../model/sort.model';
import {UrlHelperService} from '../../../../utilities/service/url-helper.service';
import {Button} from 'primeng/button';
@@ -16,6 +16,7 @@ import {UserService} from '../../../../settings/user-management/user.service';
import {BookMetadataCenterComponent} from '../../../../metadata/book-metadata-center-component/book-metadata-center.component';
import {DialogService} from 'primeng/dynamicdialog';
import {take, takeUntil} from 'rxjs/operators';
import {ReadStatusHelper} from '../../../helpers/read-status.helper';
@Component({
selector: 'app-book-table',
@@ -26,7 +27,8 @@ import {take, takeUntil} from 'rxjs/operators';
Rating,
FormsModule,
Button,
TooltipModule
TooltipModule,
NgClass
],
styleUrls: ['./book-table.component.scss'],
providers: [DatePipe]
@@ -47,11 +49,13 @@ export class BookTableComponent implements OnInit, OnDestroy, OnChanges {
private dialogService = inject(DialogService);
private router = inject(Router);
private datePipe = inject(DatePipe);
private readStatusHelper = inject(ReadStatusHelper);
private metadataCenterViewMode: 'route' | 'dialog' = 'route';
private destroy$ = new Subject<void>();
readonly allColumns = [
{field: 'readStatus', header: '📖'},
{field: 'title', header: 'Title'},
{field: 'authors', header: 'Authors'},
{field: 'publisher', header: 'Publisher'},
@@ -189,8 +193,27 @@ export class BookTableComponent implements OnInit, OnDestroy, OnChanges {
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${mb.toFixed(2)} MB`;
}
getReadStatusIcon(readStatus: ReadStatus | undefined): string {
return this.readStatusHelper.getReadStatusIcon(readStatus);
}
getReadStatusClass(readStatus: ReadStatus | undefined): string {
return this.readStatusHelper.getReadStatusClass(readStatus);
}
getReadStatusTooltip(readStatus: ReadStatus | undefined): string {
return this.readStatusHelper.getReadStatusTooltip(readStatus);
}
shouldShowStatusIcon(readStatus: ReadStatus | undefined): boolean {
return this.readStatusHelper.shouldShowStatusIcon(readStatus);
}
getCellValue(metadata: BookMetadata, book: Book, field: string): string | number {
switch (field) {
case 'readStatus':
return this.readStatusHelper.getReadStatusTooltip(book?.readStatus);
case 'title':
return metadata.title ?? '';
@@ -16,7 +16,7 @@ export class HeaderFilter implements BookFilter {
distinctUntilChanged(),
switchMap(term => {
const normalizedTerm = normalize(term || '').trim();
if (!normalizedTerm) {
if (normalizedTerm.length < 2) {
return of(bookState);
}
return of(normalizedTerm).pipe(
@@ -14,6 +14,7 @@ export class TableColumnPreferenceService {
readonly preferences$ = this.preferencesSubject.asObservable();
private readonly allAvailableColumns = [
{field: 'readStatus', header: 'Read'},
{field: 'title', header: 'Title'},
{field: 'authors', header: 'Authors'},
{field: 'publisher', header: 'Publisher'},
@@ -53,7 +53,10 @@ export class BookSearcherComponent implements OnInit, OnDestroy {
})
).subscribe({
next: (filteredState) => {
this.books = this.searchQuery.trim() ? (filteredState.books || []) : [];
const term = this.searchQuery.trim();
this.books = term.length >= 2
? (filteredState.books || []).slice(0, 50)
: [];
},
error: (error) => console.error('Subscription error:', error)
});
@@ -7,8 +7,11 @@
@if (!isLoading) {
<div>
<div class="epub-header">
<div>
<div class="header-left">
<p-button size="small" class="menu-toggle-button" (click)="toggleDrawer()" icon="pi pi-bars" severity="secondary"></p-button>
<div class="progress-info">
<span class="progress-percentage"><span class="progress-label">Progress: </span>{{ progressPercentage }}%</span>
</div>
<p-drawer [(visible)]="isDrawerVisible" [modal]="true" [position]="'left'" header="Chapters">
<ul class="chapter-list">
@for (chapter of chapters; track chapter) {
@@ -19,8 +22,12 @@
</ul>
</p-drawer>
</div>
<p>{{ currentChapter }}</p>
<div>
<div class="header-center">
<p class="text-sm font-medium">{{ currentChapter }}</p>
</div>
<div class="header-right">
<div class="flex gap-4">
@if (!locationsReady) {
<div class="location-indicator" pTooltip="Saving progress not ready yet">
@@ -5,6 +5,49 @@
justify-content: space-between;
padding-left: 1rem;
padding-right: 1rem;
position: relative;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
flex: 0 0 auto;
}
.header-center {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.header-right {
display: flex;
align-items: center;
flex: 0 0 auto;
margin-left: auto;
}
.progress-info {
display: flex;
align-items: center;
justify-content: center;
}
.progress-percentage {
font-size: 0.85rem;
color: var(--text-color);
font-weight: 500;
display: flex;
align-items: center;
}
.progress-label {
@media (max-width: 768px) {
display: none;
}
}
.epub-viewer-container {
@@ -12,6 +55,7 @@
flex-direction: column;
height: 100dvh;
overflow: hidden;
background-color: #171717;
}
#epubContainer {
@@ -88,11 +132,7 @@
}
.chapter-item:hover {
color: greenyellow;
}
.chapter-item:active {
background-color: var(--card-background);
color: var(--primary-color);
}
.location-indicator {
@@ -125,3 +165,8 @@
::ng-deep .p-divider.p-divider-horizontal {
margin: 0.25rem 0 0.5rem 0 !important;
}
::ng-deep .p-drawer-left,
::ng-deep .p-drawer-right {
background-color: #171717 !important;
}
@@ -38,6 +38,7 @@ export class EpubViewerComponent implements OnInit, OnDestroy {
public locationsReady = false;
public approxProgress = 0;
public exactProgress = 0;
public progressPercentage = 0;
private book: any;
private rendition: any;
@@ -309,22 +310,39 @@ export class EpubViewerComponent implements OnInit, OnDestroy {
const currentIndex = location.start.index;
const totalSpineItems = this.book.spine.items.length;
let percentage: number;
if (this.locationsReady) {
if (this.locationsReady && this.book.locations.total > 0) {
percentage = this.book.locations.percentageFromCfi(cfi);
this.exactProgress = Math.round(percentage * 1000) / 10;
this.progressPercentage = Math.round(percentage * 1000) / 10;
} else {
if (totalSpineItems > 0) {
percentage = currentIndex / totalSpineItems;
percentage = (currentIndex + 1) / totalSpineItems;
} else {
percentage = 0;
}
this.approxProgress = Math.round(percentage * 1000) / 10;
this.progressPercentage = Math.round(percentage * 1000) / 10;
}
this.currentChapter = getChapter(this.book, location)?.label;
this.bookService.saveEpubProgress(this.epub.id, cfi, Math.round(percentage * 1000) / 10).subscribe();
});
this.book.ready.then(() => this.book.locations.generate(10000)).then(() => {
this.book.ready.then(() => {
return this.book.locations.generate(1600);
}).then(() => {
this.locationsReady = true;
// Recalculate progress with new locations
if (this.rendition.currentLocation()) {
const location = this.rendition.currentLocation();
const cfi = location.end.cfi;
const percentage = this.book.locations.percentageFromCfi(cfi);
this.progressPercentage = Math.round(percentage * 1000) / 10;
}
}).catch(() => {
// If location generation fails, keep using spine-based calculation
this.locationsReady = false;
});
}
@@ -0,0 +1,79 @@
import {Injectable} from '@angular/core';
import {ReadStatus} from '../model/book.model';
@Injectable({
providedIn: 'root'
})
export class ReadStatusHelper {
getReadStatusIcon(readStatus: ReadStatus | undefined): string {
if (!readStatus) return '';
switch (readStatus) {
case ReadStatus.READ:
return 'pi pi-check';
case ReadStatus.READING:
return 'pi pi-play';
case ReadStatus.RE_READING:
return 'pi pi-refresh';
case ReadStatus.PARTIALLY_READ:
return 'pi pi-clock';
case ReadStatus.PAUSED:
return 'pi pi-pause';
case ReadStatus.ABANDONED:
return 'pi pi-times';
case ReadStatus.WONT_READ:
return 'pi pi-ban';
default:
return '';
}
}
getReadStatusClass(readStatus: ReadStatus | undefined): string {
if (!readStatus) return '';
switch (readStatus) {
case ReadStatus.READ:
return 'status-read';
case ReadStatus.READING:
return 'status-reading';
case ReadStatus.RE_READING:
return 'status-re-reading';
case ReadStatus.PARTIALLY_READ:
return 'status-partially-read';
case ReadStatus.PAUSED:
return 'status-paused';
case ReadStatus.ABANDONED:
return 'status-abandoned';
case ReadStatus.WONT_READ:
return 'status-wont-read';
default:
return '';
}
}
getReadStatusTooltip(readStatus: ReadStatus | undefined): string {
if (!readStatus) return '';
switch (readStatus) {
case ReadStatus.READ:
return 'Read';
case ReadStatus.READING:
return 'Currently Reading';
case ReadStatus.RE_READING:
return 'Re-reading';
case ReadStatus.PARTIALLY_READ:
return 'Partially Read';
case ReadStatus.PAUSED:
return 'Paused';
case ReadStatus.ABANDONED:
return 'Abandoned';
case ReadStatus.WONT_READ:
return 'Won\'t Read';
default:
return '';
}
}
shouldShowStatusIcon(readStatus: ReadStatus | undefined): boolean {
return !!(readStatus && readStatus !== ReadStatus.UNREAD && readStatus !== ReadStatus.UNSET);
}
}
@@ -42,6 +42,7 @@ export class BookdropFileMetadataPickerComponent {
metadataFieldsTop = [
{label: 'Title', controlName: 'title', fetchedKey: 'title'},
{label: 'Subtitle', controlName: 'subtitle', fetchedKey: 'subtitle'},
{label: 'Publisher', controlName: 'publisher', fetchedKey: 'publisher'},
{label: 'Published', controlName: 'publishedDate', fetchedKey: 'publishedDate'}
];
@@ -2,9 +2,4 @@ export interface AppState {
preset?: string;
primary?: string;
surface?: string;
backgroundImage?: string;
backgroundBlur?: number;
showBackground?: boolean;
lastUpdated?: number; // Not persisted, used for cache busting
surfaceAlpha?: number;
}
@@ -1,9 +1,8 @@
import {DOCUMENT, isPlatformBrowser} from '@angular/common';
import {effect, inject, Injectable, PLATFORM_ID, signal} from '@angular/core';
import {$t} from '@primeng/themes';
import {$t, updatePreset, updateSurfacePalette} from '@primeng/themes';
import Aura from '@primeng/themes/aura';
import {AppState} from '../model/app-state.model';
import {UrlHelperService} from '../../utilities/service/url-helper.service';
type ColorPalette = Record<string, string>;
@@ -16,15 +15,10 @@ interface Palette {
providedIn: 'root',
})
export class AppConfigService {
public static readonly DEFAULT_BACKGROUND_BLUR = 20;
public static readonly DEFAULT_SURFACE_ALPHA = 0.88;
public static readonly DEFAULT_PRIMARY_COLOR = 'indigo';
private readonly STORAGE_KEY = 'appConfigState';
appState = signal<AppState>({});
document = inject(DOCUMENT);
platformId = inject(PLATFORM_ID);
private readonly urlHelper = inject(UrlHelperService);
private initialized = false;
readonly surfaces: Palette[] = [
@@ -168,20 +162,17 @@ export class AppConfigService {
constructor() {
const initialState = this.loadAppState();
this.appState.set(initialState);
this.appState.set({...initialState});
this.document.documentElement.classList.add('p-dark');
if (isPlatformBrowser(this.platformId)) {
this.setBackendImage();
setTimeout(() => {
this.onPresetChange();
this.initialized = true;
}, 0);
this.onPresetChange();
}
effect(() => {
const state = this.appState();
if (!this.initialized || !state) {
this.initialized = true;
return;
}
this.saveAppState(state);
@@ -189,87 +180,33 @@ export class AppConfigService {
}, {allowSignalWrites: true});
}
private setBackendImage(): void {
const backendUrl = this.urlHelper.getBackgroundImageUrl(Date.now());
this.appState.update(state => ({
...state,
backgroundImage: backendUrl,
lastUpdated: Date.now()
}));
}
refreshBackgroundImage(): void {
const timestamp = Date.now();
const backendUrl = this.urlHelper.getBackgroundImageUrl(timestamp);
this.appState.update(state => ({
...state,
backgroundImage: backendUrl,
lastUpdated: timestamp
}));
}
private loadAppState(): AppState {
const defaultState: AppState = {
preset: 'Aura',
primary: AppConfigService.DEFAULT_PRIMARY_COLOR,
surface: 'neutral',
backgroundBlur: AppConfigService.DEFAULT_BACKGROUND_BLUR,
showBackground: true,
surfaceAlpha: AppConfigService.DEFAULT_SURFACE_ALPHA,
};
if (isPlatformBrowser(this.platformId)) {
const storedState = localStorage.getItem(this.STORAGE_KEY);
if (storedState) {
try {
const parsed = JSON.parse(storedState);
return {
preset: parsed.preset || defaultState.preset,
primary: parsed.primary || defaultState.primary,
surface: parsed.surface || defaultState.surface,
backgroundBlur: parsed.backgroundBlur ?? defaultState.backgroundBlur,
showBackground: parsed.showBackground ?? defaultState.showBackground,
surfaceAlpha: parsed.surfaceAlpha ?? defaultState.surfaceAlpha,
};
} catch (error) {
return defaultState;
}
return JSON.parse(storedState);
}
}
return defaultState;
return {
preset: 'Aura',
primary: 'green',
surface: 'neutral',
};
}
private saveAppState(state: AppState): void {
if (isPlatformBrowser(this.platformId)) {
const {backgroundImage, lastUpdated, ...stateToSave} = state;
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(stateToSave));
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
}
}
private getSurfacePalette(surface: string): ColorPalette {
const palette = this.surfaces.find(s => s.name === surface)?.palette ?? {};
const alpha = this.appState().surfaceAlpha ?? AppConfigService.DEFAULT_SURFACE_ALPHA;
const transparentPalette: ColorPalette = {};
// Text/content colors that should remain opaque (not transparent)
const opaqueKeys = ['0', '50', '100', '200', '300', '400'];
Object.entries(palette).forEach(([key, hex]) => {
if (opaqueKeys.includes(key)) {
// Keep text colors opaque
transparentPalette[key] = hex;
} else {
// Apply transparency to background colors (500-950)
transparentPalette[key] = this.hexToRgba(hex, alpha);
}
});
return transparentPalette;
return this.surfaces.find(s => s.name === surface)?.palette ?? {};
}
getPresetExt(): object {
const surfacePalette = this.getSurfacePalette(this.appState().surface ?? 'neutral');
const primaryName = this.appState().primary ?? AppConfigService.DEFAULT_PRIMARY_COLOR;
const primaryName = this.appState().primary ?? 'green';
const presetPalette = (Aura.primitive ?? {}) as Record<string, ColorPalette>;
const color = presetPalette[primaryName] ?? {};
@@ -311,8 +248,8 @@ export class AppConfigService {
highlight: {
background: 'color-mix(in srgb, {primary.400}, transparent 84%)',
focusBackground: 'color-mix(in srgb, {primary.400}, transparent 76%)',
color: 'rgba(255,255,255,.88)',
focusColor: 'rgba(255,255,255,.88)'
color: 'rgba(255,255,255,.87)',
focusColor: 'rgba(255,255,255,.87)'
}
}
}
@@ -320,30 +257,9 @@ export class AppConfigService {
};
}
private hexToRgba(hex: string, alpha: number = AppConfigService.DEFAULT_SURFACE_ALPHA): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
onPresetChange(): void {
const surfacePalette = this.getSurfacePalette(this.appState().surface ?? 'neutral');
const preset = this.getPresetExt();
$t().preset(Aura).preset(preset).surfacePalette(surfacePalette).use({useDefaultOptions: true});
}
updateBackgroundBlur(blur: number): void {
this.appState.update(state => ({
...state,
backgroundBlur: blur
}));
}
updateSurfaceAlpha(alpha: number): void {
this.appState.update(state => ({
...state,
surfaceAlpha: alpha
}));
}
}
@@ -28,67 +28,6 @@
</button>
}
</div>
<div class="surface-alpha-control">
<span class="config-panel-label">Transparency: {{ (1 - surfaceAlphaValue).toFixed(2) }}</span>
<p-slider
[(ngModel)]="surfaceAlphaValue"
[min]="0.75"
[max]="1"
[step]="0.01"
(onSlideEnd)="updateSurfaceAlpha($event)"
class="alpha-slider">
</p-slider>
</div>
</div>
<div class="config-panel-colors">
<span class="config-panel-label">Background</span>
<div class="background-center-wrapper">
<div class="background-controls">
<div class="background-control">
<label>Show: </label>
<p-toggleswitch
size="small"
[(ngModel)]="backgroundVisible">
</p-toggleswitch>
</div>
@if (backgroundVisible) {
<div class="background-control">
<label for="blurRange">Blur: {{ backgroundBlurValue }}</label>
<p-slider
[(ngModel)]="backgroundBlurValue"
[min]="0"
[max]="30"
[step]="1"
(onSlideEnd)="updateBackgroundBlur($event)"
class="blur-slider">
</p-slider>
</div>
}
</div>
@if (backgroundVisible) {
<div class="background-actions-row">
<p-button
class="upload-bg-btn"
size="small"
outlined
severity="primary"
(click)="openUploadDialog()"
label="Upload Image"
icon="pi pi-upload">
</p-button>
<p-button
class="reset-bg-btn"
size="small"
outlined
severity="secondary"
(click)="resetBackground()"
label="Reset"
icon="pi pi-refresh">
</p-button>
</div>
}
</div>
</div>
</div>
@@ -1,95 +0,0 @@
.config-panel-section {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--surface-border);
}
.config-panel-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
label {
font-weight: 500;
color: var(--text-color);
}
input[type="text"], input[type="range"] {
padding: 0.5rem;
border: 1px solid var(--surface-border);
border-radius: var(--border-radius);
background: var(--surface-ground);
color: var(--text-color);
}
input[type="range"] {
width: 100%;
}
}
// Background section styles
.config-panel-colors {
.surface-alpha-control {
display: flex;
flex-direction: column;
margin-top: 0.75rem;
padding: 0.5rem 0;
.config-panel-label {
margin-bottom: 0.5rem;
}
.alpha-slider {
width: 100%;
}
}
.background-center-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.background-controls {
display: flex;
flex-direction: column;
width: 100%;
box-sizing: border-box;
}
.background-control {
display: flex;
align-items: center;
padding-top: 0.5rem;
padding-bottom: 0.1rem;
label {
min-width: 80px;
font-size: 0.85em;
font-weight: 500;
color: var(--text-secondary-color);
}
.blur-slider {
flex: 1;
margin-left: 0.5rem;
}
}
.background-actions-row {
display: flex;
flex-direction: row;
gap: 0.75rem;
margin-top: 0.75rem;
width: 100%;
box-sizing: border-box;
align-items: center;
justify-content: flex-end;
.upload-bg-btn {
margin-left: 0;
}
}
}
}
@@ -1,19 +1,13 @@
import {CommonModule} from '@angular/common';
import {Component, computed, effect, inject} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {$t} from '@primeng/themes';
import Aura from '@primeng/themes/aura';
import {ButtonModule} from 'primeng/button';
import {RadioButtonModule} from 'primeng/radiobutton';
import {ToggleSwitchModule} from 'primeng/toggleswitch';
import {InputTextModule} from 'primeng/inputtext';
import {SliderModule, SliderSlideEndEvent} from 'primeng/slider';
import {AppConfigService} from '../../../core/service/app-config.service';
import {FaviconService} from './favicon-service';
import {DialogService, DynamicDialogRef} from 'primeng/dynamicdialog';
import {UploadDialogComponent} from './upload-dialog/upload-dialog.component';
import {UrlHelperService} from '../../../utilities/service/url-helper.service';
import {BackgroundUploadService} from './background-upload.service';
import {debounceTime, Subject} from 'rxjs';
type ColorPalette = Record<string, string>;
@@ -26,7 +20,6 @@ interface Palette {
selector: 'app-theme-configurator',
standalone: true,
templateUrl: './theme-configurator.component.html',
styleUrls: ['./theme-configurator.component.scss'],
host: {
class: 'config-panel hidden'
},
@@ -35,17 +28,12 @@ interface Palette {
FormsModule,
ButtonModule,
RadioButtonModule,
ToggleSwitchModule,
InputTextModule,
SliderModule
],
providers: [DialogService]
ToggleSwitchModule
]
})
export class ThemeConfiguratorComponent {
readonly configService = inject(AppConfigService);
readonly faviconService = inject(FaviconService);
readonly urlHelper = inject(UrlHelperService);
private readonly backgroundUploadService = inject(BackgroundUploadService);
readonly surfaces = this.configService.surfaces;
@@ -53,7 +41,7 @@ export class ThemeConfiguratorComponent {
readonly selectedSurfaceColor = computed(() => this.configService.appState().surface);
readonly faviconColor = computed(() => {
const name = this.selectedPrimaryColor() ?? AppConfigService.DEFAULT_PRIMARY_COLOR;
const name = this.selectedPrimaryColor() ?? 'green';
const presetPalette = (Aura.primitive ?? {}) as Record<string, ColorPalette>;
const colorPalette = presetPalette[name];
return colorPalette?.[500] ?? name;
@@ -74,64 +62,6 @@ export class ThemeConfiguratorComponent {
);
});
get backgroundVisible(): boolean {
return this.configService.appState().showBackground ?? true;
}
set backgroundVisible(value: boolean) {
this.configService.appState.update(state => ({
...state,
showBackground: value
}));
}
get backgroundBlurValue(): number {
return this.configService.appState().backgroundBlur ?? AppConfigService.DEFAULT_BACKGROUND_BLUR;
}
set backgroundBlurValue(value: number) {
this.configService.updateBackgroundBlur(value);
}
get surfaceAlphaValue(): number {
return this.configService.appState().surfaceAlpha ?? AppConfigService.DEFAULT_SURFACE_ALPHA;
}
set surfaceAlphaValue(value: number) {
this.configService.updateSurfaceAlpha(value);
}
private readonly dialogService = inject(DialogService);
private dialogRef: DynamicDialogRef | undefined;
private surfaceAlphaSubject = new Subject<number>();
constructor() {
this.surfaceAlphaSubject.pipe(
debounceTime(100)
).subscribe(value => {
this.configService.updateSurfaceAlpha(value);
});
}
openUploadDialog() {
this.dialogRef = this.dialogService.open(UploadDialogComponent, {
header: 'Upload or Paste Image URL',
width: '450px',
modal: true,
closable: true,
data: {}
});
this.dialogRef.onClose.subscribe((result) => {
if (result) {
if (result.success || result.uploaded || result.url || result.imageUrl) {
this.configService.refreshBackgroundImage();
}
}
});
}
updateColors(event: Event, type: 'primary' | 'surface', color: { name: string; palette?: ColorPalette }) {
this.configService.appState.update((state) => ({
...state,
@@ -139,26 +69,4 @@ export class ThemeConfiguratorComponent {
}));
event.stopPropagation();
}
updateBackgroundBlur(event: SliderSlideEndEvent): void {
this.configService.appState.update(state => ({
...state,
backgroundBlur: Number(event.value)
}));
}
updateSurfaceAlpha(event: SliderSlideEndEvent): void {
this.surfaceAlphaSubject.next(Number(event.value));
}
resetBackground() {
this.backgroundUploadService.resetToDefault().subscribe({
next: () => {
this.configService.refreshBackgroundImage();
},
error: (err) => {
console.error('Failed to reset background:', err);
}
});
}
}
@@ -16,7 +16,7 @@
accept=".jpg,.jpeg,.png,image/jpeg,image/png"
(change)="onFileSelected($event)"
style="display: none;">
<small class="file-note">Only JPG and PNG files are supported</small>
<small class="file-note">Supported formats: JPEG, PNG • Maximum file size: 5 MB</small>
@if (uploadFile) {
<small class="selected-file">{{ uploadFile.name }}</small>
}
@@ -35,7 +35,7 @@
placeholder="Paste image URL here"
class="url-input"
pInputText>
<small class="url-note">Only JPG and PNG URLs are supported</small>
<small class="url-note">Supported formats: JPEG, PNG • Maximum file size: 5 MB</small>
</div>
@if (uploadError) {
@@ -42,6 +42,7 @@ export class MetadataPickerComponent implements OnInit {
metadataFieldsTop = [
{label: 'Title', controlName: 'title', lockedKey: 'titleLocked', fetchedKey: 'title'},
{label: 'Subtitle', controlName: 'subtitle', lockedKey: 'subtitleLocked', fetchedKey: 'subtitle'},
{label: 'Publisher', controlName: 'publisher', lockedKey: 'publisherLocked', fetchedKey: 'publisher'},
{label: 'Published', controlName: 'publishedDate', lockedKey: 'publishedDateLocked', fetchedKey: 'publishedDate'}
];
@@ -69,7 +69,7 @@
<div class="flex flex-col items-center md:items-start gap-1">
<div class="flex items-center gap-2 justify-center md:justify-start flex-wrap text-center md:text-left">
<h2 class="text-2xl md:text-3xl font-extrabold leading-tight">
{{ book?.metadata!.title }}
{{ book?.metadata?.title }}@if (book?.metadata?.subtitle) {: {{ book.metadata?.subtitle }}}
</h2>
<i
class="pi align-middle"
@@ -548,7 +548,7 @@
[horizontal]="true">
@for (book of recommendedBooks; track book.book.id) {
<div class="dashboard-scroller-card">
<app-book-card-lite-component [book]="book.book" ></app-book-card-lite-component>
<app-book-card-lite-component [book]="book.book"></app-book-card-lite-component>
</div>
}
</div>
@@ -114,15 +114,14 @@
}
.config-panel-colors {
// Only apply flex to color picker rows, not background-center-wrapper
> div:not(.background-center-wrapper) {
> div {
justify-content: flex-start;
padding-top: .5rem;
display: flex;
gap: .5rem;
flex-wrap: wrap;
button:not([pbutton]):not(.p-button) {
button {
border: none;
width: 1.25rem;
height: 1.25rem;