Adds a cards/list toggle to the bookmarks screen. List mode is the existing screen, untouched and still the default; cards mode is the library presentation running on real bookmarks. Structure: - LibraryKit.swift holds what both the shipping screen and the prototype draw from — the paper palette, the Bookmark -> LibraryItem projection, the color card, and the tab strip. The prototype in Views/Prototypes keeps only its standalone chrome and sample data, so the two can no longer drift. - BookmarkActions.swift extracts the context menu and the podcast launch path out of BookmarkListRow. Cards and rows now offer exactly the same actions because they are the same code. The menu is a @ViewBuilder rather than a ViewModifier: each host already owns the sheets it presents, and a modifier would have forced a second copy of that state. - LibraryGridView.swift is the Bookmark-driven grid, with the same tap-to -open, long-press-for-menu, pagination and podcast sheets as the list. Two behaviors worth calling out: Tag tabs filter server-side through linkding's `#tag` search syntax. Filtering the loaded page client-side would only ever search the most recent 50 of 600+ bookmarks and quietly look empty; verified against the server that `q=#dev-tools` returns 64 tagged results where `q=dev-tools` returns 0. Leaving cards mode clears any tag filter. The classic list has no filter strip to display one, so a filter that survived the switch would be invisible and the list would look like it had lost bookmarks. The layout choice persists in @AppStorage. The toolbar button shows the layout it switches *to* — a two-state toggle labelled with its current state reads as a status light rather than a control. Verified on an iPhone 17 Pro simulator against the live linkding server in both layouts and both color schemes. Full test suite passes (16 tests). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
399 lines
15 KiB
Swift
399 lines
15 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - Library design kit
|
||
//
|
||
// The vocabulary the library presentation is built from: a paper palette, the
|
||
// Bookmark -> LibraryItem projection, and the two pieces of chrome (color card,
|
||
// browser-tab filter strip) shared by the real screen and the standalone
|
||
// prototype in Views/Prototypes/LibraryView.swift.
|
||
|
||
// 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
|
||
}
|
||
}
|
||
|
||
// MARK: Filters
|
||
|
||
struct LibraryFilter: Identifiable, Hashable {
|
||
let id = UUID()
|
||
var name: String
|
||
/// nil = "everything", the tab you can't close.
|
||
var tag: String?
|
||
}
|
||
|
||
/// 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: - Card
|
||
|
||
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: {})
|
||
}
|
||
}
|
||
|
||
// MARK: - Filter strip
|
||
|
||
/// The browser-tab strip of saved filters. Tabs sit on a hairline that the live
|
||
/// tab erases, which is what sells the metaphor.
|
||
struct LibraryTagStrip: View {
|
||
@Binding var filters: [LibraryFilter]
|
||
@Binding var selection: LibraryFilter.ID?
|
||
/// Tags available to pin that aren't already open.
|
||
let available: [String]
|
||
var onChange: () -> Void = {}
|
||
|
||
@Namespace private var strip
|
||
|
||
var body: 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(available, id: \.self) { tag in
|
||
Button(tag) { add(tag: tag) }
|
||
}
|
||
if !filters.contains(where: { $0.tag == nil }) {
|
||
Divider()
|
||
Button("Everything") { add(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 == selection
|
||
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: strip)
|
||
}
|
||
}
|
||
.contentShape(.rect)
|
||
.onTapGesture {
|
||
guard filter.id != selection else { return }
|
||
withAnimation(.spring(duration: 0.35, bounce: 0.1)) { selection = filter.id }
|
||
onChange()
|
||
}
|
||
}
|
||
|
||
private func add(tag: String?) {
|
||
let new = LibraryFilter(name: tag ?? "Everything", tag: tag)
|
||
withAnimation(.spring(duration: 0.35, bounce: 0.1)) {
|
||
filters.append(new)
|
||
selection = new.id
|
||
}
|
||
onChange()
|
||
}
|
||
|
||
private func close(_ filter: LibraryFilter) {
|
||
filters.removeAll { $0.id == filter.id }
|
||
if selection == filter.id {
|
||
selection = filters.first?.id
|
||
onChange()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Open path: up the left edge, across the top, down the right — no bottom
|
||
/// stroke, so the tab merges into the page.
|
||
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
|
||
}
|
||
}
|