Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5329bf35a9 | ||
|
|
80e5d5b0d1 | ||
|
|
b3a429d7e1 | ||
|
|
d8179bb066 | ||
|
|
960305dfa9 | ||
|
|
16bbeec5a3 | ||
|
|
39f9c39360 | ||
|
|
011b6d14e4 | ||
|
|
d4661e7f04 |
+4
-1
@@ -15,7 +15,10 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
|
|||||||
|
|
||||||
# Copy package files and install npm dependencies (cached unless package.json changes)
|
# Copy package files and install npm dependencies (cached unless package.json changes)
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN --mount=type=cache,target=/root/.npm \
|
# npm cache mount renamed from /root/.npm: the old volume held a stale
|
||||||
|
# foliate-js tarball (name@version reuse) that --no-cache and builder prune
|
||||||
|
# could not evict; a new mount id guarantees a clean fetch.
|
||||||
|
RUN --mount=type=cache,target=/root/.npm-cache2 \
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Copy Go mod files (cached unless go.mod changes)
|
# Copy Go mod files (cached unless go.mod changes)
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error {
|
|||||||
|
|
||||||
// Determine if panel detection should be enabled
|
// Determine if panel detection should be enabled
|
||||||
enablePanelDetection := shouldEnablePanelDetection(libraryWithType.TypeName, mediaItem.FormatGroup)
|
enablePanelDetection := shouldEnablePanelDetection(libraryWithType.TypeName, mediaItem.FormatGroup)
|
||||||
|
treatAsComic := ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup)
|
||||||
|
|
||||||
// Get reading progress
|
// Get reading progress
|
||||||
var progress database.ReadingProgress
|
var progress database.ReadingProgress
|
||||||
@@ -133,6 +134,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error {
|
|||||||
"progress": progress,
|
"progress": progress,
|
||||||
"bookmarks": bookmarks,
|
"bookmarks": bookmarks,
|
||||||
"enable_panel_detection": enablePanelDetection,
|
"enable_panel_detection": enablePanelDetection,
|
||||||
|
"treat_as_comic": treatAsComic,
|
||||||
"format_group": mediaItem.FormatGroup,
|
"format_group": mediaItem.FormatGroup,
|
||||||
"manga_type": mediaItem.MangaType,
|
"manga_type": mediaItem.MangaType,
|
||||||
"reading_direction": mediaItem.ReadingDirection,
|
"reading_direction": mediaItem.ReadingDirection,
|
||||||
@@ -639,6 +641,25 @@ func (h *ReaderHandler) ParseEbook(c *echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShouldTreatAsComic reports whether a fixed-layout item that lives in a
|
||||||
|
// comics or manga library should get comic reader treatment: bookmarks
|
||||||
|
// only — no text-selection highlights, no annotations, no in-book search.
|
||||||
|
// Comic archives already behave this way; this flag extends it to fixed-
|
||||||
|
// layout PDFs shelved in comic libraries (scanned or official manga PDFs).
|
||||||
|
func ShouldTreatAsComic(libraryType string, formatGroup string) bool {
|
||||||
|
comicLibraries := map[string]bool{
|
||||||
|
"manga": true,
|
||||||
|
"comics": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
fixedFormats := map[string]bool{
|
||||||
|
"fixed_layout": true,
|
||||||
|
"comic_archive": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return comicLibraries[libraryType] && fixedFormats[formatGroup]
|
||||||
|
}
|
||||||
|
|
||||||
// shouldEnablePanelDetection determines if panel detection should be enabled
|
// shouldEnablePanelDetection determines if panel detection should be enabled
|
||||||
// based on library type and format group
|
// based on library type and format group
|
||||||
func shouldEnablePanelDetection(libraryType string, formatGroup string) bool {
|
func shouldEnablePanelDetection(libraryType string, formatGroup string) bool {
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ func registerReaderRoutes(cfg *Config) {
|
|||||||
if !visible {
|
if !visible {
|
||||||
return renderErrorPage(c, "Access denied", "access_denied")
|
return renderErrorPage(c, "Access denied", "access_denied")
|
||||||
}
|
}
|
||||||
|
// Library type decides comic reader treatment: fixed-layout items
|
||||||
|
// (e.g. manga PDFs) shelved in comics/manga libraries are paged
|
||||||
|
// through like comics — bookmarks only, no highlights or search.
|
||||||
|
libraryWithType, err := cfg.Queries.GetLibraryWithType(c.Request().Context(), mediaItem.LibraryID)
|
||||||
|
if err != nil {
|
||||||
|
return renderErrorPage(c, "Failed to get library info", "library_error")
|
||||||
|
}
|
||||||
// Convert to template types. Reading state is deliberately NOT
|
// Convert to template types. Reading state is deliberately NOT
|
||||||
// fetched or embedded: the reader pulls position, bookmarks, and
|
// fetched or embedded: the reader pulls position, bookmarks, and
|
||||||
// annotations from the APIs at open time so the page can never
|
// annotations from the APIs at open time so the page can never
|
||||||
@@ -89,6 +96,7 @@ func registerReaderRoutes(cfg *Config) {
|
|||||||
FileURL: utils.ResolveMediaURL(mediaItem.LibraryID, pgtype.Text{String: mediaItem.FilePath, Valid: true}),
|
FileURL: utils.ResolveMediaURL(mediaItem.LibraryID, pgtype.Text{String: mediaItem.FilePath, Valid: true}),
|
||||||
TotalCharacters: mediaItem.TotalCharacters.Int64,
|
TotalCharacters: mediaItem.TotalCharacters.Int64,
|
||||||
EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||||
|
TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup),
|
||||||
}
|
}
|
||||||
// Render template
|
// Render template
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"dev": "npm run build:ts:dev && npm run build:css"
|
"dev": "npm run build:ts:dev && npm run build:css"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#8a20399",
|
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#e16530a",
|
||||||
"alpinejs": "^3.15.8",
|
"alpinejs": "^3.15.8",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
|||||||
+15
-18
@@ -16,6 +16,7 @@ func readerInitExpr(metadata ReaderMetadata) string {
|
|||||||
"formatGroup": metadata.FormatGroup,
|
"formatGroup": metadata.FormatGroup,
|
||||||
"readingDirection": metadata.ReadingDirection,
|
"readingDirection": metadata.ReadingDirection,
|
||||||
"mangaType": metadata.MangaType,
|
"mangaType": metadata.MangaType,
|
||||||
|
"treatAsComic": metadata.TreatAsComic,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(config)
|
jsonBytes, _ := json.Marshal(config)
|
||||||
return fmt.Sprintf("initReader(%s)", string(jsonBytes))
|
return fmt.Sprintf("initReader(%s)", string(jsonBytes))
|
||||||
@@ -68,9 +69,7 @@ templ Reader(user User, metadata ReaderMetadata) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ReaderChrome(metadata)
|
@ReaderChrome(metadata)
|
||||||
|
|
||||||
<!-- Drawer scrim -->
|
<!-- Drawer scrim -->
|
||||||
<div
|
<div
|
||||||
x-show="tocOpen || settingsOpen || bookmarksOpen || searchOpen"
|
x-show="tocOpen || settingsOpen || bookmarksOpen || searchOpen"
|
||||||
@@ -79,7 +78,6 @@ templ Reader(user User, metadata ReaderMetadata) {
|
|||||||
class="drawer-scrim"
|
class="drawer-scrim"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- TOC drawer (left) -->
|
<!-- TOC drawer (left) -->
|
||||||
<div
|
<div
|
||||||
id="toc-drawer"
|
id="toc-drawer"
|
||||||
@@ -96,7 +94,6 @@ templ Reader(user User, metadata ReaderMetadata) {
|
|||||||
>
|
>
|
||||||
@ReaderTOCDrawer()
|
@ReaderTOCDrawer()
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings drawer (right) -->
|
<!-- Settings drawer (right) -->
|
||||||
<div
|
<div
|
||||||
id="settings-drawer"
|
id="settings-drawer"
|
||||||
@@ -113,7 +110,6 @@ templ Reader(user User, metadata ReaderMetadata) {
|
|||||||
>
|
>
|
||||||
@ReaderSettingsDrawer()
|
@ReaderSettingsDrawer()
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Selection popover (highlights) -->
|
<!-- Selection popover (highlights) -->
|
||||||
<div
|
<div
|
||||||
id="selection-popover"
|
id="selection-popover"
|
||||||
@@ -139,7 +135,13 @@ templ Reader(user User, metadata ReaderMetadata) {
|
|||||||
:style="'background-color:' + c"
|
:style="'background-color:' + c"
|
||||||
:title="selectionPopover.mode === 'create' ? 'Highlight' : 'Set color'"
|
:title="selectionPopover.mode === 'create' ? 'Highlight' : 'Set color'"
|
||||||
:aria-label="'Highlight color ' + c"
|
:aria-label="'Highlight color ' + c"
|
||||||
@click="selectionPopover.mode === 'create' ? createHighlight(c) : (selectionPopover.color = c, saveHighlightChanges())"
|
@click="selectionPopover.noteOpen
|
||||||
|
? (selectionPopover.mode === 'create'
|
||||||
|
? (selectionPopover.color = c)
|
||||||
|
: (selectionPopover.color = c, saveHighlightChanges(true)))
|
||||||
|
: (selectionPopover.mode === 'create'
|
||||||
|
? createHighlight(c)
|
||||||
|
: (selectionPopover.color = c, saveHighlightChanges()))"
|
||||||
></button>
|
></button>
|
||||||
</template>
|
</template>
|
||||||
<div class="w-px h-5 reader-sep"></div>
|
<div class="w-px h-5 reader-sep"></div>
|
||||||
@@ -188,12 +190,11 @@ templ Reader(user User, metadata ReaderMetadata) {
|
|||||||
type="button"
|
type="button"
|
||||||
class="flex-1 py-1 text-xs border rounded hover:opacity-80"
|
class="flex-1 py-1 text-xs border rounded hover:opacity-80"
|
||||||
style="border-color: var(--border);"
|
style="border-color: var(--border);"
|
||||||
@click="selectionPopover.noteOpen = false"
|
@click="hideSelectionPopover()"
|
||||||
>Cancel</button>
|
>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Annotations drawer (right): highlights / notes / bookmarks -->
|
<!-- Annotations drawer (right): highlights / notes / bookmarks -->
|
||||||
<div
|
<div
|
||||||
id="annotations-drawer"
|
id="annotations-drawer"
|
||||||
@@ -210,7 +211,6 @@ templ Reader(user User, metadata ReaderMetadata) {
|
|||||||
>
|
>
|
||||||
@ReaderAnnotationsDrawer()
|
@ReaderAnnotationsDrawer()
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search drawer (right, reflowable only) -->
|
<!-- Search drawer (right, reflowable only) -->
|
||||||
<div
|
<div
|
||||||
id="search-drawer"
|
id="search-drawer"
|
||||||
@@ -227,7 +227,6 @@ templ Reader(user User, metadata ReaderMetadata) {
|
|||||||
>
|
>
|
||||||
@ReaderSearchDrawer()
|
@ReaderSearchDrawer()
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shortcuts & gestures help modal -->
|
<!-- Shortcuts & gestures help modal -->
|
||||||
<div
|
<div
|
||||||
x-show="helpOpen"
|
x-show="helpOpen"
|
||||||
@@ -263,17 +262,14 @@ templ Reader(user User, metadata ReaderMetadata) {
|
|||||||
<div class="help-row"><span>Settings</span><span class="help-keys"><kbd class="kbd">s</kbd></span></div>
|
<div class="help-row"><span>Settings</span><span class="help-keys"><kbd class="kbd">s</kbd></span></div>
|
||||||
<div class="help-row"><span>Add bookmark</span><span class="help-keys"><kbd class="kbd">b</kbd></span></div>
|
<div class="help-row"><span>Add bookmark</span><span class="help-keys"><kbd class="kbd">b</kbd></span></div>
|
||||||
<div class="help-row"><span>Close menus / toggle chrome</span><span class="help-keys"><kbd class="kbd">Esc</kbd></span></div>
|
<div class="help-row"><span>Close menus / toggle chrome</span><span class="help-keys"><kbd class="kbd">Esc</kbd></span></div>
|
||||||
|
|
||||||
<h4 class="reader-help-section" x-show="isFixedLayout">Zoom & pan <span class="help-note">(comics, PDFs)</span></h4>
|
<h4 class="reader-help-section" x-show="isFixedLayout">Zoom & pan <span class="help-note">(comics, PDFs)</span></h4>
|
||||||
<div class="help-row" x-show="isFixedLayout"><span>Zoom in / out / reset</span><span class="help-keys"><kbd class="kbd">+</kbd><kbd class="kbd">−</kbd><kbd class="kbd">0</kbd> or wheel</span></div>
|
<div class="help-row" x-show="isFixedLayout"><span>Zoom in / out / reset</span><span class="help-keys"><kbd class="kbd">+</kbd><kbd class="kbd">−</kbd><kbd class="kbd">0</kbd> or wheel</span></div>
|
||||||
<div class="help-row" x-show="isFixedLayout"><span>Move the page</span><span class="help-keys">drag with the mouse</span></div>
|
<div class="help-row" x-show="isFixedLayout"><span>Move the page</span><span class="help-keys">drag with the mouse</span></div>
|
||||||
<div class="help-row" x-show="isFixedLayout"><span>Recenter <span class="help-note">(keeps zoom)</span></span><span class="help-keys">⊙ button</span></div>
|
<div class="help-row" x-show="isFixedLayout"><span>Recenter <span class="help-note">(keeps zoom)</span></span><span class="help-keys">⊙ button</span></div>
|
||||||
<div class="help-row" x-show="isPDF"><span>Select text in a PDF</span><span class="help-keys">Smart mode: drag over text · <kbd class="kbd">Shift</kbd>+drag anywhere · Text mode</span></div>
|
<div class="help-row" x-show="isPDF"><span>Select text in a PDF</span><span class="help-keys">Smart mode: drag over text · <kbd class="kbd">Shift</kbd>+drag anywhere · Text mode</span></div>
|
||||||
|
|
||||||
<h4 class="reader-help-section">Highlights</h4>
|
<h4 class="reader-help-section">Highlights</h4>
|
||||||
<div class="help-row"><span>Create</span><span class="help-keys">select text → pick a color</span></div>
|
<div class="help-row"><span>Create</span><span class="help-keys">select text → pick a color</span></div>
|
||||||
<div class="help-row"><span>Edit / delete</span><span class="help-keys">click the highlight</span></div>
|
<div class="help-row"><span>Edit / delete</span><span class="help-keys">click the highlight</span></div>
|
||||||
|
|
||||||
<h4 class="reader-help-section">Touch</h4>
|
<h4 class="reader-help-section">Touch</h4>
|
||||||
<div class="help-row"><span>Page turn</span><span class="help-keys">swipe, or tap outer edges</span></div>
|
<div class="help-row"><span>Page turn</span><span class="help-keys">swipe, or tap outer edges</span></div>
|
||||||
<div class="help-row"><span>Toggle chrome</span><span class="help-keys">tap center</span></div>
|
<div class="help-row"><span>Toggle chrome</span><span class="help-keys">tap center</span></div>
|
||||||
@@ -295,7 +291,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
|
|||||||
<h1 class="text-base sm:text-lg font-semibold hidden sm:block sm:truncate">{ metadata.Title }</h1>
|
<h1 class="text-base sm:text-lg font-semibold hidden sm:block sm:truncate">{ metadata.Title }</h1>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button @click="addBookmark()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Bookmark this position (b)">🏷️</button>
|
<button @click="addBookmark()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Bookmark this position (b)">🏷️</button>
|
||||||
<button x-show="!isFixedLayout || isPDF" @click="toggleSearch()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Search (/)">🔍</button>
|
<button x-show="(!isFixedLayout || isPDF) && !treatAsComic" @click="toggleSearch()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Search (/)">🔍</button>
|
||||||
<button @click="toggleBookmarks()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Annotations">📝</button>
|
<button @click="toggleBookmarks()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Annotations">📝</button>
|
||||||
<button @click="toggleSettings()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Settings (s)">Aa</button>
|
<button @click="toggleSettings()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Settings (s)">Aa</button>
|
||||||
<button @click="toggleHelp()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700 text-sm font-bold" title="Shortcuts & gestures (?)" aria-label="Shortcuts and gestures">?</button>
|
<button @click="toggleHelp()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700 text-sm font-bold" title="Shortcuts & gestures (?)" aria-label="Shortcuts and gestures">?</button>
|
||||||
@@ -415,8 +411,9 @@ templ ReaderChrome(metadata ReaderMetadata) {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Pointer mode (PDF only): Smart / Pan / Text -->
|
<!-- Pointer mode (PDF only): Smart / Pan / Text — hidden for
|
||||||
<div class="reader-seg shrink-0" x-show="isPDF" role="group" aria-label="Pointer mode" title="Smart: drag text to select, drag blank to pan · Pan: drag anywhere to move · Text: selection only. Shift always force-pans.">
|
comic-treated PDFs: no text selection, pan only -->
|
||||||
|
<div class="reader-seg shrink-0" x-show="isPDF && !treatAsComic" role="group" aria-label="Pointer mode" title="Smart: drag text to select, drag blank to pan · Pan: drag anywhere to move · Text: selection only. Shift always force-pans.">
|
||||||
<button :class="interactionMode === 'select' ? 'active' : ''" @click="setInteractionMode('select')" title="Smart: drag text to select, drag blank to pan">Smart</button>
|
<button :class="interactionMode === 'select' ? 'active' : ''" @click="setInteractionMode('select')" title="Smart: drag text to select, drag blank to pan">Smart</button>
|
||||||
<button :class="interactionMode === 'pan' ? 'active' : ''" @click="setInteractionMode('pan')" title="Pan: drag anywhere to move">Pan</button>
|
<button :class="interactionMode === 'pan' ? 'active' : ''" @click="setInteractionMode('pan')" title="Pan: drag anywhere to move">Pan</button>
|
||||||
<button :class="interactionMode === 'text' ? 'active' : ''" @click="setInteractionMode('text')" title="Text: selection only, never pan (Shift overrides)">Text</button>
|
<button :class="interactionMode === 'text' ? 'active' : ''" @click="setInteractionMode('text')" title="Text: selection only, never pan (Shift overrides)">Text</button>
|
||||||
@@ -528,7 +525,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
|
|||||||
<span>Night mode</span>
|
<span>Night mode</span>
|
||||||
<button @click="fxInvert = !fxInvert; applyFxFilter(true)" class="reader-popover-btn text-xs px-2" :class="fxInvert ? 'text-blue-400' : ''" x-text="fxInvert ? 'On' : 'Off'">Off</button>
|
<button @click="fxInvert = !fxInvert; applyFxFilter(true)" class="reader-popover-btn text-xs px-2" :class="fxInvert ? 'text-blue-400' : ''" x-text="fxInvert ? 'On' : 'Off'">Off</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="reader-tools-row" x-show="isPDF">
|
<div class="reader-tools-row" x-show="isPDF && !treatAsComic">
|
||||||
<span>Pointer</span>
|
<span>Pointer</span>
|
||||||
<div class="reader-seg" role="group" aria-label="Pointer mode">
|
<div class="reader-seg" role="group" aria-label="Pointer mode">
|
||||||
<button :class="interactionMode === 'select' ? 'active' : ''" @click="setInteractionMode('select')">Smart</button>
|
<button :class="interactionMode === 'select' ? 'active' : ''" @click="setInteractionMode('select')">Smart</button>
|
||||||
@@ -700,7 +697,7 @@ templ ReaderAnnotationsDrawer() {
|
|||||||
@keydown.enter="renameBookmark(bookmark)"
|
@keydown.enter="renameBookmark(bookmark)"
|
||||||
@keydown.escape="bookmark.renameOpen = false"
|
@keydown.escape="bookmark.renameOpen = false"
|
||||||
placeholder="Bookmark name…"
|
placeholder="Bookmark name…"
|
||||||
></input>
|
/>
|
||||||
<div class="flex gap-2 mt-1">
|
<div class="flex gap-2 mt-1">
|
||||||
<button @click="renameBookmark(bookmark)" class="flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700">Save</button>
|
<button @click="renameBookmark(bookmark)" class="flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700">Save</button>
|
||||||
<button @click="bookmark.renameOpen = false" class="flex-1 py-1 text-xs border rounded hover:opacity-80" style="border-color: var(--border);">Cancel</button>
|
<button @click="bookmark.renameOpen = false" class="flex-1 py-1 text-xs border rounded hover:opacity-80" style="border-color: var(--border);">Cancel</button>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -243,6 +243,9 @@ type ReaderMetadata struct {
|
|||||||
LibraryID string `json:"library_id"`
|
LibraryID string `json:"library_id"`
|
||||||
TotalCharacters int64 `json:"total_characters"`
|
TotalCharacters int64 `json:"total_characters"`
|
||||||
EstimatedPages int `json:"estimated_pages"`
|
EstimatedPages int `json:"estimated_pages"`
|
||||||
|
// TreatAsComic: fixed-layout item in a comics/manga library gets
|
||||||
|
// comic reader treatment (bookmarks only — no highlights, no search).
|
||||||
|
TreatAsComic bool `json:"treat_as_comic"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReadingProgress struct {
|
type ReadingProgress struct {
|
||||||
|
|||||||
+161
-83
@@ -398,6 +398,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
isFixedLayout: false,
|
isFixedLayout: false,
|
||||||
isPDF: false,
|
isPDF: false,
|
||||||
isComic: false,
|
isComic: false,
|
||||||
|
// Fixed-layout item in a comics/manga library (e.g. a manga PDF):
|
||||||
|
// comic reader treatment — bookmarks only, no text-selection
|
||||||
|
// highlights, no in-book search.
|
||||||
|
treatAsComic: false as boolean,
|
||||||
comicFlow: "paged" as string,
|
comicFlow: "paged" as string,
|
||||||
fxBrightness: 1 as number,
|
fxBrightness: 1 as number,
|
||||||
fxContrast: 1 as number,
|
fxContrast: 1 as number,
|
||||||
@@ -588,8 +592,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
formatGroup: string;
|
formatGroup: string;
|
||||||
readingDirection: string;
|
readingDirection: string;
|
||||||
mangaType: string;
|
mangaType: string;
|
||||||
|
treatAsComic: boolean;
|
||||||
}) {
|
}) {
|
||||||
this.mediaItemId = config.mediaItemId;
|
this.mediaItemId = config.mediaItemId;
|
||||||
|
this.treatAsComic = config.treatAsComic;
|
||||||
// Reading state (position, bookmarks, annotations) is never baked
|
// Reading state (position, bookmarks, annotations) is never baked
|
||||||
// into the rendered page: the web reader is intrinsically tied to
|
// into the rendered page: the web reader is intrinsically tied to
|
||||||
// the server, so it reads all of it from the APIs at open time —
|
// the server, so it reads all of it from the APIs at open time —
|
||||||
@@ -724,10 +730,30 @@ document.addEventListener("alpine:init", () => {
|
|||||||
// drags, so the popover's pointerup trigger never fires for
|
// drags, so the popover's pointerup trigger never fires for
|
||||||
// them; a selection that has been stable for a moment is the
|
// them; a selection that has been stable for a moment is the
|
||||||
// settled gesture — open the popover for it.
|
// settled gesture — open the popover for it.
|
||||||
if (!this.isFixedLayout)
|
if (
|
||||||
|
this.isFixedLayout &&
|
||||||
|
!this.treatAsComic &&
|
||||||
|
doc.querySelector(".textLayer")
|
||||||
|
)
|
||||||
|
this.schedulePDFSelectionPopover(doc, index);
|
||||||
|
else if (!this.isFixedLayout)
|
||||||
this.scheduleSelectionPopover(doc, index);
|
this.scheduleSelectionPopover(doc, index);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Clicks in the book dismiss the popover in ANY mode and for ANY
|
||||||
|
// content doc (EPUB sections and fixed-layout pages alike): iframe
|
||||||
|
// events never bubble to the host document (so the host
|
||||||
|
// outside-click dismiss never sees them). Clicking a painted
|
||||||
|
// highlight still works: this hides, then foliate's
|
||||||
|
// show-annotation re-opens it in edit mode.
|
||||||
|
doc.addEventListener(
|
||||||
|
"pointerdown",
|
||||||
|
() => {
|
||||||
|
if (this.selectionPopover.open)
|
||||||
|
this.hideSelectionPopover();
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
// Text selection → highlight popover (reflowable EPUB only;
|
// Text selection → highlight popover (reflowable EPUB only;
|
||||||
// fixed-layout highlight overlays are a later milestone).
|
// fixed-layout highlight overlays are a later milestone).
|
||||||
if (!this.isFixedLayout) {
|
if (!this.isFixedLayout) {
|
||||||
@@ -757,21 +783,6 @@ document.addEventListener("alpine:init", () => {
|
|||||||
(e: PointerEvent) => setTimeout(() => checkSelection(e), 0),
|
(e: PointerEvent) => setTimeout(() => checkSelection(e), 0),
|
||||||
{ passive: true },
|
{ passive: true },
|
||||||
);
|
);
|
||||||
// Clicks in the book dismiss the popover in ANY mode: iframe
|
|
||||||
// events never bubble to the host document (so the host
|
|
||||||
// outside-click dismiss never sees them), and the collapsed
|
|
||||||
// check above only covers create mode — edit mode had no
|
|
||||||
// outside-click path at all, leaving Esc as the only way out.
|
|
||||||
// Clicking a painted highlight still works: this hides, then
|
|
||||||
// foliate's show-annotation re-opens it in edit mode.
|
|
||||||
doc.addEventListener(
|
|
||||||
"pointerdown",
|
|
||||||
() => {
|
|
||||||
if (this.selectionPopover.open)
|
|
||||||
this.hideSelectionPopover();
|
|
||||||
},
|
|
||||||
{ passive: true },
|
|
||||||
);
|
|
||||||
doc.addEventListener(
|
doc.addEventListener(
|
||||||
"keyup",
|
"keyup",
|
||||||
(ev: KeyboardEvent) => {
|
(ev: KeyboardEvent) => {
|
||||||
@@ -784,66 +795,29 @@ document.addEventListener("alpine:init", () => {
|
|||||||
// Detected structurally: renderer.isPDF isn't set until the first
|
// Detected structurally: renderer.isPDF isn't set until the first
|
||||||
// spread renders (during view.init), which is after this listener
|
// spread renders (during view.init), which is after this listener
|
||||||
// attaches — the stale copy here would always be falsy.
|
// attaches — the stale copy here would always be falsy.
|
||||||
if (this.isFixedLayout && doc.querySelector(".textLayer")) {
|
// Comic-treated PDFs never attach it: no highlights, like comics.
|
||||||
const checkPDFSelection = () => {
|
if (
|
||||||
const sel = doc.getSelection();
|
this.isFixedLayout &&
|
||||||
if (!sel || sel.isCollapsed || !sel.rangeCount) return;
|
!this.treatAsComic &&
|
||||||
const range = sel.getRangeAt(0);
|
doc.querySelector(".textLayer")
|
||||||
const text = sel.toString().replace(/\s+/g, " ").trim();
|
) {
|
||||||
if (!text) return;
|
|
||||||
// Denominator: the element whose post-transform screen rect IS
|
|
||||||
// the visible page. For PDFs that's the rendered canvas — pdf.js
|
|
||||||
// scales <html> by 1/devicePixelRatio, so documentElement's rect
|
|
||||||
// is dpr× too small and would inflate every fraction (highlight
|
|
||||||
// shifted right/oversized on any dpr != 1 display). The canvas's
|
|
||||||
// rect is in the same transform-inclusive space as the textLayer
|
|
||||||
// span rects, so the dpr scaling cancels exactly.
|
|
||||||
const denom =
|
|
||||||
(doc.querySelector("#canvas canvas") as HTMLElement) ||
|
|
||||||
(doc.querySelector("img") as HTMLElement);
|
|
||||||
let dr = denom?.getBoundingClientRect();
|
|
||||||
if (!dr || !dr.width || !dr.height) {
|
|
||||||
const vw = doc.defaultView;
|
|
||||||
dr = {
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
width: vw?.innerWidth || 1,
|
|
||||||
height: vw?.innerHeight || 1,
|
|
||||||
} as DOMRect;
|
|
||||||
}
|
|
||||||
const rects: number[][] = [];
|
|
||||||
for (const r of range.getClientRects()) {
|
|
||||||
const x = (r.left - dr.left) / dr.width;
|
|
||||||
const y = (r.top - dr.top) / dr.height;
|
|
||||||
const w = r.width / dr.width;
|
|
||||||
const h = r.height / dr.height;
|
|
||||||
if (w > 0 && h > 0) rects.push([x, y, w, h]);
|
|
||||||
}
|
|
||||||
if (!rects.length) return;
|
|
||||||
// Map the first rect to host-space for popover placement.
|
|
||||||
// The canvas's screen rect maps 1:1 onto the host iframe box
|
|
||||||
// (the visible page fills the iframe), so sx/sy are 1 for PDFs;
|
|
||||||
// kept general for the comic img fallback.
|
|
||||||
const frame = doc.defaultView?.frameElement as HTMLElement | null;
|
|
||||||
if (!frame) return;
|
|
||||||
const fr = frame.getBoundingClientRect();
|
|
||||||
const first = range.getBoundingClientRect();
|
|
||||||
const sx = fr.width / dr.width;
|
|
||||||
const sy = fr.height / dr.height;
|
|
||||||
this.pdfSelDoc = doc;
|
|
||||||
this.openSelectionPopover({
|
|
||||||
mode: "create",
|
|
||||||
x: fr.left + first.left * sx + (first.width * sx) / 2,
|
|
||||||
y: fr.top + first.top * sy,
|
|
||||||
text,
|
|
||||||
cfi: "",
|
|
||||||
pdfPage: index,
|
|
||||||
pdfRects: rects,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
doc.addEventListener(
|
doc.addEventListener(
|
||||||
"pointerup",
|
"pointerup",
|
||||||
() => setTimeout(checkPDFSelection, 0),
|
(e: PointerEvent) =>
|
||||||
|
setTimeout(() => {
|
||||||
|
// Chrome on touch selects the word under a quick tap —
|
||||||
|
// that is a page-turn tap, not a selection gesture; clear
|
||||||
|
// it and show nothing (same guard as the reflowable path).
|
||||||
|
if (
|
||||||
|
e.pointerType === "touch" &&
|
||||||
|
this.lastTouchDownT &&
|
||||||
|
Date.now() - this.lastTouchDownT < 300
|
||||||
|
) {
|
||||||
|
doc.getSelection()?.removeAllRanges();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.checkPDFSelectionFor(doc, index);
|
||||||
|
}, 0),
|
||||||
{ passive: true },
|
{ passive: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -980,6 +954,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
// Smart|Pan|Text control appear for PDFs.
|
// Smart|Pan|Text control appear for PDFs.
|
||||||
this.isPDF = !!this.renderer?.isPDF;
|
this.isPDF = !!this.renderer?.isPDF;
|
||||||
if (this.isPDF) {
|
if (this.isPDF) {
|
||||||
|
// Comic-treated PDFs are paged through like comics: no text
|
||||||
|
// selection, so pointer interactions stay in pan mode.
|
||||||
|
if (this.treatAsComic) this.interactionMode = "pan";
|
||||||
this.renderer.setAttribute("interaction-mode", this.interactionMode);
|
this.renderer.setAttribute("interaction-mode", this.interactionMode);
|
||||||
}
|
}
|
||||||
this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null;
|
this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null;
|
||||||
@@ -1332,6 +1309,94 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.openPopoverForSelection(sel, doc, index);
|
this.openPopoverForSelection(sel, doc, index);
|
||||||
}, 400);
|
}, 400);
|
||||||
},
|
},
|
||||||
|
// PDF mirror of scheduleSelectionPopover: the gesture takeover
|
||||||
|
// swallows pointerup for touch selection drags in the fx iframe too,
|
||||||
|
// so a selection that has been stable for a moment is the settled
|
||||||
|
// gesture — open the popover for it.
|
||||||
|
schedulePDFSelectionPopover(doc: any, index: number) {
|
||||||
|
if (this.selPopTimer) clearTimeout(this.selPopTimer);
|
||||||
|
this.selPopTimer = setTimeout(() => {
|
||||||
|
this.selPopTimer = null;
|
||||||
|
// A tap on a painted highlight re-opens the popover in edit mode
|
||||||
|
// via its own path — never clobber it.
|
||||||
|
if (this.selectionPopover.open && this.selectionPopover.mode === "edit")
|
||||||
|
return;
|
||||||
|
const sel = doc.getSelection();
|
||||||
|
if (!sel || sel.isCollapsed || !sel.rangeCount) {
|
||||||
|
if (
|
||||||
|
this.selectionPopover.open &&
|
||||||
|
this.selectionPopover.mode === "create"
|
||||||
|
)
|
||||||
|
this.hideSelectionPopover();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.checkPDFSelectionFor(doc, index);
|
||||||
|
}, 400);
|
||||||
|
},
|
||||||
|
checkPDFSelectionFor(doc: any, index: number) {
|
||||||
|
const sel = doc.getSelection();
|
||||||
|
if (!sel || sel.isCollapsed || !sel.rangeCount) return;
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
const text = sel.toString().replace(/\s+/g, " ").trim();
|
||||||
|
if (!text) return;
|
||||||
|
// Denominator: the element whose post-transform screen rect IS
|
||||||
|
// the visible page. For PDFs that's the rendered canvas — pdf.js
|
||||||
|
// scales <html> by 1/devicePixelRatio, so documentElement's rect
|
||||||
|
// is dpr× too small and would inflate every fraction (highlight
|
||||||
|
// shifted right/oversized on any dpr != 1 display). The canvas's
|
||||||
|
// rect is in the same transform-inclusive space as the textLayer
|
||||||
|
// span rects, so the dpr scaling cancels exactly.
|
||||||
|
const denom =
|
||||||
|
(doc.querySelector("#canvas canvas") as HTMLElement) ||
|
||||||
|
(doc.querySelector("img") as HTMLElement);
|
||||||
|
let dr = denom?.getBoundingClientRect();
|
||||||
|
if (!dr || !dr.width || !dr.height) {
|
||||||
|
const vw = doc.defaultView;
|
||||||
|
dr = {
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: vw?.innerWidth || 1,
|
||||||
|
height: vw?.innerHeight || 1,
|
||||||
|
} as DOMRect;
|
||||||
|
}
|
||||||
|
const rects: number[][] = [];
|
||||||
|
for (const r of range.getClientRects()) {
|
||||||
|
const x = (r.left - dr.left) / dr.width;
|
||||||
|
const y = (r.top - dr.top) / dr.height;
|
||||||
|
const w = r.width / dr.width;
|
||||||
|
const h = r.height / dr.height;
|
||||||
|
if (w > 0 && h > 0) rects.push([x, y, w, h]);
|
||||||
|
}
|
||||||
|
if (!rects.length) return;
|
||||||
|
// Map the first rect to host-space for popover placement.
|
||||||
|
// The canvas's screen rect maps 1:1 onto the host iframe box
|
||||||
|
// (the visible page fills the iframe), so sx/sy are 1 for PDFs;
|
||||||
|
// kept general for the comic img fallback.
|
||||||
|
const frame = doc.defaultView?.frameElement as HTMLElement | null;
|
||||||
|
if (!frame) return;
|
||||||
|
const fr = frame.getBoundingClientRect();
|
||||||
|
const first = range.getBoundingClientRect();
|
||||||
|
const sx = fr.width / dr.width;
|
||||||
|
const sy = fr.height / dr.height;
|
||||||
|
// On touch the browser's own selection menu attaches ABOVE the
|
||||||
|
// selection and its drag handles sit just below it — place ours
|
||||||
|
// below both (same +90 clearance the reflowable path uses). Fine
|
||||||
|
// pointers (desktop) keep the above-the-selection placement.
|
||||||
|
const coarse = window.matchMedia("(pointer: coarse)").matches;
|
||||||
|
const y = coarse
|
||||||
|
? fr.top + first.bottom * sy + 90
|
||||||
|
: fr.top + first.top * sy;
|
||||||
|
this.pdfSelDoc = doc;
|
||||||
|
this.openSelectionPopover({
|
||||||
|
mode: "create",
|
||||||
|
x: fr.left + first.left * sx + (first.width * sx) / 2,
|
||||||
|
y,
|
||||||
|
text,
|
||||||
|
cfi: "",
|
||||||
|
pdfPage: index,
|
||||||
|
pdfRects: rects,
|
||||||
|
});
|
||||||
|
},
|
||||||
// Trim a touch selection to the visible page. The visible column
|
// Trim a touch selection to the visible page. The visible column
|
||||||
// occupies content x [start - size, start]; endpoints beyond it are
|
// occupies content x [start - size, start]; endpoints beyond it are
|
||||||
// re-mapped to the page edge on their own line via caretRangeFromPoint.
|
// re-mapped to the page edge on their own line via caretRangeFromPoint.
|
||||||
@@ -1560,7 +1625,12 @@ document.addEventListener("alpine:init", () => {
|
|||||||
const rows = await hlResp.json();
|
const rows = await hlResp.json();
|
||||||
this.highlightItems = (rows as any[])
|
this.highlightItems = (rows as any[])
|
||||||
.map((r) => this.mapHighlightRow(r))
|
.map((r) => this.mapHighlightRow(r))
|
||||||
.filter((hl: any) => hl.cfi || hl.pdfPage >= 0);
|
// Comic-treated items show no highlights (bookmarks only),
|
||||||
|
// and previously-created PDF highlights stop rendering.
|
||||||
|
.filter(
|
||||||
|
(hl: any) =>
|
||||||
|
!this.treatAsComic && (hl.cfi || hl.pdfPage >= 0),
|
||||||
|
);
|
||||||
this.renderAllHighlights();
|
this.renderAllHighlights();
|
||||||
}
|
}
|
||||||
if (noteResp.ok) {
|
if (noteResp.ok) {
|
||||||
@@ -1579,6 +1649,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
const p = this.selectionPopover;
|
const p = this.selectionPopover;
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token || !this.mediaItemId || (!p.cfi && p.pdfPage < 0)) return;
|
if (!token || !this.mediaItemId || (!p.cfi && p.pdfPage < 0)) return;
|
||||||
|
// The note editor writes into p.note live; "Highlight with note"
|
||||||
|
// creates the highlight AND its note in one POST.
|
||||||
|
const note = p.note.trim();
|
||||||
const pdfAnchor =
|
const pdfAnchor =
|
||||||
p.pdfPage >= 0
|
p.pdfPage >= 0
|
||||||
? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects })
|
? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects })
|
||||||
@@ -1599,7 +1672,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
epubcfi_start: p.pdfPage >= 0 ? pdfAnchor : p.cfi,
|
epubcfi_start: p.pdfPage >= 0 ? pdfAnchor : p.cfi,
|
||||||
epubcfi_end: p.pdfPage >= 0 ? pdfAnchor : p.cfiEnd,
|
epubcfi_end: p.pdfPage >= 0 ? pdfAnchor : p.cfiEnd,
|
||||||
color,
|
color,
|
||||||
note_text: "",
|
note_text: note,
|
||||||
percentage_start: this.lastRelocateDetail?.fraction ?? 0,
|
percentage_start: this.lastRelocateDetail?.fraction ?? 0,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -1628,7 +1701,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
? ""
|
? ""
|
||||||
: this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi,
|
: this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi,
|
||||||
color,
|
color,
|
||||||
note: "",
|
note,
|
||||||
id: row.id,
|
id: row.id,
|
||||||
});
|
});
|
||||||
// Clear the DOM selection: the gesture is consumed, and it is
|
// Clear the DOM selection: the gesture is consumed, and it is
|
||||||
@@ -1644,7 +1717,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
/* ignore highlight errors */
|
/* ignore highlight errors */
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async saveHighlightChanges() {
|
async saveHighlightChanges(keepNoteOpen = false) {
|
||||||
const p = this.selectionPopover;
|
const p = this.selectionPopover;
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token || !this.mediaItemId || !p.id) return;
|
if (!token || !this.mediaItemId || !p.id) return;
|
||||||
@@ -1708,7 +1781,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
id: p.id,
|
id: p.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
p.noteOpen = false;
|
// Explicit saves (the Save button) close the whole popover; color
|
||||||
|
// tweaks made while the note editor is open keep everything open.
|
||||||
|
if (!keepNoteOpen) this.hideSelectionPopover();
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
/* ignore highlight errors */
|
/* ignore highlight errors */
|
||||||
}
|
}
|
||||||
@@ -2091,6 +2166,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
setInteractionMode(mode: string) {
|
setInteractionMode(mode: string) {
|
||||||
if (!this.isFixedLayout || !this.isPDF) return;
|
if (!this.isFixedLayout || !this.isPDF) return;
|
||||||
|
// Comic-treated PDFs have no text selection: pan only.
|
||||||
|
if (this.treatAsComic) return;
|
||||||
if (mode !== "select" && mode !== "pan" && mode !== "text") return;
|
if (mode !== "select" && mode !== "pan" && mode !== "text") return;
|
||||||
this.renderer.setAttribute("interaction-mode", mode);
|
this.renderer.setAttribute("interaction-mode", mode);
|
||||||
this.interactionMode = mode;
|
this.interactionMode = mode;
|
||||||
@@ -2185,8 +2262,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
toggleSearch() {
|
toggleSearch() {
|
||||||
// Reflowable books use foliate's DOM search; PDFs use extracted text.
|
// Reflowable books use foliate's DOM search; PDFs use extracted text.
|
||||||
// Comics have no text at all.
|
// Comics have no text at all — and comic-treated PDFs (manga scans)
|
||||||
if (this.isFixedLayout && !this.isPDF) return;
|
// opt out of search to match comic treatment: bookmarks only.
|
||||||
|
if ((this.isFixedLayout && !this.isPDF) || this.treatAsComic) return;
|
||||||
const opening = !this.searchOpen;
|
const opening = !this.searchOpen;
|
||||||
this.closeDrawers();
|
this.closeDrawers();
|
||||||
this.searchOpen = opening;
|
this.searchOpen = opening;
|
||||||
@@ -2594,8 +2672,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.hyphenate = true;
|
this.hyphenate = true;
|
||||||
this.chromeBehavior = "auto-hide";
|
this.chromeBehavior = "auto-hide";
|
||||||
this.applyChromeBehavior();
|
this.applyChromeBehavior();
|
||||||
this.interactionMode = "select";
|
this.interactionMode = this.treatAsComic ? "pan" : "select";
|
||||||
this.setInteractionMode("select");
|
this.setInteractionMode(this.interactionMode);
|
||||||
this.fxBrightness = 1;
|
this.fxBrightness = 1;
|
||||||
this.fxContrast = 1;
|
this.fxContrast = 1;
|
||||||
this.fxInvert = false;
|
this.fxInvert = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user