|
|
@@ -0,0 +1,652 @@
|
|
|
+import SwiftUI
|
|
|
+
|
|
|
+struct JournalFinderView: View {
|
|
|
+ @StateObject private var viewModel = JournalFinderViewModel()
|
|
|
+
|
|
|
+ private let panelBackground = Color(red: 0.96, green: 0.97, blue: 0.98)
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ GeometryReader { proxy in
|
|
|
+ let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
|
|
|
+ let dynamicTopPadding = min(max(proxy.size.height * 0.015, 8), 16)
|
|
|
+ let dynamicBottomPadding = min(max(proxy.size.height * 0.04, 20), 40)
|
|
|
+ let dynamicEditorHeight = min(max(proxy.size.height * 0.28, 180), 320)
|
|
|
+
|
|
|
+ Group {
|
|
|
+ if viewModel.hasResults, let result = viewModel.result {
|
|
|
+ resultsLayout(
|
|
|
+ result: result,
|
|
|
+ horizontalPadding: dynamicHorizontalPadding,
|
|
|
+ topPadding: dynamicTopPadding,
|
|
|
+ bottomPadding: dynamicBottomPadding
|
|
|
+ )
|
|
|
+ } else {
|
|
|
+ searchLayout(
|
|
|
+ editorHeight: dynamicEditorHeight,
|
|
|
+ horizontalPadding: dynamicHorizontalPadding,
|
|
|
+ topPadding: dynamicTopPadding,
|
|
|
+ bottomPadding: dynamicBottomPadding
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .onChange(of: viewModel.title) { newValue in
|
|
|
+ if newValue.count > JournalFinderViewModel.maxTitleLength {
|
|
|
+ viewModel.title = String(newValue.prefix(JournalFinderViewModel.maxTitleLength))
|
|
|
+ }
|
|
|
+ if viewModel.hasResults { viewModel.resetResults() }
|
|
|
+ }
|
|
|
+ .onChange(of: viewModel.abstractText) { newValue in
|
|
|
+ if newValue.count > JournalFinderViewModel.maxAbstractLength {
|
|
|
+ viewModel.abstractText = String(newValue.prefix(JournalFinderViewModel.maxAbstractLength))
|
|
|
+ }
|
|
|
+ if viewModel.hasResults { viewModel.resetResults() }
|
|
|
+ }
|
|
|
+ .onChange(of: viewModel.keywords) { newValue in
|
|
|
+ if newValue.count > JournalFinderViewModel.maxKeywordsLength {
|
|
|
+ viewModel.keywords = String(newValue.prefix(JournalFinderViewModel.maxKeywordsLength))
|
|
|
+ }
|
|
|
+ if viewModel.hasResults { viewModel.resetResults() }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Search Layout
|
|
|
+
|
|
|
+ private func searchLayout(
|
|
|
+ editorHeight: CGFloat,
|
|
|
+ horizontalPadding: CGFloat,
|
|
|
+ topPadding: CGFloat,
|
|
|
+ bottomPadding: CGFloat
|
|
|
+ ) -> some View {
|
|
|
+ ScrollView(.vertical, showsIndicators: false) {
|
|
|
+ VStack(alignment: .leading, spacing: 0) {
|
|
|
+ header
|
|
|
+ .padding(.bottom, 24)
|
|
|
+
|
|
|
+ inputCard(editorHeight: editorHeight)
|
|
|
+ .padding(.bottom, 20)
|
|
|
+
|
|
|
+ filtersCard
|
|
|
+ .padding(.bottom, 20)
|
|
|
+
|
|
|
+ if let errorMessage = viewModel.errorMessage {
|
|
|
+ errorBanner(errorMessage)
|
|
|
+ .padding(.bottom, 16)
|
|
|
+ }
|
|
|
+
|
|
|
+ GradientButton(
|
|
|
+ title: viewModel.isSearching ? "Finding Journals..." : "Find Journals"
|
|
|
+ ) {
|
|
|
+ viewModel.findJournals()
|
|
|
+ }
|
|
|
+ .disabled(!viewModel.canSearch)
|
|
|
+ }
|
|
|
+ .frame(maxWidth: 1200, alignment: .topLeading)
|
|
|
+ .frame(maxWidth: .infinity, alignment: .topLeading)
|
|
|
+ .padding(.horizontal, horizontalPadding)
|
|
|
+ .padding(.top, topPadding)
|
|
|
+ .padding(.bottom, bottomPadding)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Results Layout
|
|
|
+
|
|
|
+ private func resultsLayout(
|
|
|
+ result: JournalFinderResult,
|
|
|
+ horizontalPadding: CGFloat,
|
|
|
+ topPadding: CGFloat,
|
|
|
+ bottomPadding: CGFloat
|
|
|
+ ) -> some View {
|
|
|
+ ScrollView(.vertical, showsIndicators: false) {
|
|
|
+ VStack(alignment: .leading, spacing: 0) {
|
|
|
+ resultsHeader(result: result)
|
|
|
+ .padding(.bottom, 24)
|
|
|
+
|
|
|
+ LazyVStack(spacing: 16) {
|
|
|
+ ForEach(Array(result.recommendations.enumerated()), id: \.element.id) { index, journal in
|
|
|
+ journalCard(journal, rank: index + 1)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .frame(maxWidth: 1200, alignment: .topLeading)
|
|
|
+ .frame(maxWidth: .infinity, alignment: .topLeading)
|
|
|
+ .padding(.horizontal, horizontalPadding)
|
|
|
+ .padding(.top, topPadding)
|
|
|
+ .padding(.bottom, bottomPadding)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Header
|
|
|
+
|
|
|
+ private var header: some View {
|
|
|
+ HStack(alignment: .top) {
|
|
|
+ VStack(alignment: .leading, spacing: 8) {
|
|
|
+ Text("Journal Finder")
|
|
|
+ .font(.system(size: 30, weight: .bold))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+
|
|
|
+ HStack(spacing: 0) {
|
|
|
+ Text("Find the ")
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ Text("right journal")
|
|
|
+ .fontWeight(.semibold)
|
|
|
+ .foregroundStyle(AppTheme.teal)
|
|
|
+ Text(" for your ")
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ Text("research")
|
|
|
+ .fontWeight(.semibold)
|
|
|
+ .foregroundStyle(AppTheme.teal)
|
|
|
+ Text(".")
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ }
|
|
|
+ .font(.system(size: 15))
|
|
|
+ }
|
|
|
+
|
|
|
+ Spacer()
|
|
|
+
|
|
|
+ IconButton {
|
|
|
+ SettingsGearIcon()
|
|
|
+ } action: {}
|
|
|
+ .accessibilityLabel("Settings")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func resultsHeader(result: JournalFinderResult) -> some View {
|
|
|
+ VStack(alignment: .leading, spacing: 16) {
|
|
|
+ IconButton(iconName: "arrow.left") {
|
|
|
+ viewModel.resetResults()
|
|
|
+ }
|
|
|
+ .accessibilityLabel("Go back")
|
|
|
+
|
|
|
+ HStack(alignment: .top) {
|
|
|
+ VStack(alignment: .leading, spacing: 8) {
|
|
|
+ Text("Recommended Journals")
|
|
|
+ .font(.system(size: 30, weight: .bold))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+
|
|
|
+ Text(result.summary)
|
|
|
+ .font(.system(size: 15))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ .fixedSize(horizontal: false, vertical: true)
|
|
|
+ }
|
|
|
+
|
|
|
+ Spacer()
|
|
|
+
|
|
|
+ resultCountBadge(count: result.recommendations.count)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Input Card
|
|
|
+
|
|
|
+ private func inputCard(editorHeight: CGFloat) -> some View {
|
|
|
+ VStack(alignment: .leading, spacing: 18) {
|
|
|
+ Image("YourContentLogo")
|
|
|
+ .resizable()
|
|
|
+ .aspectRatio(contentMode: .fit)
|
|
|
+ .frame(height: 52, alignment: .topLeading)
|
|
|
+ .frame(height: 46, alignment: .topLeading)
|
|
|
+ .clipped()
|
|
|
+ .accessibilityLabel("Your Content")
|
|
|
+
|
|
|
+ titleField
|
|
|
+ abstractEditor(height: editorHeight)
|
|
|
+ keywordsField
|
|
|
+
|
|
|
+ HStack(spacing: 8) {
|
|
|
+ ToolbarButton(title: "Paste Abstract", iconName: "doc.on.clipboard") {
|
|
|
+ viewModel.pasteAbstract()
|
|
|
+ }
|
|
|
+
|
|
|
+ Spacer()
|
|
|
+
|
|
|
+ ClearButton {
|
|
|
+ viewModel.clearAll()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .padding(22)
|
|
|
+ .background(AppTheme.cardBackground)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
|
|
|
+ .stroke(AppTheme.border, lineWidth: 1)
|
|
|
+ )
|
|
|
+ .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
|
|
|
+ }
|
|
|
+
|
|
|
+ private var titleField: some View {
|
|
|
+ VStack(alignment: .leading, spacing: 8) {
|
|
|
+ fieldLabel("Manuscript Title", optional: true)
|
|
|
+
|
|
|
+ TextField("Enter your paper title...", text: $viewModel.title)
|
|
|
+ .textFieldStyle(.plain)
|
|
|
+ .font(.system(size: 15))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+ .padding(.horizontal, 16)
|
|
|
+ .padding(.vertical, 13)
|
|
|
+ .background(Color.white)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
|
|
|
+ .stroke(AppTheme.inputBorder, lineWidth: 1)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func abstractEditor(height: CGFloat) -> some View {
|
|
|
+ VStack(alignment: .leading, spacing: 8) {
|
|
|
+ fieldLabel("Abstract or Summary", optional: false)
|
|
|
+
|
|
|
+ ZStack(alignment: .topLeading) {
|
|
|
+ ThinCaretTextEditor(text: $viewModel.abstractText)
|
|
|
+ .frame(height: height)
|
|
|
+
|
|
|
+ if viewModel.abstractText.isEmpty {
|
|
|
+ Text("Paste your abstract, introduction, or manuscript summary here...")
|
|
|
+ .font(.system(size: 15))
|
|
|
+ .foregroundStyle(AppTheme.textMuted)
|
|
|
+ .padding(.horizontal, AppTheme.textEditorHorizontalInset + 2)
|
|
|
+ .padding(.vertical, AppTheme.textEditorVerticalInset + 2)
|
|
|
+ .allowsHitTesting(false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .background(Color.white)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
|
|
|
+ .stroke(AppTheme.inputBorder, lineWidth: 1)
|
|
|
+ )
|
|
|
+
|
|
|
+ Text(viewModel.abstractCharacterCountLabel)
|
|
|
+ .font(.system(size: 12))
|
|
|
+ .foregroundStyle(AppTheme.textMuted)
|
|
|
+ .frame(maxWidth: .infinity, alignment: .trailing)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var keywordsField: some View {
|
|
|
+ VStack(alignment: .leading, spacing: 8) {
|
|
|
+ fieldLabel("Keywords", optional: true)
|
|
|
+
|
|
|
+ TextField("e.g. machine learning, climate change, public health", text: $viewModel.keywords)
|
|
|
+ .textFieldStyle(.plain)
|
|
|
+ .font(.system(size: 15))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+ .padding(.horizontal, 16)
|
|
|
+ .padding(.vertical, 13)
|
|
|
+ .background(Color.white)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
|
|
|
+ .stroke(AppTheme.inputBorder, lineWidth: 1)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Filters Card
|
|
|
+
|
|
|
+ private var filtersCard: some View {
|
|
|
+ VStack(alignment: .leading, spacing: 18) {
|
|
|
+ Text("Search Preferences")
|
|
|
+ .font(.system(size: 15, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+
|
|
|
+ HStack(spacing: 16) {
|
|
|
+ fieldPicker
|
|
|
+ Spacer()
|
|
|
+ }
|
|
|
+
|
|
|
+ HStack(spacing: 20) {
|
|
|
+ Toggle(isOn: $viewModel.openAccessOnly) {
|
|
|
+ Label("Open Access only", systemImage: "lock.open.fill")
|
|
|
+ .font(.system(size: 14, weight: .medium))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ }
|
|
|
+ .toggleStyle(.switch)
|
|
|
+ .tint(AppTheme.teal)
|
|
|
+ }
|
|
|
+
|
|
|
+ VStack(alignment: .leading, spacing: 10) {
|
|
|
+ Text("Indexed in")
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+
|
|
|
+ HStack(spacing: 8) {
|
|
|
+ ForEach(JournalIndex.allCases) { index in
|
|
|
+ FilterChip(
|
|
|
+ title: index.rawValue,
|
|
|
+ isSelected: viewModel.selectedIndexes.contains(index)
|
|
|
+ ) {
|
|
|
+ viewModel.toggleIndex(index)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .padding(22)
|
|
|
+ .background(AppTheme.cardBackground)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
|
|
|
+ .stroke(AppTheme.border, lineWidth: 1)
|
|
|
+ )
|
|
|
+ .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
|
|
|
+ }
|
|
|
+
|
|
|
+ private var fieldPicker: some View {
|
|
|
+ VStack(alignment: .leading, spacing: 8) {
|
|
|
+ Text("Research Field")
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+
|
|
|
+ ResearchFieldPicker(selection: $viewModel.field)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Journal Card
|
|
|
+
|
|
|
+ private func journalCard(_ journal: JournalRecommendation, rank: Int) -> some View {
|
|
|
+ VStack(alignment: .leading, spacing: 16) {
|
|
|
+ HStack(alignment: .top, spacing: 16) {
|
|
|
+ MatchScoreRing(score: journal.matchScore)
|
|
|
+
|
|
|
+ VStack(alignment: .leading, spacing: 6) {
|
|
|
+ HStack(spacing: 10) {
|
|
|
+ RankBadge(rank: rank)
|
|
|
+
|
|
|
+ if journal.isOpenAccess {
|
|
|
+ StatusBadge(title: "Open Access", iconName: "lock.open.fill", style: .teal)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Text(journal.name)
|
|
|
+ .font(.system(size: 20, weight: .bold))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+ .fixedSize(horizontal: false, vertical: true)
|
|
|
+
|
|
|
+ Text(journal.publisher)
|
|
|
+ .font(.system(size: 14))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ }
|
|
|
+
|
|
|
+ Spacer()
|
|
|
+
|
|
|
+ if journal.websiteURL != nil {
|
|
|
+ Button {
|
|
|
+ viewModel.openJournalWebsite(journal)
|
|
|
+ } label: {
|
|
|
+ HStack(spacing: 6) {
|
|
|
+ Image(systemName: "safari")
|
|
|
+ .font(.system(size: 12, weight: .semibold))
|
|
|
+ Text("Visit")
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
+ }
|
|
|
+ .foregroundStyle(AppTheme.teal)
|
|
|
+ .padding(.horizontal, 14)
|
|
|
+ .padding(.vertical, 9)
|
|
|
+ .background(AppTheme.tealLight)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: 10))
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Text(journal.scope)
|
|
|
+ .font(.system(size: 14))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+ .fixedSize(horizontal: false, vertical: true)
|
|
|
+
|
|
|
+ HStack(spacing: 8) {
|
|
|
+ ForEach(journal.indexes, id: \.self) { index in
|
|
|
+ StatusBadge(title: index, iconName: "checkmark.seal.fill", style: .neutral)
|
|
|
+ }
|
|
|
+
|
|
|
+ if let weeks = journal.averageReviewWeeks {
|
|
|
+ StatusBadge(
|
|
|
+ title: "~\(weeks) wk review",
|
|
|
+ iconName: "clock",
|
|
|
+ style: .neutral
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ VStack(alignment: .leading, spacing: 6) {
|
|
|
+ Text("Why this journal")
|
|
|
+ .font(.system(size: 12, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+
|
|
|
+ Text(journal.fitReason)
|
|
|
+ .font(.system(size: 14))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ .fixedSize(horizontal: false, vertical: true)
|
|
|
+ }
|
|
|
+ .padding(14)
|
|
|
+ .frame(maxWidth: .infinity, alignment: .leading)
|
|
|
+ .background(panelBackground)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
|
|
|
+ .stroke(AppTheme.border.opacity(0.6), lineWidth: 1)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ .padding(22)
|
|
|
+ .background(AppTheme.cardBackground)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
|
|
|
+ .stroke(AppTheme.border, lineWidth: 1)
|
|
|
+ )
|
|
|
+ .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Helpers
|
|
|
+
|
|
|
+ private func fieldLabel(_ title: String, optional: Bool) -> some View {
|
|
|
+ HStack(spacing: 4) {
|
|
|
+ Text(title)
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+
|
|
|
+ if optional {
|
|
|
+ Text("(optional)")
|
|
|
+ .font(.system(size: 12))
|
|
|
+ .foregroundStyle(AppTheme.textMuted)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func errorBanner(_ message: String) -> some View {
|
|
|
+ HStack(spacing: 10) {
|
|
|
+ Image(systemName: "exclamationmark.triangle.fill")
|
|
|
+ .font(.system(size: 14))
|
|
|
+ .foregroundStyle(AppTheme.clearForeground)
|
|
|
+
|
|
|
+ Text(message)
|
|
|
+ .font(.system(size: 14))
|
|
|
+ .foregroundStyle(AppTheme.clearForeground)
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity, alignment: .leading)
|
|
|
+ .padding(.horizontal, 16)
|
|
|
+ .padding(.vertical, 12)
|
|
|
+ .background(AppTheme.clearBackground)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: 10))
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: 10)
|
|
|
+ .stroke(AppTheme.clearBorder, lineWidth: 1)
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ private func resultCountBadge(count: Int) -> some View {
|
|
|
+ Text("\(count) matches")
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.teal)
|
|
|
+ .padding(.horizontal, 14)
|
|
|
+ .padding(.vertical, 8)
|
|
|
+ .background(AppTheme.tealLight)
|
|
|
+ .clipShape(Capsule())
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// MARK: - Subviews
|
|
|
+
|
|
|
+private struct ResearchFieldPicker: View {
|
|
|
+ @Binding var selection: ResearchField
|
|
|
+ @State private var isHovered = false
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ Menu {
|
|
|
+ ForEach(ResearchField.allCases) { field in
|
|
|
+ Button {
|
|
|
+ selection = field
|
|
|
+ } label: {
|
|
|
+ HStack {
|
|
|
+ Text(field.displayName)
|
|
|
+ if selection == field {
|
|
|
+ Spacer()
|
|
|
+ Image(systemName: "checkmark")
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } label: {
|
|
|
+ HStack(spacing: 8) {
|
|
|
+ Image(systemName: "books.vertical")
|
|
|
+ .font(.system(size: 12))
|
|
|
+
|
|
|
+ Text(selection.displayName)
|
|
|
+ .font(.system(size: 13, weight: .medium))
|
|
|
+
|
|
|
+ Spacer(minLength: 0)
|
|
|
+
|
|
|
+ Image(systemName: "chevron.down")
|
|
|
+ .font(.system(size: 10, weight: .semibold))
|
|
|
+ }
|
|
|
+ .foregroundStyle(isHovered ? AppTheme.toolbarForegroundHover : AppTheme.textSecondary)
|
|
|
+ .frame(maxWidth: 280, alignment: .leading)
|
|
|
+ .contentShape(Rectangle())
|
|
|
+ }
|
|
|
+ .menuStyle(.borderlessButton)
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ .padding(.horizontal, 16)
|
|
|
+ .padding(.vertical, 10)
|
|
|
+ .background(isHovered ? AppTheme.toolbarBackgroundHover : Color.white)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
|
|
|
+ .stroke(isHovered ? AppTheme.toolbarBorderHover : AppTheme.inputBorder, lineWidth: 1)
|
|
|
+ )
|
|
|
+ .onHover { isHovered = $0 }
|
|
|
+ .accessibilityLabel("Research field: \(selection.displayName)")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct FilterChip: View {
|
|
|
+ let title: String
|
|
|
+ let isSelected: Bool
|
|
|
+ let action: () -> Void
|
|
|
+
|
|
|
+ @State private var isHovered = false
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ Button(action: action) {
|
|
|
+ Text(title)
|
|
|
+ .font(.system(size: 12, weight: .medium))
|
|
|
+ .foregroundStyle(isSelected ? AppTheme.teal : AppTheme.textSecondary)
|
|
|
+ .padding(.horizontal, 12)
|
|
|
+ .padding(.vertical, 7)
|
|
|
+ .background(isSelected ? AppTheme.tealLight : (isHovered ? AppTheme.toolbarBackgroundHover : Color.white))
|
|
|
+ .clipShape(Capsule())
|
|
|
+ .overlay(
|
|
|
+ Capsule()
|
|
|
+ .stroke(isSelected ? AppTheme.teal.opacity(0.4) : AppTheme.border.opacity(0.7), lineWidth: 1)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ .onHover { isHovered = $0 }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct MatchScoreRing: View {
|
|
|
+ let score: Int
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ ZStack {
|
|
|
+ Circle()
|
|
|
+ .stroke(AppTheme.border.opacity(0.5), lineWidth: 4)
|
|
|
+ .frame(width: 56, height: 56)
|
|
|
+
|
|
|
+ Circle()
|
|
|
+ .trim(from: 0, to: CGFloat(score) / 100)
|
|
|
+ .stroke(
|
|
|
+ AppTheme.primaryGradient,
|
|
|
+ style: StrokeStyle(lineWidth: 4, lineCap: .round)
|
|
|
+ )
|
|
|
+ .rotationEffect(.degrees(-90))
|
|
|
+ .frame(width: 56, height: 56)
|
|
|
+
|
|
|
+ Text("\(score)%")
|
|
|
+ .font(.system(size: 13, weight: .bold))
|
|
|
+ .foregroundStyle(AppTheme.teal)
|
|
|
+ }
|
|
|
+ .accessibilityLabel("\(score) percent match")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct RankBadge: View {
|
|
|
+ let rank: Int
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ Text("#\(rank)")
|
|
|
+ .font(.system(size: 11, weight: .bold))
|
|
|
+ .foregroundStyle(AppTheme.textMuted)
|
|
|
+ .padding(.horizontal, 8)
|
|
|
+ .padding(.vertical, 4)
|
|
|
+ .background(panelBackgroundColor)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: 6))
|
|
|
+ }
|
|
|
+
|
|
|
+ private var panelBackgroundColor: Color {
|
|
|
+ Color(red: 0.96, green: 0.97, blue: 0.98)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct StatusBadge: View {
|
|
|
+ enum Style {
|
|
|
+ case teal
|
|
|
+ case neutral
|
|
|
+ }
|
|
|
+
|
|
|
+ let title: String
|
|
|
+ let iconName: String
|
|
|
+ let style: Style
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ HStack(spacing: 5) {
|
|
|
+ Image(systemName: iconName)
|
|
|
+ .font(.system(size: 10, weight: .semibold))
|
|
|
+
|
|
|
+ Text(title)
|
|
|
+ .font(.system(size: 11, weight: .medium))
|
|
|
+ }
|
|
|
+ .foregroundStyle(foregroundColor)
|
|
|
+ .padding(.horizontal, 10)
|
|
|
+ .padding(.vertical, 5)
|
|
|
+ .background(backgroundColor)
|
|
|
+ .clipShape(Capsule())
|
|
|
+ }
|
|
|
+
|
|
|
+ private var foregroundColor: Color {
|
|
|
+ switch style {
|
|
|
+ case .teal: AppTheme.teal
|
|
|
+ case .neutral: AppTheme.textSecondary
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var backgroundColor: Color {
|
|
|
+ switch style {
|
|
|
+ case .teal: AppTheme.tealLight
|
|
|
+ case .neutral: Color.white
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|