159 lines
5.9 KiB
Swift
159 lines
5.9 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("Tags", systemImage: "tag", value: AppTab.tags) {
|
|
TagsView(viewModel: viewModel)
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|