mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
526 lines
21 KiB
Swift
526 lines
21 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
|
|
struct LinkListView: View {
|
|
@EnvironmentObject var linkViewModel: LinkViewModel
|
|
@Environment(\.managedObjectContext) private var viewContext
|
|
@State private var showingAddLink = false
|
|
@State private var showingFilterSheet = false
|
|
@State private var selectedLink: Link?
|
|
@State private var showingMarkdownContent = false
|
|
@State private var markdownLink: Link?
|
|
@State private var showingTemplateParameters = false
|
|
@State private var templateLink: Link?
|
|
@State private var templateParameters: [String: String] = [:]
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
VStack {
|
|
// Search bar
|
|
SearchBar(text: $linkViewModel.searchText,
|
|
onCustomContentTap: { link in
|
|
markdownLink = link
|
|
showingMarkdownContent = true
|
|
},
|
|
onTemplateParameterInput: { link in
|
|
templateLink = link
|
|
templateParameters = [:]
|
|
showingTemplateParameters = true
|
|
})
|
|
.environmentObject(linkViewModel)
|
|
.padding(.horizontal)
|
|
|
|
// Filter and sort bar
|
|
HStack {
|
|
Button(action: { showingFilterSheet = true }) {
|
|
HStack {
|
|
Image(systemName: "line.horizontal.3.decrease.circle")
|
|
Text("filter")
|
|
if linkViewModel.selectedTag != nil { Text("•").foregroundColor(.blue) }
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Sorting controls
|
|
HStack(spacing: 8) {
|
|
Button(action: { linkViewModel.sortAscending = true }) {
|
|
Image(systemName: "arrow.up")
|
|
.foregroundColor(linkViewModel.sortAscending ? .blue : .gray)
|
|
}
|
|
Button(action: { linkViewModel.sortAscending = false }) {
|
|
Image(systemName: "arrow.down")
|
|
.foregroundColor(!linkViewModel.sortAscending ? .blue : .gray)
|
|
}
|
|
Menu {
|
|
ForEach(LinkViewModel.SortOption.allCases, id: \.self) { option in
|
|
Button(action: { linkViewModel.selectedSortOption = option }) {
|
|
HStack {
|
|
Text(option.displayName)
|
|
if linkViewModel.selectedSortOption == option { Image(systemName: "checkmark") }
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
Text(linkViewModel.selectedSortOption.displayName)
|
|
.foregroundColor(.blue)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 8)
|
|
|
|
// Count indicator (shows filtered count and total)
|
|
if !linkViewModel.isLoading {
|
|
HStack(spacing: 6) {
|
|
let filtered = linkViewModel.filteredLinks.count
|
|
let total = linkViewModel.links.count
|
|
Text("\(filtered)")
|
|
.font(.caption)
|
|
.fontWeight(.semibold)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.secondary.opacity(0.15))
|
|
.foregroundColor(.secondary)
|
|
.cornerRadius(4)
|
|
if filtered != total && total > 0 {
|
|
Text("of \(total)")
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.bottom, 2)
|
|
}
|
|
|
|
// Links list
|
|
if linkViewModel.isLoading {
|
|
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if linkViewModel.filteredLinks.isEmpty {
|
|
VStack {
|
|
Image(systemName: "link.badge.plus").font(.system(size: 60)).foregroundColor(.secondary)
|
|
Text("no_links_found").font(.title2).foregroundColor(.secondary)
|
|
Text("tap_plus_to_add_link").font(.caption).foregroundColor(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
List {
|
|
ForEach(linkViewModel.filteredLinks) { link in
|
|
LinkRowView(link: link) { customLink in
|
|
markdownLink = customLink
|
|
showingMarkdownContent = true
|
|
}
|
|
.onTapGesture { selectedLink = link }
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
Button("delete", role: .destructive) { linkViewModel.deleteLink(link) }
|
|
Button("edit") { selectedLink = link; showingAddLink = true }.tint(.blue)
|
|
}
|
|
}
|
|
}
|
|
.refreshable { linkViewModel.fetchLinks() }
|
|
}
|
|
}
|
|
.navigationTitle("")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
HStack { Image(systemName: "dog.fill").foregroundColor(.black); Text("Heygo").font(.headline).foregroundColor(.primary) }
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button(action: { selectedLink = nil; showingAddLink = true }) { Image(systemName: "plus") }
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingAddLink) { LinkFormView(link: selectedLink).environmentObject(linkViewModel) }
|
|
.sheet(isPresented: $showingFilterSheet) { FilterView().environmentObject(linkViewModel) }
|
|
.sheet(item: $selectedLink) { link in LinkDetailView(link: link).environmentObject(linkViewModel) }
|
|
.sheet(isPresented: $showingMarkdownContent) { if let markdownLink = markdownLink { MarkdownContentView(link: markdownLink) } }
|
|
.sheet(isPresented: $showingTemplateParameters) {
|
|
if let templateLink = templateLink {
|
|
TemplateParameterView(
|
|
link: templateLink,
|
|
parameters: $templateParameters,
|
|
onOpen: { params in
|
|
linkViewModel.openLink(templateLink, with: params)
|
|
showingTemplateParameters = false
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.onAppear { if linkViewModel.links.isEmpty { linkViewModel.fetchLinks() } }
|
|
.alert("Error", isPresented: .constant(linkViewModel.errorMessage != nil)) { Button("OK") { linkViewModel.errorMessage = nil } } message: { Text(linkViewModel.errorMessage ?? "") }
|
|
}
|
|
}
|
|
|
|
struct LinkRowView: View {
|
|
let link: Link
|
|
let onCustomContentTap: (Link) -> Void
|
|
@EnvironmentObject var linkViewModel: LinkViewModel
|
|
|
|
var body: some View {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
// Alias
|
|
HStack {
|
|
Text(link.alias)
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
|
|
Spacer()
|
|
|
|
// Link type badge
|
|
Text(link.linkTypeEnum.displayName)
|
|
.font(.caption)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 2)
|
|
.background(linkTypeColor(link.linkTypeEnum))
|
|
.foregroundColor(.white)
|
|
.cornerRadius(4)
|
|
}
|
|
|
|
// URL or description
|
|
if let url = link.originalURL, !url.isEmpty {
|
|
Text(url)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(1)
|
|
} else if let text = link.text, !text.isEmpty {
|
|
Text("custom_content - \(link.alias)")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.italic()
|
|
}
|
|
|
|
// Description
|
|
if let description = link.linkDescription, !description.isEmpty {
|
|
Text(description)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
|
|
// Tags and stats
|
|
HStack {
|
|
// Tags
|
|
if !link.sortedTags.isEmpty {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 4) {
|
|
ForEach(link.sortedTags, id: \.id) { tag in
|
|
Text(tag.name)
|
|
.font(.caption2)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.blue.opacity(0.2))
|
|
.foregroundColor(.blue)
|
|
.cornerRadius(4)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Click count
|
|
HStack(spacing: 2) {
|
|
Image(systemName: "hand.tap")
|
|
Text("\(link.clickCount)")
|
|
}
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
// Action button
|
|
Button(action: {
|
|
switch link.linkTypeEnum {
|
|
case .custom:
|
|
// Record click for custom links and show markdown content
|
|
linkViewModel.recordClick(for: link)
|
|
onCustomContentTap(link)
|
|
case .link, .template:
|
|
// Open URL for regular links and templates
|
|
linkViewModel.openLink(link)
|
|
}
|
|
}) {
|
|
Image(systemName: "arrow.up.right.circle.fill")
|
|
.foregroundColor(.blue)
|
|
.font(.title2)
|
|
}
|
|
.buttonStyle(PlainButtonStyle())
|
|
}
|
|
.contentShape(Rectangle())
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
private func linkTypeColor(_ linkType: LinkType) -> Color {
|
|
switch linkType {
|
|
case .link:
|
|
return .blue
|
|
case .custom:
|
|
return .green
|
|
case .template:
|
|
return .orange
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SearchBar: View {
|
|
@Binding var text: String
|
|
@State private var isEditing = false
|
|
@EnvironmentObject var linkViewModel: LinkViewModel
|
|
let onCustomContentTap: (Link) -> Void
|
|
let onTemplateParameterInput: (Link) -> Void
|
|
|
|
var body: some View {
|
|
HStack {
|
|
HStack(spacing: 0) {
|
|
// Blue prefix segment
|
|
Text("go/")
|
|
.font(.subheadline.monospaced())
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 6)
|
|
.background(Color(.systemBlue))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
.accessibilityLabel("go prefix")
|
|
|
|
// Input field
|
|
TextField(NSLocalizedString("quick_go_placeholder", comment: "Quick go placeholder"), text: Binding(
|
|
get: { text },
|
|
set: { newValue in
|
|
let lowered = newValue.lowercased()
|
|
let filtered = lowered.filter { ($0 >= "a" && $0 <= "z") || ($0 >= "0" && $0 <= "9") || $0 == "-" }
|
|
if filtered != text { text = filtered } else { text = lowered }
|
|
}
|
|
), onCommit: {
|
|
triggerGo()
|
|
})
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled(true)
|
|
.keyboardType(.default)
|
|
.submitLabel(.go)
|
|
.padding(.vertical, 6)
|
|
.padding(.horizontal, 8)
|
|
}
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.fill(Color(.systemGray6))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.stroke(Color(.systemGray4), lineWidth: 1)
|
|
)
|
|
.onTapGesture { self.isEditing = true }
|
|
|
|
if isEditing {
|
|
Button(action: {
|
|
self.isEditing = false
|
|
self.text = ""
|
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
|
}) {
|
|
Text("cancel")
|
|
}
|
|
.padding(.trailing, 10)
|
|
.transition(.move(edge: .trailing))
|
|
.animation(.default, value: isEditing)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func triggerGo() {
|
|
// Behavior: if first filtered link exists open it; else treat text as URL attempt
|
|
if let first = linkViewModel.filteredLinks.first {
|
|
switch first.linkTypeEnum {
|
|
case .custom:
|
|
// Show markdown content for custom links
|
|
linkViewModel.recordClick(for: first)
|
|
onCustomContentTap(first)
|
|
text = "" // Clear search after navigation
|
|
case .template:
|
|
// Show template parameter input for template links
|
|
onTemplateParameterInput(first)
|
|
text = "" // Clear search after navigation
|
|
case .link:
|
|
// Open URL directly for regular links
|
|
linkViewModel.openLink(first)
|
|
text = "" // Clear search after navigation
|
|
}
|
|
} else if !text.isEmpty {
|
|
// Attempt to open as raw URL alias? Optionally do nothing.
|
|
linkViewModel.performQuickSearch(text)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct FilterView: View {
|
|
@EnvironmentObject var linkViewModel: LinkViewModel
|
|
@Environment(\.presentationMode) var presentationMode
|
|
@FetchRequest(
|
|
sortDescriptors: [NSSortDescriptor(keyPath: \Tag.name, ascending: true)],
|
|
animation: .default)
|
|
private var tags: FetchedResults<Tag>
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
List {
|
|
Section("filters") {
|
|
HStack {
|
|
Text("selected_tag")
|
|
Spacer()
|
|
if let selectedTag = linkViewModel.selectedTag {
|
|
Text(selectedTag.name)
|
|
.foregroundColor(.secondary)
|
|
} else {
|
|
Text("all_tags")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.onTapGesture {
|
|
linkViewModel.selectedTag = nil
|
|
}
|
|
}
|
|
|
|
Section("tags") {
|
|
ForEach(tags) { tag in
|
|
HStack {
|
|
Text(tag.name)
|
|
Spacer()
|
|
Text("\(tag.linkCount)")
|
|
.foregroundColor(.secondary)
|
|
if linkViewModel.selectedTag == tag {
|
|
Image(systemName: "checkmark")
|
|
.foregroundColor(.blue)
|
|
}
|
|
}
|
|
.onTapGesture {
|
|
linkViewModel.selectedTag = tag
|
|
presentationMode.wrappedValue.dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("filter_links")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("done") {
|
|
presentationMode.wrappedValue.dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
LinkListView()
|
|
.environmentObject(LinkViewModel())
|
|
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
|
|
}
|
|
|
|
struct MarkdownContentView: View {
|
|
let link: Link
|
|
@Environment(\.presentationMode) var presentationMode
|
|
@EnvironmentObject var linkViewModel: LinkViewModel
|
|
@State private var showingEditLink = false
|
|
@State private var showingShareSheet = false
|
|
|
|
private var shareItems: [Any] {
|
|
var items: [Any] = []
|
|
|
|
// Add the alias as title
|
|
items.append("Custom Content: \(link.alias)")
|
|
|
|
// Add the markdown content if available
|
|
if let content = link.text, !content.isEmpty {
|
|
items.append(content)
|
|
}
|
|
|
|
return items
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
// Alias label
|
|
HStack {
|
|
Text(link.alias)
|
|
.font(.caption)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Color.blue.opacity(0.2))
|
|
.foregroundColor(.blue)
|
|
.cornerRadius(6)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal)
|
|
|
|
// Markdown content
|
|
ScrollView {
|
|
if let content = link.text, !content.isEmpty {
|
|
Text(MarkdownRenderer.renderToAttributedString(content))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal)
|
|
.textSelection(.enabled)
|
|
} else {
|
|
VStack {
|
|
Image(systemName: "doc.text")
|
|
.font(.system(size: 50))
|
|
.foregroundColor(.secondary)
|
|
Text("No content available")
|
|
.font(.title3)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Custom Content - \(link.alias)")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Edit") {
|
|
showingEditLink = true
|
|
}
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
HStack(spacing: 16) {
|
|
Button(action: {
|
|
showingShareSheet = true
|
|
}) {
|
|
Image(systemName: "square.and.arrow.up")
|
|
}
|
|
|
|
Button("Done") {
|
|
presentationMode.wrappedValue.dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingEditLink) {
|
|
LinkFormView(link: link)
|
|
.environmentObject(linkViewModel)
|
|
}
|
|
.sheet(isPresented: $showingShareSheet) {
|
|
ShareSheet(activityItems: shareItems)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ShareSheet: UIViewControllerRepresentable {
|
|
let activityItems: [Any]
|
|
|
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
|
let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
|
|
return controller
|
|
}
|
|
|
|
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
|
|
// No updates needed
|
|
}
|
|
}
|