mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
486 lines
18 KiB
Swift
486 lines
18 KiB
Swift
import SwiftUI
|
|
|
|
struct SearchView: View {
|
|
@EnvironmentObject var linkViewModel: LinkViewModel
|
|
@State private var searchText: String = ""
|
|
@State private var searchHistory: [String] = []
|
|
@State private var selectedFilters: Set<LinkType> = []
|
|
@State private var selectedTags: Set<Tag> = []
|
|
@State private var showingAdvancedFilters = false
|
|
|
|
@FetchRequest(
|
|
sortDescriptors: [NSSortDescriptor(keyPath: \Tag.name, ascending: true)],
|
|
animation: .default)
|
|
private var allTags: FetchedResults<Tag>
|
|
|
|
var filteredLinks: [Link] {
|
|
var links = linkViewModel.links
|
|
|
|
// Apply search text filter
|
|
if !searchText.isEmpty {
|
|
links = links.filter { link in
|
|
link.alias.localizedCaseInsensitiveContains(searchText) ||
|
|
(link.originalURL?.localizedCaseInsensitiveContains(searchText) ?? false) ||
|
|
(link.linkDescription?.localizedCaseInsensitiveContains(searchText) ?? false) ||
|
|
(link.text?.localizedCaseInsensitiveContains(searchText) ?? false) ||
|
|
link.sortedTags.contains { tag in
|
|
tag.name.localizedCaseInsensitiveContains(searchText)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply type filters
|
|
if !selectedFilters.isEmpty {
|
|
links = links.filter { link in
|
|
selectedFilters.contains(link.linkTypeEnum)
|
|
}
|
|
}
|
|
|
|
// Apply tag filters
|
|
if !selectedTags.isEmpty {
|
|
links = links.filter { link in
|
|
let linkTags = Set(link.sortedTags)
|
|
return !selectedTags.intersection(linkTags).isEmpty
|
|
}
|
|
}
|
|
|
|
return links
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
VStack {
|
|
// Search bar
|
|
HStack {
|
|
TextField("search_links", text: $searchText)
|
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
|
.onSubmit {
|
|
performSearch()
|
|
}
|
|
|
|
Button("search") {
|
|
performSearch()
|
|
}
|
|
.disabled(searchText.isEmpty)
|
|
}
|
|
.padding(.horizontal)
|
|
|
|
// Quick actions
|
|
HStack {
|
|
Button(action: {
|
|
showingAdvancedFilters.toggle()
|
|
}) {
|
|
HStack {
|
|
Image(systemName: "slider.horizontal.3")
|
|
Text("filters")
|
|
if !selectedFilters.isEmpty || !selectedTags.isEmpty {
|
|
Text("(\(selectedFilters.count + selectedTags.count))")
|
|
.foregroundColor(.blue)
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if !searchText.isEmpty && filteredLinks.isEmpty {
|
|
Button("open_as_url") {
|
|
openSearchAsURL()
|
|
}
|
|
.foregroundColor(.blue)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 8)
|
|
|
|
// Advanced filters (collapsible)
|
|
if showingAdvancedFilters {
|
|
VStack {
|
|
// Type filters
|
|
VStack(alignment: .leading) {
|
|
Text("link_types")
|
|
.font(.headline)
|
|
.padding(.horizontal)
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack {
|
|
ForEach(LinkType.allCases, id: \.self) { linkType in
|
|
FilterChip(
|
|
title: linkType.displayName,
|
|
isSelected: selectedFilters.contains(linkType),
|
|
onTap: {
|
|
if selectedFilters.contains(linkType) {
|
|
selectedFilters.remove(linkType)
|
|
} else {
|
|
selectedFilters.insert(linkType)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
|
|
// Tag filters
|
|
if !allTags.isEmpty {
|
|
VStack(alignment: .leading) {
|
|
Text("tags")
|
|
.font(.headline)
|
|
.padding(.horizontal)
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack {
|
|
ForEach(Array(allTags.prefix(10)), id: \.id) { tag in
|
|
FilterChip(
|
|
title: tag.name,
|
|
isSelected: selectedTags.contains(tag),
|
|
onTap: {
|
|
if selectedTags.contains(tag) {
|
|
selectedTags.remove(tag)
|
|
} else {
|
|
selectedTags.insert(tag)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear filters button
|
|
if !selectedFilters.isEmpty || !selectedTags.isEmpty {
|
|
Button("clear_filters") {
|
|
selectedFilters.removeAll()
|
|
selectedTags.removeAll()
|
|
}
|
|
.foregroundColor(.red)
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
.padding(.vertical)
|
|
.background(Color(.systemGray6))
|
|
}
|
|
|
|
// Results
|
|
if searchText.isEmpty && selectedFilters.isEmpty && selectedTags.isEmpty {
|
|
// Show search suggestions and history
|
|
SearchSuggestionsView(
|
|
searchHistory: searchHistory,
|
|
allTags: Array(allTags),
|
|
onSearchTap: { suggestion in
|
|
searchText = suggestion
|
|
performSearch()
|
|
}
|
|
)
|
|
} else if filteredLinks.isEmpty {
|
|
// No results
|
|
VStack {
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.system(size: 60))
|
|
.foregroundColor(.secondary)
|
|
|
|
Text("no_results_found")
|
|
.font(.title2)
|
|
.foregroundColor(.secondary)
|
|
|
|
if !searchText.isEmpty {
|
|
Button("open_as_url") {
|
|
openSearchAsURL()
|
|
}
|
|
.padding(.top)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
// Results list
|
|
List {
|
|
ForEach(filteredLinks) { link in
|
|
SearchResultRow(link: link)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("search")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
}
|
|
.onAppear {
|
|
loadSearchHistory()
|
|
}
|
|
}
|
|
|
|
private func performSearch() {
|
|
let trimmedText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmedText.isEmpty else { return }
|
|
|
|
// Add to search history
|
|
if !searchHistory.contains(trimmedText) {
|
|
searchHistory.insert(trimmedText, at: 0)
|
|
searchHistory = Array(searchHistory.prefix(10)) // Keep only last 10
|
|
saveSearchHistory()
|
|
}
|
|
|
|
// Hide keyboard
|
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
|
}
|
|
|
|
private func openSearchAsURL() {
|
|
let urlString = searchText.hasPrefix("http") ? searchText : "https://\(searchText)"
|
|
|
|
if let url = URL(string: urlString) {
|
|
UIApplication.shared.open(url)
|
|
}
|
|
}
|
|
|
|
private func loadSearchHistory() {
|
|
if let data = UserDefaults.standard.data(forKey: "searchHistory"),
|
|
let history = try? JSONDecoder().decode([String].self, from: data) {
|
|
searchHistory = history
|
|
}
|
|
}
|
|
|
|
private func saveSearchHistory() {
|
|
if let data = try? JSONEncoder().encode(searchHistory) {
|
|
UserDefaults.standard.set(data, forKey: "searchHistory")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct FilterChip: View {
|
|
let title: String
|
|
let isSelected: Bool
|
|
let onTap: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: onTap) {
|
|
Text(title)
|
|
.font(.caption)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(isSelected ? Color.blue : Color(.systemGray5))
|
|
.foregroundColor(isSelected ? .white : .primary)
|
|
.cornerRadius(16)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SearchSuggestionsView: View {
|
|
let searchHistory: [String]
|
|
let allTags: [Tag]
|
|
let onSearchTap: (String) -> Void
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
// Search history
|
|
if !searchHistory.isEmpty {
|
|
VStack(alignment: .leading) {
|
|
Text("recent_searches")
|
|
.font(.headline)
|
|
.padding(.horizontal)
|
|
|
|
ForEach(searchHistory, id: \.self) { search in
|
|
Button(action: {
|
|
onSearchTap(search)
|
|
}) {
|
|
HStack {
|
|
Image(systemName: "clock")
|
|
.foregroundColor(.secondary)
|
|
Text(search)
|
|
.foregroundColor(.primary)
|
|
Spacer()
|
|
Image(systemName: "arrow.up.left")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Popular tags
|
|
if !allTags.isEmpty {
|
|
VStack(alignment: .leading) {
|
|
Text("popular_tags")
|
|
.font(.headline)
|
|
.padding(.horizontal)
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack {
|
|
ForEach(allTags.prefix(10), id: \.id) { tag in
|
|
Button(action: {
|
|
onSearchTap(tag.name)
|
|
}) {
|
|
VStack {
|
|
Text(tag.name)
|
|
.font(.caption)
|
|
.fontWeight(.medium)
|
|
Text("\(tag.linkCount)")
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(Color(.systemGray6))
|
|
.cornerRadius(8)
|
|
}
|
|
.foregroundColor(.primary)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Quick tips
|
|
VStack(alignment: .leading) {
|
|
Text("search_tips")
|
|
.font(.headline)
|
|
.padding(.horizontal)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
SearchTip(
|
|
icon: "magnifyingglass",
|
|
title: "search_by_alias",
|
|
description: "search_by_alias_description"
|
|
)
|
|
|
|
SearchTip(
|
|
icon: "link",
|
|
title: "search_by_url",
|
|
description: "search_by_url_description"
|
|
)
|
|
|
|
SearchTip(
|
|
icon: "tag",
|
|
title: "search_by_tag",
|
|
description: "search_by_tag_description"
|
|
)
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
.padding(.vertical)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SearchTip: View {
|
|
let icon: String
|
|
let title: String
|
|
let description: String
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top) {
|
|
Image(systemName: icon)
|
|
.foregroundColor(.blue)
|
|
.frame(width: 20)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(NSLocalizedString(title, comment: ""))
|
|
.font(.caption)
|
|
.fontWeight(.medium)
|
|
Text(NSLocalizedString(description, comment: ""))
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SearchResultRow: View {
|
|
let link: Link
|
|
@EnvironmentObject var linkViewModel: LinkViewModel
|
|
|
|
var body: some View {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
// Alias with highlighting
|
|
Text(link.alias)
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
|
|
// URL or content type
|
|
if let url = link.originalURL {
|
|
Text(url)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(1)
|
|
} else {
|
|
Text("custom_content")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.italic()
|
|
}
|
|
|
|
// Tags
|
|
if !link.sortedTags.isEmpty {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 4) {
|
|
ForEach(link.sortedTags.prefix(3), id: \.id) { tag in
|
|
Text(tag.name)
|
|
.font(.caption2)
|
|
.padding(.horizontal, 4)
|
|
.padding(.vertical, 2)
|
|
.background(Color.blue.opacity(0.2))
|
|
.foregroundColor(.blue)
|
|
.cornerRadius(4)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stats
|
|
HStack {
|
|
HStack(spacing: 2) {
|
|
Image(systemName: "hand.tap")
|
|
Text("\(link.clickCount)")
|
|
}
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
|
|
Spacer()
|
|
|
|
Text(link.linkTypeEnum.displayName)
|
|
.font(.caption2)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(linkTypeColor(link.linkTypeEnum))
|
|
.foregroundColor(.white)
|
|
.cornerRadius(4)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button(action: {
|
|
linkViewModel.openLink(link)
|
|
}) {
|
|
Image(systemName: "arrow.up.right.circle.fill")
|
|
.foregroundColor(.blue)
|
|
.font(.title2)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
private func linkTypeColor(_ linkType: LinkType) -> Color {
|
|
switch linkType {
|
|
case .link:
|
|
return .blue
|
|
case .custom:
|
|
return .green
|
|
case .template:
|
|
return .orange
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
SearchView()
|
|
.environmentObject(LinkViewModel())
|
|
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
|
|
}
|