import Elementary import Foundation import Infrastructure import Persistence /// The collection tracker: what the viewer owns of each series, what is missing, and which of the gaps are listed right now. struct CollectionPage { // MARK: Properties let analytics: Analytics? let assetVersion: String? let locale: Locale /// Every series in the catalogue, for the add-books form. let catalogue: [Series] /// The series the viewer owns issues of, each with the issues owned and the numbers of those listed for sale. let entries: [Entry] /// The signed-in viewer. let viewer: Viewer? // MARK: Initializers /// Creates the collection tracker. /// - Parameters: /// - catalogue: every series in the catalogue. /// - owned: the issues the viewer owns, by series slug. /// - listings: every listing for sale, to tell which gaps are listed. /// - viewer: the signed-in viewer. /// - 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( catalogue: [Series], owned: [String: Set], listings: [Listing], viewer: Viewer? = nil, assetVersion: String? = nil, analytics: Analytics? = nil ) { self.analytics = analytics self.assetVersion = assetVersion self.catalogue = catalogue self.entries = catalogue.compactMap { series in guard let issues = owned[series.slug], !issues.isEmpty else { return nil } let ofSeries = listings.filter { $0.seriesSlug == series.slug } return .init( series: series, owned: issues, listed: Set(ofSeries.compactMap(\.issueNumber)), cover: ofSeries.first?.cover ) } self.locale = .init(identifier: "en") self.viewer = viewer } } // MARK: - Structs extension CollectionPage { /// A series in the collection. struct Entry: Sendable { let series: Series let owned: Set let listed: Set let cover: Listing.Cover? /// The issues of the run not owned yet. var missing: Set { Set(1...max(series.issueCount, 1)).subtracting(owned) } /// The share of the run owned, to the nearest twentieth — what the progress bar's classes come in. var progress: Int { Int((Double(owned.count) / Double(max(series.issueCount, 1)) * 20).rounded()) * 5 } } } // MARK: - Page extension CollectionPage: Page { // MARK: Computed var content: some HTML { SiteLayout(viewer: viewer) { div(.class("page wants")) { div(.class("section-head")) { h1(.class("type-h1")) { "Collection" } span(.class("type-small")) { "\(total(\.owned)) \(total(\.owned) == 1 ? "book" : "books") · \(entries.count) series" } } if entries.isEmpty { div(.class("empty")) { h2(.class("type-panel")) { "Your collection is empty" } p(.class("type-body-sm")) { "Add what you own and every series page shows the gaps — and which of them are listed right now." } } } else { ul(.class("tiles tiles--three")) { tile(total(\.owned), "owned") tile(total(\.missing), "missing") tile(entries.reduce(0) { $0 + $1.missing.intersection($1.listed).count }, "listed now") } ul(.class("wants__list")) { ForEach(entries) { entry in li { a( .class("card collection__series"), .href(.Route.series(entry.series.slug)) ) { if let cover = entry.cover { Cover(cover, modifier: "cover--thumb") } span(.class("collection__text")) { span(.class("collection__top")) { strong { entry.series.name } strong { "\(entry.owned.count)/\(entry.series.issueCount)" } } span(.class("type-small")) { "\(entry.series.publisher) · \(entry.series.years)" } span(.class("progress progress--\(entry.progress)")) {} if entry.missing.isEmpty { span(.class("collection__complete")) { "Complete" } } else { span(.class("type-small")) { "\(entry.missing.count) missing · \(entry.missing.intersection(entry.listed).count) listed now" } } } } } } } } form( .class("card wants__form"), .action(.Route.collection), .method(.post) ) { h2(.class("type-panel")) { "Add books" } div(.class("listing-form__grid")) { SelectField( name: "series", title: "Series", options: catalogue.map { ($0.slug, "\($0.name) · \($0.issueCount) issues") } ) TextField( name: "issues", title: "Issues you own", placeholder: "1-6, 9, 12", hint: "Numbers and ranges, separated by commas", isWide: true, maxLength: 120 ) } div(.class("listing-form__submit")) { button( .class("button button--primary"), .type(.submit) ) { "Add to collection" } } } p(.class("type-small wants__note")) { "On a series page, every issue has its own switch — and the gaps are one tap from an alert." } } } } @HTMLBuilder var metadata: some HTML { meta( .name("robots"), .content("noindex") ) siteMetadata } var scripts: [any Asset] { [StaticFile.shared] } var stylesheets: [any Asset] { [StaticFile.shared, StaticFile.trade] } var title: String { "Collection — CCN" } } // MARK: - Helpers private extension CollectionPage { /// A figure and what it counts. func tile( _ figure: Int, _ label: String ) -> some HTML { li(.class("card tiles__tile")) { span(.class("type-stat")) { "\(figure)" } span(.class("type-small")) { label } } } /// The size of a set, summed over the series. func total( _ issues: KeyPath> ) -> Int { entries.reduce(0) { $0 + $1[keyPath: issues].count } } } // MARK: - Extensions extension CollectionPage { // MARK: Methods /// The issue numbers a collector typed — `1-6, 9, 12` — capped so a range cannot run away. static func issues( in text: String ) -> [Int] { text .split { $0 == "," || $0 == " " || $0 == ";" } .flatMap { part -> [Int] in let bounds = part.split(separator: "-").compactMap { Int($0) } switch bounds.count { case 1: return [bounds[0]] case 2 where bounds[0] <= bounds[1] && bounds[1] - bounds[0] < 500: return Array(bounds[0]...bounds[1]) default: return [] } } .filter { (1...999).contains($0) } } }