271 lines
9.2 KiB
Swift
271 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) }
|
||
|
|
}
|
||
|
|
|
||
|
|
}
|