2026-09-21 13:32:05 +02:00
|
|
|
import Elementary
|
|
|
|
|
import Foundation
|
|
|
|
|
import Infrastructure
|
|
|
|
|
import Persistence
|
|
|
|
|
|
|
|
|
|
/// A series: its issues with what is listed of each — and what is not, one tap from an alert — the copies of one issue, and its collected editions.
|
|
|
|
|
struct SeriesPage {
|
|
|
|
|
|
|
|
|
|
// MARK: Properties
|
|
|
|
|
|
|
|
|
|
let analytics: Analytics?
|
|
|
|
|
let assetVersion: String?
|
|
|
|
|
let locale: Locale
|
|
|
|
|
|
|
|
|
|
/// The issue whose copies are listed, or `nil` for none.
|
|
|
|
|
let issue: Int?
|
|
|
|
|
|
|
|
|
|
/// Every listing of the series still for sale.
|
|
|
|
|
let listings: [Listing]
|
|
|
|
|
|
2026-09-21 13:53:13 +02:00
|
|
|
/// The issues the viewer owns of the series, or `nil` signed out.
|
|
|
|
|
let owned: Set<Int>?
|
|
|
|
|
|
2026-09-21 13:32:05 +02:00
|
|
|
/// The series shown.
|
|
|
|
|
let series: Series
|
|
|
|
|
|
|
|
|
|
/// The signed-in viewer, or `nil` when signed out.
|
|
|
|
|
let viewer: Viewer?
|
|
|
|
|
|
|
|
|
|
// MARK: Initializers
|
|
|
|
|
|
|
|
|
|
/// Creates a series' screen.
|
|
|
|
|
/// - Parameters:
|
|
|
|
|
/// - series: the series shown.
|
|
|
|
|
/// - listings: every listing of the series still for sale.
|
|
|
|
|
/// - issue: the issue whose copies are listed, or `nil` (the default) for none.
|
2026-09-21 13:53:13 +02:00
|
|
|
/// - owned: the issues the viewer owns of the series, or `nil` (the default) signed out.
|
2026-09-21 13:32:05 +02:00
|
|
|
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
|
|
|
|
|
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
|
|
|
|
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
|
|
|
|
|
init(
|
|
|
|
|
series: Series,
|
|
|
|
|
listings: [Listing],
|
|
|
|
|
issue: Int? = nil,
|
2026-09-21 13:53:13 +02:00
|
|
|
owned: Set<Int>? = nil,
|
2026-09-21 13:32:05 +02:00
|
|
|
viewer: Viewer? = nil,
|
|
|
|
|
assetVersion: String? = nil,
|
|
|
|
|
analytics: Analytics? = nil
|
|
|
|
|
) {
|
|
|
|
|
self.analytics = analytics
|
|
|
|
|
self.assetVersion = assetVersion
|
|
|
|
|
self.issue = issue
|
|
|
|
|
self.listings = listings
|
|
|
|
|
self.locale = .init(identifier: "en")
|
2026-09-21 13:53:13 +02:00
|
|
|
self.owned = owned
|
2026-09-21 13:32:05 +02:00
|
|
|
self.series = series
|
|
|
|
|
self.viewer = viewer
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Page
|
|
|
|
|
|
|
|
|
|
extension SeriesPage: Page {
|
|
|
|
|
|
|
|
|
|
// MARK: Constants
|
|
|
|
|
|
|
|
|
|
/// The most copies compared side by side.
|
|
|
|
|
static let maxCompared = 3
|
|
|
|
|
|
|
|
|
|
// MARK: Computed
|
|
|
|
|
|
|
|
|
|
var content: some HTML {
|
|
|
|
|
SiteLayout(viewer: viewer) {
|
|
|
|
|
div(.class("page series")) {
|
|
|
|
|
nav(
|
|
|
|
|
.class("breadcrumb"),
|
|
|
|
|
.custom(name: "aria-label", value: "Breadcrumb")
|
|
|
|
|
) {
|
|
|
|
|
a(.href(.Route.home)) {
|
|
|
|
|
"Home"
|
|
|
|
|
}
|
|
|
|
|
span(.custom(name: "aria-hidden", value: "true")) {
|
|
|
|
|
"/"
|
|
|
|
|
}
|
|
|
|
|
a(.href(.Route.series)) {
|
|
|
|
|
"Series"
|
|
|
|
|
}
|
|
|
|
|
span(.custom(name: "aria-hidden", value: "true")) {
|
|
|
|
|
"/"
|
|
|
|
|
}
|
|
|
|
|
span(
|
|
|
|
|
.class("breadcrumb__current"),
|
|
|
|
|
.custom(name: "aria-current", value: "page")
|
|
|
|
|
) {
|
|
|
|
|
series.name
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
section(.class("series__head")) {
|
|
|
|
|
if let cover = listings.first(where: { $0.issueNumber != nil }) ?? listings.first {
|
|
|
|
|
ListingImage(cover, modifier: "cover--series")
|
|
|
|
|
}
|
|
|
|
|
div(.class("series__intro")) {
|
|
|
|
|
p(.class("type-eyebrow")) {
|
|
|
|
|
"Series · \(series.publisher) · \(series.years)\(series.issueCount > 0 ? " · \(series.issueCount) issues" : "")"
|
|
|
|
|
}
|
|
|
|
|
h1(.class("type-h1")) {
|
|
|
|
|
series.name
|
|
|
|
|
}
|
|
|
|
|
p(.class("type-lead")) {
|
|
|
|
|
"\(series.blurb)\(series.blurb.isEmpty ? "" : " ")\(listings.count) \(listings.count == 1 ? "copy" : "copies") listed across CCN right now."
|
|
|
|
|
}
|
2026-09-21 13:53:13 +02:00
|
|
|
if let owned, series.issueCount > 0 {
|
|
|
|
|
div(.class("card series__collection")) {
|
|
|
|
|
p(.class("type-eyebrow")) {
|
|
|
|
|
"Your collection"
|
|
|
|
|
}
|
|
|
|
|
p(.class("type-panel")) {
|
|
|
|
|
"\(owned.count) of \(series.issueCount) owned"
|
|
|
|
|
}
|
|
|
|
|
p(.class("type-small")) {
|
|
|
|
|
"\(gaps.count) missing · \(gaps.filter { !$0.copies.isEmpty }.count) of them listed now"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-21 13:32:05 +02:00
|
|
|
ul(.class("series__keys")) {
|
|
|
|
|
ForEach(keys) { key in
|
|
|
|
|
li(.class("chip chip--warn")) {
|
|
|
|
|
"#\(key.issue) · \(key.notes)"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !issues.isEmpty {
|
|
|
|
|
section(.class("series__section")) {
|
|
|
|
|
div(.class("section-head")) {
|
|
|
|
|
h2(.class("type-h2")) {
|
|
|
|
|
series.issueCount > 0 ? "All \(series.issueCount) issues" : "Issues listed"
|
|
|
|
|
}
|
|
|
|
|
span(.class("type-small")) {
|
|
|
|
|
"Cheapest listed copy per issue"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ol(.class("series__issues")) {
|
|
|
|
|
ForEach(issues) { entry in
|
2026-09-21 13:53:13 +02:00
|
|
|
li(.class(issueClass(entry))) {
|
2026-09-21 13:32:05 +02:00
|
|
|
span(.class("series__number")) {
|
|
|
|
|
"#\(entry.number)"
|
|
|
|
|
}
|
|
|
|
|
if let cheapest = entry.copies.min(by: { $0.priceCents < $1.priceCents }) {
|
|
|
|
|
a(.href(.Route.series(series.slug, issue: entry.number) + "#copies")) {
|
|
|
|
|
"\(entry.copies.count) listed · from \(cheapest.priceCents.euros)"
|
|
|
|
|
}
|
|
|
|
|
.attributes(.custom(name: "aria-current", value: "true"), when: entry.number == issue)
|
|
|
|
|
} else {
|
|
|
|
|
a(.href(.Route.gated(
|
|
|
|
|
.Route.newWant(title: "\(series.name) #\(entry.number)"),
|
|
|
|
|
from: .Route.series(series.slug),
|
|
|
|
|
viewer: viewer
|
|
|
|
|
))) {
|
|
|
|
|
"None listed · alert me"
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-21 13:53:13 +02:00
|
|
|
if let owned {
|
|
|
|
|
form(
|
|
|
|
|
.action("\(String.Route.series(series.slug))/own"),
|
|
|
|
|
.method(.post)
|
|
|
|
|
) {
|
|
|
|
|
input(
|
|
|
|
|
.type(.hidden),
|
|
|
|
|
.name("issue"),
|
|
|
|
|
.value("\(entry.number)")
|
|
|
|
|
)
|
|
|
|
|
button(
|
|
|
|
|
.class("series__own"),
|
|
|
|
|
.type(.submit),
|
|
|
|
|
.custom(name: "aria-pressed", value: owned.contains(entry.number) ? "true" : "false")
|
|
|
|
|
) {
|
|
|
|
|
owned.contains(entry.number) ? "Owned ✓" : "I own this"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-21 13:32:05 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let issue, !copies.isEmpty {
|
|
|
|
|
section(
|
|
|
|
|
.class("series__section"),
|
|
|
|
|
.id("copies")
|
|
|
|
|
) {
|
|
|
|
|
h2(.class("type-h2")) {
|
|
|
|
|
"Copies of #\(issue) — \(copies.count) listed"
|
|
|
|
|
}
|
|
|
|
|
// A plain `GET` form: the copies ticked become the comparison's query.
|
|
|
|
|
form(
|
|
|
|
|
.class("series__copies"),
|
|
|
|
|
.action(.Route.compare),
|
|
|
|
|
.method(.get)
|
|
|
|
|
) {
|
|
|
|
|
div(.class("table-scroll")) {
|
|
|
|
|
table(.class("table")) {
|
|
|
|
|
thead {
|
|
|
|
|
tr {
|
|
|
|
|
th {
|
|
|
|
|
span(.class("visually-hidden")) {
|
|
|
|
|
"Compare"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ForEach(["Grade", "Printing", "Pages", "Defects", "Price", "Ship to NL", "Shop", ""]) { heading in
|
|
|
|
|
th {
|
|
|
|
|
heading
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
tbody {
|
|
|
|
|
ForEach(copies) { copy in
|
|
|
|
|
tr {
|
|
|
|
|
td {
|
|
|
|
|
input(
|
|
|
|
|
.type(.checkbox),
|
|
|
|
|
.name("copy"),
|
|
|
|
|
.value(copy.slug),
|
|
|
|
|
.custom(name: "aria-label", value: "Compare the \(copy.chip.label) from \(copy.shop.name)")
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
td {
|
|
|
|
|
Chip(copy.chip.label, tone: copy.chip.tone, isCertified: copy.chip.isCertified)
|
|
|
|
|
}
|
|
|
|
|
td {
|
|
|
|
|
copy.printing
|
|
|
|
|
}
|
|
|
|
|
td {
|
|
|
|
|
copy.pageQuality ?? "—"
|
|
|
|
|
}
|
|
|
|
|
td {
|
|
|
|
|
copy.defects.isEmpty ? "None declared" : copy.defects.joined(separator: "; ")
|
|
|
|
|
}
|
|
|
|
|
td {
|
|
|
|
|
strong {
|
|
|
|
|
copy.priceCents.euros
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
td {
|
|
|
|
|
"\(copy.shippingCents.euros) · \(copy.shop.country)"
|
|
|
|
|
}
|
|
|
|
|
td {
|
|
|
|
|
a(.href(copy.shop.path)) {
|
|
|
|
|
copy.shop.name
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
td {
|
|
|
|
|
a(
|
|
|
|
|
.class("link-ink"),
|
|
|
|
|
.href(copy.path)
|
|
|
|
|
) {
|
|
|
|
|
"View"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
button(
|
|
|
|
|
.class("button button--dark"),
|
|
|
|
|
.type(.submit)
|
|
|
|
|
) {
|
|
|
|
|
"Compare the copies ticked (up to \(Self.maxCompared))"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !collected.isEmpty {
|
|
|
|
|
section(.class("series__section")) {
|
|
|
|
|
h2(.class("type-h2")) {
|
|
|
|
|
"Collected editions"
|
|
|
|
|
}
|
|
|
|
|
div(.class("card-grid")) {
|
|
|
|
|
ForEach(collected) { listing in
|
|
|
|
|
ListingCard(listing)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var scripts: [any Asset] {
|
|
|
|
|
[StaticFile.shared]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var stylesheets: [any Asset] {
|
|
|
|
|
[StaticFile.shared, StaticFile.collecting]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var summary: String? {
|
|
|
|
|
"\(series.name), \(series.publisher), \(series.years): every issue, the copies listed of each, and the gaps."
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var title: String {
|
|
|
|
|
"\(series.name) — CCN"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Helpers
|
|
|
|
|
|
|
|
|
|
private extension SeriesPage {
|
|
|
|
|
|
2026-09-21 13:53:13 +02:00
|
|
|
/// The issues of the run the viewer does not own.
|
|
|
|
|
var gaps: [(number: Int, copies: [Listing])] {
|
|
|
|
|
issues.filter { !(owned ?? []).contains($0.number) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// An issue's tile: a gap reads quieter, and an owned issue is dimmed.
|
|
|
|
|
func issueClass(
|
|
|
|
|
_ entry: (number: Int, copies: [Listing])
|
|
|
|
|
) -> String {
|
|
|
|
|
[
|
|
|
|
|
"series__issue",
|
|
|
|
|
entry.copies.isEmpty ? "series__issue--gap" : nil,
|
|
|
|
|
owned?.contains(entry.number) == true ? "series__issue--owned" : nil,
|
|
|
|
|
]
|
|
|
|
|
.compactMap(\.self)
|
|
|
|
|
.joined(separator: " ")
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 13:32:05 +02:00
|
|
|
/// The series' manga, trade paperbacks, and hardcovers.
|
|
|
|
|
var collected: [Listing] {
|
|
|
|
|
listings.filter { $0.issueNumber == nil }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The copies of the issue picked, cheapest delivered first.
|
|
|
|
|
var copies: [Listing] {
|
|
|
|
|
listings
|
|
|
|
|
.filter { $0.issueNumber == issue }
|
|
|
|
|
.sorted { $0.priceCents + $0.shippingCents < $1.priceCents + $1.shippingCents }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Every issue of the run with the copies listed of it; for a series without a known run, the issues listed alone.
|
|
|
|
|
var issues: [(number: Int, copies: [Listing])] {
|
|
|
|
|
let listed = Dictionary(grouping: listings.filter { $0.issueNumber != nil }) { $0.issueNumber ?? 0 }
|
|
|
|
|
let numbers = series.issueCount > 0 ? Array(1...series.issueCount) : listed.keys.sorted()
|
|
|
|
|
|
|
|
|
|
return numbers.map { ($0, listed[$0] ?? []) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The key issues, as the listings of them say.
|
|
|
|
|
var keys: [(issue: Int, notes: String)] {
|
|
|
|
|
var seen = Set<Int>()
|
|
|
|
|
|
|
|
|
|
return listings
|
|
|
|
|
.compactMap { listing in
|
|
|
|
|
guard let issue = listing.issueNumber, let notes = listing.keyNotes, seen.insert(issue).inserted else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (issue, notes)
|
|
|
|
|
}
|
|
|
|
|
.sorted { $0.issue < $1.issue }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|