Files
linkding-ios/Marks/Views/SourcesView.swift
T
Krishna KumarandClaude Opus 5 fffb3999bf
CI / build-and-deploy (pull_request) Successful in 34s
CI / build-and-deploy (push) Successful in 34s
Carry the library design across the rest of the app
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
2026-07-27 12:50:45 -05:00

452 lines
16 KiB
Swift

import QuickLook
import SwiftUI
import UniformTypeIdentifiers
struct SourcesView: View {
@Bindable var library: IngestedSourceLibrary
let podcastPlayer: PodcastPlayerViewModel
let podcastGenerator: PodcastGenerationManager
let claude: ClaudeService
@State private var searchText = ""
@State private var showTextImport = false
@State private var showFileImporter = false
@State private var selectedSource: IngestedSource?
@State private var editingSource: IngestedSource?
@State private var showFullPlayer = false
private var results: [IngestedSource] {
library.search(searchText)
}
var body: some View {
NavigationStack {
List {
ForEach(results) { source in
HStack(spacing: 10) {
Button {
selectedSource = source
} label: {
SourceRow(source: source)
}
.buttonStyle(.plain)
Button {
playOrGeneratePodcast(for: source)
} label: {
Image(systemName: podcastIcon(for: source))
.font(.system(size: 22))
.foregroundStyle(Paper.accent)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
.disabled(source.podcastText.isEmpty)
}
.swipeActions {
Button {
playOrGeneratePodcast(for: source)
} label: {
Label("Podcast", systemImage: "headphones")
}
.tint(Paper.accent)
Button(role: .destructive) {
library.delete(source)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.listStyle(.plain)
.paperSurface()
.navigationTitle("Sources")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
showTextImport = true
} label: {
Label("Import Text", systemImage: "doc.text")
}
Button {
showFileImporter = true
} label: {
Label("Import File", systemImage: "doc.badge.plus")
}
} label: {
Image(systemName: "plus")
}
}
}
.overlay {
if results.isEmpty {
PaperEmptyState(
title: searchText.isEmpty ? "No Sources" : "No Results",
systemImage: searchText.isEmpty ? "tray" : "magnifyingglass",
message: searchText.isEmpty
? "Import text, PDFs, or files to keep non-web material in Marks."
: "Try a different search."
)
}
}
.searchable(text: $searchText, prompt: "Search sources")
}
.task { library.load() }
.sheet(isPresented: $showTextImport) {
ImportTextSourceView(library: library)
}
.sheet(item: $selectedSource) { source in
SourceDetailView(
source: source,
fileURL: library.fileURL(for: source),
podcastCached: podcastCached(for: source),
podcastGenerating: podcastGenerator.isGenerating(source.podcastURL),
onEdit: {
selectedSource = nil
Task {
try? await Task.sleep(for: .milliseconds(250))
editingSource = source
}
},
onPodcast: {
selectedSource = nil
Task {
try? await Task.sleep(for: .milliseconds(250))
playOrGeneratePodcast(for: source)
}
}
)
}
.sheet(item: $editingSource) { source in
EditSourceView(source: source, library: library)
}
.sheet(isPresented: $showFullPlayer) {
PodcastPlayerView(
vm: podcastPlayer,
articleUrl: podcastPlayer.currentArticleUrl,
articleTitle: podcastPlayer.currentArticleTitle,
claude: claude,
stopOnDismiss: false
)
}
.fileImporter(
isPresented: $showFileImporter,
allowedContentTypes: [.pdf, .plainText, .text, .data],
allowsMultipleSelection: true
) { result in
switch result {
case .success(let urls):
for url in urls {
library.importFile(from: url, tags: [])
}
case .failure(let error):
library.error = error.localizedDescription
}
}
.alert("Source Error", isPresented: Binding(
get: { library.error != nil },
set: { if !$0 { library.error = nil } }
)) {
Button("OK") { library.error = nil }
} message: {
Text(library.error ?? "")
}
}
private func playOrGeneratePodcast(for source: IngestedSource) {
let podcastURL = source.podcastURL
let title = source.displayTitle
let existing = PodcastIndex.find(for: podcastURL).first
if let existing {
podcastPlayer.start(articleUrl: existing.articleUrl, articleTitle: existing.title ?? title, claude: claude)
showFullPlayer = true
return
}
if podcastPlayer.currentArticleUrl.isEmpty {
podcastPlayer.start(
articleUrl: podcastURL,
articleTitle: title,
claude: claude,
sourceText: source.podcastText,
sourceKind: source.kind.rawValue
)
showFullPlayer = true
} else {
podcastGenerator.enqueue(
articleUrl: podcastURL,
title: title,
sourceText: source.podcastText,
sourceKind: source.kind.rawValue
)
}
}
private func podcastCached(for source: IngestedSource) -> Bool {
FileManager.default.fileExists(atPath: ClaudeService.cachedPodcastURL(for: source.podcastURL).path)
}
private func podcastIcon(for source: IngestedSource) -> String {
if podcastGenerator.isGenerating(source.podcastURL) { return "waveform.circle.fill" }
if podcastCached(for: source) { return "headphones.circle.fill" }
return "headphones.circle"
}
}
private struct SourceRow: View {
let source: IngestedSource
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: iconName)
.font(PaperType.stamp)
.foregroundStyle(Paper.accent)
.frame(width: 30, height: 30)
.background(Paper.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 7))
VStack(alignment: .leading, spacing: 5) {
Text(source.displayTitle)
.font(PaperType.title)
.foregroundStyle(Paper.ink)
.lineLimit(2)
if !source.excerpt.isEmpty {
Text(source.excerpt)
.font(PaperType.meta)
.foregroundStyle(Paper.tertiary)
.lineLimit(2)
}
HStack(spacing: 8) {
Text(source.kind.label)
Text(source.createdAt, style: .date)
if !source.tags.isEmpty {
Text(source.tags.joined(separator: ", "))
}
}
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
}
}
.padding(.vertical, 8)
}
private var iconName: String {
switch source.kind {
case .text: "doc.text"
case .pdf: "doc.richtext"
case .file: "doc"
}
}
}
private struct ImportTextSourceView: View {
@Bindable var library: IngestedSourceLibrary
@Environment(\.dismiss) private var dismiss
@State private var title = ""
@State private var bodyText = ""
@State private var tagsText = ""
var body: some View {
NavigationStack {
Form {
Section("Title") {
TextField("Optional", text: $title)
}
Section("Text") {
TextEditor(text: $bodyText)
.frame(minHeight: 180)
.textInputAutocapitalization(.sentences)
}
Section {
TextField("comma separated", text: $tagsText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} header: {
Text("Tags")
} footer: {
Text("Separate tags with commas")
}
}
.paperSurface()
.navigationTitle("Import Text")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
library.addText(title: title, body: bodyText, tags: tagsText.parsedTags)
dismiss()
}
.disabled(bodyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
}
}
private struct EditSourceView: View {
let source: IngestedSource
@Bindable var library: IngestedSourceLibrary
@Environment(\.dismiss) private var dismiss
@State private var title: String
@State private var bodyText: String
@State private var tagsText: String
init(source: IngestedSource, library: IngestedSourceLibrary) {
self.source = source
self.library = library
_title = State(initialValue: source.title)
_bodyText = State(initialValue: source.bodyText)
_tagsText = State(initialValue: source.tags.joined(separator: ", "))
}
var body: some View {
NavigationStack {
Form {
Section("Title") {
TextField("Optional", text: $title)
}
Section {
TextEditor(text: $bodyText)
.frame(minHeight: 220)
.textInputAutocapitalization(.sentences)
} header: {
Text(source.kind == .text ? "Text" : "Extracted Text")
} footer: {
if source.kind != .text {
Text("Editing extracted text does not modify the original file.")
}
}
Section {
TextField("comma separated", text: $tagsText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} header: {
Text("Tags")
} footer: {
Text("Separate tags with commas")
}
}
.listRowBackground(Paper.raised)
.paperSurface()
.navigationTitle("Edit Source")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
library.update(source, title: title, bodyText: bodyText, tags: tagsText.parsedTags)
dismiss()
}
.disabled(bodyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
}
}
private struct SourceDetailView: View {
let source: IngestedSource
let fileURL: URL?
let podcastCached: Bool
let podcastGenerating: Bool
let onEdit: () -> Void
let onPodcast: () -> Void
@State private var previewURL: URL?
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text(source.displayTitle)
.font(PaperType.display)
.foregroundStyle(Paper.ink)
Text(source.kind.label)
.font(PaperType.meta)
.foregroundStyle(Paper.tertiary)
}
if !source.tags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(source.tags, id: \.self) { tag in
Text(tag)
.font(PaperType.micro)
.foregroundStyle(Paper.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Paper.accent.opacity(0.12), in: Capsule())
}
}
}
}
if let fileURL {
HStack {
Button {
previewURL = fileURL
} label: {
Label("Open Original", systemImage: "doc.viewfinder")
}
.buttonStyle(.bordered)
Button {
onPodcast()
} label: {
Label(podcastTitle, systemImage: "headphones")
}
.buttonStyle(.borderedProminent)
.disabled(source.podcastText.isEmpty || podcastGenerating)
}
} else {
Button {
onPodcast()
} label: {
Label(podcastTitle, systemImage: "headphones")
}
.buttonStyle(.borderedProminent)
.disabled(source.podcastText.isEmpty || podcastGenerating)
}
Text(source.bodyText.isEmpty ? "No extractable text." : source.bodyText)
.font(PaperType.body)
.foregroundStyle(Paper.ink)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding()
}
.paperSurface()
.navigationTitle("Source")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
onEdit()
} label: {
Label("Edit", systemImage: "pencil")
}
}
}
}
.quickLookPreview($previewURL)
}
private var podcastTitle: String {
if podcastGenerating { return "Generating" }
if podcastCached { return "Play Podcast" }
return "Create Podcast"
}
}