1153 lines
41 KiB
Swift
1153 lines
41 KiB
Swift
//
|
|
// ContentView.swift
|
|
// Ventry Upload Watcher
|
|
//
|
|
// Created by Jan-Marlon Leibl on 18.01.25.
|
|
//
|
|
|
|
import SwiftUI
|
|
import AppKit
|
|
import UserNotifications
|
|
import ServiceManagement
|
|
import CoreServices
|
|
|
|
extension NSWindow {
|
|
open override func awakeFromNib() {
|
|
super.awakeFromNib()
|
|
self.styleMask.remove(.resizable)
|
|
self.styleMask.remove(.fullScreen)
|
|
}
|
|
}
|
|
|
|
extension Color {
|
|
static let accentPurple = Color(hex: 0x8331FD)
|
|
}
|
|
|
|
extension Color {
|
|
init(hex: UInt) {
|
|
self.init(
|
|
.sRGB,
|
|
red: Double((hex >> 16) & 0xff) / 255,
|
|
green: Double((hex >> 8) & 0xff) / 255,
|
|
blue: Double(hex & 0xff) / 255,
|
|
opacity: 1
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Upload Manager
|
|
class UploadManager {
|
|
private static let endpoint = URL(string: "https://ventry.host/api/upload")!
|
|
|
|
static func uploadFile(_ fileURL: URL, apiKey: String, completion: @escaping (Result<String, Error>) -> Void) {
|
|
var request = URLRequest(url: endpoint)
|
|
request.httpMethod = "POST"
|
|
request.setValue(apiKey, forHTTPHeaderField: "Authorization")
|
|
let boundary = "Boundary-\(UUID().uuidString)"
|
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
|
|
|
URLSession.shared.uploadTask(with: request, from: createMultipartFormData(fileURL: fileURL, boundary: boundary)) { data, response, error in
|
|
if let error = error { return completion(.failure(error)) }
|
|
guard let httpResponse = response as? HTTPURLResponse,
|
|
(200...299).contains(httpResponse.statusCode),
|
|
let data = data,
|
|
let responseString = String(data: data, encoding: .utf8) else {
|
|
return completion(.failure(NSError(domain: "", code: (response as? HTTPURLResponse)?.statusCode ?? -1)))
|
|
}
|
|
completion(.success(responseString))
|
|
}.resume()
|
|
}
|
|
|
|
private static func createMultipartFormData(fileURL: URL, boundary: String) -> Data {
|
|
var data = Data()
|
|
let filename = fileURL.lastPathComponent.replacingOccurrences(of: " ", with: "%20")
|
|
data.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\nContent-Type: \(fileURL.mimeType())\r\n\r\n".data(using: .utf8)!)
|
|
if let fileData = try? Data(contentsOf: fileURL) {
|
|
data.append(fileData)
|
|
data.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
|
}
|
|
return data
|
|
}
|
|
}
|
|
|
|
extension URL {
|
|
func mimeType() -> String {
|
|
let mimeTypes = ["jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",
|
|
"gif": "image/gif", "webp": "image/webp", "mp4": "video/mp4",
|
|
"mov": "video/quicktime"]
|
|
return mimeTypes[pathExtension.lowercased()] ?? "application/octet-stream"
|
|
}
|
|
}
|
|
|
|
// MARK: - Folder Monitor
|
|
class FolderMonitor: ObservableObject {
|
|
@Published private(set) var isWatching = false
|
|
private var timer: Timer?
|
|
private var currentPaths: [String] = []
|
|
private var knownFiles = Set<String>()
|
|
var onFileDetected: ((URL) -> Void)?
|
|
|
|
deinit { stopWatching() }
|
|
|
|
func startWatching(paths: [String]) {
|
|
stopWatching()
|
|
currentPaths = paths
|
|
for path in paths {
|
|
let url = URL(fileURLWithPath: path)
|
|
guard url.startAccessingSecurityScopedResource() else {
|
|
NotificationManager.send(title: "Permission Required", message: "Please grant folder access")
|
|
continue
|
|
}
|
|
saveBookmark(for: url)
|
|
}
|
|
updateKnownFiles()
|
|
startMonitoring()
|
|
}
|
|
|
|
private func startMonitoring() {
|
|
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
|
self?.checkForNewFiles()
|
|
}
|
|
isWatching = true
|
|
}
|
|
|
|
private func checkForNewFiles() {
|
|
for path in currentPaths {
|
|
do {
|
|
let contents = try FileManager.default.contentsOfDirectory(atPath: path)
|
|
let newFiles = Set(contents).subtracting(knownFiles)
|
|
|
|
for file in newFiles {
|
|
let fileURL = URL(fileURLWithPath: path).appendingPathComponent(file)
|
|
if fileURL.lastPathModified.timeIntervalSinceNow > -2 {
|
|
onFileDetected?(fileURL)
|
|
}
|
|
}
|
|
|
|
knownFiles = knownFiles.union(newFiles)
|
|
} catch {
|
|
print("Error checking files in \(path): \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updateKnownFiles() {
|
|
var allFiles = Set<String>()
|
|
for path in currentPaths {
|
|
guard let contents = try? FileManager.default.contentsOfDirectory(atPath: path) else { continue }
|
|
allFiles = allFiles.union(Set(contents.map { path + "/" + $0 }))
|
|
}
|
|
knownFiles = allFiles
|
|
}
|
|
|
|
private func saveBookmark(for url: URL) {
|
|
guard let bookmarkData = try? url.bookmarkData(
|
|
options: .withSecurityScope,
|
|
includingResourceValuesForKeys: nil,
|
|
relativeTo: nil
|
|
) else { return }
|
|
|
|
UserDefaults.standard.set(bookmarkData, forKey: "FolderBookmark-\(url.path)")
|
|
}
|
|
|
|
func stopWatching() {
|
|
timer?.invalidate()
|
|
timer = nil
|
|
isWatching = false
|
|
currentPaths.removeAll()
|
|
knownFiles.removeAll()
|
|
}
|
|
|
|
func addPath(_ path: String) {
|
|
if !currentPaths.contains(path) {
|
|
currentPaths.append(path)
|
|
let url = URL(fileURLWithPath: path)
|
|
guard url.startAccessingSecurityScopedResource() else {
|
|
NotificationManager.send(title: "Permission Required", message: "Please grant folder access")
|
|
return
|
|
}
|
|
saveBookmark(for: url)
|
|
updateKnownFiles()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Notification Manager
|
|
class NotificationManager {
|
|
static func send(title: String, message: String) {
|
|
let content = UNMutableNotificationContent()
|
|
content.title = title
|
|
content.body = message
|
|
content.sound = .default
|
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
|
|
if granted {
|
|
UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: UUID().uuidString,
|
|
content: content, trigger: nil))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Haptics
|
|
class HapticManager {
|
|
static func play(_ type: NSHapticFeedbackManager.FeedbackPattern) {
|
|
NSHapticFeedbackManager.defaultPerformer.perform(type, performanceTime: .default)
|
|
}
|
|
|
|
static func success() { play(.levelChange) }
|
|
static func error() { play(.generic) }
|
|
static func tap() { play(.alignment) }
|
|
}
|
|
|
|
// MARK: - Views
|
|
struct CardView<Content: View>: View {
|
|
let content: Content
|
|
|
|
init(@ViewBuilder content: () -> Content) {
|
|
self.content = content()
|
|
}
|
|
|
|
var body: some View {
|
|
content
|
|
.padding()
|
|
.background(Color(.windowBackgroundColor))
|
|
.cornerRadius(12)
|
|
}
|
|
}
|
|
|
|
struct StatusBadge: View {
|
|
let status: String
|
|
let color: Color
|
|
|
|
var body: some View {
|
|
Text(status)
|
|
.font(.caption.bold())
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(color.opacity(0.2))
|
|
.foregroundColor(color)
|
|
.cornerRadius(6)
|
|
}
|
|
}
|
|
|
|
class LaunchAtLoginHelper {
|
|
static func setLaunchAtLogin(_ enabled: Bool) {
|
|
let service = SMAppService.mainApp
|
|
do {
|
|
if enabled {
|
|
try service.register()
|
|
} else {
|
|
try service.unregister()
|
|
}
|
|
print("Successfully \(enabled ? "enabled" : "disabled") launch at login")
|
|
} catch {
|
|
print("Failed to \(enabled ? "enable" : "disable") launch at login: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SettingsView: View {
|
|
@Binding var isPresented: Bool
|
|
@Binding var apiKey: String
|
|
@Environment(\.colorScheme) var colorScheme
|
|
@FocusState private var isAPIKeyFocused: Bool
|
|
@AppStorage("launchAtLogin") private var launchAtLogin = false
|
|
|
|
var body: some View {
|
|
VStack(spacing: Design.padding) {
|
|
HStack {
|
|
Text("Settings")
|
|
.font(.system(size: 24, weight: .bold))
|
|
Spacer()
|
|
Button(action: {
|
|
withAnimation(Animation.spring) {
|
|
isPresented = false
|
|
}
|
|
}) {
|
|
Circle()
|
|
.fill(Color(.windowBackgroundColor).opacity(0.8))
|
|
.frame(width: 32, height: 32)
|
|
.overlay(
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: Design.spacing) {
|
|
Text("API Key")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
|
|
SecureField("Enter your ventry.host API key", text: $apiKey)
|
|
.textFieldStyle(.plain)
|
|
.padding(12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: Design.cornerRadius, style: .continuous)
|
|
.fill(Color(.textBackgroundColor))
|
|
)
|
|
.focused($isAPIKeyFocused)
|
|
|
|
Text("Get your API key from ventry.host")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.bottom, 8)
|
|
|
|
VStack(alignment: .leading, spacing: Design.spacing) {
|
|
Text("Startup Options")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.padding(.bottom, 4)
|
|
|
|
Toggle(isOn: $launchAtLogin) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Launch at login")
|
|
.font(.system(size: 14))
|
|
Text("Automatically start the app when you log in")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.onChange(of: launchAtLogin) { _, newValue in
|
|
LaunchAtLoginHelper.setLaunchAtLogin(newValue)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button(action: {
|
|
HapticManager.success()
|
|
withAnimation(Animation.spring) {
|
|
isPresented = false
|
|
}
|
|
}) {
|
|
Text("Save")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundColor(.white)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: Design.cornerRadius, style: .continuous)
|
|
.fill(Color.accentPurple)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.keyboardShortcut(.return)
|
|
}
|
|
.padding(24)
|
|
.frame(width: 400, height: 360)
|
|
.background(colorScheme == .dark ? Color(.windowBackgroundColor) : .white)
|
|
.onAppear { isAPIKeyFocused = true }
|
|
}
|
|
}
|
|
|
|
@propertyWrapper
|
|
struct UserDefaultsArray<T: Codable> {
|
|
private let key: String
|
|
|
|
init(key: String) {
|
|
self.key = key
|
|
}
|
|
|
|
var wrappedValue: [T] {
|
|
get {
|
|
guard let data = UserDefaults.standard.data(forKey: key),
|
|
let array = try? JSONDecoder().decode([T].self, from: data) else {
|
|
return []
|
|
}
|
|
return array
|
|
}
|
|
set {
|
|
guard let data = try? JSONEncoder().encode(newValue) else { return }
|
|
UserDefaults.standard.set(data, forKey: key)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct HeaderView: View {
|
|
@Binding var showingSettings: Bool
|
|
|
|
var body: some View {
|
|
HStack(spacing: Design.padding) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("ventry.host")
|
|
.font(.system(size: 24, weight: .bold))
|
|
Text("Tool for uploading files to ventry.host")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button(action: {
|
|
HapticManager.tap()
|
|
withAnimation(Animation.spring) {
|
|
showingSettings.toggle()
|
|
}
|
|
}) {
|
|
Image(systemName: "gear")
|
|
.font(.system(size: Design.iconSize))
|
|
.foregroundColor(.secondary)
|
|
.frame(width: 32, height: 32)
|
|
.background(Color(.controlBackgroundColor))
|
|
.clipShape(Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct StatusCardView: View {
|
|
let folderMonitor: FolderMonitor
|
|
let uploadStatus: String
|
|
let lastUpload: String
|
|
|
|
var body: some View {
|
|
VStack(spacing: Design.padding) {
|
|
HStack {
|
|
ZStack {
|
|
Circle()
|
|
.fill(folderMonitor.isWatching ? Color.green.opacity(0.15) : Color.red.opacity(0.15))
|
|
.frame(width: 24, height: 24)
|
|
|
|
Circle()
|
|
.fill(folderMonitor.isWatching ? Color.green : Color.red)
|
|
.frame(width: 8, height: 8)
|
|
.blur(radius: folderMonitor.isWatching ? 2 : 0)
|
|
}
|
|
|
|
Text(folderMonitor.isWatching ? "Watching" : "Inactive")
|
|
.font(.system(size: Design.iconSize, weight: .medium))
|
|
.foregroundColor(folderMonitor.isWatching ? .green : .red)
|
|
Spacer()
|
|
}
|
|
|
|
VStack(spacing: Design.spacing) {
|
|
statusRow(
|
|
icon: "arrow.up.circle",
|
|
title: "Status",
|
|
value: uploadStatus
|
|
)
|
|
|
|
Divider()
|
|
.background(Color.primary.opacity(Design.opacity))
|
|
|
|
statusRow(
|
|
icon: "clock",
|
|
title: "Last Upload",
|
|
value: lastUpload
|
|
)
|
|
}
|
|
}
|
|
.padding(Design.padding)
|
|
.background(Color(.controlBackgroundColor))
|
|
.cornerRadius(Design.cornerRadius)
|
|
.animation(Animation.spring, value: folderMonitor.isWatching)
|
|
}
|
|
|
|
private func statusRow(icon: String, title: String, value: String) -> some View {
|
|
HStack(spacing: Design.spacing) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: Design.iconSize))
|
|
.foregroundColor(.secondary)
|
|
.frame(width: 16)
|
|
|
|
Text(title)
|
|
.font(.system(size: Design.iconSize))
|
|
.foregroundColor(.secondary)
|
|
|
|
Spacer()
|
|
|
|
Text(value)
|
|
.font(.system(size: Design.iconSize, weight: .medium))
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct WatchedFoldersView: View {
|
|
@Binding var watchedFolders: [String]
|
|
let folderMonitor: FolderMonitor
|
|
let onAddFolder: () -> Void
|
|
let onStopWatching: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: Design.spacing) {
|
|
List {
|
|
ForEach(watchedFolders, id: \.self) { path in
|
|
folderCard(path)
|
|
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
|
|
.listRowBackground(Color.clear)
|
|
.listRowSeparator(.hidden)
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
|
Button(role: .destructive) {
|
|
withAnimation(Animation.spring) {
|
|
watchedFolders.removeAll { $0 == path }
|
|
if watchedFolders.isEmpty {
|
|
folderMonitor.stopWatching()
|
|
} else {
|
|
folderMonitor.startWatching(paths: watchedFolders)
|
|
}
|
|
}
|
|
HapticManager.tap()
|
|
} label: {
|
|
Label("Remove", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
.frame(height: CGFloat(watchedFolders.count * 60))
|
|
.padding(.horizontal, -8)
|
|
|
|
VStack(spacing: Design.spacing) {
|
|
Button(action: {
|
|
HapticManager.tap()
|
|
onAddFolder()
|
|
}) {
|
|
HStack(spacing: Design.spacing) {
|
|
Image(systemName: "folder.badge.plus")
|
|
.font(.system(size: Design.iconSize))
|
|
Text("Add Another Folder")
|
|
.font(.system(size: Design.iconSize))
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: Design.cornerRadius)
|
|
.fill(Color.accentPurple.opacity(Design.opacity))
|
|
)
|
|
.foregroundColor(.accentPurple)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Button(action: {
|
|
HapticManager.tap()
|
|
onStopWatching()
|
|
}) {
|
|
HStack(spacing: Design.spacing) {
|
|
Image(systemName: "stop.circle.fill")
|
|
.font(.system(size: Design.iconSize))
|
|
Text("Stop Watching")
|
|
.font(.system(size: Design.iconSize))
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: Design.cornerRadius)
|
|
.fill(Color.red.opacity(Design.opacity))
|
|
)
|
|
.foregroundColor(.red)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func folderCard(_ path: String) -> some View {
|
|
HStack(spacing: Design.spacing) {
|
|
Image(systemName: "folder.fill")
|
|
.font(.system(size: Design.iconSize))
|
|
.foregroundColor(.accentPurple)
|
|
|
|
Text(path)
|
|
.font(.system(size: Design.iconSize))
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(.vertical, 12)
|
|
.padding(.horizontal, Design.padding)
|
|
.frame(maxWidth: .infinity)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: Design.cornerRadius, style: .continuous)
|
|
.fill(Color.accentPurple.opacity(Design.opacity))
|
|
)
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
}
|
|
}
|
|
|
|
struct LeftSideView: View {
|
|
let folderMonitor: FolderMonitor
|
|
let uploadStatus: String
|
|
let lastUpload: String
|
|
@Binding var showingSettings: Bool
|
|
@Binding var watchedFolders: [String]
|
|
let apiKey: String
|
|
let onToggleWatching: () -> Void
|
|
let onAddFolder: () -> Void
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
HeaderView(showingSettings: $showingSettings)
|
|
StatusCardView(folderMonitor: folderMonitor, uploadStatus: uploadStatus, lastUpload: lastUpload)
|
|
|
|
if !watchedFolders.isEmpty {
|
|
WatchedFoldersView(
|
|
watchedFolders: $watchedFolders,
|
|
folderMonitor: folderMonitor,
|
|
onAddFolder: onAddFolder,
|
|
onStopWatching: onToggleWatching
|
|
)
|
|
}
|
|
|
|
if watchedFolders.isEmpty {
|
|
ActionButtonView(folderMonitor: folderMonitor, apiKey: apiKey, onToggleWatching: onToggleWatching)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(24)
|
|
}
|
|
.frame(width: 360)
|
|
.background(Color(.windowBackgroundColor))
|
|
}
|
|
}
|
|
|
|
struct RightSideView: View {
|
|
let isUploading: Bool
|
|
@Binding var uploadHistory: [(date: Date, url: String, filePath: String)]
|
|
@Binding var copiedURL: String?
|
|
let folderMonitor: FolderMonitor
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("Recent Uploads")
|
|
.font(.system(size: 20, weight: .bold))
|
|
|
|
if isUploading {
|
|
UploadingCardView()
|
|
}
|
|
|
|
UploadHistoryListView(
|
|
uploadHistory: $uploadHistory,
|
|
copiedURL: $copiedURL,
|
|
folderMonitor: folderMonitor
|
|
)
|
|
}
|
|
.padding(24)
|
|
.frame(maxWidth: .infinity)
|
|
.background(Color(.textBackgroundColor))
|
|
}
|
|
}
|
|
|
|
struct ActionButtonView: View {
|
|
let folderMonitor: FolderMonitor
|
|
let apiKey: String
|
|
let onToggleWatching: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: {
|
|
HapticManager.tap()
|
|
onToggleWatching()
|
|
}) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: folderMonitor.isWatching ? "stop.circle.fill" : "folder.badge.plus")
|
|
Text(folderMonitor.isWatching ? "Stop Watching" : "Add Folders...")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 44)
|
|
.background(folderMonitor.isWatching ? Color.red : Color(.controlAccentColor))
|
|
.cornerRadius(12)
|
|
.foregroundColor(.white)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(apiKey.isEmpty)
|
|
.opacity(apiKey.isEmpty ? 0.5 : 1)
|
|
.help(folderMonitor.isWatching ? "Stop watching all folders" : "Select multiple folders to watch")
|
|
}
|
|
}
|
|
|
|
struct UploadingCardView: View {
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
Text("Uploading file...")
|
|
.font(.system(size: 14, weight: .medium))
|
|
Spacer()
|
|
}
|
|
.padding(16)
|
|
.background(
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
.fill(Color.accentPurple.opacity(0.1))
|
|
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [Color.accentPurple.opacity(0.1), Color.clear],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
}
|
|
)
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
}
|
|
}
|
|
|
|
private enum Animation {
|
|
static let spring = SwiftUI.Animation.spring(response: 0.3, dampingFraction: 0.7)
|
|
static let copyDelay: TimeInterval = 1.5
|
|
}
|
|
|
|
private enum Design {
|
|
static let cornerRadius: CGFloat = 12
|
|
static let opacity = 0.1
|
|
static let iconSize: CGFloat = 14
|
|
static let spacing: CGFloat = 8
|
|
static let padding: CGFloat = 16
|
|
}
|
|
|
|
struct UploadHistoryListView: View {
|
|
@Binding var uploadHistory: [(date: Date, url: String, filePath: String)]
|
|
@Binding var copiedURL: String?
|
|
let folderMonitor: FolderMonitor
|
|
@Namespace private var scrollSpace
|
|
|
|
var body: some View {
|
|
ScrollViewReader { proxy in
|
|
List {
|
|
ForEach(uploadHistory, id: \.date) { upload in
|
|
lastUploadCard(upload.url, date: upload.date, filePath: upload.filePath)
|
|
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
|
|
.listRowBackground(Color.clear)
|
|
.listRowSeparator(.hidden)
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
Button(role: .destructive) {
|
|
withAnimation(Animation.spring) {
|
|
uploadHistory.removeAll { $0.url == upload.url }
|
|
}
|
|
HapticManager.tap()
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
|
|
Button {
|
|
if let url = URL(string: upload.url) {
|
|
NSWorkspace.shared.open(url)
|
|
HapticManager.tap()
|
|
}
|
|
} label: {
|
|
Label("Open", systemImage: "safari")
|
|
}
|
|
.tint(.accentPurple)
|
|
}
|
|
.id(upload.date)
|
|
}
|
|
|
|
if uploadHistory.isEmpty && !folderMonitor.isWatching {
|
|
VStack(spacing: Design.spacing) {
|
|
Image(systemName: "folder.badge.plus")
|
|
.font(.system(size: 32))
|
|
.foregroundColor(.secondary)
|
|
Text("Select a folder to start watching")
|
|
.font(.system(size: Design.iconSize))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 160)
|
|
.listRowInsets(EdgeInsets())
|
|
.listRowBackground(Color.clear)
|
|
.listRowSeparator(.hidden)
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
.onChange(of: uploadHistory.map(\.url)) { oldValue, newValue in
|
|
if !newValue.isEmpty {
|
|
withAnimation(Animation.spring) {
|
|
proxy.scrollTo(uploadHistory[0].date, anchor: .top)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func lastUploadCard(_ url: String, date: Date = Date(), filePath: String? = nil) -> some View {
|
|
Button(action: {
|
|
copyToClipboard(url)
|
|
withAnimation(Animation.spring) {
|
|
copiedURL = url
|
|
}
|
|
HapticManager.success()
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + Animation.copyDelay) {
|
|
withAnimation(Animation.spring) {
|
|
if copiedURL == url {
|
|
copiedURL = nil
|
|
}
|
|
}
|
|
}
|
|
}) {
|
|
VStack(alignment: .leading, spacing: Design.spacing) {
|
|
if let filePath = filePath,
|
|
let nsImage = NSImage(contentsOf: URL(fileURLWithPath: filePath)) {
|
|
Image(nsImage: nsImage)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
.frame(height: 120)
|
|
.frame(maxWidth: .infinity)
|
|
.clipShape(RoundedRectangle(cornerRadius: Design.cornerRadius))
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: Design.spacing) {
|
|
Text(date.formatted(date: .numeric, time: .shortened))
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.secondary)
|
|
|
|
HStack(spacing: Design.spacing) {
|
|
Image(systemName: "link")
|
|
.font(.system(size: Design.iconSize))
|
|
.foregroundColor(copiedURL == url ? .green : .accentPurple)
|
|
|
|
Text(url)
|
|
.font(.system(size: Design.iconSize))
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
|
|
Spacer()
|
|
|
|
HStack(spacing: 4) {
|
|
if copiedURL == url {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: Design.iconSize))
|
|
.transition(.scale.combined(with: .opacity))
|
|
Text("Copied!")
|
|
} else {
|
|
Text("Copy")
|
|
}
|
|
}
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(copiedURL == url ? .green : .accentPurple)
|
|
.animation(Animation.spring, value: copiedURL)
|
|
}
|
|
}
|
|
}
|
|
.padding(Design.padding)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: Design.cornerRadius, style: .continuous)
|
|
.fill(copiedURL == url ? Color.green.opacity(Design.opacity) : Color.accentPurple.opacity(Design.opacity))
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.contentShape(Rectangle())
|
|
.animation(Animation.spring, value: copiedURL)
|
|
}
|
|
|
|
private func copyToClipboard(_ text: String) {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(text, forType: .string)
|
|
}
|
|
}
|
|
|
|
class WindowManager {
|
|
static let shared = WindowManager()
|
|
private var window: NSWindow?
|
|
|
|
func setWindow(_ window: NSWindow?) {
|
|
self.window = window
|
|
}
|
|
|
|
func hideWindow() {
|
|
window?.orderOut(nil)
|
|
}
|
|
|
|
func showWindow() {
|
|
window?.makeKeyAndOrderFront(nil)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
}
|
|
}
|
|
|
|
struct ContentView: View {
|
|
@StateObject private var folderMonitor = FolderMonitor()
|
|
@State private var uploadStatus = "No active uploads"
|
|
@State private var lastUpload = "Never"
|
|
@State private var showingSettings = false
|
|
@AppStorage("apiKey") private var apiKey = ""
|
|
@State private var watchedFolders: [String] = UserDefaults.standard.stringArray(forKey: "watchedFolders") ?? []
|
|
@State private var errorMessage: String?
|
|
@State private var showError = false
|
|
@State private var lastUploadURL: String?
|
|
@State private var isUploading = false
|
|
@State private var showCopiedBadge = false
|
|
@Environment(\.colorScheme) var colorScheme
|
|
@State private var uploadHistory: [(date: Date, url: String, filePath: String)] = []
|
|
@State private var copiedURL: String? = nil
|
|
|
|
var body: some View {
|
|
MainLayout {
|
|
HStack(spacing: 0) {
|
|
LeftSideView(
|
|
folderMonitor: folderMonitor,
|
|
uploadStatus: uploadStatus,
|
|
lastUpload: lastUpload,
|
|
showingSettings: $showingSettings,
|
|
watchedFolders: $watchedFolders,
|
|
apiKey: apiKey,
|
|
onToggleWatching: toggleWatching,
|
|
onAddFolder: selectFolder
|
|
)
|
|
|
|
RightSideView(
|
|
isUploading: isUploading,
|
|
uploadHistory: $uploadHistory,
|
|
copiedURL: $copiedURL,
|
|
folderMonitor: folderMonitor
|
|
)
|
|
}
|
|
}
|
|
.frame(width: 800, height: 400)
|
|
.background(Color(.windowBackgroundColor))
|
|
.alert("Error", isPresented: $showError) {
|
|
Button("OK", role: .cancel) {}
|
|
} message: {
|
|
Text(errorMessage ?? "Unknown error")
|
|
}
|
|
.sheet(isPresented: $showingSettings) {
|
|
SettingsView(isPresented: $showingSettings, apiKey: $apiKey)
|
|
}
|
|
.onAppear {
|
|
setupFolderMonitor()
|
|
restoreWatching()
|
|
}
|
|
.onChange(of: watchedFolders) { oldValue, newValue in
|
|
UserDefaults.standard.set(newValue, forKey: "watchedFolders")
|
|
if !folderMonitor.isWatching && !newValue.isEmpty {
|
|
folderMonitor.startWatching(paths: newValue)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func toggleWatching() {
|
|
if folderMonitor.isWatching {
|
|
folderMonitor.stopWatching()
|
|
uploadStatus = "Watching stopped"
|
|
watchedFolders.removeAll()
|
|
} else {
|
|
selectFolder()
|
|
}
|
|
}
|
|
|
|
private func selectFolder() {
|
|
let panel = NSOpenPanel()
|
|
panel.allowsMultipleSelection = true
|
|
panel.canChooseDirectories = true
|
|
panel.canChooseFiles = false
|
|
panel.message = "Select folders to watch for screenshots"
|
|
panel.prompt = "Watch Folders"
|
|
|
|
panel.begin { response in
|
|
guard response == .OK else { return }
|
|
|
|
DispatchQueue.main.async {
|
|
for url in panel.urls {
|
|
guard url.startAccessingSecurityScopedResource() else {
|
|
NotificationManager.send(title: "Permission Required", message: "Please grant folder access for \(url.path)")
|
|
continue
|
|
}
|
|
|
|
if !watchedFolders.contains(url.path) {
|
|
watchedFolders.append(url.path)
|
|
|
|
if let bookmarkData = try? url.bookmarkData(options: .withSecurityScope) {
|
|
UserDefaults.standard.set(bookmarkData, forKey: "FolderBookmark-\(url.path)")
|
|
}
|
|
}
|
|
}
|
|
|
|
if !watchedFolders.isEmpty {
|
|
if !folderMonitor.isWatching {
|
|
uploadStatus = "Starting to watch folders..."
|
|
folderMonitor.startWatching(paths: watchedFolders)
|
|
} else {
|
|
for url in panel.urls {
|
|
folderMonitor.addPath(url.path)
|
|
}
|
|
}
|
|
|
|
uploadStatus = "Watching for new files..."
|
|
NotificationManager.send(title: "Folders Added", message: "Now watching \(panel.urls.count) new folder(s)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setupFolderMonitor() {
|
|
folderMonitor.onFileDetected = handleNewFile
|
|
}
|
|
|
|
private func handleNewFile(_ fileURL: URL) {
|
|
uploadStatus = "Uploading \(fileURL.lastPathComponent)..."
|
|
NotificationManager.send(title: "Uploading File", message: "Starting upload of \(fileURL.lastPathComponent)")
|
|
withAnimation { isUploading = true }
|
|
|
|
UploadManager.uploadFile(fileURL, apiKey: apiKey) { result in
|
|
DispatchQueue.main.async {
|
|
withAnimation { isUploading = false }
|
|
handleUploadResult(result)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleUploadResult(_ result: Result<String, Error>) {
|
|
switch result {
|
|
case .success(let response): handleSuccessfulUpload(response)
|
|
case .failure(let error): handleFailedUpload(error)
|
|
}
|
|
}
|
|
|
|
private func handleSuccessfulUpload(_ response: String) {
|
|
do {
|
|
if let jsonData = response.data(using: .utf8),
|
|
let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
|
|
let url = json["url"] as? String,
|
|
let currentFile = lastUploadedFile?.path {
|
|
withAnimation(.spring()) {
|
|
uploadStatus = "Upload successful"
|
|
lastUpload = Date().formatted(date: .numeric, time: .shortened)
|
|
lastUploadURL = url
|
|
uploadHistory.insert((date: Date(), url: url, filePath: currentFile), at: 0)
|
|
if uploadHistory.count > 50 {
|
|
uploadHistory.removeLast()
|
|
}
|
|
}
|
|
copyToClipboard(url)
|
|
HapticManager.success()
|
|
NotificationManager.send(title: "Upload Successful", message: "URL copied to clipboard")
|
|
} else {
|
|
throw NSError(domain: "", code: -1)
|
|
}
|
|
} catch {
|
|
handleFailedUpload(error)
|
|
}
|
|
}
|
|
|
|
private func handleFailedUpload(_ error: Error) {
|
|
withAnimation {
|
|
errorMessage = error.localizedDescription
|
|
showError = true
|
|
uploadStatus = "Upload failed"
|
|
}
|
|
HapticManager.error()
|
|
NotificationManager.send(title: "Upload Failed", message: error.localizedDescription)
|
|
}
|
|
|
|
private var lastUploadedFile: URL? {
|
|
guard let path = watchedFolders.first else { return nil }
|
|
let fm = FileManager.default
|
|
let contents = try? fm.contentsOfDirectory(atPath: path)
|
|
return contents?.compactMap { URL(fileURLWithPath: path).appendingPathComponent($0) }
|
|
.sorted { $0.lastPathModified > $1.lastPathModified }
|
|
.first
|
|
}
|
|
|
|
private func copyToClipboard(_ text: String) {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(text, forType: .string)
|
|
uploadStatus = "URL copied to clipboard"
|
|
}
|
|
|
|
private func restoreWatching() {
|
|
guard !apiKey.isEmpty else { return }
|
|
|
|
var validPaths: [String] = []
|
|
|
|
for path in watchedFolders {
|
|
guard FileManager.default.fileExists(atPath: path),
|
|
let bookmarkData = UserDefaults.standard.data(forKey: "FolderBookmark-\(path)") else { continue }
|
|
|
|
var isStale = false
|
|
do {
|
|
let url = try URL(resolvingBookmarkData: bookmarkData,
|
|
options: .withSecurityScope,
|
|
relativeTo: nil,
|
|
bookmarkDataIsStale: &isStale)
|
|
|
|
guard url.startAccessingSecurityScopedResource() else {
|
|
print("Failed to access security scoped resource for path: \(path)")
|
|
continue
|
|
}
|
|
|
|
validPaths.append(url.path)
|
|
} catch {
|
|
print("Failed to resolve bookmark for path: \(path), error: \(error)")
|
|
}
|
|
}
|
|
|
|
if !validPaths.isEmpty {
|
|
DispatchQueue.main.async {
|
|
watchedFolders = validPaths
|
|
folderMonitor.startWatching(paths: validPaths)
|
|
uploadStatus = "Watching for new files..."
|
|
NotificationManager.send(title: "Ventry Upload Watcher", message: "Resumed watching \(validPaths.count) folder(s)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MainLayout<Content: View>: View {
|
|
let content: Content
|
|
@Environment(\.colorScheme) var colorScheme
|
|
|
|
init(@ViewBuilder content: () -> Content) {
|
|
self.content = content()
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color(colorScheme == .dark ? .windowBackgroundColor : .white)
|
|
.ignoresSafeArea()
|
|
content
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ContentView()
|
|
}
|
|
|
|
extension URL {
|
|
var lastPathModified: Date {
|
|
(try? resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? Date.distantPast
|
|
}
|
|
}
|
|
|
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
|
var statusItem: NSStatusItem?
|
|
|
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
setupStatusItem()
|
|
|
|
if let window = NSApplication.shared.windows.first {
|
|
WindowManager.shared.setWindow(window)
|
|
}
|
|
}
|
|
|
|
private func setupStatusItem() {
|
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
|
|
if let button = statusItem?.button {
|
|
let icon = NSImage(named: "ventry-logo-white")
|
|
icon?.isTemplate = true
|
|
icon?.size = NSSize(width: 18, height: 18)
|
|
button.image = icon
|
|
|
|
let menu = NSMenu()
|
|
menu.addItem(NSMenuItem(title: "Show Window", action: #selector(showWindow), keyEquivalent: ""))
|
|
menu.addItem(NSMenuItem.separator())
|
|
menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
|
|
|
|
statusItem?.menu = menu
|
|
}
|
|
}
|
|
|
|
@objc private func showWindow() {
|
|
WindowManager.shared.showWindow()
|
|
}
|
|
}
|