Просмотр исходного кода

Add Journal Finder page with AI-powered journal recommendations.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 месяц назад
Родитель
Сommit
8df488eae0

+ 79 - 0
gramora/Models/JournalFinderModels.swift

@@ -0,0 +1,79 @@
+import Foundation
+
+struct JournalFinderSearchCriteria: Equatable {
+    var title: String = ""
+    var abstract: String = ""
+    var keywords: String = ""
+    var field: ResearchField = .all
+    var openAccessOnly: Bool = false
+    var selectedIndexes: Set<JournalIndex> = []
+}
+
+enum ResearchField: String, CaseIterable, Identifiable {
+    case all
+    case computerScience = "Computer Science"
+    case medicine = "Medicine & Health"
+    case engineering = "Engineering"
+    case business = "Business & Economics"
+    case socialSciences = "Social Sciences"
+    case naturalSciences = "Natural Sciences"
+    case humanities = "Humanities"
+    case law = "Law"
+    case education = "Education"
+
+    var id: String { rawValue }
+
+    var displayName: String {
+        switch self {
+        case .all: "All Fields"
+        default: rawValue
+        }
+    }
+}
+
+enum JournalIndex: String, CaseIterable, Identifiable {
+    case scopus = "Scopus"
+    case webOfScience = "Web of Science"
+    case pubmed = "PubMed"
+    case doaj = "DOAJ"
+
+    var id: String { rawValue }
+}
+
+struct JournalRecommendation: Identifiable, Equatable {
+    let id = UUID()
+    let name: String
+    let publisher: String
+    let matchScore: Int
+    let scope: String
+    let fitReason: String
+    let isOpenAccess: Bool
+    let indexes: [String]
+    let websiteURL: String?
+    let averageReviewWeeks: Int?
+}
+
+struct JournalFinderResult: Equatable {
+    let recommendations: [JournalRecommendation]
+    let summary: String
+}
+
+enum JournalFinderServiceError: LocalizedError {
+    case emptyInput
+    case missingAPIKey
+    case invalidResponse
+    case apiError(String)
+
+    var errorDescription: String? {
+        switch self {
+        case .emptyInput:
+            "Enter an abstract or manuscript summary to find matching journals."
+        case .missingAPIKey:
+            "OpenAI API key is not configured."
+        case .invalidResponse:
+            "Could not read the journal finder response."
+        case .apiError(let message):
+            message
+        }
+    }
+}

+ 203 - 0
gramora/Services/JournalFinderService.swift

@@ -0,0 +1,203 @@
+import Foundation
+
+protocol JournalFinderServicing {
+    func findJournals(for criteria: JournalFinderSearchCriteria) async throws -> JournalFinderResult
+}
+
+struct JournalFinderService: JournalFinderServicing {
+    private let session: URLSession
+    private let apiKeyProvider: () -> String?
+
+    init(
+        session: URLSession = .shared,
+        apiKeyProvider: @escaping () -> String? = { APIConfiguration.openAIAPIKey }
+    ) {
+        self.session = session
+        self.apiKeyProvider = apiKeyProvider
+    }
+
+    func findJournals(for criteria: JournalFinderSearchCriteria) async throws -> JournalFinderResult {
+        let abstract = criteria.abstract.trimmingCharacters(in: .whitespacesAndNewlines)
+        let title = criteria.title.trimmingCharacters(in: .whitespacesAndNewlines)
+        let keywords = criteria.keywords.trimmingCharacters(in: .whitespacesAndNewlines)
+
+        guard !abstract.isEmpty || !title.isEmpty else {
+            throw JournalFinderServiceError.emptyInput
+        }
+
+        guard let apiKey = apiKeyProvider() else {
+            throw JournalFinderServiceError.missingAPIKey
+        }
+
+        var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
+        request.httpMethod = "POST"
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+
+        let userPrompt = Self.buildUserPrompt(from: criteria, title: title, abstract: abstract, keywords: keywords)
+
+        let body = JournalFinderChatRequest(
+            model: "gpt-4o-mini",
+            messages: [
+                .init(
+                    role: "system",
+                    content: """
+                    You are an academic publishing advisor. Recommend real, well-known scholarly \
+                    journals that fit the user's research. Respond with JSON only using this schema:
+                    {
+                      "summary": "one sentence overview of the search",
+                      "recommendations": [
+                        {
+                          "name": "journal title",
+                          "publisher": "publisher name",
+                          "matchScore": 85,
+                          "scope": "brief scope description",
+                          "fitReason": "why this journal fits the manuscript",
+                          "isOpenAccess": true,
+                          "indexes": ["Scopus", "Web of Science"],
+                          "websiteURL": "https://example.com",
+                          "averageReviewWeeks": 12
+                        }
+                      ]
+                    }
+                    Return 5 to 8 journals sorted by matchScore descending. \
+                    matchScore must be 0-100. Use only real journals. \
+                    averageReviewWeeks may be null if unknown.
+                    """
+                ),
+                .init(role: "user", content: userPrompt)
+            ],
+            responseFormat: .init(type: "json_object")
+        )
+
+        request.httpBody = try JSONEncoder().encode(body)
+
+        let (data, response) = try await session.data(for: request)
+
+        if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
+            let message = Self.errorMessage(from: data) ?? "Request failed with status \(httpResponse.statusCode)."
+            throw JournalFinderServiceError.apiError(message)
+        }
+
+        let completion = try JSONDecoder().decode(JournalFinderChatResponse.self, from: data)
+        guard
+            let content = completion.choices.first?.message.content,
+            let contentData = content.data(using: .utf8)
+        else {
+            throw JournalFinderServiceError.invalidResponse
+        }
+
+        let decoded = try JSONDecoder().decode(JournalFinderAPIResponse.self, from: contentData)
+        let recommendations = decoded.recommendations.map {
+            JournalRecommendation(
+                name: $0.name,
+                publisher: $0.publisher,
+                matchScore: min(100, max(0, $0.matchScore)),
+                scope: $0.scope,
+                fitReason: $0.fitReason,
+                isOpenAccess: $0.isOpenAccess,
+                indexes: $0.indexes,
+                websiteURL: $0.websiteURL,
+                averageReviewWeeks: $0.averageReviewWeeks
+            )
+        }
+
+        return JournalFinderResult(
+            recommendations: recommendations.sorted { $0.matchScore > $1.matchScore },
+            summary: decoded.summary
+        )
+    }
+
+    private static func buildUserPrompt(
+        from criteria: JournalFinderSearchCriteria,
+        title: String,
+        abstract: String,
+        keywords: String
+    ) -> String {
+        var lines: [String] = []
+
+        if !title.isEmpty {
+            lines.append("Title: \(title)")
+        }
+        if !abstract.isEmpty {
+            lines.append("Abstract / Summary: \(abstract)")
+        }
+        if !keywords.isEmpty {
+            lines.append("Keywords: \(keywords)")
+        }
+        if criteria.field != .all {
+            lines.append("Preferred field: \(criteria.field.displayName)")
+        }
+        if criteria.openAccessOnly {
+            lines.append("Requirement: Open access journals only.")
+        }
+        if !criteria.selectedIndexes.isEmpty {
+            let names = criteria.selectedIndexes.map(\.rawValue).sorted().joined(separator: ", ")
+            lines.append("Must be indexed in: \(names)")
+        }
+
+        return lines.joined(separator: "\n")
+    }
+
+    private static func errorMessage(from data: Data) -> String? {
+        struct APIErrorResponse: Decodable {
+            struct APIError: Decodable {
+                let message: String
+            }
+
+            let error: APIError
+        }
+
+        return (try? JSONDecoder().decode(APIErrorResponse.self, from: data))?.error.message
+    }
+}
+
+private struct JournalFinderAPIResponse: Decodable {
+    struct Recommendation: Decodable {
+        let name: String
+        let publisher: String
+        let matchScore: Int
+        let scope: String
+        let fitReason: String
+        let isOpenAccess: Bool
+        let indexes: [String]
+        let websiteURL: String?
+        let averageReviewWeeks: Int?
+    }
+
+    let summary: String
+    let recommendations: [Recommendation]
+}
+
+private struct JournalFinderChatRequest: Encodable {
+    struct Message: Encodable {
+        let role: String
+        let content: String
+    }
+
+    struct ResponseFormat: Encodable {
+        let type: String
+    }
+
+    let model: String
+    let messages: [Message]
+    let responseFormat: ResponseFormat
+
+    enum CodingKeys: String, CodingKey {
+        case model
+        case messages
+        case responseFormat = "response_format"
+    }
+}
+
+private struct JournalFinderChatResponse: Decodable {
+    struct Choice: Decodable {
+        struct Message: Decodable {
+            let content: String
+        }
+
+        let message: Message
+    }
+
+    let choices: [Choice]
+}

+ 111 - 0
gramora/ViewModels/JournalFinderViewModel.swift

@@ -0,0 +1,111 @@
+import AppKit
+import Combine
+import Foundation
+
+@MainActor
+final class JournalFinderViewModel: ObservableObject {
+    static let maxTitleLength = 300
+    static let maxAbstractLength = 10_000
+    static let maxKeywordsLength = 500
+
+    @Published var title: String = ""
+    @Published var abstractText: String = ""
+    @Published var keywords: String = ""
+    @Published var field: ResearchField = .all
+    @Published var openAccessOnly: Bool = false
+    @Published var selectedIndexes: Set<JournalIndex> = []
+
+    @Published private(set) var result: JournalFinderResult?
+    @Published private(set) var hasResults = false
+    @Published private(set) var isSearching = false
+    @Published private(set) var errorMessage: String?
+
+    private let service: JournalFinderServicing
+
+    init(service: JournalFinderServicing = JournalFinderService()) {
+        self.service = service
+    }
+
+    var abstractCharacterCount: Int { abstractText.count }
+
+    var abstractCharacterCountLabel: String {
+        "\(abstractCharacterCount)/\(Self.maxAbstractLength)"
+    }
+
+    var canSearch: Bool {
+        let hasInput = !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+            || !abstractText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+        return hasInput && !isSearching
+    }
+
+    var searchCriteria: JournalFinderSearchCriteria {
+        JournalFinderSearchCriteria(
+            title: title,
+            abstract: abstractText,
+            keywords: keywords,
+            field: field,
+            openAccessOnly: openAccessOnly,
+            selectedIndexes: selectedIndexes
+        )
+    }
+
+    func pasteAbstract() {
+        guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
+        abstractText = String(clipboard.prefix(Self.maxAbstractLength))
+        resetResults()
+    }
+
+    func clearAll() {
+        title = ""
+        abstractText = ""
+        keywords = ""
+        field = .all
+        openAccessOnly = false
+        selectedIndexes = []
+        resetResults()
+    }
+
+    func resetResults() {
+        result = nil
+        hasResults = false
+        errorMessage = nil
+    }
+
+    func toggleIndex(_ index: JournalIndex) {
+        if selectedIndexes.contains(index) {
+            selectedIndexes.remove(index)
+        } else {
+            selectedIndexes.insert(index)
+        }
+        if hasResults {
+            resetResults()
+        }
+    }
+
+    func findJournals() {
+        guard canSearch else { return }
+
+        isSearching = true
+        errorMessage = nil
+
+        Task {
+            do {
+                let searchResult = try await service.findJournals(for: searchCriteria)
+                result = searchResult
+                hasResults = true
+            } catch {
+                errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+            }
+
+            isSearching = false
+        }
+    }
+
+    func openJournalWebsite(_ journal: JournalRecommendation) {
+        guard
+            let urlString = journal.websiteURL,
+            let url = URL(string: urlString)
+        else { return }
+        NSWorkspace.shared.open(url)
+    }
+}

+ 652 - 0
gramora/Views/JournalFinderView.swift

@@ -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
+        }
+    }
+}

+ 2 - 0
gramora/Views/MainView.swift

@@ -68,6 +68,8 @@ struct MainView: View {
             SpellCheckerView()
         case .dictionary:
             DictionaryView()
+        case .journalFinder:
+            JournalFinderView()
         default:
             PlaceholderView(destination: viewModel.selectedDestination)
         }