mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
515 lines
19 KiB
Swift
515 lines
19 KiB
Swift
import SwiftUI
|
|
|
|
struct LinkFormView: View {
|
|
@EnvironmentObject var linkViewModel: LinkViewModel
|
|
@Environment(\.presentationMode) var presentationMode
|
|
@Environment(\.managedObjectContext) private var viewContext
|
|
|
|
let link: Link?
|
|
|
|
@State private var alias: String = ""
|
|
@State private var originalURL: String = ""
|
|
@State private var text: String = ""
|
|
@State private var linkDescription: String = ""
|
|
@State private var selectedLinkType: LinkType = .link
|
|
@State private var selectedTags: [Tag] = []
|
|
@State private var tagSearchText: String = ""
|
|
@State private var isValidatingAlias: Bool = false
|
|
@State private var aliasValidationMessage: String = ""
|
|
@State private var urlValidationMessage: String = ""
|
|
@State private var showingTagPicker: Bool = false
|
|
@State private var saveErrorMessage: String = ""
|
|
@State private var showingSaveError: Bool = false
|
|
|
|
@FetchRequest(
|
|
sortDescriptors: [NSSortDescriptor(keyPath: \Tag.name, ascending: true)],
|
|
animation: .default)
|
|
private var allTags: FetchedResults<Tag>
|
|
|
|
var isEditing: Bool {
|
|
return link != nil
|
|
}
|
|
|
|
var filteredTags: [Tag] {
|
|
if tagSearchText.isEmpty {
|
|
return Array(allTags)
|
|
} else {
|
|
return allTags.filter { $0.name.localizedCaseInsensitiveContains(tagSearchText) }
|
|
}
|
|
}
|
|
|
|
var canSave: Bool {
|
|
return !alias.isEmpty &&
|
|
(selectedLinkType == .custom ? !text.isEmpty : !originalURL.isEmpty) &&
|
|
aliasValidationMessage.isEmpty &&
|
|
urlValidationMessage.isEmpty
|
|
}
|
|
|
|
var templateValidationView: some View {
|
|
Group {
|
|
if selectedLinkType == .template && !originalURL.isEmpty {
|
|
let isValidTemplate = URLTemplateProcessor.validateTemplate(originalURL)
|
|
let hasParameters = URLTemplateProcessor.hasParameters(originalURL)
|
|
|
|
if isValidTemplate && hasParameters {
|
|
// Valid template with parameters - show success with iOS blue
|
|
HStack {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.blue)
|
|
Text("Valid template with parameters")
|
|
.font(.caption)
|
|
.foregroundColor(.blue)
|
|
}
|
|
} else if !hasParameters {
|
|
// No parameters detected - show warning
|
|
HStack {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundColor(.orange)
|
|
Text("Template should contain parameters like {query} or {query,default=value}")
|
|
.font(.caption)
|
|
.foregroundColor(.orange)
|
|
}
|
|
} else {
|
|
// Invalid template - show error
|
|
HStack {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundColor(.red)
|
|
Text("Invalid template syntax. Use {parameter} or {parameter,default=value}")
|
|
.font(.caption)
|
|
.foregroundColor(.red)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
Form {
|
|
Section("basic_information") {
|
|
// Alias field
|
|
VStack(alignment: .leading) {
|
|
TextField("alias", text: $alias)
|
|
.autocapitalization(.none)
|
|
.autocorrectionDisabled()
|
|
.onChange(of: alias) { _, newValue in
|
|
validateAlias(newValue)
|
|
}
|
|
|
|
if !aliasValidationMessage.isEmpty {
|
|
Text(aliasValidationMessage)
|
|
.font(.caption)
|
|
.foregroundColor(.red)
|
|
}
|
|
}
|
|
|
|
// Link Type Picker
|
|
Picker("link_type", selection: $selectedLinkType) {
|
|
ForEach(LinkType.allCases, id: \.self) { linkType in
|
|
Text(linkType.displayName).tag(linkType)
|
|
}
|
|
}
|
|
.pickerStyle(SegmentedPickerStyle())
|
|
.onChange(of: selectedLinkType) { _, newValue in
|
|
// Re-validate URL when link type changes
|
|
if newValue != .custom {
|
|
validateURL(originalURL)
|
|
} else {
|
|
urlValidationMessage = ""
|
|
}
|
|
}
|
|
|
|
// URL or Text field based on type
|
|
if selectedLinkType == .custom {
|
|
VStack(alignment: .leading) {
|
|
Text("markdown_content")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
TextEditor(text: $text)
|
|
.frame(minHeight: 100)
|
|
}
|
|
} else {
|
|
VStack(alignment: .leading) {
|
|
TextField("original_url", text: $originalURL)
|
|
.autocapitalization(.none)
|
|
.autocorrectionDisabled()
|
|
.keyboardType(.URL)
|
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(getBorderColor(), lineWidth: 2)
|
|
)
|
|
.onChange(of: originalURL) { _, newValue in
|
|
validateURL(newValue)
|
|
}
|
|
|
|
if !urlValidationMessage.isEmpty {
|
|
HStack {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.foregroundColor(.orange)
|
|
Text(urlValidationMessage)
|
|
.font(.caption)
|
|
.foregroundColor(.orange)
|
|
}
|
|
}
|
|
|
|
if selectedLinkType == .template {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
// Template help text
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Template type allows you to create dynamic URLs with parameters. Use {query, default=value} syntax in the URL.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Text("Example: https://google.com/search?q={query, default=hello}")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Color.gray.opacity(0.1))
|
|
.cornerRadius(4)
|
|
}
|
|
|
|
// Template validation status
|
|
if !originalURL.isEmpty {
|
|
templateValidationView
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Description field
|
|
TextField("description_optional", text: $linkDescription)
|
|
}
|
|
|
|
Section("tags") {
|
|
// Selected tags
|
|
if !selectedTags.isEmpty {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack {
|
|
ForEach(selectedTags, id: \.id) { tag in
|
|
TagChip(tag: tag, onRemove: {
|
|
selectedTags.removeAll { $0.id == tag.id }
|
|
})
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
|
|
// Tag search and selection
|
|
HStack {
|
|
TextField("search_or_create_tag", text: $tagSearchText)
|
|
|
|
Button("add") {
|
|
addOrCreateTag()
|
|
}
|
|
.disabled(tagSearchText.isEmpty)
|
|
}
|
|
|
|
// Filtered tags list
|
|
ForEach(filteredTags.prefix(5), id: \.id) { tag in
|
|
HStack {
|
|
Text(tag.name)
|
|
Spacer()
|
|
if selectedTags.contains(where: { $0.id == tag.id }) {
|
|
Image(systemName: "checkmark")
|
|
.foregroundColor(.blue)
|
|
}
|
|
}
|
|
.onTapGesture {
|
|
toggleTag(tag)
|
|
}
|
|
}
|
|
}
|
|
|
|
if isEditing {
|
|
Section("actions") {
|
|
Button("delete_link", role: .destructive) {
|
|
if let link = link {
|
|
linkViewModel.deleteLink(link)
|
|
presentationMode.wrappedValue.dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle(isEditing ? "edit_link" : "add_link")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("cancel") {
|
|
presentationMode.wrappedValue.dismiss()
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("save") {
|
|
saveLink()
|
|
}
|
|
.disabled(!canSave)
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
setupForm()
|
|
}
|
|
.alert("Error", isPresented: $showingSaveError) {
|
|
Button("OK") {
|
|
showingSaveError = false
|
|
saveErrorMessage = ""
|
|
}
|
|
} message: {
|
|
Text(saveErrorMessage)
|
|
}
|
|
}
|
|
|
|
private func setupForm() {
|
|
if let link = link {
|
|
alias = link.alias
|
|
originalURL = link.originalURL ?? ""
|
|
text = link.text ?? ""
|
|
linkDescription = link.linkDescription ?? ""
|
|
selectedLinkType = link.linkTypeEnum
|
|
selectedTags = link.sortedTags
|
|
|
|
// Validate existing data
|
|
validateAlias(alias)
|
|
validateURL(originalURL)
|
|
}
|
|
}
|
|
|
|
private func validateAlias(_ newAlias: String) {
|
|
isValidatingAlias = true
|
|
|
|
// Basic validation
|
|
if newAlias.isEmpty {
|
|
aliasValidationMessage = ""
|
|
isValidatingAlias = false
|
|
return
|
|
}
|
|
|
|
// Check format (alphanumeric and some special characters)
|
|
let allowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
|
if newAlias.unicodeScalars.contains(where: { !allowedCharacters.contains($0) }) {
|
|
aliasValidationMessage = NSLocalizedString("alias_invalid_characters", comment: "Alias contains invalid characters")
|
|
isValidatingAlias = false
|
|
return
|
|
}
|
|
|
|
// Check if alias exists (excluding current link if editing)
|
|
if !isEditing || (isEditing && newAlias != link?.alias) {
|
|
if linkViewModel.aliasExists(newAlias) {
|
|
aliasValidationMessage = NSLocalizedString("alias_already_exists", comment: "Alias already exists")
|
|
} else {
|
|
aliasValidationMessage = ""
|
|
}
|
|
} else {
|
|
aliasValidationMessage = ""
|
|
}
|
|
|
|
isValidatingAlias = false
|
|
}
|
|
|
|
private func validateURL(_ url: String) {
|
|
// Don't validate empty URLs (allow empty for templates with placeholders)
|
|
if url.isEmpty {
|
|
urlValidationMessage = ""
|
|
return
|
|
}
|
|
|
|
// For templates, use URLTemplateProcessor for validation
|
|
if selectedLinkType == .template {
|
|
// Check if it's a valid template
|
|
if URLTemplateProcessor.validateTemplate(url) {
|
|
// Check if the base URL (without parameters) is valid
|
|
let urlWithoutPlaceholders = url.replacingOccurrences(of: #"\{[^}]+\}"#, with: "placeholder", options: .regularExpression)
|
|
if isValidURL(urlWithoutPlaceholders) {
|
|
urlValidationMessage = ""
|
|
return
|
|
} else {
|
|
urlValidationMessage = NSLocalizedString("invalid_base_url_format", comment: "Invalid base URL format")
|
|
return
|
|
}
|
|
} else {
|
|
urlValidationMessage = NSLocalizedString("invalid_template_format", comment: "Invalid template format")
|
|
return
|
|
}
|
|
}
|
|
|
|
// For regular links, validate the URL strictly
|
|
if selectedLinkType == .link {
|
|
if isValidURL(url) {
|
|
urlValidationMessage = ""
|
|
return
|
|
}
|
|
}
|
|
|
|
// If we reach here, the URL is invalid
|
|
urlValidationMessage = NSLocalizedString("invalid_url_format", comment: "Invalid URL format")
|
|
}
|
|
|
|
private func isValidURL(_ urlString: String) -> Bool {
|
|
// First check if it's a valid URL
|
|
guard let url = URL(string: urlString) else {
|
|
return false
|
|
}
|
|
|
|
// Check if it has a scheme
|
|
guard let scheme = url.scheme else {
|
|
return false
|
|
}
|
|
|
|
// Allow http, https, and other common schemes
|
|
let allowedSchemes = ["http", "https", "ftp", "mailto", "tel", "sms"]
|
|
if !allowedSchemes.contains(scheme.lowercased()) {
|
|
return false
|
|
}
|
|
|
|
// Check if it has a host for http/https URLs
|
|
if ["http", "https"].contains(scheme.lowercased()) {
|
|
guard let host = url.host, !host.isEmpty else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
private func getBorderColor() -> Color {
|
|
// Show error state if there's a validation message
|
|
if !urlValidationMessage.isEmpty {
|
|
return .orange
|
|
}
|
|
|
|
// For template links, show blue if valid template with parameters
|
|
if selectedLinkType == .template && !originalURL.isEmpty {
|
|
let isValidTemplate = URLTemplateProcessor.validateTemplate(originalURL)
|
|
let hasParameters = URLTemplateProcessor.hasParameters(originalURL)
|
|
|
|
if isValidTemplate && hasParameters {
|
|
return .blue
|
|
}
|
|
}
|
|
|
|
return .clear
|
|
}
|
|
|
|
private func toggleTag(_ tag: Tag) {
|
|
if selectedTags.contains(where: { $0.id == tag.id }) {
|
|
selectedTags.removeAll { $0.id == tag.id }
|
|
} else {
|
|
selectedTags.append(tag)
|
|
}
|
|
}
|
|
|
|
private func addOrCreateTag() {
|
|
let trimmedText = tagSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmedText.isEmpty else { return }
|
|
|
|
// Check if tag already exists
|
|
if let existingTag = allTags.first(where: { $0.name.lowercased() == trimmedText.lowercased() }) {
|
|
if !selectedTags.contains(where: { $0.id == existingTag.id }) {
|
|
selectedTags.append(existingTag)
|
|
}
|
|
} else {
|
|
// Create new tag
|
|
let newTag = Tag(context: viewContext)
|
|
newTag.name = trimmedText
|
|
newTag.slug = trimmedText.lowercased().replacingOccurrences(of: " ", with: "-")
|
|
newTag.createdAt = Date()
|
|
|
|
do {
|
|
try viewContext.save()
|
|
selectedTags.append(newTag)
|
|
} catch {
|
|
print("Error creating tag: \(error)")
|
|
}
|
|
}
|
|
|
|
tagSearchText = ""
|
|
}
|
|
|
|
private func saveLink() {
|
|
// Clear any previous error
|
|
saveErrorMessage = ""
|
|
showingSaveError = false
|
|
|
|
// Validate before saving
|
|
if !aliasValidationMessage.isEmpty {
|
|
saveErrorMessage = aliasValidationMessage
|
|
showingSaveError = true
|
|
return
|
|
}
|
|
|
|
if !urlValidationMessage.isEmpty && selectedLinkType != .custom {
|
|
saveErrorMessage = urlValidationMessage
|
|
showingSaveError = true
|
|
return
|
|
}
|
|
|
|
let success: Bool
|
|
|
|
if isEditing, let link = link {
|
|
success = linkViewModel.updateLink(
|
|
link,
|
|
alias: alias,
|
|
originalURL: selectedLinkType == .custom ? nil : originalURL,
|
|
text: selectedLinkType == .custom ? text : nil,
|
|
linkType: selectedLinkType,
|
|
description: linkDescription.isEmpty ? nil : linkDescription,
|
|
tags: selectedTags
|
|
)
|
|
} else {
|
|
success = linkViewModel.createLink(
|
|
alias: alias,
|
|
originalURL: selectedLinkType == .custom ? nil : originalURL,
|
|
text: selectedLinkType == .custom ? text : nil,
|
|
linkType: selectedLinkType,
|
|
description: linkDescription.isEmpty ? nil : linkDescription,
|
|
tags: selectedTags
|
|
)
|
|
}
|
|
|
|
if success {
|
|
presentationMode.wrappedValue.dismiss()
|
|
} else {
|
|
// Show error from view model
|
|
if let errorMessage = linkViewModel.errorMessage {
|
|
saveErrorMessage = errorMessage
|
|
showingSaveError = true
|
|
linkViewModel.errorMessage = nil // Clear the error
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct TagChip: View {
|
|
let tag: Tag
|
|
let onRemove: () -> Void
|
|
|
|
var body: some View {
|
|
HStack(spacing: 4) {
|
|
Text(tag.name)
|
|
.font(.caption)
|
|
|
|
Button(action: onRemove) {
|
|
Image(systemName: "xmark")
|
|
.font(.caption2)
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Color.blue.opacity(0.2))
|
|
.foregroundColor(.blue)
|
|
.cornerRadius(6)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
LinkFormView(link: nil)
|
|
.environmentObject(LinkViewModel())
|
|
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
|
|
}
|