diff --git a/NEWS.md b/NEWS.md
index d6db613cd..3cd1de184 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -10,6 +10,7 @@ border-radius: 128px;
- Features
- Series years, status and summary in the metadata pane.
+ - Browser title shows series years and a Tags button.
- Tag editor edits the MetronInfo series start year.
## v2.5.5
diff --git a/codex/serializers/browser/page.py b/codex/serializers/browser/page.py
index eb68a6ab9..291ff4c40 100644
--- a/codex/serializers/browser/page.py
+++ b/codex/serializers/browser/page.py
@@ -154,6 +154,8 @@ class BrowserTitleSerializer(Serializer):
collection_name = CharField(read_only=True)
collection_number_to = CharField(read_only=True)
collection_count = IntegerField(read_only=True, allow_null=True)
+ collection_start_year = IntegerField(read_only=True, allow_null=True)
+ collection_end_year = IntegerField(read_only=True, allow_null=True)
def _emit_cover(row, instance) -> None:
diff --git a/codex/views/browser/title.py b/codex/views/browser/title.py
index dade93f82..647fe564a 100644
--- a/codex/views/browser/title.py
+++ b/codex/views/browser/title.py
@@ -1,10 +1,22 @@
"""Browser title."""
from collections.abc import Mapping
+from types import MappingProxyType
+from typing import Final
-from codex.models import Comic, Volume
+from codex.models import Comic, Series, Volume
from codex.views.browser.breadcrumbs import BrowserBreadcrumbsView
+_EMPTY_TITLE: Final = MappingProxyType(
+ {
+ "collection_name": "",
+ "collection_number_to": None,
+ "collection_count": 0,
+ "collection_start_year": None,
+ "collection_end_year": None,
+ }
+)
+
class BrowserTitleView(BrowserBreadcrumbsView):
"""Browser title methods."""
@@ -19,7 +31,7 @@ def _get_root_collection_name(self) -> tuple:
raise ValueError(reason)
return plural.capitalize(), 0
- def _get_collection_name(self) -> tuple:
+ def _get_collection_name(self) -> Mapping:
"""
Name the collection the route points at.
@@ -29,32 +41,27 @@ def _get_collection_name(self) -> tuple:
collection this user cannot see, so the empty defaults below are
now only the root-listing case (no pks).
"""
- collection_number_to = None
- collection_count = 0
- collection_name = ""
+ title = dict(_EMPTY_TITLE)
if gi := self.collection_instance:
- collection_name = gi.name
- if isinstance(gi, Volume):
- collection_number_to = gi.number_to
- collection_count = gi.series.volume_count
+ title["collection_name"] = gi.name
+ if isinstance(gi, Series):
+ title["collection_start_year"] = gi.start_year
+ title["collection_end_year"] = gi.end_year
+ elif isinstance(gi, Volume):
+ title["collection_number_to"] = gi.number_to
+ title["collection_count"] = gi.series.volume_count
elif isinstance(gi, Comic):
- collection_number_to = gi.volume.number_to
- collection_count = gi.volume.issue_count
- return collection_name, collection_number_to, collection_count
+ title["collection_number_to"] = gi.volume.number_to
+ title["collection_count"] = gi.volume.issue_count
+ return title
def get_browser_page_title(self) -> Mapping:
"""Get the proper title for this browse level."""
- pks = self.kwargs.get("pks")
- if not pks:
- collection_name, collection_count = self._get_root_collection_name()
- collection_number_to = None
- else:
- collection_name, collection_number_to, collection_count = (
- self._get_collection_name()
- )
-
+ if self.kwargs.get("pks"):
+ return self._get_collection_name()
+ collection_name, collection_count = self._get_root_collection_name()
return {
+ **_EMPTY_TITLE,
"collection_name": collection_name,
- "collection_number_to": collection_number_to,
"collection_count": collection_count,
}
diff --git a/frontend/src/comic-name.js b/frontend/src/comic-name.js
index d82c206a9..7fd5776ac 100644
--- a/frontend/src/comic-name.js
+++ b/frontend/src/comic-name.js
@@ -77,8 +77,17 @@ export const getFullComicName = function (
return [seriesName, fvn, issueName].filter(Boolean).join(" ");
};
+export const formattedYearRange = function (start, end) {
+ // A series run: "1963–1996", open-ended "1963–", "–1996", or one year.
+ if (!start && !end) {
+ return "";
+ }
+ return start === end ? String(start) : `${start || ""}–${end || ""}`;
+};
+
export default {
getFullComicName,
formattedVolumeName,
+ formattedYearRange,
getIssueName,
};
diff --git a/frontend/src/components/browser/toolbars/browser-toolbar-title.vue b/frontend/src/components/browser/toolbars/browser-toolbar-title.vue
index aec57b91e..aaee8b895 100644
--- a/frontend/src/components/browser/toolbars/browser-toolbar-title.vue
+++ b/frontend/src/components/browser/toolbars/browser-toolbar-title.vue
@@ -3,6 +3,14 @@
{{ title }}
+
+
+
{{ subtitle }}
@@ -15,13 +23,17 @@
import { mdiReload } from "@mdi/js";
import { mapState } from "pinia";
-import { formattedVolumeName } from "@/comic-name";
+import { formattedVolumeName, formattedYearRange } from "@/comic-name";
+import MetadataDialog from "@/components/metadata/metadata-dialog.vue";
import { collectionForRoute } from "@/route";
import { useAuthStore } from "@/stores/auth";
import { useBrowserStore } from "@/stores/browser";
export default {
name: "BrowserHeader",
+ components: {
+ MetadataDialog,
+ },
data() {
return {
mdiReload,
@@ -44,11 +56,23 @@ export default {
modelCollection: (state) => state.page.modelCollection,
}),
...mapState(useAuthStore, ["isUserAdmin"]),
- routeCollection() {
+ routeTarget() {
return collectionForRoute({
collection: this.$route.params.collection,
parentIds: this.$route.params.parentIds,
- }).collection;
+ });
+ },
+ routeCollection() {
+ return this.routeTarget.collection;
+ },
+ titleBook() {
+ // The collection being browsed, as a card-shaped book for its Tags button.
+ const { collection, pks } = this.routeTarget;
+ if (collection === "root" || pks.length === 0) {
+ return null;
+ }
+ const ids = pks.map(Number);
+ return { collection, ids, pk: ids[0], childCount: 0 };
},
title() {
let title;
@@ -56,8 +80,13 @@ export default {
title = "All";
} else if (this.browserTitle) {
let names = [];
- const { collectionName, collectionNumberTo, collectionCount } =
- this.browserTitle;
+ const {
+ collectionName,
+ collectionNumberTo,
+ collectionCount,
+ collectionStartYear,
+ collectionEndYear,
+ } = this.browserTitle;
const collection = this.routeCollection;
const formattedCollectionName =
collection === "volumes"
@@ -66,6 +95,13 @@ export default {
if (formattedCollectionName) {
names.push(formattedCollectionName);
}
+ const years = formattedYearRange(
+ collectionStartYear,
+ collectionEndYear,
+ );
+ if (years) {
+ names.push(years);
+ }
if (collectionCount) {
const formattedCollectionCount = `of ${collectionCount}`;
names.push(formattedCollectionCount);
@@ -90,6 +126,16 @@ export default {
padding-bottom: 6px;
}
+/* Out of the flex flow so the title stays centred on the whole bar. */
+.titleTagButton {
+ position: absolute;
+ top: 0px;
+ right: 0px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+}
+
.browserTitle {
margin: 0px;
text-align: center;
@@ -98,6 +144,8 @@ export default {
}
#browserTitle {
+ /* Symmetric room for the Tags button keeps a long title centred. */
+ padding-inline: 24px;
font-size: clamp(12px, 2vw, 20px);
}
diff --git a/frontend/src/components/metadata/metadata-activator.vue b/frontend/src/components/metadata/metadata-activator.vue
index 9e1959b18..567f10d6f 100644
--- a/frontend/src/components/metadata/metadata-activator.vue
+++ b/frontend/src/components/metadata/metadata-activator.vue
@@ -4,6 +4,8 @@
class="tagButton cardControlButton"
:variant="variant"
:icon="mdiTagOutline"
+ :size="size"
+ :density="density"
title="Tags"
@mouseenter="onMouseEnter"
@click.prevent
@@ -21,6 +23,8 @@ export default {
props: {
book: { type: Object, required: true },
toolbar: { type: Boolean, require: true },
+ size: { type: String, default: undefined },
+ density: { type: String, default: undefined },
},
data() {
return {
diff --git a/frontend/src/components/metadata/metadata-body.vue b/frontend/src/components/metadata/metadata-body.vue
index a532ec504..a62c6fafb 100644
--- a/frontend/src/components/metadata/metadata-body.vue
+++ b/frontend/src/components/metadata/metadata-body.vue
@@ -4,7 +4,7 @@
diff --git a/frontend/src/components/metadata/metadata-dialog.vue b/frontend/src/components/metadata/metadata-dialog.vue
index 251b573f0..2ccc7b247 100644
--- a/frontend/src/components/metadata/metadata-dialog.vue
+++ b/frontend/src/components/metadata/metadata-dialog.vue
@@ -5,6 +5,8 @@
v-bind="activatorProps"
:book="book"
:toolbar="toolbar"
+ :size="size"
+ :density="density"
/>
@@ -79,6 +81,14 @@ export default {
type: Boolean,
default: false,
},
+ size: {
+ type: String,
+ default: undefined,
+ },
+ density: {
+ type: String,
+ default: undefined,
+ },
},
emits: ["close"],
data() {
diff --git a/frontend/src/components/metadata/metadata-header.vue b/frontend/src/components/metadata/metadata-header.vue
index a5e4801e3..00d3b4efb 100644
--- a/frontend/src/components/metadata/metadata-header.vue
+++ b/frontend/src/components/metadata/metadata-header.vue
@@ -24,8 +24,6 @@
collection="series"
:highlight="'series' === md.collection"
/>
-
-
+
+ {{ seriesFacts }}
+
{{ collectionTitle }} {{ md.name }}
@@ -125,7 +126,7 @@
import { mapState } from "pinia";
import prettyBytes from "pretty-bytes";
-import { formattedIssue } from "@/comic-name";
+import { formattedIssue, formattedYearRange } from "@/comic-name";
import MetadataControls from "@/components/metadata/metadata-controls.vue";
import MetadataBookCover from "@/components/metadata/metadata-cover.vue";
import MetadataTags from "@/components/metadata/metadata-tags.vue";
@@ -242,11 +243,12 @@ export default {
return this?.md?.fileType || "Unknown";
},
seriesYears() {
- const start = this.md.seriesStartYear;
- const end = this.md.seriesEndYear;
- if (!start && !end) return "";
- if (start === end) return `(${start})`;
- return `(${start || ""}–${end || " "})`;
+ return formattedYearRange(this.md.seriesStartYear, this.md.seriesEndYear);
+ },
+ seriesFacts() {
+ return [this.seriesYears, this.md.seriesStatus]
+ .filter(Boolean)
+ .join(" · ");
},
seriesVolumeCount() {
const count = this.md.seriesVolumeCount;
@@ -304,7 +306,8 @@ export default {
padding-top: 0px;
}
- #titleRow {
+ #titleRow,
+ #seriesFactsRow {
font-size: large;
}
@@ -312,10 +315,6 @@ export default {
color: rgb(var(--v-theme-text-disabled));
}
- .noWrap {
- white-space: nowrap;
- }
-
#pageDateRow {
display: block;
font-size: smaller;
@@ -335,7 +334,8 @@ export default {
font-size: large;
}
- #titleRow {
+ #titleRow,
+ #seriesFactsRow {
font-size: x-small;
}
}
diff --git a/frontend/tests/unit/browser-toolbar-title.test.js b/frontend/tests/unit/browser-toolbar-title.test.js
new file mode 100644
index 000000000..2051fc8c4
--- /dev/null
+++ b/frontend/tests/unit/browser-toolbar-title.test.js
@@ -0,0 +1,111 @@
+/*
+ * Tests for ``browser-toolbar-title.vue`` — the centred collection title.
+ *
+ * A series title carries the series' year range, and every collection
+ * page but the root listing gets a Tags button that opens the browsed
+ * collection's own metadata, built like a browser card's book.
+ */
+import { createTestingPinia } from "@pinia/testing";
+import { mount } from "@vue/test-utils";
+import { describe, expect, test } from "vitest";
+
+import BrowserToolbarTitle from "@/components/browser/toolbars/browser-toolbar-title.vue";
+import MetadataDialog from "@/components/metadata/metadata-dialog.vue";
+import vuetify from "@/plugins/vuetify";
+
+function mountTitle({ params, title = {}, modelCollection = "comics" }) {
+ const pinia = createTestingPinia({
+ initialState: {
+ browser: { page: { title, modelCollection } },
+ },
+ });
+ return mount(BrowserToolbarTitle, {
+ global: {
+ plugins: [pinia, vuetify],
+ mocks: { $route: { params } },
+ stubs: { MetadataDialog: true },
+ },
+ });
+}
+
+const titleText = (wrapper) => wrapper.find("#browserTitle").text();
+
+describe("BrowserToolbarTitle — title", () => {
+ test("names the root listing All", () => {
+ const wrapper = mountTitle({
+ params: { collection: "publishers" },
+ title: { collectionName: "Publishers", collectionCount: 0 },
+ modelCollection: "publishers",
+ });
+ expect(titleText(wrapper)).toBe("All");
+ });
+
+ test("follows a series name with its years", () => {
+ const wrapper = mountTitle({
+ params: { collection: "series", parentIds: "5" },
+ title: {
+ collectionName: "Amazing Spider-Man",
+ collectionCount: 0,
+ collectionStartYear: 1963,
+ collectionEndYear: 1996,
+ },
+ });
+ expect(titleText(wrapper)).toBe("Amazing Spider-Man 1963–1996");
+ });
+
+ test("shows a series without years by name alone", () => {
+ const wrapper = mountTitle({
+ params: { collection: "series", parentIds: "5" },
+ title: {
+ collectionName: "Amazing Spider-Man",
+ collectionCount: 0,
+ collectionStartYear: null,
+ collectionEndYear: null,
+ },
+ });
+ expect(titleText(wrapper)).toBe("Amazing Spider-Man");
+ });
+
+ test("keeps a volume's own name and count", () => {
+ const wrapper = mountTitle({
+ params: { collection: "volumes", parentIds: "7" },
+ title: {
+ collectionName: "1963",
+ collectionNumberTo: null,
+ collectionCount: 3,
+ collectionStartYear: null,
+ collectionEndYear: null,
+ },
+ });
+ expect(titleText(wrapper)).toBe("(1963) of 3");
+ });
+});
+
+describe("BrowserToolbarTitle — Tags button", () => {
+ test("is absent on the root listing", () => {
+ const wrapper = mountTitle({
+ params: { collection: "publishers" },
+ title: { collectionName: "Publishers", collectionCount: 0 },
+ modelCollection: "publishers",
+ });
+ expect(wrapper.findComponent(MetadataDialog).exists()).toBe(false);
+ });
+
+ test("opens the browsed series' own metadata", () => {
+ const wrapper = mountTitle({
+ params: { collection: "series", parentIds: "5" },
+ title: { collectionName: "Amazing Spider-Man", collectionCount: 0 },
+ });
+ const dialog = wrapper.findComponent(MetadataDialog);
+ expect(dialog.exists()).toBe(true);
+ expect(dialog.props("book")).toStrictEqual({
+ collection: "series",
+ ids: [5],
+ pk: 5,
+ childCount: 0,
+ });
+ expect(dialog.props("toolbar")).toBe(true);
+ expect(dialog.props("size")).toBe("small");
+ expect(dialog.props("density")).toBe("compact");
+ });
+});
diff --git a/frontend/tests/unit/metadata-header.test.js b/frontend/tests/unit/metadata-header.test.js
index a7cf7eb6d..0e6e61f99 100644
--- a/frontend/tests/unit/metadata-header.test.js
+++ b/frontend/tests/unit/metadata-header.test.js
@@ -71,22 +71,22 @@ const seriesYears = (md) => mountHeader({ md }).vm.seriesYears;
describe("MetadataHeader — series years", () => {
test("shows a closed range", () => {
expect(seriesYears({ seriesStartYear: 1963, seriesEndYear: 1996 })).toBe(
- "(1963–1996)",
+ "1963–1996",
);
});
test("leaves an ongoing range open", () => {
- expect(seriesYears({ seriesStartYear: 1963 })).toBe("(1963– )");
+ expect(seriesYears({ seriesStartYear: 1963 })).toBe("1963–");
});
test("collapses a single-year run", () => {
expect(seriesYears({ seriesStartYear: 1963, seriesEndYear: 1963 })).toBe(
- "(1963)",
+ "1963",
);
});
test("shows an end year with no known start", () => {
- expect(seriesYears({ seriesEndYear: 1996 })).toBe("(–1996)");
+ expect(seriesYears({ seriesEndYear: 1996 })).toBe("–1996");
});
test("shows nothing when neither year is known", () => {
@@ -95,3 +95,43 @@ describe("MetadataHeader — series years", () => {
});
export default {};
+
+const seriesFacts = (md) => mountHeader({ md }).vm.seriesFacts;
+const ONE_SERIES = Object.freeze([{ ids: [1], name: "Amazing Spider-Man" }]);
+
+describe("MetadataHeader — series facts", () => {
+ test("joins the years and the status", () => {
+ expect(
+ seriesFacts({
+ seriesStartYear: 1963,
+ seriesEndYear: 1996,
+ seriesStatus: "Completed",
+ }),
+ ).toBe("1963–1996 · Completed");
+ });
+
+ test("shows the status alone when no year is known", () => {
+ expect(seriesFacts({ seriesStatus: "Ongoing" })).toBe("Ongoing");
+ });
+
+ test("is empty when nothing is known", () => {
+ expect(seriesFacts({})).toBe("");
+ });
+
+ test("renders its own line under the series row", () => {
+ const wrapper = mountHeader({
+ md: {
+ seriesList: ONE_SERIES,
+ seriesStartYear: 1963,
+ seriesStatus: "Ongoing",
+ },
+ });
+ expect(wrapper.find("#seriesFactsRow").text()).toBe("1963– · Ongoing");
+ });
+
+ test("renders no line when there are no facts", () => {
+ const wrapper = mountHeader({ md: { seriesList: ONE_SERIES } });
+ expect(wrapper.find("#seriesRow").exists()).toBe(true);
+ expect(wrapper.find("#seriesFactsRow").exists()).toBe(false);
+ });
+});
diff --git a/tests/test_metadata_series_fields.py b/tests/test_metadata_series_fields.py
index 784f11a2d..054615103 100644
--- a/tests/test_metadata_series_fields.py
+++ b/tests/test_metadata_series_fields.py
@@ -1,4 +1,4 @@
-"""The metadata pane carries the series' year range, status and summary."""
+"""The metadata pane and browser title carry the series' attributes."""
import shutil
from typing import Final, override
@@ -18,6 +18,10 @@
_STATUS: Final = "Completed"
_SUMMARY: Final = "A friendly neighborhood series."
_COMIC_SUMMARY: Final = "One issue of it."
+_SHOW_ALL_SETTINGS: Final = (
+ '{"topCollection": "publishers", "show": {"publishers": true,'
+ ' "imprints": true, "series": true, "volumes": true}}'
+)
def _v4(response):
@@ -48,7 +52,7 @@ def setUp(self) -> None:
status=_STATUS,
summary=_SUMMARY,
)
- volume = Volume.objects.create(
+ self.volume = Volume.objects.create( # pyright: ignore[reportUninitializedInstanceVariable]
name="1963", series=self.series, imprint=imprint, publisher=publisher
)
path = TMP_DIR / "c1.cbz"
@@ -61,7 +65,7 @@ def setUp(self) -> None:
publisher=publisher,
imprint=imprint,
series=self.series,
- volume=volume,
+ volume=self.volume,
size=42,
summary=_COMIC_SUMMARY,
)
@@ -107,3 +111,27 @@ def test_unknown_years_are_omitted(self) -> None:
data = self._get_metadata("comics", self.comic.pk)
assert "seriesStartYear" not in data
assert "seriesEndYear" not in data
+
+ def _get_title(self, collection: str, pk: int) -> dict:
+ """Browse one collection with every level shown and return its title."""
+ response = self.client.patch(
+ "/api/v4/browse/publishers/settings",
+ data=_SHOW_ALL_SETTINGS,
+ content_type="application/json",
+ )
+ assert response.status_code == _HTTP_OK, response.content
+ response = self.client.get(f"/api/v4/browse/{collection}/{pk}?page=1")
+ assert response.status_code == _HTTP_OK, response.content
+ return _v4(response)["title"]
+
+ def test_series_title_has_years(self) -> None:
+ title = self._get_title("series", self.series.pk)
+ assert title["collectionName"] == "Ser"
+ assert title["collectionStartYear"] == _START_YEAR
+ assert title["collectionEndYear"] == _END_YEAR
+
+ def test_volume_title_has_no_years(self) -> None:
+ title = self._get_title("volumes", self.volume.pk)
+ assert title["collectionName"] == "1963"
+ assert title["collectionStartYear"] is None
+ assert title["collectionEndYear"] is None