mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
470 lines
15 KiB
Swift
470 lines
15 KiB
Swift
import SwiftUI
|
|
import Charts
|
|
|
|
struct LinkDetailView: View {
|
|
let link: Link
|
|
@EnvironmentObject var linkViewModel: LinkViewModel
|
|
@Environment(\.presentationMode) var presentationMode
|
|
@State private var selectedTimeRange: TimeRange = .threeMonths
|
|
@State private var showingEdit = false
|
|
@State private var templateParameters: [String: String] = [:]
|
|
@State private var showingTemplateDialog = false
|
|
|
|
enum TimeRange: String, CaseIterable {
|
|
case threeMonths = "3m"
|
|
case sixMonths = "6m"
|
|
case oneYear = "1y"
|
|
case allTime = "all"
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .threeMonths:
|
|
return NSLocalizedString("3_months", comment: "3 months")
|
|
case .sixMonths:
|
|
return NSLocalizedString("6_months", comment: "6 months")
|
|
case .oneYear:
|
|
return NSLocalizedString("1_year", comment: "1 year")
|
|
case .allTime:
|
|
return NSLocalizedString("all_time", comment: "All time")
|
|
}
|
|
}
|
|
|
|
var dateRange: DateInterval? {
|
|
let calendar = Calendar.current
|
|
let endDate = Date()
|
|
|
|
switch self {
|
|
case .threeMonths:
|
|
let startDate = calendar.date(byAdding: .month, value: -3, to: endDate)!
|
|
return DateInterval(start: startDate, end: endDate)
|
|
case .sixMonths:
|
|
let startDate = calendar.date(byAdding: .month, value: -6, to: endDate)!
|
|
return DateInterval(start: startDate, end: endDate)
|
|
case .oneYear:
|
|
let startDate = calendar.date(byAdding: .year, value: -1, to: endDate)!
|
|
return DateInterval(start: startDate, end: endDate)
|
|
case .allTime:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
var filteredClickLogs: [ClickLog] {
|
|
let logs = link.sortedClickLogs
|
|
|
|
guard let dateRange = selectedTimeRange.dateRange else {
|
|
return logs
|
|
}
|
|
|
|
return logs.filter { log in
|
|
dateRange.contains(log.clickedAt)
|
|
}
|
|
}
|
|
|
|
var chartData: [ChartDataPoint] {
|
|
let calendar = Calendar.current
|
|
let logs = filteredClickLogs
|
|
|
|
guard !logs.isEmpty else { return [] }
|
|
|
|
// Determine the grouping based on time range
|
|
let component: Calendar.Component
|
|
let dateFormat: String
|
|
|
|
switch selectedTimeRange {
|
|
case .threeMonths, .sixMonths:
|
|
component = .day
|
|
dateFormat = "MMM d"
|
|
case .oneYear:
|
|
component = .weekOfYear
|
|
dateFormat = "MMM d"
|
|
case .allTime:
|
|
component = .month
|
|
dateFormat = "MMM yyyy"
|
|
}
|
|
|
|
// Group clicks by the component
|
|
let groupedLogs = Dictionary(grouping: logs) { log in
|
|
calendar.dateInterval(of: component, for: log.clickedAt)?.start ?? log.clickedAt
|
|
}
|
|
|
|
// Create chart data points
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = dateFormat
|
|
|
|
return groupedLogs.map { date, logs in
|
|
ChartDataPoint(
|
|
date: date,
|
|
count: logs.count,
|
|
label: formatter.string(from: date)
|
|
)
|
|
}.sorted { $0.date < $1.date }
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
headerSection
|
|
tagsSection
|
|
analyticsSection
|
|
customContentSection
|
|
recentClicksSection
|
|
changeHistorySection
|
|
}
|
|
.padding()
|
|
}
|
|
.navigationTitle("link_details")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("close") {
|
|
presentationMode.wrappedValue.dismiss()
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
toolbarMenu
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingEdit) {
|
|
LinkFormView(link: link)
|
|
.environmentObject(linkViewModel)
|
|
}
|
|
.sheet(isPresented: $showingTemplateDialog) {
|
|
TemplateParameterView(
|
|
link: link,
|
|
parameters: $templateParameters,
|
|
onOpen: { params in
|
|
linkViewModel.openLink(link, with: params)
|
|
presentationMode.wrappedValue.dismiss()
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private var headerSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text(link.alias)
|
|
.font(.largeTitle)
|
|
.fontWeight(.bold)
|
|
|
|
Spacer()
|
|
|
|
Text(link.linkTypeEnum.displayName)
|
|
.font(.caption)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(linkTypeColor(link.linkTypeEnum))
|
|
.foregroundColor(.white)
|
|
.cornerRadius(6)
|
|
}
|
|
|
|
if let url = link.originalURL {
|
|
Text(url)
|
|
.font(.body)
|
|
.foregroundColor(.blue)
|
|
.lineLimit(nil)
|
|
}
|
|
|
|
if let description = link.linkDescription {
|
|
Text(description)
|
|
.font(.body)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(.systemGray6))
|
|
.cornerRadius(12)
|
|
}
|
|
|
|
private var tagsSection: some View {
|
|
Group {
|
|
if !link.sortedTags.isEmpty {
|
|
VStack(alignment: .leading) {
|
|
Text("tags")
|
|
.font(.headline)
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack {
|
|
ForEach(link.sortedTags, id: \.id) { tag in
|
|
Text(tag.name)
|
|
.font(.caption)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Color.blue.opacity(0.2))
|
|
.foregroundColor(.blue)
|
|
.cornerRadius(6)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var analyticsSection: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Text("analytics")
|
|
.font(.headline)
|
|
|
|
Spacer()
|
|
|
|
Picker("time_range", selection: $selectedTimeRange) {
|
|
ForEach(TimeRange.allCases, id: \.self) { range in
|
|
Text(range.displayName).tag(range)
|
|
}
|
|
}
|
|
.pickerStyle(SegmentedPickerStyle())
|
|
.frame(maxWidth: 200)
|
|
}
|
|
|
|
analyticsStatsCards
|
|
analyticsChart
|
|
}
|
|
}
|
|
|
|
private var analyticsStatsCards: some View {
|
|
HStack(spacing: 16) {
|
|
StatCard(
|
|
title: "total_clicks",
|
|
value: "\(link.clickCount)",
|
|
icon: "hand.tap"
|
|
)
|
|
|
|
StatCard(
|
|
title: "clicks_in_period",
|
|
value: "\(filteredClickLogs.count)",
|
|
icon: "chart.bar"
|
|
)
|
|
}
|
|
}
|
|
|
|
private var analyticsChart: some View {
|
|
Group {
|
|
if !chartData.isEmpty {
|
|
Chart(chartData) { dataPoint in
|
|
BarMark(
|
|
x: .value("Date", dataPoint.date),
|
|
y: .value("Clicks", dataPoint.count)
|
|
)
|
|
.foregroundStyle(.blue)
|
|
}
|
|
.frame(height: 200)
|
|
.chartXAxisLabel("Date")
|
|
.chartYAxisLabel("Clicks")
|
|
} else {
|
|
Text("no_data_for_period")
|
|
.font(.body)
|
|
.foregroundColor(.secondary)
|
|
.frame(height: 200)
|
|
.frame(maxWidth: .infinity)
|
|
.background(Color(.systemGray6))
|
|
.cornerRadius(8)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var customContentSection: some View {
|
|
Group {
|
|
if link.linkTypeEnum == .custom, let text = link.text {
|
|
VStack(alignment: .leading) {
|
|
Text("content_preview")
|
|
.font(.headline)
|
|
|
|
MarkdownView(text: text)
|
|
.frame(maxHeight: 300)
|
|
.background(Color(.systemGray6))
|
|
.cornerRadius(8)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var recentClicksSection: some View {
|
|
Group {
|
|
if !filteredClickLogs.isEmpty {
|
|
VStack(alignment: .leading) {
|
|
Text("recent_clicks")
|
|
.font(.headline)
|
|
|
|
ForEach(filteredClickLogs.prefix(10), id: \.id) { clickLog in
|
|
HStack {
|
|
Image(systemName: "hand.tap")
|
|
.foregroundColor(.blue)
|
|
|
|
Text(clickLog.clickedAt, format: .dateTime)
|
|
.font(.body)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var changeHistorySection: some View {
|
|
Group {
|
|
if ((link.changeLogs?.allObjects.isEmpty) == nil) {
|
|
VStack(alignment: .leading) {
|
|
Text("change_history")
|
|
.font(.headline)
|
|
|
|
let changeLogs = (link.changeLogs?.allObjects as? [ChangeLog] ?? [])
|
|
.sorted { $0.changedAt > $1.changedAt }
|
|
|
|
ForEach(changeLogs.prefix(10), id: \.id) { changeLog in
|
|
HStack {
|
|
Image(systemName: changeLog.changeType == "CREATE" ? "plus.circle" : "pencil.circle")
|
|
.foregroundColor(changeLog.changeType == "CREATE" ? .green : .orange)
|
|
|
|
VStack(alignment: .leading) {
|
|
Text(changeLog.changeDescription)
|
|
.font(.body)
|
|
Text(changeLog.changedAt, format: .dateTime)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var toolbarMenu: some View {
|
|
Menu {
|
|
Button("edit") {
|
|
showingEdit = true
|
|
}
|
|
|
|
if link.isTemplate {
|
|
Button("open_with_parameters") {
|
|
showingTemplateDialog = true
|
|
}
|
|
} else {
|
|
Button("open_link") {
|
|
linkViewModel.openLink(link)
|
|
}
|
|
}
|
|
|
|
Button("delete", role: .destructive) {
|
|
linkViewModel.deleteLink(link)
|
|
presentationMode.wrappedValue.dismiss()
|
|
}
|
|
} label: {
|
|
Image(systemName: "ellipsis.circle")
|
|
}
|
|
}
|
|
|
|
private func linkTypeColor(_ linkType: LinkType) -> Color {
|
|
switch linkType {
|
|
case .link:
|
|
return .blue
|
|
case .custom:
|
|
return .green
|
|
case .template:
|
|
return .orange
|
|
}
|
|
}
|
|
}
|
|
|
|
struct StatCard: View {
|
|
let title: String
|
|
let value: String
|
|
let icon: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Image(systemName: icon)
|
|
.foregroundColor(.blue)
|
|
Text(title)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Text(value)
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color(.systemGray6))
|
|
.cornerRadius(8)
|
|
}
|
|
}
|
|
|
|
struct ChartDataPoint: Identifiable {
|
|
let id = UUID()
|
|
let date: Date
|
|
let count: Int
|
|
let label: String
|
|
}
|
|
|
|
struct TemplateParameterView: View {
|
|
let link: Link
|
|
@Binding var parameters: [String: String]
|
|
let onOpen: ([String: String]) -> Void
|
|
@Environment(\.presentationMode) var presentationMode
|
|
@State private var extractedParams: [TemplateParameter] = []
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
Form {
|
|
Section("template_parameters") {
|
|
ForEach(extractedParams, id: \.name) { param in
|
|
VStack(alignment: .leading) {
|
|
Text(param.name)
|
|
.font(.headline)
|
|
|
|
TextField(param.defaultValue ?? "enter_value", text: Binding(
|
|
get: { parameters[param.name] ?? param.defaultValue ?? "" },
|
|
set: { parameters[param.name] = $0 }
|
|
))
|
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("enter_parameters")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("cancel") {
|
|
presentationMode.wrappedValue.dismiss()
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("open") {
|
|
onOpen(parameters)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
extractTemplateParameters()
|
|
}
|
|
}
|
|
|
|
private func extractTemplateParameters() {
|
|
guard let url = link.originalURL else { return }
|
|
extractedParams = URLTemplateProcessor.extractParameters(from: url)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
LinkDetailView(link: PersistenceController.preview.container.viewContext.registeredObjects.first { $0 is Link } as! Link)
|
|
.environmentObject(LinkViewModel())
|
|
}
|