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

243 lines
8.8 KiB
Swift
Raw Normal View History

2026-08-19 22:55:00 +02:00
import Elementary
import Foundation
import Infrastructure
import Localization
import Persistence
2026-08-19 22:55:00 +02:00
/// The website's landing page, with its text localized to a given locale.
struct IndexPage {
// MARK: Properties
/// The analytics tracker embedded as a deferred script in the document head, or `nil` to omit it.
let analytics: Analytics?
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
let assetVersion: String?
/// The listings the page shows in its rows.
let listings: HomeListings
2026-08-19 22:55:00 +02:00
/// The locale the page content is localized to.
let locale: Locale
2026-09-04 13:40:35 +00:00
/// The public origin the page derives its canonical URL and language alternates from, or `nil` to omit them.
let siteOrigin: String?
/// The signed-in viewer, or `nil` when signed out.
let viewer: Viewer?
2026-08-19 22:55:00 +02:00
/// Resolves the page's text from the bundled String Catalog for the page's ``locale``.
private let localize: Localize
// MARK: Initializers
/// Creates a landing page localized to the given locale.
/// - Parameters:
/// - locale: the locale the page content is localized to.
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
2026-09-04 13:40:35 +00:00
/// - siteOrigin: the public origin the page derives its canonical URL and language alternates from, or `nil` (the default) to omit them.
2026-08-19 22:55:00 +02:00
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
/// - listings: the listings the page shows in its rows; none by default.
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
2026-08-19 22:55:00 +02:00
init(
locale: Locale,
assetVersion: String? = nil,
2026-09-04 13:40:35 +00:00
siteOrigin: String? = nil,
analytics: Analytics? = nil,
listings: HomeListings = .init(),
viewer: Viewer? = nil
2026-08-19 22:55:00 +02:00
) {
self.viewer = viewer
2026-08-19 22:55:00 +02:00
self.analytics = analytics
self.assetVersion = assetVersion
self.listings = listings
2026-08-19 22:55:00 +02:00
self.locale = locale
2026-09-04 13:40:35 +00:00
self.siteOrigin = siteOrigin
2026-08-19 22:55:00 +02:00
self.localize = .init(bundle: .module)
}
}
// MARK: - Page
extension IndexPage: Page {
2026-09-04 13:40:35 +00:00
// MARK: Constants
/// The path the page is served at; ``RootController`` registers its route against it, and the page builds its canonical URL from it.
static let path = "/"
// MARK: Computed
/// The canonical URL of the page's edition in its language, or `nil` when the origin is unknown.
///
/// The origin alone for the default language, since canonical URLs carry no trailing slash — `TrailingSlashRedirectMiddleware` enforces
/// that on every path but the root, which has nothing to strip.
var canonicalURL: String? {
siteOrigin.map {
Language(of: locale).url(
origin: $0,
path: Self.path
)
}
}
/// The site-wide metadata, preceded by the `hreflang` alternates tying the page's language editions together.
@HTMLBuilder
var metadata: some HTML {
languageAlternates(
origin: siteOrigin,
path: Self.path
)
siteMetadata
}
2026-08-19 22:55:00 +02:00
// MARK: Properties
var content: some HTML {
SiteLayout(viewer: viewer) {
div(.class("page home")) {
section(.class("hero")) {
div(.class("hero__text")) {
p(.class("type-eyebrow")) {
"Single issues · Manga · Trades · Hardcovers"
}
h1(.class("type-hero")) {
localize("index.heading", locale: locale)
}
p(.class("type-lead")) {
"Search every listing by title, print and grade. Browse any seller's shop without an account — sign in with a passkey only when you're ready to buy, make an offer or message."
}
div(.class("hero__actions")) {
a(
.class("button button--primary button--lg"),
.href(Format.singleIssues.path)
) {
"Browse single issues"
}
a(
.class("button button--outline button--lg"),
.href(.Route.series)
) {
"Browse by series"
}
}
}
if listings.singleIssues.count >= 3 {
div(.class("hero__covers")) {
Cover(listings.singleIssues[1].cover, modifier: "cover--left")
Cover(listings.singleIssues[0].cover, modifier: "cover--centre")
Cover(listings.singleIssues[2].cover, modifier: "cover--right")
}
}
}
nav(
.class("formats"),
.custom(name: "aria-label", value: "Browse by format")
) {
for format in Format.allCases {
a(
.class("formats__tile"),
.href(format.path)
) {
span(.class("formats__name")) {
format.label
}
span(.class("type-caption")) {
format.summary
}
}
}
}
row(
"New in single issues",
more: "All single issues",
path: Format.singleIssues.path,
listings: listings.singleIssues
)
row(
"Manga, trades and hardcovers",
more: "All collected editions",
path: SearchParameters(items: Format.allCases.dropFirst().map {
.init(name: SearchParameters.Name.format.rawValue, value: $0.rawValue)
}).path(.Route.search),
listings: listings.collected
)
section(.class("promises")) {
for promise in Self.promises {
div(.class("promises__item")) {
Icon(promise.glyph, size: 28, weight: "1.8")
h3(.class("promises__title")) {
promise.title
}
p(.class("type-body-sm promises__text")) {
promise.text
}
}
}
}
}
2026-08-19 22:55:00 +02:00
}
}
var scripts: [any Asset] {
[StaticFile.index, StaticFile.shared]
}
var stylesheets: [any Asset] {
[StaticFile.shared, StaticFile.index]
}
var title: String {
localize("index.title", locale: locale)
}
}
// MARK: - Helpers
private extension IndexPage {
// MARK: Constants
/// What the marketplace promises, under the listing rows.
static let promises: [(glyph: Icon.Glyph, title: String, text: String)] = [
(.key, "Passkeys, not passwords", "Sign up and sign in with Face ID, a fingerprint or a hardware key. Nothing to phish, nothing to reuse, nothing to reset."),
(.document, "Grade stated up front", "Every listing declares slab or raw, grader and grade, printing and defects, with photos of the actual copy front and back."),
(.shop, "Shops are open to everyone", "Anyone can browse a seller's inventory without an account. Buying, offers and messages need a signed-in account on both sides."),
]
// MARK: Methods
/// A titled row of listing cards; renders nothing without listings.
@HTMLBuilder
func row(
_ title: String,
more: String,
path: String,
listings: [Listing]
) -> some HTML {
if !listings.isEmpty {
section(.class("home__row")) {
div(.class("section-head")) {
h2(.class("type-h2")) {
title
}
a(
.class("link-ink"),
.href(path)
) {
more
}
}
div(.class("card-grid")) {
for listing in listings {
ListingCard(listing)
}
}
}
}
}
}