Library design: cards + redesigned list on the bookmarks screen, tag picker sheet #9
@@ -50,8 +50,8 @@
|
||||
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; };
|
||||
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; };
|
||||
95D9848F60EB303D9EACCDDA /* SourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB1DA808EE8041C4546DAAB /* SourcesView.swift */; };
|
||||
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */; };
|
||||
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; };
|
||||
A30907113FD5D682478750A7 /* LibraryKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */; };
|
||||
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
|
||||
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; };
|
||||
AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */; };
|
||||
@@ -165,7 +165,6 @@
|
||||
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; };
|
||||
AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
ADEAC824576633CC77370262 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkRow.swift; sourceTree = "<group>"; };
|
||||
B13B9F2D890C7953531AC0D2 /* TagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsView.swift; sourceTree = "<group>"; };
|
||||
BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
||||
C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSearchTool.swift; sourceTree = "<group>"; };
|
||||
@@ -335,7 +334,6 @@
|
||||
217E6702DE1210AC38ED16D1 /* AskView.swift */,
|
||||
240DBB87940F8D255A812EB2 /* BookmarkActions.swift */,
|
||||
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */,
|
||||
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
|
||||
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
|
||||
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */,
|
||||
629C41E0BC28EB6359D50CFD /* BrowserView.swift */,
|
||||
@@ -539,6 +537,7 @@
|
||||
files = (
|
||||
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */,
|
||||
70DE16F6334D0F3798C98064 /* IngestPayload.swift in Sources */,
|
||||
A30907113FD5D682478750A7 /* LibraryKit.swift in Sources */,
|
||||
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */,
|
||||
B085CDBFC47F0357D1A28911 /* Log.swift in Sources */,
|
||||
B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */,
|
||||
@@ -576,7 +575,6 @@
|
||||
FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */,
|
||||
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */,
|
||||
778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */,
|
||||
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
|
||||
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */,
|
||||
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
|
||||
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
|
||||
|
||||
@@ -8,18 +8,19 @@ struct BookmarkSnippetView: View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "bookmark.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
.foregroundStyle(Paper.accent)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(entity.title)
|
||||
.font(.headline)
|
||||
.font(PaperType.heading)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.lineLimit(2)
|
||||
Text(entity.host)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
if !entity.tags.isEmpty {
|
||||
Text(entity.tags.map { "#\($0)" }.joined(separator: " "))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
@@ -38,14 +39,15 @@ struct SummarySnippetView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles").foregroundStyle(.tint)
|
||||
Text(title).font(.headline).lineLimit(2)
|
||||
Image(systemName: "sparkles").foregroundStyle(Paper.accent)
|
||||
Text(title).font(PaperType.heading).foregroundStyle(Paper.ink).lineLimit(2)
|
||||
}
|
||||
Text(host)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
Text(summary)
|
||||
.font(.body)
|
||||
.font(PaperType.body)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding()
|
||||
|
||||
@@ -70,6 +70,10 @@ struct MainContainer: View {
|
||||
SearchView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
// One accent for every system control the app doesn't draw itself —
|
||||
// tab bar selection, search fields, switches, swipe actions. Without
|
||||
// this the paper screens sit inside system-blue chrome.
|
||||
.tint(Paper.accent)
|
||||
.onOpenURL { url in
|
||||
handleDeepLink(url)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ struct AddBookmarkView: View {
|
||||
Form {
|
||||
Section {
|
||||
TextEditor(text: $importText)
|
||||
.paperField()
|
||||
.frame(minHeight: 96)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
@@ -27,6 +28,8 @@ struct AddBookmarkView: View {
|
||||
extractImportText()
|
||||
} label: {
|
||||
Label("Extract Link", systemImage: "link.badge.plus")
|
||||
.font(PaperType.label)
|
||||
.foregroundStyle(Paper.accent)
|
||||
}
|
||||
.disabled(importText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
@@ -34,56 +37,62 @@ struct AddBookmarkView: View {
|
||||
|
||||
if let importMessage {
|
||||
Text(importMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Import Text")
|
||||
Text("Import Text").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
} footer: {
|
||||
Text("Paste an email, newsletter, or message and Marks will pull out the first web link.")
|
||||
Text("Paste an email, newsletter, or message and Marks will pull out the first web link.").font(PaperType.micro).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("https://", text: $url)
|
||||
.paperField()
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
} header: {
|
||||
Text("URL")
|
||||
Text("URL").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Optional", text: $title)
|
||||
.paperField()
|
||||
} header: {
|
||||
Text("Title")
|
||||
Text("Title").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Optional", text: $description, axis: .vertical)
|
||||
.paperField()
|
||||
.lineLimit(2...5)
|
||||
} header: {
|
||||
Text("Notes")
|
||||
Text("Notes").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("comma separated", text: $tagsText)
|
||||
.paperField()
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
} header: {
|
||||
Text("Tags")
|
||||
Text("Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
} footer: {
|
||||
Text("Separate tags with commas")
|
||||
Text("Separate tags with commas").font(PaperType.micro).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
if let error {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.font(.footnote)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.alarm)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Paper.raised)
|
||||
.paperSurface()
|
||||
.navigationTitle("Add Bookmark")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -92,6 +101,8 @@ struct AddBookmarkView: View {
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") { save() }
|
||||
.font(PaperType.label)
|
||||
.tint(Paper.accent)
|
||||
.disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving)
|
||||
.overlay {
|
||||
if isSaving { ProgressView().scaleEffect(0.7) }
|
||||
|
||||
+31
-17
@@ -18,20 +18,23 @@ struct AskView: View {
|
||||
case .checking:
|
||||
ProgressView()
|
||||
case .unavailable(let message):
|
||||
ContentUnavailableView(
|
||||
"Unavailable",
|
||||
PaperEmptyState(
|
||||
title: "Unavailable",
|
||||
systemImage: "sparkles.slash",
|
||||
description: Text(message)
|
||||
message: message
|
||||
)
|
||||
case .ready:
|
||||
ready
|
||||
}
|
||||
}
|
||||
.paperSurface()
|
||||
.navigationTitle("Ask Your Bookmarks")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.font(PaperType.label)
|
||||
.tint(Paper.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,17 +45,19 @@ struct AskView: View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if answer.isEmpty && !isLoading && errorText == nil {
|
||||
ContentUnavailableView {
|
||||
Label("Ask anything", systemImage: "sparkles")
|
||||
} description: {
|
||||
Text("Answers come from your saved bookmarks, generated on-device.")
|
||||
}
|
||||
PaperEmptyState(
|
||||
title: "Ask anything",
|
||||
systemImage: "sparkles",
|
||||
message: "Answers come from your saved bookmarks, generated on-device."
|
||||
)
|
||||
.padding(.top, 40)
|
||||
}
|
||||
if isLoading {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Searching your bookmarks…").foregroundStyle(.secondary)
|
||||
Text("Searching your bookmarks…")
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
}
|
||||
if !answer.isEmpty {
|
||||
@@ -61,7 +66,9 @@ struct AskView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
if let errorText {
|
||||
Text(errorText).foregroundStyle(.red)
|
||||
Text(errorText)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.alarm)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
@@ -69,17 +76,24 @@ struct AskView: View {
|
||||
|
||||
HStack(spacing: 10) {
|
||||
TextField("Ask about your bookmarks…", text: $question, axis: .vertical)
|
||||
.font(PaperType.body)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.lineLimit(1...4)
|
||||
.focused($focused)
|
||||
.submitLabel(.send)
|
||||
.onSubmit(send)
|
||||
Button(action: send) {
|
||||
Image(systemName: "arrow.up.circle.fill").font(.title2)
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.system(size: 26))
|
||||
.foregroundStyle(Paper.accent)
|
||||
}
|
||||
.disabled(question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading)
|
||||
}
|
||||
.padding()
|
||||
.background(.bar)
|
||||
.background(Paper.raised)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle().fill(Paper.rule.opacity(0.5)).frame(height: 0.6)
|
||||
}
|
||||
}
|
||||
.onAppear { focused = true }
|
||||
}
|
||||
@@ -112,18 +126,18 @@ private struct MarkdownAnswer: View {
|
||||
private enum Block: Hashable { case heading(String), bullet(String), paragraph(String) }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
|
||||
switch block {
|
||||
case .heading(let line):
|
||||
inline(line).font(.headline)
|
||||
inline(line).font(PaperType.heading)
|
||||
case .bullet(let line):
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text("•").foregroundStyle(.secondary)
|
||||
inline(line)
|
||||
Text("•").foregroundStyle(Paper.tertiary)
|
||||
inline(line).font(PaperType.body)
|
||||
}
|
||||
case .paragraph(let line):
|
||||
inline(line)
|
||||
inline(line).font(PaperType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Full-featured list row used by BookmarksView, TagBookmarksView, and SearchView.
|
||||
/// Owns podcast and episode-picker sheet state; parent owns BrowserView sheet.
|
||||
/// Full-featured list row used by BookmarksView and SearchView. Owns podcast
|
||||
/// and episode-picker sheet state; parent owns the BrowserView sheet.
|
||||
struct BookmarkListRow: View {
|
||||
/// Which visual language the row speaks. `.library` is the paper design
|
||||
/// used by the bookmarks screen; `.classic` is the system-styled row the
|
||||
/// Tags and Search screens still use, so redesigning one doesn't silently
|
||||
/// restyle the others.
|
||||
enum Style { case classic, library }
|
||||
|
||||
let bookmark: Bookmark
|
||||
let viewModel: BookmarksViewModel
|
||||
var readingProgress: Double = 0
|
||||
var style: Style = .classic
|
||||
let onOpen: () -> Void
|
||||
var onEdit: (() -> Void)? = nil
|
||||
|
||||
@@ -21,13 +14,17 @@ struct BookmarkListRow: View {
|
||||
@State private var episodePickerBookmark: Bookmark?
|
||||
|
||||
var body: some View {
|
||||
row
|
||||
LibraryListRow(
|
||||
bookmark: bookmark,
|
||||
readingProgress: readingProgress,
|
||||
onPodcast: handlePodcast
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onOpen() }
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 18, bottom: 0, trailing: 18))
|
||||
.listRowSeparator(.visible)
|
||||
.listRowSeparatorTint(style == .library ? Paper.rule.opacity(0.5) : nil)
|
||||
.listRowBackground(style == .library ? Paper.sheet : nil)
|
||||
.listRowSeparatorTint(Paper.rule.opacity(0.5))
|
||||
.listRowBackground(Paper.sheet)
|
||||
// Delete is destructive and not undoable — require an explicit tap on the
|
||||
// revealed button rather than letting a single full swipe delete instantly.
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
@@ -68,24 +65,6 @@ struct BookmarkListRow: View {
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var row: some View {
|
||||
switch style {
|
||||
case .classic:
|
||||
BookmarkRow(
|
||||
bookmark: bookmark,
|
||||
readingProgress: readingProgress,
|
||||
onPodcast: handlePodcast
|
||||
)
|
||||
case .library:
|
||||
LibraryListRow(
|
||||
bookmark: bookmark,
|
||||
readingProgress: readingProgress,
|
||||
onPodcast: handlePodcast
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePodcast() {
|
||||
launchPodcast(
|
||||
for: bookmark,
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BookmarkRow: View {
|
||||
let bookmark: Bookmark
|
||||
var readingProgress: Double = 0
|
||||
var onPodcast: (() -> Void)? = nil
|
||||
|
||||
@State private var podcastTapCount = 0
|
||||
@State private var podcastCached = false
|
||||
|
||||
/// Compact, static relative date ("6 min ago"). Using a formatter instead of
|
||||
/// `Text(_, style: .relative)` avoids the live per-second ticking timer.
|
||||
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
||||
let f = RelativeDateTimeFormatter()
|
||||
f.unitsStyle = .abbreviated
|
||||
f.dateTimeStyle = .named
|
||||
return f
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top, spacing: 11) {
|
||||
FaviconView(url: bookmark.faviconUrl)
|
||||
.overlay(alignment: .topLeading) {
|
||||
if bookmark.unread {
|
||||
Circle()
|
||||
.fill(.blue)
|
||||
.frame(width: 8, height: 8)
|
||||
.offset(x: -3, y: -3)
|
||||
}
|
||||
}
|
||||
.padding(.top, 1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(bookmark.displayTitle)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text(bookmark.domain)
|
||||
Text("·")
|
||||
Text(Self.relativeFormatter.localizedString(for: bookmark.dateAdded, relativeTo: Date()))
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let onPodcast {
|
||||
Button {
|
||||
podcastTapCount += 1
|
||||
onPodcast()
|
||||
} label: {
|
||||
Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle")
|
||||
.font(.title3)
|
||||
.foregroundStyle(podcastCached ? .blue : Color(.systemGray3))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 1)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: podcastTapCount)
|
||||
}
|
||||
}
|
||||
|
||||
if let excerpt = rowExcerpt {
|
||||
Text(excerpt.text)
|
||||
.font(.subheadline)
|
||||
.italic(excerpt.isAI)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.padding(.leading, 44)
|
||||
}
|
||||
|
||||
if !effectiveTags.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
GlassEffectContainer(spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(effectiveTags, id: \.self) { tag in
|
||||
Text(tag)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.glassEffect(in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 44)
|
||||
}
|
||||
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.contentShape(Rectangle())
|
||||
.overlay(alignment: .bottom) {
|
||||
if readingProgress > 0.02 {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Rectangle()
|
||||
.fill(Color(.systemGray5))
|
||||
Rectangle()
|
||||
.fill(Color(.systemGreen))
|
||||
.frame(width: geo.size.width * min(readingProgress, 1))
|
||||
}
|
||||
}
|
||||
.frame(height: 2)
|
||||
}
|
||||
}
|
||||
.task(id: bookmark.url) {
|
||||
// Stat the podcast cache off the render path: once per appearance
|
||||
// (and when the URL changes), not on every `body` recomputation.
|
||||
let path = ClaudeService.cachedPodcastURL(for: bookmark.url).path
|
||||
podcastCached = await Task.detached { FileManager.default.fileExists(atPath: path) }.value
|
||||
}
|
||||
}
|
||||
|
||||
/// Excerpt shown under the title: prefer the AI summary (italic), else the
|
||||
/// page's scraped description / user note. nil hides the line entirely.
|
||||
private var rowExcerpt: (text: String, isAI: Bool)? {
|
||||
if let s = bookmark.aiSummary?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty {
|
||||
return (s, true)
|
||||
}
|
||||
if let e = bookmark.contentExcerpt {
|
||||
return (e, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private var effectiveTags: [String] {
|
||||
let base = bookmark.tagNames
|
||||
let ai = bookmark.aiTags ?? []
|
||||
let extra = ai.filter { !base.contains($0) }.prefix(3)
|
||||
return (base + extra).prefix(6).map { $0 }
|
||||
}
|
||||
}
|
||||
|
||||
struct FaviconView: View {
|
||||
let url: String?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let urlString = url, let faviconUrl = URL(string: urlString) {
|
||||
AsyncImage(url: faviconUrl) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFit()
|
||||
default:
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
.frame(width: 32, height: 32)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color(.systemGray5))
|
||||
.overlay {
|
||||
Image(systemName: "bookmark")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(Color(.systemGray2))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,17 +8,17 @@ private struct SkeletonRow: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 11) {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 32, height: 32)
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(Paper.ink.opacity(0.09))
|
||||
.frame(width: 34, height: 46)
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Capsule()
|
||||
.fill(Color(.systemGray5))
|
||||
.fill(Paper.ink.opacity(0.09))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 13)
|
||||
.frame(height: 15)
|
||||
Capsule()
|
||||
.fill(Color(.systemGray6))
|
||||
.frame(width: 140, height: 10)
|
||||
.fill(Paper.ink.opacity(0.06))
|
||||
.frame(width: 140, height: 12)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
Spacer()
|
||||
@@ -154,10 +154,10 @@ struct BookmarksView: View {
|
||||
}
|
||||
.overlay {
|
||||
if !viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Bookmarks",
|
||||
PaperEmptyState(
|
||||
title: "No Bookmarks",
|
||||
systemImage: "bookmark",
|
||||
description: Text("Bookmarks you save will appear here.")
|
||||
message: "Bookmarks you save will appear here."
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
@@ -248,15 +248,14 @@ struct BookmarksView: View {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView(value: viewModel.enrichmentProgress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(.primary)
|
||||
.tint(Paper.ink)
|
||||
Text("Adding AI summaries…")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.paperCard(cornerRadius: 12)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
@@ -265,15 +264,16 @@ struct BookmarksView: View {
|
||||
return HStack(spacing: 10) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.blue)
|
||||
.foregroundStyle(Paper.accent)
|
||||
.symbolEffect(.variableColor.iterative, isActive: true)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(jobs.count == 1 ? "Generating podcast…" : "Generating \(jobs.count) podcasts…")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.font(PaperType.stamp)
|
||||
.foregroundStyle(Paper.ink)
|
||||
if let first = jobs.first {
|
||||
Text(first.title.isEmpty ? first.label : first.title)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
@@ -281,7 +281,7 @@ struct BookmarksView: View {
|
||||
if jobs.count == 1, let progress = jobs.first?.progress, progress > 0 {
|
||||
ProgressView(value: progress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(.blue)
|
||||
.tint(Paper.accent)
|
||||
.frame(width: 44)
|
||||
} else {
|
||||
ProgressView()
|
||||
@@ -289,8 +289,7 @@ struct BookmarksView: View {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.paperCard(cornerRadius: 12)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
@@ -308,7 +307,6 @@ struct BookmarksView: View {
|
||||
bookmark: bookmark,
|
||||
viewModel: viewModel,
|
||||
readingProgress: readingProgress[bookmark.url] ?? 0,
|
||||
style: .library,
|
||||
onOpen: { browsingBookmark = bookmark },
|
||||
onEdit: { editingBookmark = bookmark }
|
||||
)
|
||||
|
||||
@@ -253,7 +253,7 @@ struct BrowserView: View {
|
||||
if state.readingProgress > 0.01 && state.readingProgress < 0.99 {
|
||||
GeometryReader { geo in
|
||||
Rectangle()
|
||||
.fill(Color.blue.opacity(0.55))
|
||||
.fill(Paper.accent.opacity(0.55))
|
||||
.frame(width: geo.size.width * state.readingProgress, height: 3)
|
||||
}
|
||||
.frame(height: 3)
|
||||
@@ -409,11 +409,11 @@ struct BrowserView: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(.primary)
|
||||
.font(PaperType.label)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.background(.bar)
|
||||
.background(Paper.raised)
|
||||
.overlay(alignment: .top) { Divider() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,41 +11,44 @@ struct CollectionsView: View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text("Building smart collections…")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if viewModel.smartCollections.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Collections Yet",
|
||||
PaperEmptyState(
|
||||
title: "No Collections Yet",
|
||||
systemImage: "sparkles",
|
||||
description: Text("Tap \"Smart Collections\" to group your bookmarks by topic.")
|
||||
message: "Tap \"Smart Collections\" to group your bookmarks by topic."
|
||||
)
|
||||
} else {
|
||||
List(viewModel.smartCollections) { collection in
|
||||
Section {
|
||||
let items = viewModel.bookmarks.filter { collection.bookmarkIds.contains($0.id) }
|
||||
ForEach(items) { bookmark in
|
||||
BookmarkRow(bookmark: bookmark)
|
||||
LibraryListRow(bookmark: bookmark)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||
.listRowBackground(Paper.sheet)
|
||||
.listRowSeparatorTint(Paper.rule.opacity(0.5))
|
||||
}
|
||||
} header: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(collection.name)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.font(PaperType.heading)
|
||||
.foregroundStyle(Paper.ink)
|
||||
if !collection.description.isEmpty {
|
||||
Text(collection.description)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
.paperSurface()
|
||||
.navigationTitle("Smart Collections")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -59,6 +62,8 @@ struct CollectionsView: View {
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.font(PaperType.label)
|
||||
.tint(Paper.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,44 +27,59 @@ struct EditBookmarkView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("URL") {
|
||||
Section {
|
||||
TextField("https://", text: $url)
|
||||
.paperField()
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
} header: {
|
||||
Text("URL").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section("Title") {
|
||||
Section {
|
||||
TextField("Optional", text: $title)
|
||||
.paperField()
|
||||
} header: {
|
||||
Text("Title").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section("Description") {
|
||||
Section {
|
||||
TextField("Optional", text: $description, axis: .vertical)
|
||||
.paperField()
|
||||
.lineLimit(3...6)
|
||||
} header: {
|
||||
Text("Description").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("comma separated", text: $tagsText)
|
||||
.paperField()
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
} header: {
|
||||
Text("Tags")
|
||||
Text("Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
} footer: {
|
||||
Text("Separate tags with commas")
|
||||
Text("Separate tags with commas").font(PaperType.micro).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Mark as unread", isOn: $unread)
|
||||
.font(PaperType.label)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.tint(Paper.accent)
|
||||
}
|
||||
|
||||
if let error {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.font(.footnote)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.alarm)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Paper.raised)
|
||||
.paperSurface()
|
||||
.navigationTitle("Edit Bookmark")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -73,6 +88,8 @@ struct EditBookmarkView: View {
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") { save() }
|
||||
.font(PaperType.label)
|
||||
.tint(Paper.accent)
|
||||
.disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving)
|
||||
.overlay {
|
||||
if isSaving { ProgressView().scaleEffect(0.7) }
|
||||
|
||||
@@ -17,6 +17,29 @@ enum Paper {
|
||||
static let ink = dynamic(light: 0x14110C, dark: 0xF1ECE1)
|
||||
static var rule: Color { ink.opacity(0.28) }
|
||||
|
||||
/// Text weights, named by role so screens stop hand-picking opacities.
|
||||
static var secondary: Color { ink.opacity(0.6) }
|
||||
static var tertiary: Color { ink.opacity(0.45) }
|
||||
static var faint: Color { ink.opacity(0.3) }
|
||||
|
||||
/// A raised surface on the sheet — cards, fields, banners. Barely separated
|
||||
/// from the ground on purpose: this palette does contrast with rules and
|
||||
/// type, not with stacked greys.
|
||||
static let raised = dynamic(light: 0xFFFDF8, dark: 0x201C16)
|
||||
|
||||
/// The one non-palette color the design spends, for genuinely interactive
|
||||
/// affordances (links, progress, selection). It is the palette's own blue
|
||||
/// rather than the system blue, so it belongs to the paper.
|
||||
static let accent = dynamic(light: 0x115AB5, dark: 0x5B8FD0)
|
||||
|
||||
/// Destructive actions still need to read as dangerous; the vermilion
|
||||
/// swatch does that without importing systemRed.
|
||||
static let alarm = dynamic(light: 0xC03A18, dark: 0xE07A5C)
|
||||
|
||||
/// "Done / played / complete." The forest swatch, so success still comes
|
||||
/// out of the palette rather than from systemGreen.
|
||||
static let affirm = dynamic(light: 0x2F6B34, dark: 0x6FA772)
|
||||
|
||||
/// Swatches lifted from the reference, each paired with a dark-mode
|
||||
/// counterpart. Order matters — items hash into it.
|
||||
///
|
||||
@@ -73,6 +96,95 @@ enum Paper {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Type
|
||||
|
||||
/// The library's two voices. Prose — titles, summaries, anything a person
|
||||
/// wrote — is serif. Everything the machine contributes — domains, dates,
|
||||
/// counts, tags, labels, buttons — is monospaced. Keeping the split strict is
|
||||
/// what makes the design read as archival rather than as decoration.
|
||||
enum PaperType {
|
||||
/// Screen titles that aren't the navigation bar's.
|
||||
static let display = Font.system(size: 28, design: .serif)
|
||||
static let title = Font.system(size: 21, design: .serif)
|
||||
static let heading = Font.system(size: 18, weight: .medium, design: .serif)
|
||||
static let body = Font.system(size: 18, design: .serif)
|
||||
static let quote = Font.system(size: 16.5, design: .serif)
|
||||
|
||||
/// Machine voice.
|
||||
static let label = Font.system(size: 16.5, design: .monospaced)
|
||||
static let meta = Font.system(size: 15, design: .monospaced)
|
||||
static let micro = Font.system(size: 12, design: .monospaced)
|
||||
/// Section headers and anything that wants to read as a stamp.
|
||||
static let stamp = Font.system(size: 12, weight: .medium, design: .monospaced)
|
||||
}
|
||||
|
||||
// MARK: Surfaces
|
||||
|
||||
extension View {
|
||||
/// Puts a screen on the paper ground: clears the system list/scroll
|
||||
/// background so the sheet shows through, and tints the bar to match so a
|
||||
/// large title doesn't sit on a white strip above the content.
|
||||
func paperSurface() -> some View {
|
||||
self
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Paper.sheet.ignoresSafeArea())
|
||||
.toolbarBackground(Paper.sheet, for: .navigationBar)
|
||||
}
|
||||
|
||||
/// Editable text. Monospaced, because in this design anything you type is
|
||||
/// data rather than prose — which is also what the reference's form screen
|
||||
/// did with every field.
|
||||
func paperField() -> some View {
|
||||
self
|
||||
.font(PaperType.label)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.tint(Paper.accent)
|
||||
}
|
||||
|
||||
/// A raised block — settings groups, banners, editor fields.
|
||||
func paperCard(cornerRadius: CGFloat = 8) -> some View {
|
||||
self
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: cornerRadius).fill(Paper.raised)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.stroke(Paper.rule.opacity(0.5), lineWidth: 0.6)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty state
|
||||
|
||||
/// The paper equivalent of `ContentUnavailableView`, which can't be restyled —
|
||||
/// it draws its own bold system type and grey, which was the loudest remaining
|
||||
/// system voice once every screen moved onto the sheet.
|
||||
struct PaperEmptyState: View {
|
||||
let title: String
|
||||
let systemImage: String
|
||||
var message: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 34, weight: .ultraLight))
|
||||
.foregroundStyle(Paper.faint)
|
||||
Text(title)
|
||||
.font(PaperType.title)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
if let message {
|
||||
Text(message)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 280)
|
||||
}
|
||||
}
|
||||
.padding(32)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIColor {
|
||||
convenience init(rgb: UInt32) {
|
||||
self.init(
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The library's list presentation. Carries everything `BookmarkRow` carried —
|
||||
/// unread state, excerpt, tags, podcast affordance, reading progress — in the
|
||||
/// paper vocabulary. The favicon becomes the color chip, so the same swatch
|
||||
/// that identifies a card identifies its row.
|
||||
/// The library's list presentation, and now the app's only bookmark row. It
|
||||
/// carries what the old system-styled row did — unread state, excerpt, tags,
|
||||
/// podcast affordance, reading progress — in the paper vocabulary. The favicon
|
||||
/// became the color chip, so the same swatch that identifies a card in the grid
|
||||
/// identifies its row in the list.
|
||||
struct LibraryListRow: View {
|
||||
let bookmark: Bookmark
|
||||
var readingProgress: Double = 0
|
||||
|
||||
@@ -17,10 +17,11 @@ struct OnboardingView: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Marks")
|
||||
.font(.system(size: 42, weight: .semibold, design: .rounded))
|
||||
.font(.system(size: 42, design: .serif))
|
||||
.foregroundStyle(Paper.ink)
|
||||
Text("Your bookmarks, beautifully.")
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.body)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 28)
|
||||
@@ -29,8 +30,8 @@ struct OnboardingView: View {
|
||||
VStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Server URL")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.stamp)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
.padding(.horizontal, 2)
|
||||
TextField("https://links.example.com", text: $urlText)
|
||||
.textContentType(.URL)
|
||||
@@ -41,14 +42,15 @@ struct OnboardingView: View {
|
||||
.submitLabel(.next)
|
||||
.onSubmit { focused = .token }
|
||||
.padding(14)
|
||||
.background(Color(.systemGray6))
|
||||
.paperField()
|
||||
.background(Paper.raised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("API Token")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.stamp)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
.padding(.horizontal, 2)
|
||||
SecureField("Paste your token", text: $token)
|
||||
.textContentType(.password)
|
||||
@@ -58,11 +60,12 @@ struct OnboardingView: View {
|
||||
.submitLabel(.done)
|
||||
.onSubmit { Task { await connect() } }
|
||||
.padding(14)
|
||||
.background(Color(.systemGray6))
|
||||
.paperField()
|
||||
.background(Paper.raised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
Text("Find your token at Settings → API Token in Linkding.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.tertiary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
}
|
||||
@@ -70,8 +73,8 @@ struct OnboardingView: View {
|
||||
|
||||
if let err = errorMessage {
|
||||
Text(err)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.red)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.alarm)
|
||||
.padding(.top, 12)
|
||||
.padding(.horizontal, 28)
|
||||
}
|
||||
@@ -84,7 +87,7 @@ struct OnboardingView: View {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text("Connect")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.font(PaperType.label)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
@@ -510,6 +510,7 @@ struct PodcastPlayerView: View {
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.paperSurface()
|
||||
.navigationTitle("Podcast")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -520,7 +521,7 @@ struct PodcastPlayerView: View {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { vm.stop(); dismiss() } label: {
|
||||
Image(systemName: "stop.circle")
|
||||
.foregroundStyle(.red)
|
||||
.foregroundStyle(Paper.alarm)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -556,16 +557,16 @@ struct PodcastPlayerView: View {
|
||||
VStack(spacing: 28) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
.symbolEffect(.variableColor.iterative, isActive: true)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
ProgressView(value: progress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(.blue)
|
||||
.tint(Paper.accent)
|
||||
Text(label)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -573,16 +574,16 @@ struct PodcastPlayerView: View {
|
||||
private func playerView(title: String) -> some View {
|
||||
VStack(spacing: 28) {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(Color(.systemGray6))
|
||||
.fill(Paper.raised)
|
||||
.frame(width: 220, height: 220)
|
||||
.overlay {
|
||||
Image(systemName: "waveform.circle.fill")
|
||||
.font(.system(size: 80))
|
||||
.foregroundStyle(.blue)
|
||||
.foregroundStyle(Paper.accent)
|
||||
}
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.font(PaperType.heading)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(3)
|
||||
|
||||
@@ -591,32 +592,32 @@ struct PodcastPlayerView: View {
|
||||
value: Binding(get: { vm.currentTime }, set: { vm.seek(to: $0) }),
|
||||
in: 0...vm.duration
|
||||
)
|
||||
.tint(.primary)
|
||||
.tint(Paper.ink)
|
||||
|
||||
HStack {
|
||||
Text(formatTime(vm.currentTime))
|
||||
Spacer()
|
||||
Text(formatTime(vm.duration))
|
||||
}
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
HStack(spacing: 36) {
|
||||
Button { vm.skipBackward15() } label: {
|
||||
Image(systemName: "gobackward.15")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(.primary)
|
||||
.foregroundStyle(Paper.ink)
|
||||
}
|
||||
Button { vm.togglePlayPause() } label: {
|
||||
Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 72))
|
||||
.foregroundStyle(.primary)
|
||||
.foregroundStyle(Paper.ink)
|
||||
}
|
||||
Button { vm.skipForward15() } label: {
|
||||
Image(systemName: "goforward.15")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(.primary)
|
||||
.foregroundStyle(Paper.ink)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,8 +637,8 @@ struct PodcastPlayerView: View {
|
||||
}
|
||||
} label: {
|
||||
Text(vm.playbackSpeed == 1.0 ? "1× Speed" : "\(String(format: "%g", vm.playbackSpeed))×")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
.glassEffect(in: Capsule())
|
||||
@@ -652,7 +653,7 @@ struct PodcastPlayerView: View {
|
||||
} label: {
|
||||
Label(sleepLabel, systemImage: sleepActive ? "moon.fill" : "moon")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(sleepActive ? Color.blue : .secondary)
|
||||
.foregroundStyle(sleepActive ? Paper.accent : .secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
.glassEffect(in: Capsule())
|
||||
@@ -676,7 +677,7 @@ struct PodcastPlayerView: View {
|
||||
ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) {
|
||||
Label("Share Episode", systemImage: "square.and.arrow.up")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -687,12 +688,12 @@ struct PodcastPlayerView: View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.red)
|
||||
.foregroundStyle(Paper.alarm)
|
||||
Text("Generation Failed")
|
||||
.font(.headline)
|
||||
.font(PaperType.heading)
|
||||
Text(message)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button {
|
||||
vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude)
|
||||
@@ -701,7 +702,7 @@ struct PodcastPlayerView: View {
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
}
|
||||
.buttonStyle(.glassProminent)
|
||||
.tint(.blue)
|
||||
.tint(Paper.accent)
|
||||
.buttonBorderShape(.capsule)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
@@ -724,18 +725,18 @@ struct MiniPlayerView: View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.blue)
|
||||
.foregroundStyle(Paper.accent)
|
||||
.symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || vm.isGenerating)
|
||||
.frame(width: 22)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(vm.currentArticleTitle.isEmpty ? "Podcast" : vm.currentArticleTitle)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.lineLimit(1)
|
||||
Text(progressLabel)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -749,7 +750,7 @@ struct MiniPlayerView: View {
|
||||
} label: {
|
||||
Image(systemName: vm.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(.primary)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -760,20 +761,20 @@ struct MiniPlayerView: View {
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(.regularMaterial)
|
||||
.background(Paper.raised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.overlay(alignment: .bottom) {
|
||||
// Progress bar along bottom edge
|
||||
GeometryReader { geo in
|
||||
Rectangle()
|
||||
.fill(Color.blue.opacity(0.6))
|
||||
.fill(Paper.accent.opacity(0.6))
|
||||
.frame(width: geo.size.width * progressFraction, height: 3)
|
||||
}
|
||||
.frame(height: 3)
|
||||
@@ -819,10 +820,10 @@ struct PodcastLibraryView: View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if library.entries.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Podcasts",
|
||||
PaperEmptyState(
|
||||
title: "No Podcasts",
|
||||
systemImage: "headphones",
|
||||
description: Text("Podcasts you generate from bookmarks will appear here.")
|
||||
message: "Podcasts you generate from bookmarks will appear here."
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
@@ -840,6 +841,7 @@ struct PodcastLibraryView: View {
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
}
|
||||
.paperSurface()
|
||||
.navigationTitle("Podcasts")
|
||||
.toolbar {
|
||||
if !unplayed.isEmpty {
|
||||
@@ -888,18 +890,18 @@ struct PodcastLibraryView: View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.title ?? entry.articleUrl)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.font(PaperType.quote)
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(entry.isPlayed ? .secondary : .primary)
|
||||
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if entry.isPlayed {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(.green)
|
||||
.foregroundStyle(Paper.affirm)
|
||||
.accessibilityLabel("Played")
|
||||
}
|
||||
Button {
|
||||
@@ -907,7 +909,7 @@ struct PodcastLibraryView: View {
|
||||
} label: {
|
||||
Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(.blue)
|
||||
.foregroundStyle(Paper.accent)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -1005,17 +1007,17 @@ struct EpisodePickerView: View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.title ?? entry.articleUrl)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.font(PaperType.quote)
|
||||
.lineLimit(2)
|
||||
if entry.articleUrl != bookmark.url {
|
||||
Text(entry.articleUrl)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.tertiary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
@@ -1026,7 +1028,7 @@ struct EpisodePickerView: View {
|
||||
} label: {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(.blue)
|
||||
.foregroundStyle(Paper.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -1095,21 +1097,21 @@ struct EpisodeDetailView: View {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(entry.title ?? entry.articleUrl)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.font(PaperType.display)
|
||||
Label(domain, systemImage: "link")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 12) {
|
||||
Label(entry.createdAt.formatted(date: .abbreviated, time: .omitted),
|
||||
systemImage: "calendar")
|
||||
if isPlayed {
|
||||
Label("Played", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.foregroundStyle(Paper.affirm)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
@@ -1118,7 +1120,7 @@ struct EpisodeDetailView: View {
|
||||
Section("Summary") {
|
||||
Text(summary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ struct SearchView: View {
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.paperSurface()
|
||||
.navigationTitle("Search")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
@@ -33,9 +34,9 @@ struct SearchView: View {
|
||||
}
|
||||
.overlay {
|
||||
if searchText.isEmpty {
|
||||
ContentUnavailableView("Search Bookmarks", systemImage: "magnifyingglass", description: Text("Search by title, URL, or tag."))
|
||||
PaperEmptyState(title: "Search Bookmarks", systemImage: "magnifyingglass", message: "Search by title, URL, or tag.")
|
||||
} else if results.isEmpty && !viewModel.isLoading {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
PaperEmptyState(title: "No Results", systemImage: "magnifyingglass", message: "Nothing matches \u{201C}\(searchText)\u{201D}.")
|
||||
}
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
|
||||
@@ -11,14 +11,18 @@ struct SettingsView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section("AI Features") {
|
||||
Section {
|
||||
Label("Semantic search, auto-tagging, smart collections, and podcast generation are active.", systemImage: "sparkles")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
} header: {
|
||||
Text("AI Features").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("e.g. listen podcast", text: $autoTagText)
|
||||
.font(PaperType.label)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: autoTagText) { _, new in
|
||||
@@ -27,25 +31,35 @@ struct SettingsView: View {
|
||||
.map(String.init)
|
||||
}
|
||||
} header: {
|
||||
Text("Auto-Podcast Tags")
|
||||
Text("Auto-Podcast Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
} footer: {
|
||||
Text("Saving a bookmark with any of these tags automatically generates a podcast for it.")
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section("Server") {
|
||||
Button(role: .destructive) {
|
||||
Section {
|
||||
Button {
|
||||
dismiss()
|
||||
onDisconnect()
|
||||
} label: {
|
||||
Label("Disconnect", systemImage: "person.crop.circle.badge.minus")
|
||||
.font(PaperType.label)
|
||||
.foregroundStyle(Paper.alarm)
|
||||
}
|
||||
} header: {
|
||||
Text("Server").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Paper.raised)
|
||||
.paperSurface()
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.font(PaperType.label)
|
||||
.tint(Paper.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ struct SourcesView: View {
|
||||
playOrGeneratePodcast(for: source)
|
||||
} label: {
|
||||
Image(systemName: podcastIcon(for: source))
|
||||
.font(.title3)
|
||||
.foregroundStyle(.blue)
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(Paper.accent)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -48,7 +48,7 @@ struct SourcesView: View {
|
||||
} label: {
|
||||
Label("Podcast", systemImage: "headphones")
|
||||
}
|
||||
.tint(.blue)
|
||||
.tint(Paper.accent)
|
||||
|
||||
Button(role: .destructive) {
|
||||
library.delete(source)
|
||||
@@ -59,6 +59,7 @@ struct SourcesView: View {
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.paperSurface()
|
||||
.navigationTitle("Sources")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
@@ -80,10 +81,12 @@ struct SourcesView: View {
|
||||
}
|
||||
.overlay {
|
||||
if results.isEmpty {
|
||||
ContentUnavailableView(
|
||||
searchText.isEmpty ? "No Sources" : "No Results",
|
||||
PaperEmptyState(
|
||||
title: searchText.isEmpty ? "No Sources" : "No Results",
|
||||
systemImage: searchText.isEmpty ? "tray" : "magnifyingglass",
|
||||
description: Text(searchText.isEmpty ? "Import text, PDFs, or files to keep non-web material in Marks." : "Try a different search.")
|
||||
message: searchText.isEmpty
|
||||
? "Import text, PDFs, or files to keep non-web material in Marks."
|
||||
: "Try a different search."
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -198,19 +201,20 @@ private struct SourceRow: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.blue)
|
||||
.font(PaperType.stamp)
|
||||
.foregroundStyle(Paper.accent)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Color.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 7))
|
||||
.background(Paper.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 7))
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(source.displayTitle)
|
||||
.font(.headline)
|
||||
.font(PaperType.title)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.lineLimit(2)
|
||||
if !source.excerpt.isEmpty {
|
||||
Text(source.excerpt)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
@@ -220,8 +224,8 @@ private struct SourceRow: View {
|
||||
Text(source.tags.joined(separator: ", "))
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
@@ -265,6 +269,7 @@ private struct ImportTextSourceView: View {
|
||||
Text("Separate tags with commas")
|
||||
}
|
||||
}
|
||||
.paperSurface()
|
||||
.navigationTitle("Import Text")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -329,6 +334,8 @@ private struct EditSourceView: View {
|
||||
Text("Separate tags with commas")
|
||||
}
|
||||
}
|
||||
.listRowBackground(Paper.raised)
|
||||
.paperSurface()
|
||||
.navigationTitle("Edit Source")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -363,10 +370,11 @@ private struct SourceDetailView: View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(source.displayTitle)
|
||||
.font(.title2.weight(.semibold))
|
||||
.font(PaperType.display)
|
||||
.foregroundStyle(Paper.ink)
|
||||
Text(source.kind.label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
if !source.tags.isEmpty {
|
||||
@@ -374,10 +382,11 @@ private struct SourceDetailView: View {
|
||||
HStack {
|
||||
ForEach(source.tags, id: \.self) { tag in
|
||||
Text(tag)
|
||||
.font(.caption.weight(.medium))
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.blue.opacity(0.12), in: Capsule())
|
||||
.background(Paper.accent.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -411,12 +420,14 @@ private struct SourceDetailView: View {
|
||||
}
|
||||
|
||||
Text(source.bodyText.isEmpty ? "No extractable text." : source.bodyText)
|
||||
.font(.body)
|
||||
.font(PaperType.body)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.paperSurface()
|
||||
.navigationTitle("Source")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
||||
@@ -40,14 +40,12 @@ struct TagsView: View {
|
||||
}
|
||||
.overlay {
|
||||
if visibleTags.isEmpty {
|
||||
ContentUnavailableView(
|
||||
query.isEmpty ? "No Tags" : "No Matching Tags",
|
||||
PaperEmptyState(
|
||||
title: query.isEmpty ? "No Tags" : "No Matching Tags",
|
||||
systemImage: "tag",
|
||||
description: Text(
|
||||
query.isEmpty
|
||||
message: query.isEmpty
|
||||
? "Tags from your bookmarks will appear here."
|
||||
: "No tag matches “\(query)”."
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ struct ShareView: View {
|
||||
case .loading:
|
||||
HStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Checking…").foregroundStyle(.secondary)
|
||||
Text("Checking…").font(PaperType.meta).foregroundStyle(Paper.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 24)
|
||||
@@ -85,7 +85,11 @@ struct ShareView: View {
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -99,19 +103,21 @@ struct ShareView: View {
|
||||
? "Already saved"
|
||||
: "Already saved · \(Self.relativeFormatter.localizedString(for: existing.dateAdded, relativeTo: Date()))")
|
||||
} icon: {
|
||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(Paper.affirm)
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font(PaperType.label)
|
||||
.foregroundStyle(Paper.ink)
|
||||
} else {
|
||||
Text("Save bookmark").font(.headline)
|
||||
Text("Save bookmark").font(PaperType.heading).foregroundStyle(Paper.ink)
|
||||
}
|
||||
|
||||
Text(title.isEmpty ? url : title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.font(PaperType.quote)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.lineLimit(2)
|
||||
Text(domain)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,8 +134,8 @@ struct ShareView: View {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Suggesting tags…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,11 +145,11 @@ struct ShareView: View {
|
||||
ForEach(suggestionChips, id: \.tag) { item in
|
||||
Button { addTag(item.tag) } label: {
|
||||
Text(item.isAI ? "✨ \(item.tag)" : "+ \(item.tag)")
|
||||
.font(.caption.weight(.medium))
|
||||
.font(PaperType.micro)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background((item.isAI ? Color.purple : .blue).opacity(0.12), in: Capsule())
|
||||
.foregroundStyle(item.isAI ? Color.purple : .blue)
|
||||
.background((item.isAI ? Paper.accent : Paper.ink).opacity(0.1), in: Capsule())
|
||||
.foregroundStyle(item.isAI ? Paper.accent : Paper.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -157,12 +163,14 @@ struct ShareView: View {
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Toggle("Read later", isOn: $readLater)
|
||||
.font(.subheadline)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
|
||||
Toggle(isOn: $createPodcast) {
|
||||
Label("Create podcast", systemImage: "headphones")
|
||||
}
|
||||
.font(.subheadline)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,8 +197,8 @@ struct ShareView: View {
|
||||
|
||||
private func statusCard(icon: String, tint: Color, text: String) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon).font(.title3).foregroundStyle(tint)
|
||||
Text(text).font(.headline)
|
||||
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)
|
||||
|
||||
@@ -93,6 +93,9 @@ targets:
|
||||
- path: Marks/Services/Log.swift
|
||||
# Cross-process podcast request queue: the extension enqueues, the app runs it.
|
||||
- path: Marks/Services/PodcastRequests.swift
|
||||
# The save card is a user-facing surface, so it uses the same palette and
|
||||
# type scale as the app.
|
||||
- path: Marks/Views/Library/LibraryKit.swift
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension
|
||||
|
||||
Reference in New Issue
Block a user