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

This commit is contained in:
javier committed 2026-09-21 10:22:55 +02:00
1 parent 916df7e2f0
commit 717df5208c
31 files changed
+2351 -240

No files matched your search

@@ -0,0 +1,96 @@
import Elementary
/// A decorative inline SVG stroke icon, drawn in the current text colour.
struct Icon: HTML {
// MARK: Properties
/// The glyph the icon draws.
let glyph: Glyph
/// The icon's rendered width and height, in pixels.
let size: Int
// 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.
init(
_ glyph: Glyph,
size: Int = 20
) {
self.glyph = glyph
self.size = size
}
// MARK: Computed
/// Rendered raw: the markup is static, never user input.
var body: some 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>
"""
)
}
}
// MARK: - Enumerations
extension Icon {
/// A glyph an ``Icon`` can draw.
enum Glyph {
/// A bell.
case bell
/// A bookmark.
case bookmark
/// A check mark.
case check
/// Filter lines.
case filter
/// A key.
case key
/// A speech bubble.
case message
/// A magnifying glass.
case search
/// A shopfront.
case shop
/// Sort arrows.
case sort
}
}
extension Icon.Glyph {
// MARK: Computed
/// The shapes the glyph is drawn with.
var shapes: String {
switch self {
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 .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 .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>"#
case .sort: #"<path d="M7 4v16M7 20l-3-3M7 20l3-3M17 20V4M17 4l-3 3M17 4l3 3"></path>"#
}
}
/// The glyph's stroke width; heavier for glyphs drawn small.
var strokeWidth: String {
switch self {
case .check: "3.5"
case .filter, .key, .search, .sort: "2.2"
default: "2"
}
}
}
@@ -0,0 +1,154 @@
import Elementary
/// The site footer: the about blurb, the link columns, the language toggle, and the legal line.
struct SiteFooter: HTML {
// MARK: Computed
var body: some HTML {
footer(.class("site-footer")) {
div(.class("site-footer__inner")) {
div(.class("site-footer__columns")) {
div(.class("site-footer__about")) {
span(.class("site-footer__wordmark")) {
"CCN"
}
p {
"A marketplace for single-issue comics, manga, trade paperbacks and hardcovers, sold by independent shops in the Netherlands and across the EU."
}
}
for column in Self.columns {
nav(
.class("site-footer__column"),
.custom(
name: "aria-label",
value: column.title
)
) {
h2(.class("site-footer__heading")) {
column.title
}
ul(.class("site-footer__links")) {
for link in column.links {
li {
a(.href(link.path)) {
link.label
}
}
}
}
}
}
language
}
div(.class("site-footer__legal")) {
span {
"CCN B.V. · Amsterdam · KvK [NUMBER] · BTW-id [NUMBER] · Business sellers show their KvK number on their shop page; consumers have a 14-day right of withdrawal on purchases from business sellers."
}
span(.class("site-footer__legal-links")) {
a(.href(.Route.terms)) {
"Terms"
}
a(.href(.Route.privacy)) {
"Privacy"
}
a(.href(.Route.sellerRules)) {
"Seller rules"
}
}
}
}
}
}
}
// MARK: - Helpers
private extension SiteFooter {
// MARK: Type aliases
/// A titled column of links.
typealias Column = (title: String, links: [(label: String, path: String)])
// MARK: Constants
/// The link columns, in reading order.
static let columns: [Column] = [
(
title: "Browse",
links: Format.allCases.map { ($0.label, $0.path) } + [
("By series", .Route.series)
]
),
(
title: "Sell",
links: [
("Open a shop", .Route.openShop),
("Seller dashboard", .Route.sellerDashboard),
("Grading guide", .Route.gradingGuide),
("Trade at events", .Route.events),
]
),
(
title: "Account",
links: [
("Sign in or join", .Route.signIn),
("Want list & alerts", .Route.wants),
("My collection", .Route.collection),
("Messages", .Route.messages),
("Passkeys & settings", .Route.account),
("Lost your passkey?", .Route.recover),
]
),
]
// MARK: Computed
/// The language toggle; display-only while the prototype serves English alone.
var language: some HTML {
div(.class("site-footer__column site-footer__language")) {
h2(.class("site-footer__heading")) {
"Language"
}
div(
.class("language-toggle"),
.custom(
name: "role",
value: "group"
),
.custom(
name: "aria-label",
value: "Language"
)
) {
button(
.class("language-toggle__option"),
.type(.button),
.custom(
name: "aria-pressed",
value: "false"
),
.disabled
) {
"NL"
}
button(
.class("language-toggle__option"),
.type(.button),
.custom(
name: "aria-pressed",
value: "true"
)
) {
"EN"
}
}
span(.class("site-footer__note")) {
"Prices in € · ships to NL"
}
}
}
}
@@ -0,0 +1,253 @@
import Elementary
/// The site header: the wordmark, the format navigation, the search field, and the viewer's actions.
///
/// One markup serves all three layouts; the shared stylesheet rearranges it through grid areas.
struct SiteHeader: HTML {
// MARK: Properties
/// The format marked as current in the navigation, or `nil` for none.
let active: Format?
/// The signed-in viewer, or `nil` to render the signed-out actions.
let viewer: Viewer?
// MARK: Initializers
/// Creates a site header.
/// - Parameters:
/// - active: the format marked as current, or `nil` (the default) for none.
/// - viewer: the signed-in viewer, or `nil` (the default) to render the signed-out actions.
init(
active: Format? = nil,
viewer: Viewer? = nil
) {
self.active = active
self.viewer = viewer
}
// MARK: Computed
var body: some HTML {
header(.class("site-header")) {
div(.class("site-header__inner")) {
a(
.class("site-header__brand"),
.href(.Route.home)
) {
"CCN"
}
navigation
search
div(.class("site-header__actions")) {
if let viewer {
actions(for: viewer)
} else {
guestActions
}
}
}
}
}
}
// MARK: - Helpers
private extension SiteHeader {
// MARK: Computed
/// The signed-out actions: *Open a shop* and the passkey sign-in.
@HTMLBuilder
var guestActions: some HTML {
a(
.class("site-header__icon-link phone-only"),
.href(.Route.openShop),
.custom(
name: "aria-label",
value: "Open a shop"
)
) {
Icon(.shop, size: 22)
}
a(
.class("site-header__text-link from-desktop"),
.href(.Route.openShop)
) {
"Open a shop"
}
a(
.class("site-header__sign-in"),
.href(.Route.signIn)
) {
Icon(.key, size: 15)
span {
"Sign in"
span(.class("from-desktop")) {
" with passkey"
}
}
}
}
/// The format links, plus the series and events links shown on tablet only.
var navigation: some HTML {
nav(
.class("site-nav"),
.custom(
name: "aria-label",
value: "Formats"
)
) {
for format in Format.allCases {
if format == active {
a(
.class("site-nav__link"),
.href(format.path),
.custom(
name: "aria-current",
value: "page"
)
) {
format.label
}
} else {
a(
.class("site-nav__link"),
.href(format.path)
) {
format.label
}
}
}
a(
.class("site-nav__link site-nav__link--extra"),
.href(.Route.series)
) {
"Series"
}
a(
.class("site-nav__link site-nav__link--extra"),
.href(.Route.events)
) {
"Events"
}
}
}
/// The search field, a plain `GET` form.
var search: some HTML {
form(
.class("site-search"),
.action(.Route.search),
.method(.get),
.custom(
name: "role",
value: "search"
)
) {
label(
.class("visually-hidden"),
.for("site-search-query")
) {
"Search listings"
}
input(
.class("site-search__input"),
.id("site-search-query"),
.type(.search),
.name("q"),
.placeholder("Search title, issue, grade or shop")
)
button(
.class("site-search__submit"),
.type(.submit),
.custom(
name: "aria-label",
value: "Search"
)
) {
Icon(.search, size: 16)
}
}
}
// MARK: Methods
/// The signed-in actions: want list, messages, shop, and account.
/// - Parameter viewer: the signed-in viewer.
@HTMLBuilder
func actions(
for viewer: Viewer
) -> some HTML {
counter(
.bell,
label: "Want list",
count: viewer.newWantMatches,
unit: "new matches",
path: .Route.wants
)
counter(
.message,
label: "Messages",
count: viewer.unreadMessages,
unit: "unread",
path: .Route.messages
)
a(
.class("site-header__text-link site-header__text-link--strong from-desktop"),
.href(.Route.sellerDashboard)
) {
"My shop"
}
a(
.class("site-header__account"),
.href(.Route.account),
.custom(
name: "aria-label",
value: "Account of \(viewer.displayName)"
)
) {
span(.class("from-desktop")) {
viewer.displayName
}
span(.class("site-header__avatar")) {
viewer.initials
}
}
}
/// An icon link with a count badge, omitted at zero.
/// - Parameters:
/// - glyph: the glyph the link shows.
/// - label: the link's accessible name.
/// - count: the number the badge shows.
/// - unit: what the count measures.
/// - path: the path the link leads to.
func counter(
_ glyph: Icon.Glyph,
label: String,
count: Int,
unit: String,
path: String
) -> some HTML {
a(
.class("site-header__icon-link"),
.href(path),
.custom(
name: "aria-label",
value: count > 0 ? "\(label), \(count) \(unit)" : label
)
) {
Icon(glyph)
if count > 0 {
span(.class("site-header__badge")) {
"\(count)"
}
}
}
}
}
@@ -0,0 +1,53 @@
import Elementary
/// The frame a page wraps its content in: a skip link, the ``SiteHeader``, the content inside `main`, and the ``SiteFooter``.
struct SiteLayout<Content: HTML>: HTML {
// MARK: Properties
/// The format marked as current in the header, or `nil` for none.
let active: Format?
/// The screen's markup, rendered inside the `main` landmark.
let content: Content
/// The signed-in viewer, or `nil` to render the signed-out header.
let viewer: Viewer?
// MARK: Initializers
/// Creates a site layout around a screen's content.
/// - Parameters:
/// - active: the format marked as current, or `nil` (the default) for none.
/// - viewer: the signed-in viewer, or `nil` (the default) to render the signed-out header.
/// - content: the screen's markup.
init(
active: Format? = nil,
viewer: Viewer? = nil,
@HTMLBuilder content: () -> Content
) {
self.active = active
self.content = content()
self.viewer = viewer
}
// MARK: Computed
var body: some HTML {
a(
.class("skip-link"),
.href("#main")
) {
"Skip to content"
}
SiteHeader(
active: active,
viewer: viewer
)
main(.id("main")) {
content
}
SiteFooter()
}
}