Library design: cards + redesigned list on the bookmarks screen, tag picker sheet #9
@@ -0,0 +1,629 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Library prototype
|
||||
//
|
||||
// A self-contained mock of the "Library" browse screen: paper background, serif
|
||||
// display type, monospaced chrome, and color blocks instead of thumbnails.
|
||||
// Nothing here is wired into the app yet — it drives its own `LibraryItem`
|
||||
// model so it can be previewed (and hosted in a scratch app) without touching
|
||||
// BookmarksView. Mapping `Bookmark` -> `LibraryItem` is the last step, not the
|
||||
// first: the point is to see whether color-as-index survives real data.
|
||||
|
||||
// MARK: Tokens
|
||||
|
||||
enum Paper {
|
||||
/// Dark mode is not an inversion of this palette — paper stock lit from a
|
||||
/// different angle. The ground keeps the same warm cast (it is brown-black,
|
||||
/// not neutral black) so the swatches sit on it the way ink sits on paper.
|
||||
static let sheet = dynamic(light: 0xF8F5EF, dark: 0x15130F)
|
||||
static let ink = dynamic(light: 0x14110C, dark: 0xF1ECE1)
|
||||
static var rule: Color { ink.opacity(0.28) }
|
||||
|
||||
/// Swatches lifted from the reference, each paired with a dark-mode
|
||||
/// counterpart. Order matters — items hash into it.
|
||||
///
|
||||
/// The dark variants are not the light ones dimmed uniformly. The pale end
|
||||
/// of the palette (shell, blush, pale blue) would glare as bright slabs
|
||||
/// against a dark ground, so it drops a long way; the dark end (forest,
|
||||
/// navy) would vanish into the ground, so it comes *up*. Both ends
|
||||
/// converge on the same mid band, which is what keeps twelve swatches
|
||||
/// distinguishable from each other in either scheme.
|
||||
static let swatchPairs: [(light: UInt32, dark: UInt32)] = [
|
||||
(0xFECD00, 0xD8AD10), // yellow
|
||||
(0xED663F, 0xC4552F), // vermilion
|
||||
(0xFE9D6B, 0xC87E53), // peach
|
||||
(0xAB6A1C, 0x8B5717), // ochre
|
||||
(0x033B00, 0x1F4D1B), // forest — lifted off the ground
|
||||
(0x001A55, 0x1E3167), // navy — lifted off the ground
|
||||
(0x115AB5, 0x1B5596), // blue
|
||||
(0xD4E0E8, 0x7C8E99), // pale blue — dropped hard
|
||||
(0xD4DCCF, 0x828E7C), // sage — dropped hard
|
||||
(0xE0D1BB, 0x94806A), // sand — dropped hard
|
||||
(0xF5D1BC, 0xA47A63), // blush — dropped hard
|
||||
(0xFDEDE0, 0x8E8175), // shell — dropped hard
|
||||
]
|
||||
|
||||
static func swatch(_ index: Int) -> Color {
|
||||
let pair = swatchPairs[index % swatchPairs.count]
|
||||
return dynamic(light: pair.light, dark: pair.dark)
|
||||
}
|
||||
|
||||
/// Text that stays legible on `swatch(index)`. Resolved per scheme rather
|
||||
/// than once, because a swatch can be light in one scheme and mid in the
|
||||
/// other — a single luminance test would get one of them wrong.
|
||||
static func inkOn(_ index: Int) -> Color {
|
||||
let pair = swatchPairs[index % swatchPairs.count]
|
||||
return Color(uiColor: UIColor { traits in
|
||||
let dark = traits.userInterfaceStyle == .dark
|
||||
let onLight = luminance(of: dark ? pair.dark : pair.light) > 0.55
|
||||
let text: UInt32 = onLight ? 0x14110C : (dark ? 0xF1ECE1 : 0xF8F5EF)
|
||||
return UIColor(rgb: text).withAlphaComponent(onLight ? 0.86 : 1)
|
||||
})
|
||||
}
|
||||
|
||||
static func dynamic(light: UInt32, dark: UInt32) -> Color {
|
||||
Color(uiColor: UIColor { traits in
|
||||
UIColor(rgb: traits.userInterfaceStyle == .dark ? dark : light)
|
||||
})
|
||||
}
|
||||
|
||||
static func luminance(of hex: UInt32) -> Double {
|
||||
let r = Double((hex >> 16) & 0xFF) / 255
|
||||
let g = Double((hex >> 8) & 0xFF) / 255
|
||||
let b = Double(hex & 0xFF) / 255
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
}
|
||||
|
||||
extension UIColor {
|
||||
convenience init(rgb: UInt32) {
|
||||
self.init(
|
||||
red: CGFloat((rgb >> 16) & 0xFF) / 255,
|
||||
green: CGFloat((rgb >> 8) & 0xFF) / 255,
|
||||
blue: CGFloat(rgb & 0xFF) / 255,
|
||||
alpha: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Model
|
||||
|
||||
struct LibraryItem: Identifiable, Hashable {
|
||||
let id: Int
|
||||
var title: String
|
||||
/// The bottom-left mono stamp. The reference used a publication year; real
|
||||
/// linkding data is all 2025–2026, so the year carries no signal and the
|
||||
/// domain takes the slot instead.
|
||||
var stamp: String
|
||||
var source: String
|
||||
var tags: [String]
|
||||
/// nil = derive from the primary tag, so an untouched library still reads
|
||||
/// as color-coded by subject rather than as noise.
|
||||
var colorIndex: Int?
|
||||
/// The page never gave us a title. Rendering the raw URL inside curly
|
||||
/// quotes reads as a quotation that isn't one, so these skip the quotes.
|
||||
var isUntitled = false
|
||||
|
||||
var swatch: Color { Paper.swatch(resolvedIndex) }
|
||||
var inkOnSwatch: Color { Paper.inkOn(resolvedIndex) }
|
||||
|
||||
/// What a card shows. Cards get one clause; the list gets the whole title.
|
||||
/// Real titles are overwhelmingly "name: what it does" — at card width the
|
||||
/// name is the identifier and the blurb is filler, so past 60 characters we
|
||||
/// keep the name and let the list carry the rest.
|
||||
var cardTitle: String {
|
||||
if title.count > 60,
|
||||
let colon = title.range(of: ": "),
|
||||
title.distance(from: title.startIndex, to: colon.lowerBound) <= 40 {
|
||||
return String(title[..<colon.lowerBound])
|
||||
}
|
||||
if title.count > 56 {
|
||||
return String(title.prefix(56)).trimmingCharacters(in: .whitespaces) + "…"
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
/// Cards quote the title the way the reference does — except when there is
|
||||
/// no real title to quote.
|
||||
var cardDisplay: String { isUntitled ? cardTitle : "“\(cardTitle)”" }
|
||||
var listDisplay: String { isUntitled ? title : "“\(title)”" }
|
||||
|
||||
private var resolvedIndex: Int {
|
||||
if let colorIndex { return colorIndex % Paper.swatchPairs.count }
|
||||
// Tag first: 128 of 300 real bookmarks are github.com, so hashing the
|
||||
// domain would paint half the library one color. Tags spread wider
|
||||
// (top tag is 32 items). Untagged falls back to source.
|
||||
let seed = tags.first ?? source
|
||||
return abs(seed.unicodeScalars.reduce(5381) { ($0 &* 33) &+ Int($1.value) })
|
||||
% Paper.swatchPairs.count
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Bookmark -> LibraryItem
|
||||
|
||||
extension LibraryItem {
|
||||
init(bookmark: Bookmark) {
|
||||
self.id = bookmark.id
|
||||
self.source = bookmark.domain.replacingOccurrences(of: "www.", with: "")
|
||||
self.tags = bookmark.tagNames
|
||||
self.colorIndex = nil
|
||||
|
||||
// `displayTitle` falls back to the raw URL when linkding scraped no
|
||||
// title — 15 of 300 real bookmarks. Show the domain instead and hand
|
||||
// the stamp slot the path, so both slots still say something.
|
||||
let raw = bookmark.displayTitle
|
||||
if raw.hasPrefix("http") {
|
||||
self.isUntitled = true
|
||||
self.title = self.source
|
||||
self.stamp = Self.path(of: bookmark.url)
|
||||
} else {
|
||||
self.title = Self.clean(raw)
|
||||
self.stamp = Self.registered(self.source)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cards give the stamp one 11pt monospaced line, roughly 14 characters.
|
||||
/// 92 of 300 real domains are longer than that, so drop the subdomain:
|
||||
/// `toolkit.artlist.io` -> `artlist.io`. The list still shows it in full.
|
||||
static func registered(_ host: String) -> String {
|
||||
let parts = host.split(separator: ".")
|
||||
guard parts.count > 2 else { return host }
|
||||
// Two-part public suffixes (.co.uk, .com.au) need one more label.
|
||||
let secondLevel: Set<String> = ["co", "com", "net", "org", "ac", "gov", "edu"]
|
||||
let keep = secondLevel.contains(String(parts[parts.count - 2])) ? 3 : 2
|
||||
return parts.suffix(keep).joined(separator: ".")
|
||||
}
|
||||
|
||||
/// Returned whole: the stamp label middle-truncates, and pre-clipping here
|
||||
/// too would elide it twice ("/share…fo/194…").
|
||||
static func path(of url: String) -> String {
|
||||
guard let p = URL(string: url)?.path, p != "/", !p.isEmpty else { return "—" }
|
||||
return p
|
||||
}
|
||||
|
||||
/// Linkding stores whatever the page's <title> said, which for the bulk of a
|
||||
/// real library means "GitHub - owner/repo: <the entire README blurb>".
|
||||
/// Median real title is 66 chars and the 90th percentile is 159 — the
|
||||
/// reference design assumed ~30. Strip the boilerplate, then clamp.
|
||||
static func clean(_ raw: String) -> String {
|
||||
var t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// "GitHub - owner/repo: blurb" -> "repo: blurb"
|
||||
if t.hasPrefix("GitHub - ") {
|
||||
t = String(t.dropFirst("GitHub - ".count))
|
||||
if let slash = t.firstIndex(of: "/"),
|
||||
let colon = t.firstIndex(of: ":"), slash < colon {
|
||||
t = String(t[t.index(after: slash)...])
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing site furniture: "Title | Publisher", "Title - Latent.Space".
|
||||
// Only strip a short trailing fragment off a title with something left
|
||||
// over, so hyphenated titles survive.
|
||||
for sep in [" | ", " · ", " — ", " – ", " - "] {
|
||||
if let r = t.range(of: sep, options: .backwards),
|
||||
t.distance(from: r.upperBound, to: t.endIndex) < 24,
|
||||
t.distance(from: t.startIndex, to: r.lowerBound) > 12 {
|
||||
t = String(t[..<r.lowerBound])
|
||||
}
|
||||
}
|
||||
|
||||
// Generous clamp: this is the list-mode title. `cardTitle` cuts harder.
|
||||
if t.count > 120 {
|
||||
t = String(t.prefix(120)).trimmingCharacters(in: .whitespaces) + "…"
|
||||
}
|
||||
return t.isEmpty ? "Untitled" : t
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryFilter: Identifiable, Hashable {
|
||||
let id = UUID()
|
||||
var name: String
|
||||
/// Empty = "everything", the tab you can't close.
|
||||
var tag: String?
|
||||
}
|
||||
|
||||
// MARK: Display mode
|
||||
|
||||
/// Two modes, not three. A 4-column grid was in the first pass and died on real
|
||||
/// data: with a median title of 66 characters every tile truncated mid-word, so
|
||||
/// it read as a wall of clipped text rather than as color.
|
||||
enum LibraryLayout: String, CaseIterable, Identifiable {
|
||||
case cards, list
|
||||
var id: String { rawValue }
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .cards: "rectangle.inset.filled"
|
||||
case .list: "line.3.horizontal"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Screen
|
||||
|
||||
struct LibraryView: View {
|
||||
@State private var items: [LibraryItem]
|
||||
@State private var filters: [LibraryFilter]
|
||||
@State private var selectedFilter: LibraryFilter.ID?
|
||||
@State private var layout: LibraryLayout = .cards
|
||||
@State private var searching = false
|
||||
@State private var query = ""
|
||||
@State private var appeared = false
|
||||
@FocusState private var searchFocused: Bool
|
||||
@Namespace private var blocks
|
||||
|
||||
init(items: [LibraryItem] = .sample, filters: [LibraryFilter]? = nil) {
|
||||
_items = State(initialValue: items)
|
||||
_filters = State(initialValue: filters ?? Self.defaultFilters(for: items))
|
||||
}
|
||||
|
||||
/// With 96 real tags there is no sensible "all tabs" answer — open on
|
||||
/// Everything plus the five heaviest tags, and let `+` pin the rest.
|
||||
static func defaultFilters(for items: [LibraryItem]) -> [LibraryFilter] {
|
||||
var counts: [String: Int] = [:]
|
||||
for item in items { for tag in item.tags { counts[tag, default: 0] += 1 } }
|
||||
let top = counts.sorted { ($0.value, $1.key) > ($1.value, $0.key) }.prefix(5)
|
||||
return [LibraryFilter(name: "Everything", tag: nil)]
|
||||
+ top.map { LibraryFilter(name: $0.key, tag: $0.key) }
|
||||
}
|
||||
|
||||
private var visibleItems: [LibraryItem] {
|
||||
let tag = filters.first { $0.id == selectedFilter }?.tag
|
||||
return items.filter { item in
|
||||
let matchesTag = tag.map { item.tags.contains($0) } ?? true
|
||||
let matchesQuery = query.isEmpty
|
||||
|| item.title.localizedCaseInsensitiveContains(query)
|
||||
|| item.source.localizedCaseInsensitiveContains(query)
|
||||
return matchesTag && matchesQuery
|
||||
}
|
||||
}
|
||||
|
||||
/// Tags not already pinned as a tab — the menu behind `+`.
|
||||
private var unusedTags: [String] {
|
||||
let taken = Set(filters.compactMap(\.tag))
|
||||
return Set(items.flatMap(\.tags)).subtracting(taken).sorted()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
filterStrip
|
||||
content
|
||||
}
|
||||
.background(Paper.sheet)
|
||||
.task {
|
||||
selectedFilter = filters.first?.id
|
||||
withAnimation(.easeOut(duration: 0.45)) { appeared = true }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Header
|
||||
|
||||
private var header: some View {
|
||||
VStack(spacing: 14) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Library")
|
||||
.font(.system(size: 34, weight: .regular, design: .serif))
|
||||
.foregroundStyle(Paper.ink)
|
||||
Spacer(minLength: 12)
|
||||
Button {
|
||||
withAnimation(.spring(duration: 0.3, bounce: 0.15)) {
|
||||
searching.toggle()
|
||||
if !searching { query = "" }
|
||||
}
|
||||
searchFocused = searching
|
||||
} label: {
|
||||
Image(systemName: searching ? "xmark" : "magnifyingglass")
|
||||
.font(.system(size: 15, weight: .light))
|
||||
.foregroundStyle(Paper.ink)
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
layoutToggle
|
||||
}
|
||||
|
||||
if searching {
|
||||
VStack(spacing: 5) {
|
||||
TextField("", text: $query, prompt: searchPrompt)
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.foregroundStyle(Paper.ink)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.focused($searchFocused)
|
||||
Rectangle().fill(Paper.rule).frame(height: 0.6)
|
||||
}
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
|
||||
private var searchPrompt: Text {
|
||||
Text("search the library")
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.foregroundColor(Paper.ink.opacity(0.35))
|
||||
}
|
||||
|
||||
private var layoutToggle: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(LibraryLayout.allCases) { option in
|
||||
let active = layout == option
|
||||
Button {
|
||||
withAnimation(.spring(duration: 0.42, bounce: 0.12)) { layout = option }
|
||||
} label: {
|
||||
Image(systemName: option.symbol)
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundStyle(active ? Paper.sheet : Paper.ink.opacity(0.55))
|
||||
.frame(width: 34, height: 26)
|
||||
.background {
|
||||
if active {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(Paper.ink)
|
||||
.padding(2)
|
||||
.matchedGeometryEffect(id: "layoutPill", in: blocks)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 7).stroke(Paper.rule, lineWidth: 0.6)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Filter tabs
|
||||
|
||||
private var filterStrip: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(Paper.rule)
|
||||
.frame(height: 0.6)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(filters) { filter in
|
||||
tab(filter)
|
||||
}
|
||||
Menu {
|
||||
ForEach(unusedTags, id: \.self) { tag in
|
||||
Button(tag) { addFilter(tag: tag) }
|
||||
}
|
||||
if !filters.contains(where: { $0.tag == nil }) {
|
||||
Divider()
|
||||
Button("Everything") { addFilter(tag: nil) }
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 12, weight: .light))
|
||||
.foregroundStyle(Paper.ink.opacity(0.6))
|
||||
.frame(width: 38, height: 30)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
}
|
||||
}
|
||||
.frame(height: 30)
|
||||
}
|
||||
|
||||
private func tab(_ filter: LibraryFilter) -> some View {
|
||||
let active = filter.id == selectedFilter
|
||||
return HStack(spacing: 7) {
|
||||
Text(filter.name)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(active ? Paper.ink : Paper.ink.opacity(0.45))
|
||||
.lineLimit(1)
|
||||
if filters.count > 1 {
|
||||
Button {
|
||||
withAnimation(.spring(duration: 0.3, bounce: 0)) { close(filter) }
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 8, weight: .medium))
|
||||
.foregroundStyle(Paper.ink.opacity(active ? 0.5 : 0.25))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 11)
|
||||
.frame(height: 30)
|
||||
.background(alignment: .bottom) {
|
||||
if active {
|
||||
// Paper fill sits 0.6pt proud so it erases the strip rule
|
||||
// beneath the live tab — the browser-tab read.
|
||||
UnevenRoundedRectangle(
|
||||
topLeadingRadius: 6, bottomLeadingRadius: 0,
|
||||
bottomTrailingRadius: 0, topTrailingRadius: 6
|
||||
)
|
||||
.fill(Paper.sheet)
|
||||
.overlay(TabOutline().stroke(Paper.rule, lineWidth: 0.6))
|
||||
.padding(.bottom, -0.6)
|
||||
.matchedGeometryEffect(id: "tab", in: blocks)
|
||||
}
|
||||
}
|
||||
.contentShape(.rect)
|
||||
.onTapGesture {
|
||||
withAnimation(.spring(duration: 0.35, bounce: 0.1)) { selectedFilter = filter.id }
|
||||
}
|
||||
}
|
||||
|
||||
private func addFilter(tag: String?) {
|
||||
let new = LibraryFilter(name: tag?.capitalized ?? "Everything", tag: tag)
|
||||
withAnimation(.spring(duration: 0.35, bounce: 0.1)) {
|
||||
filters.append(new)
|
||||
selectedFilter = new.id
|
||||
}
|
||||
}
|
||||
|
||||
private func close(_ filter: LibraryFilter) {
|
||||
filters.removeAll { $0.id == filter.id }
|
||||
if selectedFilter == filter.id { selectedFilter = filters.first?.id }
|
||||
}
|
||||
|
||||
// MARK: Content
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
ScrollView {
|
||||
Group {
|
||||
switch layout {
|
||||
case .cards: cardGrid
|
||||
case .list: listRows
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
}
|
||||
|
||||
private var cardGrid: some View {
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3),
|
||||
spacing: 8
|
||||
) {
|
||||
ForEach(Array(visibleItems.enumerated()), id: \.element.id) { index, item in
|
||||
LibraryCard(item: item)
|
||||
.matchedGeometryEffect(id: item.id, in: blocks)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.offset(y: appeared ? 0 : 10)
|
||||
.animation(
|
||||
.easeOut(duration: 0.4).delay(Double(index) * 0.028),
|
||||
value: appeared
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var listRows: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(visibleItems) { item in
|
||||
HStack(spacing: 12) {
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(item.swatch)
|
||||
.frame(width: 26, height: 34)
|
||||
.matchedGeometryEffect(id: item.id, in: blocks)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(item.listDisplay)
|
||||
.font(.system(size: 13, design: .serif))
|
||||
.foregroundStyle(Paper.ink)
|
||||
.lineLimit(1)
|
||||
Text(item.source)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(Paper.ink.opacity(0.45))
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
// The card's stamp is the domain, which list mode already
|
||||
// shows as the subtitle — so the right column carries the
|
||||
// primary tag instead of repeating it.
|
||||
Text(item.tags.first ?? item.stamp)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(Paper.ink.opacity(0.55))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.vertical, 11)
|
||||
Rectangle().fill(Paper.rule.opacity(0.5)).frame(height: 0.6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card
|
||||
|
||||
private struct LibraryCard: View {
|
||||
let item: LibraryItem
|
||||
@State private var pressed = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(item.cardDisplay)
|
||||
.font(.system(size: 11, design: .serif))
|
||||
.foregroundStyle(item.inkOnSwatch)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(5)
|
||||
.minimumScaleFactor(0.85)
|
||||
Spacer(minLength: 4)
|
||||
Text(item.stamp)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(item.inkOnSwatch)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.padding(9)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.aspectRatio(0.70, contentMode: .fit)
|
||||
.background(RoundedRectangle(cornerRadius: 5).fill(item.swatch))
|
||||
.scaleEffect(pressed ? 0.965 : 1)
|
||||
.animation(.spring(duration: 0.2, bounce: 0), value: pressed)
|
||||
.onLongPressGesture(minimumDuration: 0, pressing: { pressed = $0 }, perform: {})
|
||||
}
|
||||
}
|
||||
|
||||
/// Open path: up the left edge, across the top, down the right — no bottom
|
||||
/// stroke, so the tab merges into the page.
|
||||
private struct TabOutline: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let r: CGFloat = 6
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: rect.minX, y: rect.maxY))
|
||||
p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + r))
|
||||
p.addQuadCurve(
|
||||
to: CGPoint(x: rect.minX + r, y: rect.minY),
|
||||
control: CGPoint(x: rect.minX, y: rect.minY)
|
||||
)
|
||||
p.addLine(to: CGPoint(x: rect.maxX - r, y: rect.minY))
|
||||
p.addQuadCurve(
|
||||
to: CGPoint(x: rect.maxX, y: rect.minY + r),
|
||||
control: CGPoint(x: rect.maxX, y: rect.minY)
|
||||
)
|
||||
p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sample data
|
||||
|
||||
extension Array where Element == LibraryItem {
|
||||
static var sample: [LibraryItem] {
|
||||
[
|
||||
.init(id: 1, title: "The Way of the Shogun", stamp: "1833",
|
||||
source: "Edo Historical Review", tags: ["meiji", "shogunate"], colorIndex: 0),
|
||||
.init(id: 2, title: "Feudal Procession sets out from Nihonbashi in Edo 1869",
|
||||
stamp: "1869", source: "Nihonbashi Archive", tags: ["meiji", "edo"], colorIndex: 2),
|
||||
.init(id: 3, title: "The Hamlet of Otsumago and its People", stamp: "1836",
|
||||
source: "Times Daily National Intelligencer", tags: ["meiji", "villages"],
|
||||
colorIndex: 1),
|
||||
.init(id: 4, title: "Meiji Restoration Period of Crisis", stamp: "1923",
|
||||
source: "Kyoto Press", tags: ["meiji", "restoration"], colorIndex: 3),
|
||||
.init(id: 5, title: "Loss of Our Traditions Cause Civil Unrest", stamp: "1903",
|
||||
source: "Osaka Herald", tags: ["meiji", "unrest"], colorIndex: 6),
|
||||
.init(id: 6, title: "Musogukai & The Revenant Demons", stamp: "1893",
|
||||
source: "Folklore Quarterly", tags: ["meiji", "folklore"], colorIndex: 8),
|
||||
.init(id: 7, title: "It Will Never Be the Same Here Again", stamp: "1833",
|
||||
source: "Letters from Otsumago", tags: ["meiji", "letters"], colorIndex: 5),
|
||||
.init(id: 8, title: "Feudal Lords Clash Over Unchartered Territories", stamp: "1869",
|
||||
source: "Provincial Record", tags: ["meiji", "shogunate"], colorIndex: 7),
|
||||
.init(id: 9, title: "Woodblock Printing in the Late Tokugawa", stamp: "1841",
|
||||
source: "Ukiyo-e Studies", tags: ["printing", "edo"], colorIndex: 9),
|
||||
.init(id: 10, title: "Rice Riots and the Merchant Class", stamp: "1918",
|
||||
source: "Economic Histories", tags: ["unrest", "trade"], colorIndex: 4),
|
||||
.init(id: 11, title: "Correspondence of a Provincial Magistrate", stamp: "1877",
|
||||
source: "Letters from Otsumago", tags: ["letters"], colorIndex: 10),
|
||||
.init(id: 12, title: "Mountain Roads of the Nakasendō", stamp: "1852",
|
||||
source: "Survey Notes", tags: ["villages", "edo"], colorIndex: 11),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Library") {
|
||||
LibraryView()
|
||||
}
|
||||
|
||||
#Preview("Library — Dark") {
|
||||
LibraryView()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
Reference in New Issue
Block a user