Implemented phase 1 for the POC prototype in the Website service.

This commit is contained in:
javier committed 2026-09-21 11:16:52 +02:00
1 parent 6d7ab4bcba
commit ea89ea892a
37 files changed
+4173 -43

No files matched your search

@@ -0,0 +1,62 @@
import Elementary
/// A chip stating a grade, condition, or status.
struct Chip: HTML {
// MARK: Properties
/// Whether the chip leads with a check mark, for a grade a grading company certified.
let isCertified: Bool
/// The chip's text.
let label: String
/// The chip's colouring.
let tone: Tone
// MARK: Initializers
/// Creates a chip.
/// - Parameters:
/// - label: the chip's text.
/// - tone: the chip's colouring; neutral by default.
/// - isCertified: whether the chip leads with a check mark; `false` by default.
init(
_ label: String,
tone: Tone = .neutral,
isCertified: Bool = false
) {
self.isCertified = isCertified
self.label = label
self.tone = tone
}
// MARK: Computed
var body: some HTML {
span(.class(tone.className)) {
if isCertified {
Icon(.check, size: 10)
}
label
}
}
}
// MARK: - Enumerations
extension Chip {
/// A chip's colouring.
enum Tone: String, Sendable {
case bad
case good
case neutral
case warn
/// The chip's classes for the tone.
var className: String {
self == .neutral ? "chip" : "chip chip--\(rawValue)"
}
}
}
@@ -0,0 +1,60 @@
import Elementary
import Persistence
/// A placeholder cover: lettering on a toned ground, scaling with its width. Stands in until listings carry photos.
struct Cover: HTML {
// MARK: Properties
/// The cover's lettering and tone.
let cover: Listing.Cover
/// A modifier class positioning or sizing the cover, or `nil` for none.
let modifier: String?
// MARK: Initializers
/// Creates a placeholder cover.
/// - Parameters:
/// - cover: the cover's lettering and tone.
/// - modifier: a modifier class positioning or sizing the cover, or `nil` (the default) for none.
init(
_ cover: Listing.Cover,
modifier: String? = nil
) {
self.cover = cover
self.modifier = modifier
}
// MARK: Computed
/// Decorative: whatever links to the listing names it.
var body: some HTML {
div(
.class(["cover", "cover--\(cover.tone)", modifier].compactMap(\.self).joined(separator: " ")),
.custom(
name: "aria-hidden",
value: "true"
)
) {
div(.class("cover__face")) {
div(.class("cover__title")) {
for line in cover.title.split(separator: "\n") {
span {
String(line)
}
}
}
div(.class("cover__foot")) {
span(.class("cover__imprint")) {
cover.imprint
}
span(.class(cover.number.allSatisfy(\.isNumber) ? "cover__number" : "cover__number cover__number--text")) {
cover.number
}
}
}
}
}
}
@@ -11,18 +11,24 @@ struct Icon: HTML {
/// The icon's rendered width and height, in pixels.
let size: Int
/// The stroke width, or `nil` for the glyph's own.
let weight: String?
// MARK: Initializers
/// Creates an icon.
/// - Parameters:
/// - glyph: the glyph the icon draws.
/// - size: the icon's rendered width and height, in pixels; 20 by default.
/// - weight: the stroke width, or `nil` (the default) for the glyph's own.
init(
_ glyph: Glyph,
size: Int = 20
size: Int = 20,
weight: String? = nil
) {
self.glyph = glyph
self.size = size
self.weight = weight
}
// MARK: Computed
@@ -32,7 +38,7 @@ struct Icon: HTML {
HTMLRaw(
"""
<svg class="icon" width="\(size)" height="\(size)" viewBox="0 0 24 24" fill="none" stroke="currentColor" \
stroke-width="\(glyph.strokeWidth)" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\(glyph.shapes)</svg>
stroke-width="\(weight ?? glyph.strokeWidth)" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\(glyph.shapes)</svg>
"""
)
}
@@ -50,10 +56,14 @@ extension Icon {
case bookmark
/// A check mark.
case check
/// A page of text.
case document
/// Filter lines.
case filter
/// A key.
case key
/// A padlock.
case lock
/// A speech bubble.
case message
/// A magnifying glass.
@@ -75,8 +85,10 @@ extension Icon.Glyph {
case .bell: #"<path d="M6 16V11a6 6 0 0 1 12 0v5l2 2H4z"></path><path d="M10 21h4"></path>"#
case .bookmark: #"<path d="M6 3h12v18l-6-4-6 4z"></path>"#
case .check: #"<path d="m5 12 5 5 9-10"></path>"#
case .document: #"<rect x="5" y="3" width="14" height="18" rx="1"></rect><path d="M9 8h6M9 12h6M9 16h3"></path>"#
case .filter: #"<path d="M4 6h16M7 12h10M10 18h4"></path>"#
case .key: #"<circle cx="7.5" cy="15.5" r="4.5"></circle><path d="M10.7 12.3 21 2"></path><path d="m18 5 3 3"></path><path d="m15 8 3 3"></path>"#
case .lock: #"<rect x="5" y="11" width="14" height="10" rx="1"></rect><path d="M8 11V7a4 4 0 0 1 8 0v4"></path>"#
case .message: #"<path d="M4 5h16v11H8l-4 4z"></path>"#
case .search: #"<circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.5-3.5"></path>"#
case .shop: #"<path d="M3 9.5 12 4l9 5.5"></path><path d="M5 10v9h14v-9"></path><path d="M10 19v-5h4v5"></path>"#
@@ -0,0 +1,108 @@
import Elementary
import Persistence
/// A listing in a card grid: its cover, title, price, grade chip, and — depending on the ``Context`` — the shop selling it.
struct ListingCard: HTML {
// MARK: Properties
/// Where the card is shown.
let context: Context
/// The listing the card shows.
let listing: Listing
// MARK: Initializers
/// Creates a listing card.
/// - Parameters:
/// - listing: the listing the card shows.
/// - context: where the card is shown; a browsing row by default.
init(
_ listing: Listing,
context: Context = .browse
) {
self.context = context
self.listing = listing
}
// MARK: Computed
var body: some HTML {
article(.class("listing-card")) {
// Saving is a gated action: signed out, it leads to the sign-in screen.
a(
.class("listing-card__save"),
.href(.Route.signIn(returningTo: listing.path)),
.custom(
name: "aria-label",
value: "Save \(listing.title) to watchlist"
)
) {
Icon(.bookmark, size: 18)
}
a(
.class("listing-card__cover"),
.href(listing.path),
.custom(
name: "tabindex",
value: "-1"
),
.custom(
name: "aria-hidden",
value: "true"
)
) {
Cover(listing.cover)
}
div {
h3(.class("type-card-title")) {
a(
.class("listing-card__title"),
.href(listing.path)
) {
listing.title
}
}
p(.class("listing-card__meta")) {
listing.summary
}
}
div(.class("listing-card__row")) {
span(.class("type-price-card")) {
listing.priceCents.euros
}
Chip(
listing.chip.label,
tone: listing.chip.tone,
isCertified: listing.chip.isCertified
)
}
if context != .shop {
a(
.class("listing-card__shop"),
.href(listing.shop.path)
) {
context == .search
? "\(listing.shop.name) · \(listing.shop.country) · ship \(listing.shippingCents.euros)"
: listing.shop.name
}
}
}
}
}
// MARK: - Enumerations
extension ListingCard {
/// Where a listing card is shown.
enum Context: Sendable {
/// A browsing row: the shop is named.
case browse
/// Search results: the shop is named with its country and shipping cost.
case search
/// The shop's own screen: the shop goes unnamed.
case shop
}
}
@@ -0,0 +1,107 @@
import Elementary
/// The page links under a card grid; renders nothing for a single page.
struct Pagination: HTML {
// MARK: Properties
/// The page shown, counted from one.
let current: Int
/// The path of a given page.
let path: @Sendable (Int) -> String
/// The number of pages.
let total: Int
// MARK: Initializers
/// Creates the page links.
/// - Parameters:
/// - current: the page shown, counted from one.
/// - total: the number of pages.
/// - path: the path of a given page.
init(
current: Int,
total: Int,
path: @escaping @Sendable (Int) -> String
) {
self.current = current
self.path = path
self.total = total
}
// MARK: Computed
var body: some HTML {
if total > 1 {
nav(
.class("pagination"),
.custom(
name: "aria-label",
value: "Pagination"
)
) {
for page in 1...total {
if page == current {
a(
.class("pagination__page"),
.href(path(page)),
.custom(
name: "aria-current",
value: "page"
)
) {
"\(page)"
}
} else {
a(
.class("pagination__page"),
.href(path(page))
) {
"\(page)"
}
}
}
if current < total {
a(
.class("pagination__next"),
.href(path(current + 1))
) {
"Next"
}
}
}
}
}
}
// MARK: - Extensions
extension Pagination {
// MARK: Methods
/// Cuts the items of one page out of a list, clamping the page to the list's range.
/// - Parameters:
/// - items: the whole list.
/// - page: the page asked for, counted from one.
/// - size: the number of items on a page.
/// - Returns: the page's items, the page actually shown, and the number of pages.
static func slice<Item>(
_ items: [Item],
page: Int,
size: Int
) -> (items: [Item], current: Int, total: Int) {
let total = max((items.count + size - 1) / size, 1)
let current = min(max(page, 1), total)
return (
Array(items.dropFirst((current - 1) * size).prefix(size)),
current,
total
)
}
}
@@ -10,6 +10,9 @@ struct SiteHeader: HTML {
/// The format marked as current in the navigation, or `nil` for none.
let active: Format?
/// The text the search field is filled with, or `nil` to leave it empty.
let query: String?
/// The signed-in viewer, or `nil` to render the signed-out actions.
let viewer: Viewer?
@@ -18,12 +21,15 @@ struct SiteHeader: HTML {
/// Creates a site header.
/// - Parameters:
/// - active: the format marked as current, or `nil` (the default) for none.
/// - query: the text the search field is filled with, or `nil` (the default) to leave it empty.
/// - viewer: the signed-in viewer, or `nil` (the default) to render the signed-out actions.
init(
active: Format? = nil,
query: String? = nil,
viewer: Viewer? = nil
) {
self.active = active
self.query = query
self.viewer = viewer
}
@@ -159,6 +165,7 @@ private extension SiteHeader {
.id("site-search-query"),
.type(.search),
.name("q"),
.value(query ?? ""),
.placeholder("Search title, issue, grade or shop")
)
button(
@@ -11,6 +11,9 @@ struct SiteLayout<Content: HTML>: HTML {
/// The screen's markup, rendered inside the `main` landmark.
let content: Content
/// The text the header's search field is filled with, or `nil` to leave it empty.
let query: String?
/// The signed-in viewer, or `nil` to render the signed-out header.
let viewer: Viewer?
@@ -19,15 +22,18 @@ struct SiteLayout<Content: HTML>: HTML {
/// Creates a site layout around a screen's content.
/// - Parameters:
/// - active: the format marked as current, or `nil` (the default) for none.
/// - query: the text the header's search field is filled with, or `nil` (the default) to leave it empty.
/// - viewer: the signed-in viewer, or `nil` (the default) to render the signed-out header.
/// - content: the screen's markup.
init(
active: Format? = nil,
query: String? = nil,
viewer: Viewer? = nil,
@HTMLBuilder content: () -> Content
) {
self.active = active
self.content = content()
self.query = query
self.viewer = viewer
}
@@ -42,6 +48,7 @@ struct SiteLayout<Content: HTML>: HTML {
}
SiteHeader(
active: active,
query: query,
viewer: viewer
)
main(.id("main")) {
@@ -0,0 +1,63 @@
import Elementary
import Persistence
/// The sort order select. It submits its form on change when scripts run; the form's own button covers the rest.
struct SortSelect: HTML {
// MARK: Properties
/// The identifier of the form the select belongs to.
let form: String
/// The order currently applied.
let selected: ListingFilter.Sort
// MARK: Computed
var body: some HTML {
div(.class("sort")) {
label(
.class("sort__label"),
.for("\(form)-sort")
) {
"Sort"
}
select(
.class("select"),
.id("\(form)-sort"),
.name(SearchParameters.Name.sort.rawValue),
.form(form),
.custom(
name: "data-autosubmit",
value: ""
)
) {
for sort in ListingFilter.Sort.allCases {
option(.value(sort.rawValue)) {
sort.label
}
.attributes(.selected, when: sort == selected)
}
}
}
}
}
// MARK: - Extensions
extension ListingFilter.Sort {
// MARK: Computed
/// The order's name in the select.
var label: String {
switch self {
case .newest: "Newly listed"
case .priceAscending: "Price: low to high"
case .priceDescending: "Price: high to low"
case .gradeDescending: "Grade: high to low"
}
}
}