Every screen now speaks the paper vocabulary rather than only the bookmarks screen: Search, Sources, Podcasts and the player, Settings, Add/Edit, Smart Collections, Ask, Onboarding, the browser toolbar, the Siri snippets, and the share extension's save card. The change is mostly a design system rather than per-screen tweaks. LibraryKit gains: - Semantic colors — secondary/tertiary/faint text, `raised` surfaces, and accent/alarm/affirm, so screens stop reaching for .secondary, systemGray, .blue, .red and .green. The three status colors come out of the palette (the swatch blue, vermilion and forest) rather than from the system set, so the design keeps spending one set of inks. - PaperType — the two voices made explicit. Prose (titles, summaries, anything a person wrote) is serif; anything the machine contributes (domains, dates, counts, tags, labels, buttons) is monospaced. Keeping that split strict is what makes the design read as archival rather than as decoration. - paperSurface() / paperField() / paperCard() for grounds, inputs and raised blocks. - PaperEmptyState, because ContentUnavailableView can't be restyled — it draws its own bold system type and grey, which was the loudest remaining system voice once the screens moved onto the sheet. All nine usages are converted. The app also sets one accent for the system chrome it doesn't draw — tab bar, search fields, switches, swipe actions — which otherwise stayed system blue around paper screens. BookmarkRow is deleted. Once Search moved to the library row and TagBookmarksView was gone, nothing passed .classic, so the style fork in BookmarkListRow went with it. There is one bookmark row now. The share extension compiles LibraryKit directly, since its save card is a user-facing surface and should not be the one place still using system styling. Verified on device in both color schemes; screens toured with a temporary UI test harness (removed). Suite passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
347 lines
13 KiB
Swift
347 lines
13 KiB
Swift
import SwiftUI
|
|
|
|
/// Interactive save card shown by the share extension. On open it checks whether
|
|
/// the URL is already bookmarked, then lets the user set/edit tags, notes, and a
|
|
/// read-later flag before saving (new) or updating (existing) — never a duplicate.
|
|
struct ShareView: View {
|
|
let url: String
|
|
let initialTitle: String?
|
|
let initialNotes: String
|
|
/// Called when the extension should dismiss. `saved` is true after a successful
|
|
/// write, false on cancel.
|
|
let onClose: (_ saved: Bool) -> Void
|
|
|
|
@State private var phase: Phase = .loading
|
|
@State private var existing: Bookmark?
|
|
@State private var title = ""
|
|
@State private var tagText = ""
|
|
@State private var notes = ""
|
|
@State private var readLater = false
|
|
@State private var createPodcast = false
|
|
@State private var suggestedTags: [String] = [] // linkding auto_tags
|
|
@State private var knownTags: Set<String> = [] // user's existing vocabulary (lowercased)
|
|
@State private var aiSuggestions: [String] = [] // net-new AI ideas (not in vocabulary)
|
|
@State private var suggesting = false
|
|
@State private var errorMessage = ""
|
|
|
|
@FocusState private var tagsFocused: Bool
|
|
|
|
private let api: LinkdingAPI?
|
|
|
|
init(url: String, initialTitle: String? = nil, initialNotes: String = "", onClose: @escaping (_ saved: Bool) -> Void) {
|
|
self.url = url
|
|
self.initialTitle = initialTitle
|
|
self.initialNotes = initialNotes
|
|
self.onClose = onClose
|
|
self.api = ServerConfig.loadShared().map(LinkdingAPI.init)
|
|
_title = State(initialValue: initialTitle ?? "")
|
|
_notes = State(initialValue: initialNotes)
|
|
}
|
|
|
|
private enum Phase { case loading, editing, saving, done, failed }
|
|
|
|
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
|
let f = RelativeDateTimeFormatter()
|
|
f.unitsStyle = .abbreviated
|
|
f.dateTimeStyle = .named
|
|
return f
|
|
}()
|
|
|
|
var body: some View {
|
|
VStack {
|
|
Spacer()
|
|
card
|
|
.frame(maxWidth: 460)
|
|
.padding(.horizontal, 20)
|
|
Spacer()
|
|
}
|
|
.task { await check() }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var card: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
switch phase {
|
|
case .loading:
|
|
HStack(spacing: 12) {
|
|
ProgressView()
|
|
Text("Checking…").font(PaperType.meta).foregroundStyle(Paper.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.vertical, 24)
|
|
|
|
case .editing, .saving:
|
|
header
|
|
editor
|
|
actions
|
|
|
|
case .done:
|
|
statusCard(icon: "checkmark.circle.fill", tint: .green,
|
|
text: existing != nil ? "Updated" : "Saved")
|
|
|
|
case .failed:
|
|
statusCard(icon: "xmark.circle.fill", tint: .red,
|
|
text: errorMessage.isEmpty ? "Something went wrong" : errorMessage)
|
|
}
|
|
}
|
|
.padding(20)
|
|
.background(Paper.sheet, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
.stroke(Paper.rule.opacity(0.5), lineWidth: 0.6)
|
|
)
|
|
.shadow(color: .black.opacity(0.15), radius: 24, y: 8)
|
|
}
|
|
|
|
// MARK: - Sections
|
|
|
|
private var header: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
if let existing {
|
|
Label {
|
|
Text(existing.dateAdded == .distantPast
|
|
? "Already saved"
|
|
: "Already saved · \(Self.relativeFormatter.localizedString(for: existing.dateAdded, relativeTo: Date()))")
|
|
} icon: {
|
|
Image(systemName: "checkmark.circle.fill").foregroundStyle(Paper.affirm)
|
|
}
|
|
.font(PaperType.label)
|
|
.foregroundStyle(Paper.ink)
|
|
} else {
|
|
Text("Save bookmark").font(PaperType.heading).foregroundStyle(Paper.ink)
|
|
}
|
|
|
|
Text(title.isEmpty ? url : title)
|
|
.font(PaperType.quote)
|
|
.foregroundStyle(Paper.ink)
|
|
.lineLimit(2)
|
|
Text(domain)
|
|
.font(PaperType.micro)
|
|
.foregroundStyle(Paper.tertiary)
|
|
}
|
|
}
|
|
|
|
private var editor: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
TextField("Tags (space separated)", text: $tagText)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.focused($tagsFocused)
|
|
.textFieldStyle(.roundedBorder)
|
|
|
|
if suggesting && aiSuggestions.isEmpty {
|
|
HStack(spacing: 6) {
|
|
ProgressView().controlSize(.small)
|
|
Text("Suggesting tags…")
|
|
.font(PaperType.micro)
|
|
.foregroundStyle(Paper.tertiary)
|
|
}
|
|
}
|
|
|
|
if !suggestionChips.isEmpty {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 6) {
|
|
ForEach(suggestionChips, id: \.tag) { item in
|
|
Button { addTag(item.tag) } label: {
|
|
Text(item.isAI ? "✨ \(item.tag)" : "+ \(item.tag)")
|
|
.font(PaperType.micro)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 5)
|
|
.background((item.isAI ? Paper.accent : Paper.ink).opacity(0.1), in: Capsule())
|
|
.foregroundStyle(item.isAI ? Paper.accent : Paper.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
TextField("Notes", text: $notes, axis: .vertical)
|
|
.lineLimit(1...3)
|
|
.textFieldStyle(.roundedBorder)
|
|
|
|
Toggle("Read later", isOn: $readLater)
|
|
.font(PaperType.meta)
|
|
.foregroundStyle(Paper.secondary)
|
|
|
|
Toggle(isOn: $createPodcast) {
|
|
Label("Create podcast", systemImage: "headphones")
|
|
}
|
|
.font(PaperType.meta)
|
|
.foregroundStyle(Paper.secondary)
|
|
}
|
|
}
|
|
|
|
private var actions: some View {
|
|
HStack(spacing: 12) {
|
|
Button("Cancel") { onClose(false) }
|
|
.buttonStyle(.bordered)
|
|
.frame(maxWidth: .infinity)
|
|
|
|
Button {
|
|
Task { await commit() }
|
|
} label: {
|
|
Group {
|
|
if phase == .saving { ProgressView().tint(.white) }
|
|
else { Text(existing != nil ? "Update" : "Save") }
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(phase == .saving)
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
private func statusCard(icon: String, tint: Color, text: String) -> some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: icon).font(.system(size: 22)).foregroundStyle(tint)
|
|
Text(text).font(PaperType.heading).foregroundStyle(Paper.ink)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.vertical, 16)
|
|
}
|
|
|
|
// MARK: - Derived
|
|
|
|
private var domain: String { URL(string: url)?.host ?? url }
|
|
|
|
/// Tappable suggestion chips: net-new AI ideas (✨) followed by linkding's
|
|
/// auto_tags (+), excluding anything already in the field, de-duplicated.
|
|
private var suggestionChips: [(tag: String, isAI: Bool)] {
|
|
let current = Set(parsedTags.map { $0.lowercased() })
|
|
var seen = Set<String>()
|
|
var out: [(tag: String, isAI: Bool)] = []
|
|
for tag in aiSuggestions + suggestedTags {
|
|
let key = tag.lowercased()
|
|
let isAI = aiSuggestions.contains { $0.lowercased() == key }
|
|
if !current.contains(key) && seen.insert(key).inserted {
|
|
out.append((tag, isAI))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
private var parsedTags: [String] {
|
|
tagText.split(whereSeparator: { $0 == " " || $0 == "," })
|
|
.map { String($0) }
|
|
.filter { !$0.isEmpty }
|
|
}
|
|
|
|
private func addTag(_ tag: String) {
|
|
let trimmed = tagText.trimmingCharacters(in: .whitespaces)
|
|
tagText = trimmed.isEmpty ? tag : trimmed + " " + tag
|
|
tagText += " "
|
|
}
|
|
|
|
// MARK: - Networking
|
|
|
|
private func check() async {
|
|
guard let parsed = URL(string: url),
|
|
let scheme = parsed.scheme?.lowercased(),
|
|
scheme == "http" || scheme == "https",
|
|
parsed.host?.isEmpty == false else {
|
|
errorMessage = "No bookmarkable link found"
|
|
phase = .failed
|
|
return
|
|
}
|
|
guard let api else {
|
|
errorMessage = "Open Marks first"
|
|
phase = .failed
|
|
return
|
|
}
|
|
do {
|
|
let result = try await api.checkBookmark(url: url)
|
|
if let bookmark = result.bookmark {
|
|
existing = bookmark
|
|
title = bookmark.displayTitle
|
|
tagText = bookmark.tagNames.joined(separator: " ")
|
|
notes = bookmark.description
|
|
readLater = bookmark.unread
|
|
} else {
|
|
title = initialTitle ?? result.metadata?.title ?? ""
|
|
suggestedTags = result.autoTags ?? []
|
|
if notes.isEmpty {
|
|
notes = result.metadata?.description ?? initialNotes
|
|
}
|
|
}
|
|
// Pre-enable the toggle when the current tags match the auto-rule.
|
|
createPodcast = PodcastRequests.matchesAutoTag(parsedTags)
|
|
phase = .editing
|
|
await loadSuggestions()
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
phase = .failed
|
|
}
|
|
}
|
|
|
|
/// Fetches the user's existing tag vocabulary and AI tag ideas in parallel,
|
|
/// then applies the hybrid behavior: auto-fill suggestions that match the
|
|
/// existing vocabulary (new bookmarks only), surface the rest as chips.
|
|
/// Best-effort — never throws, never blocks saving.
|
|
private func loadSuggestions() async {
|
|
guard let api else { return }
|
|
suggesting = true
|
|
defer { suggesting = false }
|
|
|
|
async let vocabTask = api.fetchTags()
|
|
let ai = await TagSuggester.suggest(url: url, title: title)
|
|
let vocab = (try? await vocabTask) ?? []
|
|
knownTags = Set(vocab.map { $0.lowercased() })
|
|
|
|
if existing == nil {
|
|
// Auto-fill AI tags the user already uses; high confidence, low surprise.
|
|
let current = Set(parsedTags.map { $0.lowercased() })
|
|
for tag in ai where knownTags.contains(tag.lowercased()) && !current.contains(tag.lowercased()) {
|
|
addTag(tag)
|
|
}
|
|
}
|
|
// Everything not in the vocabulary becomes a tappable ✨ chip.
|
|
aiSuggestions = ai.filter { !knownTags.contains($0.lowercased()) }
|
|
}
|
|
|
|
private func commit() async {
|
|
guard let api else { return }
|
|
phase = .saving
|
|
do {
|
|
if let existing {
|
|
let update = BookmarkUpdate(
|
|
url: existing.url,
|
|
title: existing.title,
|
|
description: notes,
|
|
tagNames: parsedTags,
|
|
unread: readLater,
|
|
shared: existing.shared
|
|
)
|
|
_ = try await api.updateBookmark(id: existing.id, update: update)
|
|
} else {
|
|
let create = BookmarkCreate(
|
|
url: url,
|
|
title: "",
|
|
description: notes,
|
|
tagNames: parsedTags,
|
|
isArchived: false,
|
|
unread: readLater,
|
|
shared: false
|
|
)
|
|
_ = try await api.createBookmark(create)
|
|
}
|
|
// Hand off podcast generation to the main app (the extension is too
|
|
// short-lived to generate audio itself).
|
|
if createPodcast || PodcastRequests.matchesAutoTag(parsedTags) {
|
|
PodcastRequests.enqueue(url: url, title: title.isEmpty ? url : title)
|
|
}
|
|
phase = .done
|
|
try? await Task.sleep(nanoseconds: 700_000_000)
|
|
onClose(true)
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
phase = .failed
|
|
try? await Task.sleep(nanoseconds: 1_600_000_000)
|
|
onClose(false)
|
|
}
|
|
}
|
|
}
|