Implemented phase 1 for the POC prototype in the Website service.
This commit is contained in:
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"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user