mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-03-16 16:42:08 -05:00
Merge pull request #1162 from booklore-app/develop
Merge develop into master for the release
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-1
@@ -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());
|
||||
|
||||
+2
-1
@@ -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));
|
||||
}
|
||||
|
||||
+1
-1
@@ -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());
|
||||
|
||||
+170
-30
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+10
-4
@@ -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;
|
||||
}
|
||||
|
||||
+14
-3
@@ -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;
|
||||
|
||||
+24
-6
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
+11
-3
@@ -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>
|
||||
|
||||
+39
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-5
@@ -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 */
|
||||
}
|
||||
+12
-1
@@ -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;
|
||||
}
|
||||
|
||||
+26
-3
@@ -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)
|
||||
});
|
||||
|
||||
+10
-3
@@ -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">
|
||||
|
||||
+50
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
-61
@@ -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>
|
||||
|
||||
-95
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-96
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+1
@@ -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'}
|
||||
];
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user