Started the refactoring so that there is a menu service to handle opening/closing the different drawers.

This commit is contained in:
Joseph Milazzo 2025-07-04 13:19:20 -05:00
parent fc54f8571f
commit 4d4b3c7285
41 changed files with 4666 additions and 142 deletions

View file

@ -159,8 +159,9 @@ public class BookController : BaseApiController
{
var ptocBookmarks =
await _unitOfWork.UserTableOfContentRepository.GetPersonalToCForPage(User.GetUserId(), chapterId, page);
var annotations = await _unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapter.Id);
return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl, ptocBookmarks));
return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl, ptocBookmarks, annotations));
}
catch (KavitaException ex)
{

View file

@ -826,6 +826,18 @@ public class ReaderController : BaseApiController
return _readerService.GetTimeEstimate(0, pagesLeft, false);
}
/// <summary>
/// Returns the annotations for the given chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("annotations")]
public async Task<ActionResult<IEnumerable<AnnotationDto>>> GetAnnotations(int chapterId)
{
return Ok(await _unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapterId));
}
/// <summary>
/// Returns the user's personal table of contents for the given chapter
/// </summary>

View file

@ -0,0 +1,46 @@
using System;
using API.Entities;
using API.Entities.Enums;
namespace API.DTOs.Reader;
/// <summary>
/// Represents an annotation on a book
/// </summary>
public sealed record AnnotationDto
{
public int Id { get; set; }
/// <summary>
/// Starting point of the Highlight
/// </summary>
public required string XPath { get; set; }
/// <summary>
/// Ending point of the Highlight. Can be the same as <see cref="XPath"/>
/// </summary>
public string EndingXPath { get; set; }
/// <summary>
/// The text selected.
/// </summary>
public string SelectedText { get; set; }
/// <summary>
/// Rich text Comment
/// </summary>
public string? Comment { get; set; }
/// <summary>
/// The number of characters selected
/// </summary>
public int HighlightCount { get; set; }
public bool ContainsSpoiler { get; set; }
public int PageNumber { get; set; }
public HightlightColor HighlightColor { get; set; }
public required int ChapterId { get; set; }
public required int OwnerUserId { get; set; }
public string OwnerUsername { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime LastModifiedUtc { get; set; }
}

View file

@ -0,0 +1,35 @@
using API.Entities.Enums;
namespace API.DTOs.Reader;
public sealed record CreateAnnotationRequest
{
public int Id { get; set; }
/// <summary>
/// Starting point of the Highlight
/// </summary>
public required string XPath { get; set; }
/// <summary>
/// Ending point of the Highlight. Can be the same as <see cref="XPath"/>
/// </summary>
public string EndingXPath { get; set; }
/// <summary>
/// The text selected.
/// </summary>
public string SelectedText { get; set; }
/// <summary>
/// Rich text Comment
/// </summary>
public string? Comment { get; set; }
/// <summary>
/// The number of characters selected
/// </summary>
public int HighlightCount { get; set; }
public bool ContainsSpoiler { get; set; }
public int PageNumber { get; set; }
public HightlightColor HighlightColor { get; set; }
public required int ChapterId { get; set; }
}

View file

@ -80,6 +80,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<MetadataFieldMapping> MetadataFieldMapping { get; set; } = null!;
public DbSet<AppUserChapterRating> AppUserChapterRating { get; set; } = null!;
public DbSet<AppUserReadingProfile> AppUserReadingProfiles { get; set; } = null!;
public DbSet<AppUserAnnotation> AppUserAnnotation { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
{

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class BookAnnotations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SelectedText",
table: "AppUserTableOfContent",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "AppUserAnnotation",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
XPath = table.Column<string>(type: "TEXT", nullable: true),
EndingXPath = table.Column<string>(type: "TEXT", nullable: true),
SelectedText = table.Column<string>(type: "TEXT", nullable: true),
Comment = table.Column<string>(type: "TEXT", nullable: true),
HighlightCount = table.Column<int>(type: "INTEGER", nullable: false),
PageNumber = table.Column<int>(type: "INTEGER", nullable: false),
HighlightColor = table.Column<int>(type: "INTEGER", nullable: false),
ContainsSpoiler = table.Column<bool>(type: "INTEGER", nullable: false),
SeriesId = table.Column<int>(type: "INTEGER", nullable: false),
VolumeId = table.Column<int>(type: "INTEGER", nullable: false),
ChapterId = table.Column<int>(type: "INTEGER", nullable: false),
AppUserId = table.Column<int>(type: "INTEGER", nullable: false),
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModifiedUtc = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppUserAnnotation", x => x.Id);
table.ForeignKey(
name: "FK_AppUserAnnotation_AspNetUsers_AppUserId",
column: x => x.AppUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AppUserAnnotation_Chapter_ChapterId",
column: x => x.ChapterId,
principalTable: "Chapter",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AppUserAnnotation_AppUserId",
table: "AppUserAnnotation",
column: "AppUserId");
migrationBuilder.CreateIndex(
name: "IX_AppUserAnnotation_ChapterId",
table: "AppUserAnnotation",
column: "ChapterId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppUserAnnotation");
migrationBuilder.DropColumn(
name: "SelectedText",
table: "AppUserTableOfContent");
}
}
}

View file

@ -1,6 +1,8 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using API.Data;
using API.Entities.MetadataMatching;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@ -152,6 +154,69 @@ namespace API.Data.Migrations
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("API.Entities.AppUserAnnotation", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<string>("Comment")
.HasColumnType("TEXT");
b.Property<bool>("ContainsSpoiler")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("TEXT");
b.Property<string>("EndingXPath")
.HasColumnType("TEXT");
b.Property<int>("HighlightColor")
.HasColumnType("INTEGER");
b.Property<int>("HighlightCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModifiedUtc")
.HasColumnType("TEXT");
b.Property<int>("PageNumber")
.HasColumnType("INTEGER");
b.Property<string>("SelectedText")
.HasColumnType("TEXT");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.Property<string>("XPath")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.HasIndex("ChapterId");
b.ToTable("AppUserAnnotation");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
{
b.Property<int>("Id")
@ -192,7 +257,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId");
b.ToTable("AppUserBookmark", (string)null);
b.ToTable("AppUserBookmark");
});
modelBuilder.Entity("API.Entities.AppUserChapterRating", b =>
@ -227,7 +292,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("AppUserChapterRating", (string)null);
b.ToTable("AppUserChapterRating");
});
modelBuilder.Entity("API.Entities.AppUserCollection", b =>
@ -299,7 +364,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId");
b.ToTable("AppUserCollection", (string)null);
b.ToTable("AppUserCollection");
});
modelBuilder.Entity("API.Entities.AppUserDashboardStream", b =>
@ -339,7 +404,7 @@ namespace API.Data.Migrations
b.HasIndex("Visible");
b.ToTable("AppUserDashboardStream", (string)null);
b.ToTable("AppUserDashboardStream");
});
modelBuilder.Entity("API.Entities.AppUserExternalSource", b =>
@ -364,7 +429,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId");
b.ToTable("AppUserExternalSource", (string)null);
b.ToTable("AppUserExternalSource");
});
modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
@ -385,7 +450,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("AppUserOnDeckRemoval", (string)null);
b.ToTable("AppUserOnDeckRemoval");
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
@ -523,7 +588,7 @@ namespace API.Data.Migrations
b.HasIndex("ThemeId");
b.ToTable("AppUserPreferences", (string)null);
b.ToTable("AppUserPreferences");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
@ -573,7 +638,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("AppUserProgresses", (string)null);
b.ToTable("AppUserProgresses");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
@ -606,7 +671,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("AppUserRating", (string)null);
b.ToTable("AppUserRating");
});
modelBuilder.Entity("API.Entities.AppUserReadingProfile", b =>
@ -723,7 +788,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId");
b.ToTable("AppUserReadingProfiles", (string)null);
b.ToTable("AppUserReadingProfiles");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
@ -784,7 +849,7 @@ namespace API.Data.Migrations
b.HasIndex("Visible");
b.ToTable("AppUserSideNavStream", (string)null);
b.ToTable("AppUserSideNavStream");
});
modelBuilder.Entity("API.Entities.AppUserSmartFilter", b =>
@ -806,7 +871,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId");
b.ToTable("AppUserSmartFilter", (string)null);
b.ToTable("AppUserSmartFilter");
});
modelBuilder.Entity("API.Entities.AppUserTableOfContent", b =>
@ -842,6 +907,9 @@ namespace API.Data.Migrations
b.Property<int>("PageNumber")
.HasColumnType("INTEGER");
b.Property<string>("SelectedText")
.HasColumnType("TEXT");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
@ -859,7 +927,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("AppUserTableOfContent", (string)null);
b.ToTable("AppUserTableOfContent");
});
modelBuilder.Entity("API.Entities.AppUserWantToRead", b =>
@ -880,7 +948,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("AppUserWantToRead", (string)null);
b.ToTable("AppUserWantToRead");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
@ -1079,7 +1147,7 @@ namespace API.Data.Migrations
b.HasIndex("VolumeId");
b.ToTable("Chapter", (string)null);
b.ToTable("Chapter");
});
modelBuilder.Entity("API.Entities.CollectionTag", b =>
@ -1114,7 +1182,7 @@ namespace API.Data.Migrations
b.HasIndex("Id", "Promoted")
.IsUnique();
b.ToTable("CollectionTag", (string)null);
b.ToTable("CollectionTag");
});
modelBuilder.Entity("API.Entities.Device", b =>
@ -1160,7 +1228,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId");
b.ToTable("Device", (string)null);
b.ToTable("Device");
});
modelBuilder.Entity("API.Entities.EmailHistory", b =>
@ -1211,7 +1279,7 @@ namespace API.Data.Migrations
b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate");
b.ToTable("EmailHistory", (string)null);
b.ToTable("EmailHistory");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
@ -1233,7 +1301,7 @@ namespace API.Data.Migrations
b.HasIndex("LibraryId");
b.ToTable("FolderPath", (string)null);
b.ToTable("FolderPath");
});
modelBuilder.Entity("API.Entities.Genre", b =>
@ -1253,7 +1321,7 @@ namespace API.Data.Migrations
b.HasIndex("NormalizedTitle")
.IsUnique();
b.ToTable("Genre", (string)null);
b.ToTable("Genre");
});
modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b =>
@ -1273,7 +1341,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("ManualMigrationHistory", (string)null);
b.ToTable("ManualMigrationHistory");
});
modelBuilder.Entity("API.Entities.Library", b =>
@ -1347,7 +1415,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("Library", (string)null);
b.ToTable("Library");
});
modelBuilder.Entity("API.Entities.LibraryExcludePattern", b =>
@ -1366,7 +1434,7 @@ namespace API.Data.Migrations
b.HasIndex("LibraryId");
b.ToTable("LibraryExcludePattern", (string)null);
b.ToTable("LibraryExcludePattern");
});
modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b =>
@ -1385,7 +1453,7 @@ namespace API.Data.Migrations
b.HasIndex("LibraryId");
b.ToTable("LibraryFileTypeGroup", (string)null);
b.ToTable("LibraryFileTypeGroup");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
@ -1440,7 +1508,7 @@ namespace API.Data.Migrations
b.HasIndex("ChapterId");
b.ToTable("MangaFile", (string)null);
b.ToTable("MangaFile");
});
modelBuilder.Entity("API.Entities.MediaError", b =>
@ -1475,7 +1543,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("MediaError", (string)null);
b.ToTable("MediaError");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b =>
@ -1509,7 +1577,7 @@ namespace API.Data.Migrations
b.HasIndex("ChapterId");
b.ToTable("ExternalRating", (string)null);
b.ToTable("ExternalRating");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b =>
@ -1546,7 +1614,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("ExternalRecommendation", (string)null);
b.ToTable("ExternalRecommendation");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b =>
@ -1598,7 +1666,7 @@ namespace API.Data.Migrations
b.HasIndex("ChapterId");
b.ToTable("ExternalReview", (string)null);
b.ToTable("ExternalReview");
});
modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b =>
@ -1633,7 +1701,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId")
.IsUnique();
b.ToTable("ExternalSeriesMetadata", (string)null);
b.ToTable("ExternalSeriesMetadata");
});
modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b =>
@ -1652,7 +1720,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("SeriesBlacklist", (string)null);
b.ToTable("SeriesBlacklist");
});
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
@ -1767,7 +1835,7 @@ namespace API.Data.Migrations
b.HasIndex("Id", "SeriesId")
.IsUnique();
b.ToTable("SeriesMetadata", (string)null);
b.ToTable("SeriesMetadata");
});
modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b =>
@ -1791,7 +1859,7 @@ namespace API.Data.Migrations
b.HasIndex("TargetSeriesId");
b.ToTable("SeriesRelation", (string)null);
b.ToTable("SeriesRelation");
});
modelBuilder.Entity("API.Entities.MetadataFieldMapping", b =>
@ -1822,7 +1890,7 @@ namespace API.Data.Migrations
b.HasIndex("MetadataSettingsId");
b.ToTable("MetadataFieldMapping", (string)null);
b.ToTable("MetadataFieldMapping");
});
modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b =>
@ -1900,7 +1968,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("MetadataSettings", (string)null);
b.ToTable("MetadataSettings");
});
modelBuilder.Entity("API.Entities.Person.ChapterPeople", b =>
@ -1924,7 +1992,7 @@ namespace API.Data.Migrations
b.HasIndex("PersonId");
b.ToTable("ChapterPeople", (string)null);
b.ToTable("ChapterPeople");
});
modelBuilder.Entity("API.Entities.Person.Person", b =>
@ -1968,7 +2036,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("Person", (string)null);
b.ToTable("Person");
});
modelBuilder.Entity("API.Entities.Person.PersonAlias", b =>
@ -1990,7 +2058,7 @@ namespace API.Data.Migrations
b.HasIndex("PersonId");
b.ToTable("PersonAlias", (string)null);
b.ToTable("PersonAlias");
});
modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b =>
@ -2016,7 +2084,7 @@ namespace API.Data.Migrations
b.HasIndex("PersonId");
b.ToTable("SeriesMetadataPeople", (string)null);
b.ToTable("SeriesMetadataPeople");
});
modelBuilder.Entity("API.Entities.ReadingList", b =>
@ -2085,7 +2153,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId");
b.ToTable("ReadingList", (string)null);
b.ToTable("ReadingList");
});
modelBuilder.Entity("API.Entities.ReadingListItem", b =>
@ -2119,7 +2187,7 @@ namespace API.Data.Migrations
b.HasIndex("VolumeId");
b.ToTable("ReadingListItem", (string)null);
b.ToTable("ReadingListItem");
});
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b =>
@ -2164,7 +2232,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("ScrobbleError", (string)null);
b.ToTable("ScrobbleError");
});
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b =>
@ -2241,7 +2309,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("ScrobbleEvent", (string)null);
b.ToTable("ScrobbleEvent");
});
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b =>
@ -2274,7 +2342,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("ScrobbleHold", (string)null);
b.ToTable("ScrobbleHold");
});
modelBuilder.Entity("API.Entities.Series", b =>
@ -2380,7 +2448,7 @@ namespace API.Data.Migrations
b.HasIndex("LibraryId");
b.ToTable("Series", (string)null);
b.ToTable("Series");
});
modelBuilder.Entity("API.Entities.ServerSetting", b =>
@ -2397,7 +2465,7 @@ namespace API.Data.Migrations
b.HasKey("Key");
b.ToTable("ServerSetting", (string)null);
b.ToTable("ServerSetting");
});
modelBuilder.Entity("API.Entities.ServerStatistics", b =>
@ -2435,7 +2503,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("ServerStatistics", (string)null);
b.ToTable("ServerStatistics");
});
modelBuilder.Entity("API.Entities.SiteTheme", b =>
@ -2491,7 +2559,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("SiteTheme", (string)null);
b.ToTable("SiteTheme");
});
modelBuilder.Entity("API.Entities.Tag", b =>
@ -2511,7 +2579,7 @@ namespace API.Data.Migrations
b.HasIndex("NormalizedTitle")
.IsUnique();
b.ToTable("Tag", (string)null);
b.ToTable("Tag");
});
modelBuilder.Entity("API.Entities.Volume", b =>
@ -2581,7 +2649,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("Volume", (string)null);
b.ToTable("Volume");
});
modelBuilder.Entity("AppUserCollectionSeries", b =>
@ -2596,7 +2664,7 @@ namespace API.Data.Migrations
b.HasIndex("ItemsId");
b.ToTable("AppUserCollectionSeries", (string)null);
b.ToTable("AppUserCollectionSeries");
});
modelBuilder.Entity("AppUserLibrary", b =>
@ -2611,7 +2679,7 @@ namespace API.Data.Migrations
b.HasIndex("LibrariesId");
b.ToTable("AppUserLibrary", (string)null);
b.ToTable("AppUserLibrary");
});
modelBuilder.Entity("ChapterGenre", b =>
@ -2626,7 +2694,7 @@ namespace API.Data.Migrations
b.HasIndex("GenresId");
b.ToTable("ChapterGenre", (string)null);
b.ToTable("ChapterGenre");
});
modelBuilder.Entity("ChapterTag", b =>
@ -2641,7 +2709,7 @@ namespace API.Data.Migrations
b.HasIndex("TagsId");
b.ToTable("ChapterTag", (string)null);
b.ToTable("ChapterTag");
});
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
@ -2656,7 +2724,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesMetadatasId");
b.ToTable("CollectionTagSeriesMetadata", (string)null);
b.ToTable("CollectionTagSeriesMetadata");
});
modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b =>
@ -2671,7 +2739,7 @@ namespace API.Data.Migrations
b.HasIndex("ExternalSeriesMetadatasId");
b.ToTable("ExternalRatingExternalSeriesMetadata", (string)null);
b.ToTable("ExternalRatingExternalSeriesMetadata");
});
modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b =>
@ -2686,7 +2754,7 @@ namespace API.Data.Migrations
b.HasIndex("ExternalSeriesMetadatasId");
b.ToTable("ExternalRecommendationExternalSeriesMetadata", (string)null);
b.ToTable("ExternalRecommendationExternalSeriesMetadata");
});
modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b =>
@ -2701,7 +2769,7 @@ namespace API.Data.Migrations
b.HasIndex("ExternalSeriesMetadatasId");
b.ToTable("ExternalReviewExternalSeriesMetadata", (string)null);
b.ToTable("ExternalReviewExternalSeriesMetadata");
});
modelBuilder.Entity("GenreSeriesMetadata", b =>
@ -2716,7 +2784,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesMetadatasId");
b.ToTable("GenreSeriesMetadata", (string)null);
b.ToTable("GenreSeriesMetadata");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
@ -2815,7 +2883,26 @@ namespace API.Data.Migrations
b.HasIndex("TagsId");
b.ToTable("SeriesMetadataTag", (string)null);
b.ToTable("SeriesMetadataTag");
});
modelBuilder.Entity("API.Entities.AppUserAnnotation", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Annotations")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Chapter", "Chapter")
.WithMany()
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
@ -3604,6 +3691,8 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Navigation("Annotations");
b.Navigation("Bookmarks");
b.Navigation("ChapterRatings");

View file

@ -107,6 +107,7 @@ public interface IUserRepository
Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds);
Task<IEnumerable<UserTokenInfo>> GetUserTokenInfo();
Task<AppUser?> GetUserByDeviceEmail(string deviceEmail);
Task<List<AnnotationDto>> GetAnnotations(int userId, int chapterId);
}
public class UserRepository : IUserRepository
@ -550,13 +551,28 @@ public class UserRepository : IUserRepository
/// </summary>
/// <param name="deviceEmail"></param>
/// <returns></returns>
public async Task<AppUser> GetUserByDeviceEmail(string deviceEmail)
public async Task<AppUser?> GetUserByDeviceEmail(string deviceEmail)
{
return await _context.AppUser
.Where(u => u.Devices.Any(d => d.EmailAddress == deviceEmail))
.FirstOrDefaultAsync();
}
/// <summary>
/// Returns a list of annotations ordered by page number. If the user has
/// </summary>
/// <param name="userId"></param>
/// <param name="chapterId"></param>
/// <returns></returns>
public async Task<List<AnnotationDto>> GetAnnotations(int userId, int chapterId)
{
// TODO: Check settings if I should include other user's annotations
return await _context.AppUserAnnotation
.Where(a => a.AppUserId == userId && a.ChapterId == chapterId)
.ProjectTo<AnnotationDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{

View file

@ -46,6 +46,7 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
/// A list of Table of Contents for a given Chapter
/// </summary>
public ICollection<AppUserTableOfContent> TableOfContents { get; set; } = null!;
public ICollection<AppUserAnnotation> Annotations { get; set; } = null!;
/// <summary>
/// An API Key to interact with external services, like OPDS
/// </summary>

View file

@ -7,7 +7,7 @@ namespace API.Entities;
/// <summary>
/// Represents an annotation in the Epub reader
/// </summary>
public class Annotation : IEntityDate
public class AppUserAnnotation : IEntityDate
{
public int Id { get; set; }
/// <summary>
@ -15,7 +15,7 @@ public class Annotation : IEntityDate
/// </summary>
public required string XPath { get; set; }
/// <summary>
/// Ending point of the Hightlight. Can be the same as <see cref="XPath"/>
/// Ending point of the Highlight. Can be the same as <see cref="XPath"/>
/// </summary>
public string EndingXPath { get; set; }
@ -24,11 +24,19 @@ public class Annotation : IEntityDate
/// </summary>
public string SelectedText { get; set; }
/// <summary>
/// Rich text Comment
/// </summary>
public string? Comment { get; set; }
/// <summary>
/// The number of characters selected
/// </summary>
public int HighlightCount { get; set; }
public int PageNumber { get; set; }
public HightlightColor HightlightColor { get; set; }
public HightlightColor HighlightColor { get; set; }
public bool ContainsSpoiler { get; set; }
// TODO: Figure out a simple mechansim to track upvotes (hashmap of userids?)
public required int SeriesId { get; set; }
public required int VolumeId { get; set; }

View file

@ -386,7 +386,9 @@ public class AutoMapperProfiles : Profile
.ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List<MetadataSettingField>()))
.ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary<string, AgeRating>()));
CreateMap<AppUserAnnotation, AnnotationDto>()
.ForMember(dest => dest.OwnerUsername, opt => opt.MapFrom(src => src.AppUser.UserName))
.ForMember(dest => dest.OwnerUserId, opt => opt.MapFrom(src => src.AppUserId));
}
}

View file

@ -57,7 +57,7 @@ public interface IBookService
/// <param name="targetDirectory">Where the files will be extracted to. If doesn't exist, will be created.</param>
void ExtractPdfImages(string fileFilePath, string targetDirectory);
Task<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter);
Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List<PersonalToCDto> ptocBookmarks);
Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List<PersonalToCDto> ptocBookmarks, List<AnnotationDto> annotations);
Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book);
}
@ -340,13 +340,13 @@ public class BookService : IBookService
}
private static void InjectHighlights(HtmlDocument doc, EpubBookRef book, List<PersonalToCDto> ptocBookmarks)
private static void InjectAnnotations(HtmlDocument doc, EpubBookRef book, List<AnnotationDto> annotations)
{
if (ptocBookmarks.Count == 0) return;
if (annotations.Count == 0) return;
foreach (var bookmark in ptocBookmarks.Where(b => !string.IsNullOrEmpty(b.BookScrollId)))
foreach (var annotation in annotations.Where(b => !string.IsNullOrEmpty(b.XPath)))
{
var unscopedSelector = bookmark.BookScrollId.Replace("//BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]", "//BODY").ToLowerInvariant();
var unscopedSelector = annotation.XPath.Replace("//BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]", "//BODY").ToLowerInvariant();
var elem = doc.DocumentNode.SelectSingleNode(unscopedSelector);
if (elem == null) continue;
@ -355,7 +355,7 @@ public class BookService : IBookService
var originalText = elem.InnerText;
// For POC: highlight first 16 characters
const int highlightLength = 16;
var highlightLength = annotation.HighlightCount;
if (originalText.Length > highlightLength)
{
@ -366,7 +366,7 @@ public class BookService : IBookService
elem.RemoveAllChildren();
// Create the highlight element with the first 16 characters
var highlightNode = HtmlNode.CreateNode($"<app-epub-highlight>{highlightedText}</app-epub-highlight>");
var highlightNode = HtmlNode.CreateNode($"<app-epub-highlight id=\"epub-highlight-{annotation.Id}\">{highlightedText}</app-epub-highlight>");
elem.AppendChild(highlightNode);
// Add the remaining text as a text node
@ -376,7 +376,7 @@ public class BookService : IBookService
else
{
// If text is shorter than highlight length, wrap it all
var highlightNode = HtmlNode.CreateNode($"<app-epub-highlight>{originalText}</app-epub-highlight>");
var highlightNode = HtmlNode.CreateNode($"<app-epub-highlight id=\"epub-highlight-{annotation.Id}\">{originalText}</app-epub-highlight>");
elem.RemoveAllChildren();
elem.AppendChild(highlightNode);
}
@ -1083,7 +1083,8 @@ public class BookService : IBookService
/// <param name="page">Page number we are loading</param>
/// <param name="ptocBookmarks">Ptoc Bookmarks to tie against</param>
/// <returns></returns>
private async Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary<string, int> mappings, int page, List<PersonalToCDto> ptocBookmarks)
private async Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body,
Dictionary<string, int> mappings, int page, List<PersonalToCDto> ptocBookmarks, List<AnnotationDto> annotations)
{
await InlineStyles(doc, book, apiBase, body);
@ -1094,9 +1095,7 @@ public class BookService : IBookService
// Inject PTOC Bookmark Icons
InjectPTOCBookmarks(doc, book, ptocBookmarks);
// MOCK: This will mimic a highlight
InjectHighlights(doc, book, ptocBookmarks);
InjectAnnotations(doc, book, annotations);
return PrepareFinalHtml(doc, body);
}
@ -1288,7 +1287,8 @@ public class BookService : IBookService
/// <param name="baseUrl">The API base for Kavita, to rewrite urls to so we load though our endpoint</param>
/// <returns>Full epub HTML Page, scoped to Kavita's reader</returns>
/// <exception cref="KavitaException">All exceptions throw this</exception>
public async Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List<PersonalToCDto> ptocBookmarks)
public async Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl,
List<PersonalToCDto> ptocBookmarks, List<AnnotationDto> annotations)
{
using var book = await EpubReader.OpenBookAsync(cachedEpubPath, LenientBookReaderOptions);
var mappings = await CreateKeyToPageMappingAsync(book);
@ -1330,7 +1330,7 @@ public class BookService : IBookService
body = doc.DocumentNode.SelectSingleNode("/html/body");
}
return await ScopePage(doc, book, apiBase, body!, mappings, page, ptocBookmarks);
return await ScopePage(doc, book, apiBase, body!, mappings, page, ptocBookmarks, annotations);
}
} catch (Exception ex)
{

View file

@ -1,5 +1,7 @@
import {ApplicationRef, ComponentRef, createComponent, EmbeddedViewRef, inject, Injectable} from '@angular/core';
import {AnnotationCardComponent} from '../book-reader/_components/annotation-card/annotation-card.component';
import {
AnnotationCardComponent
} from '../book-reader/_components/_annotations/annotation-card/annotation-card.component';
@Injectable({
providedIn: 'root'

View file

@ -0,0 +1,65 @@
import {inject, Injectable} from '@angular/core';
import {CreateAnnotationRequest} from "../book-reader/_models/create-annotation-request";
import {NgbOffcanvas} from "@ng-bootstrap/ng-bootstrap";
import {
ViewAnnotationDrawerComponent
} from "../book-reader/_components/_drawers/view-annotation-drawer/view-annotation-drawer.component";
import {
CreateAnnotationDrawerComponent
} from "../book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component";
import {
ViewBookmarkDrawerComponent
} from "../book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component";
import {ActivatedRoute} from "@angular/router";
import {ViewTocDrawerComponent} from "../book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component";
/**
* Responsible for opening the different readers and providing any context needed. Handles closing or keeping a stack of menus open.
*/
@Injectable({
providedIn: 'root'
})
export class EpubReaderMenuService {
private readonly offcanvasService = inject(NgbOffcanvas);
private readonly route = inject(ActivatedRoute);
openCreateAnnotationDrawer(annotation: CreateAnnotationRequest) {
const ref = this.offcanvasService.open(CreateAnnotationDrawerComponent, {position: 'bottom', panelClass: ''});
ref.componentInstance.createAnnotation.set(annotation)
}
openViewAnnotationsDrawer(chapterId: number) {
if (this.offcanvasService.hasOpenOffcanvas()) {
this.offcanvasService.dismiss();
}
const ref = this.offcanvasService.open(ViewAnnotationDrawerComponent, {position: 'end', panelClass: ''});
}
openTocDrawer() {
if (this.offcanvasService.hasOpenOffcanvas()) {
this.offcanvasService.dismiss();
}
const ref = this.offcanvasService.open(ViewTocDrawerComponent, {position: 'end', panelClass: ''});
}
openViewBookmarksDrawer(chapterId: number) {
if (this.offcanvasService.hasOpenOffcanvas()) {
this.offcanvasService.dismiss();
}
const ref = this.offcanvasService.open(ViewBookmarkDrawerComponent, {position: 'end', panelClass: ''});
ref.componentInstance.chapterId.set(chapterId);
}
closeAll() {
if (this.offcanvasService.hasOpenOffcanvas()) {
this.offcanvasService.dismiss();
}
}
}

View file

@ -24,6 +24,8 @@ import {UtilityService} from "../shared/_services/utility.service";
import {translate} from "@jsverse/transloco";
import {ToastrService} from "ngx-toastr";
import {FilterField} from "../_models/metadata/v2/filter-field";
import {Annotation} from "../book-reader/_models/annotation";
import {CreateAnnotationRequest} from "../book-reader/_models/create-annotation-request";
export const CHAPTER_ID_DOESNT_EXIST = -1;
@ -330,6 +332,14 @@ export class ReaderService {
return this.httpClient.post(this.baseUrl + 'reader/create-ptoc', {libraryId, seriesId, volumeId, chapterId, pageNumber, title, bookScrollId, selectedText});
}
getAnnotations(chapterId: number) {
return this.httpClient.get<Array<Annotation>>(this.baseUrl + 'reader/annotations?chapterId=' + chapterId);
}
createAnnotation(data: CreateAnnotationRequest) {
return this.httpClient.post<Array<Annotation>>(this.baseUrl + 'reader/create-annotation', data);
}
getElementFromXPath(path: string) {
const node = document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
if (node?.nodeType === Node.ELEMENT_NODE) {
@ -390,4 +400,5 @@ export class ReaderService {
this.router.navigate(this.getNavigationArray(libraryId, seriesId, chapter.id, chapter.files[0].format),
{queryParams: {incognitoMode}});
}
}

View file

@ -1,5 +1,5 @@
import {Component, input, model, output} from '@angular/core';
import {UtcToLocaleDatePipe} from "../../../_pipes/utc-to-locale-date.pipe";
import {UtcToLocaleDatePipe} from "../../../../_pipes/utc-to-locale-date.pipe";
import {DatePipe} from "@angular/common";
@Component({

View file

@ -12,7 +12,7 @@ import {
signal,
ViewChild
} from '@angular/core';
import {Annotation} from "../../_models/annotation";
import {Annotation} from "../../../_models/annotation";
import {AnnotationCardComponent} from "../annotation-card/annotation-card.component";
import {AnnotationCardService} from 'src/app/_service/annotation-card.service';
@ -27,7 +27,7 @@ export type HighlightColor = 'blue' | 'green';
export class EpubHighlightComponent implements OnInit, AfterViewChecked, OnDestroy {
showHighlight = model<boolean>(false);
color = input<HighlightColor>('blue');
annotation = input<Annotation | null>(null);
annotation = model.required<Annotation | null>();
isHovered = signal<boolean>(false);
@ViewChild('highlightSpan', { static: false }) highlightSpan!: ElementRef;
@ -225,7 +225,7 @@ export class EpubHighlightComponent implements OnInit, AfterViewChecked, OnDestr
if (!this.annotationCardRef) {
this.annotationCardRef = this.annotationCardService.show({
position: pos,
annotationText: 'This is test text',
annotationText: this.annotation()?.comment,
createdDate: new Date('10/20/2025'),
onMouseEnter: () => this.onMouseEnter(),
onMouseLeave: () => this.onMouseLeave()

View file

@ -0,0 +1,14 @@
<ng-container *transloco="let t; prefix: 'create-annotation-drawer'">
<div class="offcanvas-header">
<h5 class="offcanvas-title">
{{t('title')}}
</h5>
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="offcanvas-body">
Hello
</div>
</ng-container>

View file

@ -0,0 +1,25 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, model} from '@angular/core';
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
import {CreateAnnotationRequest} from "../../../_models/create-annotation-request";
import {TranslocoDirective} from "@jsverse/transloco";
@Component({
selector: 'app-create-annotation-drawer',
imports: [
TranslocoDirective
],
templateUrl: './create-annotation-drawer.component.html',
styleUrl: './create-annotation-drawer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CreateAnnotationDrawerComponent {
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
private readonly cdRef = inject(ChangeDetectorRef);
createAnnotation = model<CreateAnnotationRequest | null>(null);
close() {
this.activeOffcanvas.close();
}
}

View file

@ -0,0 +1,14 @@
<ng-container *transloco="let t; prefix: 'view-annotation-drawer'">
<div class="offcanvas-header">
<h5 class="offcanvas-title">
{{t('title')}}
</h5>
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="offcanvas-body">
Hello
</div>
</ng-container>

View file

@ -0,0 +1,24 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective} from "@jsverse/transloco";
@Component({
selector: 'app-view-annotation-drawer',
imports: [
TranslocoDirective
],
templateUrl: './view-annotation-drawer.component.html',
styleUrl: './view-annotation-drawer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ViewAnnotationDrawerComponent {
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
private readonly cdRef = inject(ChangeDetectorRef);
close() {
this.activeOffcanvas.close();
}
}

View file

@ -0,0 +1,26 @@
<ng-container *transloco="let t; prefix: 'view-bookmark-drawer'">
<div class="offcanvas-header">
<h5 class="offcanvas-title">
{{t('title')}}
</h5>
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="offcanvas-body">
@let items = bookmarks();
@if (items) {
<virtual-scroller #scroll [items]="items" [bufferAmount]="1" [childHeight]="1">
<div class="card-container row g-0" #container>
@for(item of scroll.viewPortItems; let idx = $index; track item) {
<div>
<app-image [imageUrl]="imageService.getBookmarkedImage(item.chapterId, item.pageNum)" />
</div>
}
</div>
</virtual-scroller>
}
</div>
</ng-container>

View file

@ -0,0 +1,47 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, effect, inject, model} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
import {ReaderService} from "../../../../_services/reader.service";
import {PageBookmark} from "../../../../_models/readers/page-bookmark";
import {ImageService} from "../../../../_services/image.service";
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
import {ImageComponent} from "../../../../shared/image/image.component";
@Component({
selector: 'app-view-bookmarks-drawer',
imports: [
TranslocoDirective,
VirtualScrollerModule,
ImageComponent
],
templateUrl: './view-bookmark-drawer.component.html',
styleUrl: './view-bookmark-drawer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ViewBookmarkDrawerComponent {
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly readerService = inject(ReaderService);
protected readonly imageService = inject(ImageService);
chapterId = model<number>();
bookmarks = model<PageBookmark[]>();
constructor() {
effect(() => {
const id = this.chapterId();
console.log('chapter id', id);
if (!id) return;
this.readerService.getBookmarks(id).subscribe(bookmarks => {
this.bookmarks.set(bookmarks);
this.cdRef.markForCheck();
});
});
}
close() {
this.activeOffcanvas.close();
}
}

View file

@ -0,0 +1,14 @@
<ng-container *transloco="let t; prefix: 'view-toc-drawer'">
<div class="offcanvas-header">
<h5 class="offcanvas-title">
{{t('title')}}
</h5>
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="offcanvas-body">
Hello
</div>
</ng-container>

View file

@ -0,0 +1,22 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
@Component({
selector: 'app-view-toc-drawer',
imports: [
TranslocoDirective
],
templateUrl: './view-toc-drawer.component.html',
styleUrl: './view-toc-drawer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ViewTocDrawerComponent {
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
private readonly cdRef = inject(ChangeDetectorRef);
close() {
this.activeOffcanvas.close();
}
}

View file

@ -1,46 +1,66 @@
<ng-container *transloco="let t; read: 'book-line-overlay'">
<div class="overlay" *ngIf="selectedText.length > 0 || mode !== BookLineOverlayMode.None">
@if(selectedText.length > 0 || mode !== BookLineOverlayMode.None) {
<div class="overlay">
<div class="row g-0 justify-content-between">
<ng-container [ngSwitch]="mode">
<ng-container *ngSwitchCase="BookLineOverlayMode.None">
<div class="col-auto">
<button class="btn btn-icon btn-sm" (click)="copy()">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
<div>{{t('copy')}}</div>
</button>
</div>
<div class="col-auto">
<button class="btn btn-icon btn-sm" (click)="switchMode(BookLineOverlayMode.Bookmark)">
<i class="fa-solid fa-book-bookmark" aria-hidden="true"></i>
<div>{{t('bookmark')}}</div>
</button>
</div>
<div class="col-auto">
<button class="btn btn-icon btn-sm" (click)="reset()">
<i class="fa-solid fa-times-circle" aria-hidden="true"></i>
<div>{{t('close')}}</div>
</button>
</div>
</ng-container>
<ng-container *ngSwitchCase="BookLineOverlayMode.Bookmark">
<form [formGroup]="bookmarkForm">
<div class="input-group">
<input id="bookmark-name" class="form-control" formControlName="name" type="text" [placeholder]="t('book-label')"
[class.is-invalid]="bookmarkForm.get('name')?.invalid && bookmarkForm.get('name')?.touched" aria-describedby="bookmark-name-btn">
<button class="btn btn-outline-primary" id="bookmark-name-btn" (click)="createPTOC()">{{t('save')}}</button>
<div id="bookmark-name-validations" class="invalid-feedback" *ngIf="bookmarkForm.dirty || bookmarkForm.touched">
<div *ngIf="bookmarkForm.get('name')?.errors?.required" role="status">
{{t('required-field')}}
</div>
</div>
<div class="row g-0 justify-content-between">
@switch (mode) {
@case (BookLineOverlayMode.None) {
<div class="col-auto">
<button class="btn btn-icon btn-sm" (click)="copy()">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
<div>{{t('copy')}}</div>
</button>
</div>
</form>
</ng-container>
</ng-container>
<div class="col-auto">
<button class="btn btn-icon btn-sm" (click)="switchMode(BookLineOverlayMode.Annotate)">
<i class="fa-solid fa-highlighter" aria-hidden="true"></i>
<div>{{t('annotate')}}</div>
</button>
</div>
<div class="col-auto">
<button class="btn btn-icon btn-sm" (click)="switchMode(BookLineOverlayMode.Bookmark)">
<i class="fa-solid fa-book-bookmark" aria-hidden="true"></i>
<div>{{t('bookmark')}}</div>
</button>
</div>
<div class="col-auto">
<button class="btn btn-icon btn-sm" (click)="reset()">
<i class="fa-solid fa-times-circle" aria-hidden="true"></i>
<div>{{t('close')}}</div>
</button>
</div>
}
@case (BookLineOverlayMode.Annotate) {
}
@case (BookLineOverlayMode.Bookmark) {
<form [formGroup]="bookmarkForm">
<div class="input-group">
<input id="bookmark-name" class="form-control" formControlName="name" type="text" [placeholder]="t('book-label')"
[class.is-invalid]="bookmarkForm.get('name')?.invalid && bookmarkForm.get('name')?.touched" aria-describedby="bookmark-name-btn">
<button class="btn btn-outline-primary" id="bookmark-name-btn" (click)="createPTOC()">{{t('save')}}</button>
@if (bookmarkForm.dirty || bookmarkForm.touched) {
<div id="bookmark-name-validations" class="invalid-feedback">
@if (bookmarkForm.get('name')?.errors?.required) {
<div role="status">
{{t('required-field')}}
</div>
}
</div>
}
</div>
</form>
}
}
</div>
</div>
</div>
}
</ng-container>

View file

@ -11,7 +11,6 @@ import {
OnInit,
Output,
} from '@angular/core';
import {CommonModule} from '@angular/common';
import {fromEvent, merge, of} from "rxjs";
import {catchError} from "rxjs/operators";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@ -20,15 +19,19 @@ import {ReaderService} from "../../../_services/reader.service";
import {ToastrService} from "ngx-toastr";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {KEY_CODES} from "../../../shared/_services/utility.service";
import {EpubReaderMenuService} from "../../../_services/epub-reader-menu.service";
import {CreateAnnotationRequest} from "../../_models/create-annotation-request";
import {HightlightColor} from "../../_models/annotation";
enum BookLineOverlayMode {
None = 0,
Bookmark = 1
Annotate = 1,
Bookmark = 2
}
@Component({
selector: 'app-book-line-overlay',
imports: [CommonModule, ReactiveFormsModule, TranslocoDirective],
imports: [ReactiveFormsModule, TranslocoDirective],
templateUrl: './book-line-overlay.component.html',
styleUrls: ['./book-line-overlay.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -53,9 +56,12 @@ export class BookLineOverlayComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly readerService = inject(ReaderService);
private readonly toastr = inject(ToastrService);
private readonly elementRef = inject(ElementRef);
private readonly epubMenuService = inject(EpubReaderMenuService);
protected readonly BookLineOverlayMode = BookLineOverlayMode;
get BookLineOverlayMode() { return BookLineOverlayMode; }
constructor(private elementRef: ElementRef, private toastr: ToastrService) {}
@HostListener('window:keydown', ['$event'])
handleKeyPress(event: KeyboardEvent) {
@ -124,6 +130,26 @@ export class BookLineOverlayComponent implements OnInit {
if (this.mode === BookLineOverlayMode.Bookmark) {
this.bookmarkForm.get('name')?.setValue(this.selectedText);
this.focusOnBookmarkInput();
return;
}
if (this.mode === BookLineOverlayMode.Annotate) {
// TODO: Open annotation drawer
this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.pageNumber, this.xPath, this.selectedText
const createAnnotation = {
chapterId: this.chapterId,
libraryId: this.libraryId,
volumeId: this.volumeId,
comment: null,
selectedText: this.selectedText,
containsSpoiler: false,
pageNumber: this.pageNumber,
xpath: this.xPath,
endingXPath: this.xPath, // TODO: Figure this out
highlightCount: this.selectedText.length,
hightlightColor: HightlightColor.Blue
} as CreateAnnotationRequest;
this.epubMenuService.openCreateAnnotationDrawer(createAnnotation);
}
}

View file

@ -3,7 +3,7 @@
<ng-container *transloco="let t; read: 'book-reader'">
<div class="fixed-top" #stickyTop>
<a class="visually-hidden-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">{{t('skip-header')}}</a>
<ng-container [ngTemplateOutlet]="actionBar" [ngTemplateOutletContext]="{isTop: true}"></ng-container>
<ng-container [ngTemplateOutlet]="topActionBar" [ngTemplateOutletContext]="{isTop: true}"></ng-container>
<app-book-line-overlay [parent]="bookContainerElemRef" *ngIf="page !== undefined"
[libraryId]="libraryId"
[volumeId]="volumeId"
@ -134,6 +134,56 @@
</div>
</div>
</div>
<ng-template #topActionBar let-isTop>
@if (!immersiveMode || drawerOpen || actionBarVisible) {
<div class="action-bar d-flex align-items-center px-2">
<!-- Left: Drawer toggle -->
<button class="btn btn-secondary me-2" (click)="toggleDrawer()">
<i class="fa fa-bars" aria-hidden="true"></i>
</button>
<!-- Center: Book Title -->
<div class="flex-grow-1 text-center d-none d-md-block">
@if (isLoading) {
<div class="spinner-border spinner-border-sm text-primary" style="border-radius: 50%;" role="status">
<span class="visually-hidden">{{ t('loading-book') }}</span>
</div>
} @else {
<span *ngIf="incognitoMode" (click)="turnOffIncognito()" role="button" [attr.aria-label]="t('incognito-mode-alt')">
(<i class="fa fa-glasses" aria-hidden="true"></i><span class="visually-hidden">{{ t('incognito-mode-label') }}</span>)
</span>
<span class="ms-1 book-title-text" [ngbTooltip]="bookTitle">{{ bookTitle }}</span>
}
</div>
<!-- Right: Buttons -->
<div class="d-flex align-items-center ms-auto">
@if (!this.adhocPageHistory.isEmpty()) {
<button class="btn btn-outline-secondary btn-icon me-1" (click)="goBack()" [title]="t('go-back')">
<i class="fa fa-reply" aria-hidden="true"></i>
</button>
}
<button class="btn btn-secondary btn-icon me-1" (click)="closeReader()">
<i class="fa fa-times-circle" aria-hidden="true"></i>
</button>
<button class="btn btn-secondary btn-icon me-1" (click)="epubMenuService.openViewBookmarksDrawer(this.chapterId)">
<i class="fa-solid fa-book-bookmark" aria-hidden="true"></i>
</button>
<button class="btn btn-secondary btn-icon me-1" (click)="epubMenuService.openViewAnnotationsDrawer(this.chapterId)">
<i class="fa-solid fa-highlighter" aria-hidden="true"></i>
</button>
<button class="btn btn-secondary btn-icon" (click)="epubMenuService.openTocDrawer()">
<i class="fa-regular fa-rectangle-list" aria-hidden="true"></i>
</button>
</div>
</div>
}
</ng-template>
<ng-template #actionBar let-isTop>
<div class="action-bar row g-0 justify-content-between" *ngIf="!immersiveMode || drawerOpen || actionBarVisible">
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1"

View file

@ -64,7 +64,9 @@ import {
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ReadingProfile} from "../../../_models/preferences/reading-profiles";
import {ConfirmService} from "../../../shared/confirm.service";
import {EpubHighlightComponent} from "../epub-highlight/epub-highlight.component";
import {EpubHighlightComponent} from "../_annotations/epub-highlight/epub-highlight.component";
import {Annotation} from "../../_models/annotation";
import {EpubReaderMenuService} from "../../../_services/epub-reader-menu.service";
enum TabID {
@ -136,6 +138,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly themeService = inject(ThemeService);
private readonly confirmService = inject(ConfirmService);
private readonly cdRef = inject(ChangeDetectorRef);
protected readonly epubMenuService = inject(EpubReaderMenuService);
protected readonly BookPageLayoutMode = BookPageLayoutMode;
protected readonly WritingStyle = WritingStyle;
@ -338,6 +341,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
*/
hidePagination = false;
annotations: Array<Annotation> = [];
/**
* Used to refresh the Personal PoC
*/
@ -1096,23 +1101,43 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.isLoading = false;
this.cdRef.markForCheck();
this.readerService.getAnnotations(this.chapterId).subscribe(annotations => {
this.annotations = annotations;
this.setupAnnotationElements();
this.cdRef.markForCheck();
});
}
private setupAnnotationElements() {
const annoationMap: {[key: number]: Annotation} = this.annotations.reduce((map, obj) => {
// @ts-ignore
map[obj.id] = obj;
return map;
}, {});
// Make the highlight components "real"
const highlightElems = this.document.querySelectorAll('app-epub-highlight');
for (let i = 0; i < highlightElems.length; i++) {
const highlight = highlightElems[i];
const idAttr = highlight.getAttribute('id');
// Don't allow highlight injection unless the id is present
if (!idAttr) continue;
const annotationId = parseInt(idAttr.replace('epub-highlight-', ''), 10);
const componentRef = this.readingContainer.createComponent<EpubHighlightComponent>(EpubHighlightComponent,
{projectableNodes: [[document.createTextNode(highlight.innerHTML)]]});
if (highlight.parentNode != null) {
highlight.parentNode.replaceChild(componentRef.location.nativeElement, highlight);
}
// TODO: Load the highlight instance with information from the Annotation
//componentRef.instance.cdRef.markForCheck();
componentRef.instance.annotation.set(annoationMap[annotationId]);
}
}
private addEmptyPageIfRequired(): void {

View file

@ -8,14 +8,16 @@ export interface Annotation {
xpath: string;
endingXPath: string | null;
selectedText: string | null;
noteText: string;
highlightCount: number;
comment: string;
hightlightColor: HightlightColor;
containsSpoiler: boolean;
pageNumber: number;
seriesId: number;
volumeId: number;
chapterId: number;
ownerUserId: number;
ownerUsername: string;
createdUtc: string;
lastModifiedUtc: string;
}

View file

@ -0,0 +1,15 @@
import {HightlightColor} from "./annotation";
export interface CreateAnnotationRequest {
libraryId: number;
volumeId: number;
chapterId: number;
xpath: string;
endingXPath: string | null;
selectedText: string | null;
comment: string | null;
hightlightColor: HightlightColor;
highlightCount: number;
containsSpoiler: boolean;
pageNumber: number;
}

View file

@ -800,12 +800,33 @@
"book-line-overlay": {
"copy": "Copy",
"bookmark": "Bookmark",
"annotate": "Annotate",
"close": "{{common.close}}",
"required-field": "{{common.required-field}}",
"bookmark-label": "Bookmark Name",
"save": "{{common.save}}"
},
"view-annotation-drawer": {
"title": "Annotations",
"close": "{{common.close}}"
},
"view-bookmark-drawer": {
"title": "Image Bookmarks",
"close": "{{common.close}}"
},
"view-toc-drawer": {
"title": "Table of Contents",
"close": "{{common.close}}"
},
"create-annotation-drawer": {
"title": "Create/Edit an Annotation",
"close": "{{common.close}}"
},
"book-reader": {
"title": "Book Settings",
"page-label": "Page",