Files
ccn/Services/Website/Sources/Library/Internal/Pages/CollectionPage.swift
T

272 lines
9.2 KiB
Swift

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<Int>],
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<Int>
let listed: Set<Int>
let cover: Listing.Cover?
/// The issues of the run not owned yet.
var missing: Set<Int> {
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<Entry, Set<Int>>
) -> 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) }
}
}