The tab bar drops from five to four; Tags now opens as a sheet from the + in the library's filter strip, which is the control that was already about adding a tag. Picking a tag pins it as a filter tab rather than pushing to a separate per-tag list. That made TagBookmarksView redundant — the filter tab shows the same thing, in whichever layout you're already using — so it's gone. This also fixes what the + could reach. The old inline menu listed tags found on the loaded page, so it offered 21 of 96 tags, and once a tag filter was active it collapsed to just the tags co-occurring with that one — pinning a second unrelated tag was impossible. The sheet sources names from the tags endpoint via a new BookmarksViewModel.loadAllTags(), and counts still come from loaded bookmarks, so a tag we haven't paged in shows no number rather than a wrong one. The sheet is searchable, marks already-pinned tags with a check, and picking a pinned tag selects that tab instead of duplicating it. Verified by driving the UI: the tab bar is now Bookmarks/Sources/ Podcasts/Search, + opens the sheet with 42 rows where the old menu had 21, and picking "ai" pinned an "ai" tab. Suite passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
156 lines
5.8 KiB
Swift
156 lines
5.8 KiB
Swift
import SwiftUI
|
|
|
|
private let defaultConfig = ServerConfig(
|
|
host: "linkding-production-f7e0.up.railway.app",
|
|
port: nil,
|
|
path: "",
|
|
token: "04c3388f543a6f4401ae41958b4a459a0125c4bf",
|
|
useHttps: true
|
|
)
|
|
|
|
@main
|
|
struct MarksApp: App {
|
|
@State private var serverConfig: ServerConfig = ServerConfig.load() ?? defaultConfig
|
|
|
|
var body: some Scene {
|
|
WindowGroup {
|
|
MainContainer(config: serverConfig) {
|
|
defaultConfig.save()
|
|
serverConfig = defaultConfig
|
|
}
|
|
.task { serverConfig.save() }
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct IdentifiableURL: Identifiable {
|
|
let id = UUID()
|
|
let url: URL
|
|
}
|
|
|
|
// Holds the API + ViewModel so they survive re-renders
|
|
struct MainContainer: View {
|
|
let config: ServerConfig
|
|
let onDisconnect: () -> Void
|
|
|
|
@State private var viewModel: BookmarksViewModel
|
|
@State private var deepLinkBrowser: IdentifiableURL?
|
|
@State private var showDeepLinkPlayer = false
|
|
@State private var selectedTab: AppTab = .bookmarks
|
|
@State private var router = IntentRouter.shared
|
|
@State private var library = PodcastLibrary.shared
|
|
@State private var sourceLibrary = IngestedSourceLibrary()
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
|
|
init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
|
|
self.config = config
|
|
self.onDisconnect = onDisconnect
|
|
let api = LinkdingAPI(config: config)
|
|
_viewModel = State(wrappedValue: BookmarksViewModel(api: api, cacheKey: config.host))
|
|
}
|
|
|
|
var body: some View {
|
|
TabView(selection: $selectedTab) {
|
|
Tab("Bookmarks", systemImage: "bookmark", value: AppTab.bookmarks) {
|
|
BookmarksView(viewModel: viewModel, onDisconnect: onDisconnect)
|
|
}
|
|
Tab("Sources", systemImage: "tray.full", value: AppTab.sources) {
|
|
SourcesView(
|
|
library: sourceLibrary,
|
|
podcastPlayer: viewModel.podcastPlayer,
|
|
podcastGenerator: viewModel.podcastGenerator,
|
|
claude: viewModel.claude
|
|
)
|
|
}
|
|
Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) {
|
|
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator)
|
|
}
|
|
.badge(library.unplayedCount)
|
|
Tab(value: AppTab.search, role: .search) {
|
|
SearchView(viewModel: viewModel)
|
|
}
|
|
}
|
|
.onOpenURL { url in
|
|
handleDeepLink(url)
|
|
}
|
|
.task {
|
|
applyPendingIntent()
|
|
viewModel.processPendingPodcastRequests()
|
|
viewModel.podcastPlayer.restoreSession(claude: viewModel.claude)
|
|
}
|
|
.onChange(of: router.openBookmarkURL) { _, _ in applyPendingIntent() }
|
|
.onChange(of: router.searchRequest) { _, _ in applyPendingIntent() }
|
|
.onChange(of: scenePhase) { old, new in
|
|
// Refresh when returning to the foreground (cold launch is already
|
|
// covered by BookmarksView's .task, so only react to a real re-entry).
|
|
if new == .active && old != .active {
|
|
viewModel.processPendingPodcastRequests()
|
|
Task { await viewModel.load() }
|
|
}
|
|
// Persist playback position + session when leaving the foreground so a
|
|
// relaunch can resume where you left off.
|
|
if new == .background {
|
|
viewModel.podcastPlayer.saveProgress()
|
|
}
|
|
}
|
|
.sheet(item: $deepLinkBrowser) { item in
|
|
BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator)
|
|
}
|
|
.sheet(isPresented: $showDeepLinkPlayer) {
|
|
PodcastPlayerView(
|
|
vm: viewModel.podcastPlayer,
|
|
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
|
|
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
|
|
claude: viewModel.claude,
|
|
stopOnDismiss: false
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Reacts to requests an App Intent placed on `IntentRouter` (opening a
|
|
/// bookmark, or searching). Also runs once at launch so cold-starts via an
|
|
/// intent are honored.
|
|
private func applyPendingIntent() {
|
|
if let urlString = router.openBookmarkURL {
|
|
router.openBookmarkURL = nil
|
|
if let url = URL(string: urlString) {
|
|
deepLinkBrowser = IdentifiableURL(url: url)
|
|
}
|
|
}
|
|
if let query = router.searchRequest {
|
|
selectedTab = .search
|
|
Task {
|
|
viewModel.searchQuery = query
|
|
await viewModel.search()
|
|
}
|
|
// SearchView consumes the text from the router on appear; leave it
|
|
// set until then, then SearchView clears it.
|
|
}
|
|
}
|
|
|
|
private func handleDeepLink(_ url: URL) {
|
|
guard url.scheme == "marks",
|
|
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
|
let articleUrl = comps.queryItems?.first(where: { $0.name == "url" })?.value
|
|
else { return }
|
|
|
|
switch url.host {
|
|
case "bookmark":
|
|
if let browserURL = URL(string: articleUrl) {
|
|
deepLinkBrowser = IdentifiableURL(url: browserURL)
|
|
}
|
|
case "podcast":
|
|
let podcasts = WidgetDataStore.loadPodcasts()
|
|
let found = podcasts.first { $0.articleUrl == articleUrl }
|
|
viewModel.podcastPlayer.start(
|
|
articleUrl: articleUrl,
|
|
articleTitle: found?.title ?? "",
|
|
claude: viewModel.claude
|
|
)
|
|
showDeepLinkPlayer = true
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|