Implemented phase 6 for the POC prototype in the Website service.
This commit is contained in:
1 parent
256d22df40
commit
f2f445827b
40 files changed
+1906
-37
No files matched your search
@@ -0,0 +1,271 @@
|
||||
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) }
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user