Priv
This commit is contained in:
parent
be162d062b
commit
292180b204
12 changed files with 1897 additions and 13 deletions
|
@ -393,17 +393,21 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"PDSMan/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"PDSMan/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = PDSMan;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
@ -411,9 +415,12 @@
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.PDSMan;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.PDSMan;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
|
@ -422,17 +429,21 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"PDSMan/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"PDSMan/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = PDSMan;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
@ -440,9 +451,12 @@
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.PDSMan;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.PDSMan;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
|
|
@ -9,13 +9,8 @@ import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
MainAppView()
|
||||||
Image(systemName: "globe")
|
.accentColor(.blue)
|
||||||
.imageScale(.large)
|
|
||||||
.foregroundStyle(.tint)
|
|
||||||
Text("Hello, world!")
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
68
PDSMan/Models/PDSModels.swift
Normal file
68
PDSMan/Models/PDSModels.swift
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// Credentials for connecting to the PDS
|
||||||
|
struct PDSCredentials: Sendable {
|
||||||
|
var serverURL: String
|
||||||
|
var username: String
|
||||||
|
var password: String
|
||||||
|
|
||||||
|
init?(serverURL: String, username: String, password: String) {
|
||||||
|
// Validate inputs
|
||||||
|
guard !serverURL.isEmpty, !username.isEmpty, !password.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.serverURL = serverURL
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invite code model
|
||||||
|
struct InviteCode: Identifiable, Sendable {
|
||||||
|
var id: String
|
||||||
|
var code: String { id } // For compatibility with the view
|
||||||
|
var uses: [CodeUse]?
|
||||||
|
var createdAt: Date
|
||||||
|
var disabled: Bool
|
||||||
|
var isDisabled: Bool { disabled } // For backwards compatibility
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invite use model
|
||||||
|
struct CodeUse: Codable, Identifiable, Sendable {
|
||||||
|
var id: String { usedBy }
|
||||||
|
var usedBy: String
|
||||||
|
var usedAt: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// User model
|
||||||
|
struct PDSUser: Identifiable, Hashable, Sendable {
|
||||||
|
var id: String // DID
|
||||||
|
var handle: String
|
||||||
|
var displayName: String
|
||||||
|
var description: String
|
||||||
|
var joinedAt: Date
|
||||||
|
var avatar: URL?
|
||||||
|
var isActive: Bool = true
|
||||||
|
|
||||||
|
// Add Hashable conformance
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: PDSUser, rhs: PDSUser) -> Bool {
|
||||||
|
lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth state
|
||||||
|
enum AuthState: Sendable {
|
||||||
|
case loggedOut
|
||||||
|
case loggedIn
|
||||||
|
}
|
||||||
|
|
||||||
|
// View state
|
||||||
|
enum ViewTab: Sendable {
|
||||||
|
case inviteCodes
|
||||||
|
case userList
|
||||||
|
}
|
20
PDSMan/PDSApp.swift
Normal file
20
PDSMan/PDSApp.swift
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct PDSApp: App {
|
||||||
|
@StateObject private var viewModel = PDSViewModel()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
print("PDSApp: initializing")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
MainAppView()
|
||||||
|
.environmentObject(viewModel)
|
||||||
|
.onAppear {
|
||||||
|
print("PDSApp: root view appeared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,11 +7,11 @@
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@main
|
// This app entry point is replaced by PDSApp.swift
|
||||||
struct PDSManApp: App {
|
struct PDSManApp: App {
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
Text("This app entry point is not used.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
1052
PDSMan/Services/PDSService.swift
Normal file
1052
PDSMan/Services/PDSService.swift
Normal file
File diff suppressed because it is too large
Load diff
58
PDSMan/ViewModels/PDSViewModel.swift
Normal file
58
PDSMan/ViewModels/PDSViewModel.swift
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class PDSViewModel: ObservableObject {
|
||||||
|
@Published var pdsService = PDSService()
|
||||||
|
@Published var alertItem: AlertItem?
|
||||||
|
|
||||||
|
var errorMessage: String? {
|
||||||
|
pdsService.errorMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
var isLoading: Bool {
|
||||||
|
pdsService.isLoading
|
||||||
|
}
|
||||||
|
|
||||||
|
var isAuthenticated: Bool {
|
||||||
|
get {
|
||||||
|
let value = pdsService.isAuthenticated
|
||||||
|
print("PDSViewModel: isAuthenticated getter called, value = \(value)")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var users: [PDSUser] {
|
||||||
|
pdsService.users
|
||||||
|
}
|
||||||
|
|
||||||
|
var inviteCodes: [InviteCode] {
|
||||||
|
pdsService.inviteCodes
|
||||||
|
}
|
||||||
|
|
||||||
|
func login(serverURL: String, username: String, password: String) async {
|
||||||
|
print("PDSViewModel: login called")
|
||||||
|
if let credentials = PDSCredentials(serverURL: serverURL, username: username, password: password) {
|
||||||
|
let success = await pdsService.login(credentials: credentials)
|
||||||
|
print("PDSViewModel: login completed, success = \(success), isAuthenticated = \(pdsService.isAuthenticated)")
|
||||||
|
|
||||||
|
// Force update the view by triggering objectWillChange
|
||||||
|
objectWillChange.send()
|
||||||
|
} else {
|
||||||
|
pdsService.errorMessage = "Invalid credentials"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout() {
|
||||||
|
print("PDSViewModel: logout called")
|
||||||
|
pdsService.logout()
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AlertItem: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let title: String
|
||||||
|
let message: String
|
||||||
|
}
|
227
PDSMan/Views/InviteCodesView.swift
Normal file
227
PDSMan/Views/InviteCodesView.swift
Normal file
|
@ -0,0 +1,227 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct InviteCodesView: View {
|
||||||
|
@EnvironmentObject var viewModel: PDSViewModel
|
||||||
|
@State private var isCreatingCode = false
|
||||||
|
@State private var isRefreshing = false
|
||||||
|
@State private var showDisabledCodes = false
|
||||||
|
|
||||||
|
// Split codes into active and disabled
|
||||||
|
var activeCodes: [InviteCode] {
|
||||||
|
return viewModel.inviteCodes.filter { !$0.disabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
var disabledCodes: [InviteCode] {
|
||||||
|
return viewModel.inviteCodes.filter { $0.disabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
var filteredCodes: [InviteCode] {
|
||||||
|
if showDisabledCodes {
|
||||||
|
return viewModel.inviteCodes
|
||||||
|
} else {
|
||||||
|
return viewModel.inviteCodes.filter { !$0.disabled }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
Group {
|
||||||
|
if viewModel.isLoading && viewModel.inviteCodes.isEmpty {
|
||||||
|
ProgressView("Loading invite codes...")
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if filteredCodes.isEmpty {
|
||||||
|
VStack {
|
||||||
|
Text("No invite codes found")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Button("Refresh") {
|
||||||
|
refreshInviteCodes()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
List {
|
||||||
|
// Active codes section
|
||||||
|
Section(header: Text("Active Codes")) {
|
||||||
|
ForEach(activeCodes) { code in
|
||||||
|
InviteCodeRow(code: code)
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task {
|
||||||
|
await viewModel.pdsService.disableInviteCode(code.id)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Disable Code", systemImage: "xmark.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disabled codes section (only shown if toggle is on)
|
||||||
|
if showDisabledCodes && !disabledCodes.isEmpty {
|
||||||
|
Section(header: Text("Disabled Codes")) {
|
||||||
|
ForEach(disabledCodes) { code in
|
||||||
|
InviteCodeRow(code: code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("Show Disabled Codes (\(disabledCodes.count))", isOn: $showDisabledCodes)
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Invite Codes")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button {
|
||||||
|
isCreatingCode = true
|
||||||
|
Task {
|
||||||
|
await viewModel.pdsService.createInviteCode()
|
||||||
|
isCreatingCode = false
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if isCreatingCode {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Label("Create Code", systemImage: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isCreatingCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button {
|
||||||
|
refreshInviteCodes()
|
||||||
|
} label: {
|
||||||
|
if isRefreshing {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Label("Refresh", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isRefreshing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.pdsService.fetchInviteCodes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
// Only fetch if we don't already have data
|
||||||
|
if viewModel.inviteCodes.isEmpty && !viewModel.isLoading {
|
||||||
|
await viewModel.pdsService.fetchInviteCodes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Always attempt to refresh when view appears
|
||||||
|
if !viewModel.isLoading {
|
||||||
|
refreshInviteCodes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshInviteCodes() {
|
||||||
|
isRefreshing = true
|
||||||
|
Task {
|
||||||
|
await viewModel.pdsService.fetchInviteCodes()
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InviteCodeRow: View {
|
||||||
|
let code: InviteCode
|
||||||
|
@State private var copySuccess = false
|
||||||
|
|
||||||
|
var formattedDate: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter.string(from: code.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text(code.id)
|
||||||
|
.font(.system(.headline, design: .monospaced))
|
||||||
|
.foregroundColor(code.disabled ? .gray : .primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if code.disabled {
|
||||||
|
Text("DISABLED")
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.red.opacity(0.2))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.cornerRadius(4)
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
UIPasteboard.general.string = code.id
|
||||||
|
withAnimation {
|
||||||
|
copySuccess = true
|
||||||
|
}
|
||||||
|
// Reset the success message after a delay
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||||
|
withAnimation {
|
||||||
|
copySuccess = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: copySuccess ? "checkmark" : "doc.on.doc")
|
||||||
|
if copySuccess {
|
||||||
|
Text("Copied!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundColor(copySuccess ? .green : .blue)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(
|
||||||
|
(copySuccess ? Color.green : Color.blue)
|
||||||
|
.opacity(0.1)
|
||||||
|
.cornerRadius(4)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(BorderlessButtonStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Created: \(formattedDate)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let uses = code.uses {
|
||||||
|
if uses.isEmpty {
|
||||||
|
Text("Unused")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.green.opacity(0.1))
|
||||||
|
.cornerRadius(4)
|
||||||
|
} else {
|
||||||
|
Text("Uses: \(uses.count)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.blue.opacity(0.1))
|
||||||
|
.cornerRadius(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.opacity(code.disabled ? 0.7 : 1.0)
|
||||||
|
}
|
||||||
|
}
|
134
PDSMan/Views/LoginView.swift
Normal file
134
PDSMan/Views/LoginView.swift
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LoginView: View {
|
||||||
|
@EnvironmentObject var viewModel: PDSViewModel
|
||||||
|
|
||||||
|
@State private var serverURL = ""
|
||||||
|
@State private var password = ""
|
||||||
|
@State private var isLoggingIn = false
|
||||||
|
@State private var showingDebugInfo = false
|
||||||
|
@State private var debugLogs: [String] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image(systemName: "server.rack")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
|
||||||
|
Text("PDS Manager")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
TextField("Server URL", text: $serverURL)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.disableAutocorrection(true)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
|
||||||
|
SecureField("Admin Password", text: $password)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
login()
|
||||||
|
} label: {
|
||||||
|
if isLoggingIn {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
} else {
|
||||||
|
Text("Login")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.blue)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(10)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
.disabled(serverURL.isEmpty || password.isEmpty || isLoggingIn)
|
||||||
|
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
Text(errorMessage)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.font(.callout)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.red.opacity(0.1))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Toggle Debug Info") {
|
||||||
|
showingDebugInfo.toggle()
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.top, 20)
|
||||||
|
|
||||||
|
if showingDebugInfo {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(debugLogs, id: \.self) { log in
|
||||||
|
Text(log)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.frame(height: 200)
|
||||||
|
.background(Color.black.opacity(0.05))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.top, 50)
|
||||||
|
.onAppear {
|
||||||
|
// Add some example connection info
|
||||||
|
debugLogs.append("Example URLs:")
|
||||||
|
debugLogs.append("https://bsky.social - Main Bluesky server")
|
||||||
|
debugLogs.append("https://bsky.atri.dad - Your local server")
|
||||||
|
debugLogs.append("http://localhost:3000 - Local development PDS")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func login() {
|
||||||
|
isLoggingIn = true
|
||||||
|
|
||||||
|
// Add debug info
|
||||||
|
debugLogs.append("Attempting login to: \(serverURL)")
|
||||||
|
|
||||||
|
// Sanitize the URL to ensure it has the correct format
|
||||||
|
var cleanURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !cleanURL.hasPrefix("http://") && !cleanURL.hasPrefix("https://") {
|
||||||
|
cleanURL = "http://\(cleanURL)"
|
||||||
|
debugLogs.append("Added http:// prefix: \(cleanURL)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing slash if present
|
||||||
|
if cleanURL.hasSuffix("/") {
|
||||||
|
cleanURL.removeLast()
|
||||||
|
debugLogs.append("Removed trailing slash: \(cleanURL)")
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLogs.append("Using final URL: \(cleanURL)")
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await viewModel.login(serverURL: cleanURL, username: "admin", password: password)
|
||||||
|
isLoggingIn = false
|
||||||
|
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
debugLogs.append("Login failed: \(error)")
|
||||||
|
} else {
|
||||||
|
debugLogs.append("Login successful!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
50
PDSMan/Views/MainAppView.swift
Normal file
50
PDSMan/Views/MainAppView.swift
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainAppView: View {
|
||||||
|
@EnvironmentObject var viewModel: PDSViewModel
|
||||||
|
@State private var selectedTab = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
if viewModel.isAuthenticated {
|
||||||
|
authenticatedView
|
||||||
|
} else {
|
||||||
|
LoginView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert(item: $viewModel.alertItem) { alert in
|
||||||
|
Alert(
|
||||||
|
title: Text(alert.title),
|
||||||
|
message: Text(alert.message),
|
||||||
|
dismissButton: .default(Text("OK"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var authenticatedView: some View {
|
||||||
|
NavigationView {
|
||||||
|
TabView(selection: $selectedTab) {
|
||||||
|
InviteCodesView()
|
||||||
|
.tabItem {
|
||||||
|
Image(systemName: "ticket")
|
||||||
|
Text("Invite Codes")
|
||||||
|
}
|
||||||
|
.tag(0)
|
||||||
|
|
||||||
|
UserListView()
|
||||||
|
.tabItem {
|
||||||
|
Image(systemName: "person.3")
|
||||||
|
Text("Users")
|
||||||
|
}
|
||||||
|
.tag(1)
|
||||||
|
}
|
||||||
|
.navigationTitle("PDS Manager")
|
||||||
|
.navigationBarItems(trailing: Button("Logout") {
|
||||||
|
viewModel.logout()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
print("Authenticated view appeared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
260
PDSMan/Views/UserListView.swift
Normal file
260
PDSMan/Views/UserListView.swift
Normal file
|
@ -0,0 +1,260 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct UserListView: View {
|
||||||
|
@EnvironmentObject var viewModel: PDSViewModel
|
||||||
|
@State private var editingUser: PDSUser? = nil
|
||||||
|
@State private var newHandle: String = ""
|
||||||
|
@State private var showingEditSheet = false
|
||||||
|
@State private var showingSuspendAlert = false
|
||||||
|
@State private var showingReactivateAlert = false
|
||||||
|
@State private var suspensionReason: String = ""
|
||||||
|
@State private var selectedUser: PDSUser? = nil
|
||||||
|
@State private var isRefreshing = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
Group {
|
||||||
|
if viewModel.isLoading && viewModel.users.isEmpty {
|
||||||
|
ProgressView("Loading users...")
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if viewModel.users.isEmpty {
|
||||||
|
VStack {
|
||||||
|
Text("No users found")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Button("Refresh") {
|
||||||
|
refreshUsers()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(viewModel.users) { user in
|
||||||
|
UserRow(user: user)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
selectedUser = user
|
||||||
|
editingUser = user
|
||||||
|
newHandle = user.handle
|
||||||
|
showingEditSheet = true
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
|
Button(action: {
|
||||||
|
selectedUser = user
|
||||||
|
editingUser = user
|
||||||
|
newHandle = user.handle
|
||||||
|
showingEditSheet = true
|
||||||
|
}) {
|
||||||
|
Label("Edit Handle", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.isActive {
|
||||||
|
Button(action: {
|
||||||
|
selectedUser = user
|
||||||
|
suspensionReason = ""
|
||||||
|
showingSuspendAlert = true
|
||||||
|
}) {
|
||||||
|
Label("Suspend Account", systemImage: "xmark.circle")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(action: {
|
||||||
|
selectedUser = user
|
||||||
|
showingReactivateAlert = true
|
||||||
|
}) {
|
||||||
|
Label("Reactivate Account", systemImage: "checkmark.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Users")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button {
|
||||||
|
refreshUsers()
|
||||||
|
} label: {
|
||||||
|
if isRefreshing {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Label("Refresh", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isRefreshing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.pdsService.fetchUsers()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingEditSheet) {
|
||||||
|
if let user = editingUser {
|
||||||
|
NavigationView {
|
||||||
|
Form {
|
||||||
|
Section(header: Text("Edit User Handle")) {
|
||||||
|
TextField("Handle", text: $newHandle)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") {
|
||||||
|
showingEditSheet = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Save") {
|
||||||
|
Task {
|
||||||
|
if await viewModel.pdsService.editUserHandle(userId: user.id, newHandle: newHandle) {
|
||||||
|
showingEditSheet = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(newHandle.isEmpty || newHandle == user.handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Edit \(user.displayName)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Suspend User", isPresented: $showingSuspendAlert) {
|
||||||
|
TextField("Reason for suspension", text: $suspensionReason)
|
||||||
|
Button("Cancel", role: .cancel) { }
|
||||||
|
Button("Suspend", role: .destructive) {
|
||||||
|
if let user = selectedUser {
|
||||||
|
Task {
|
||||||
|
await viewModel.pdsService.suspendUser(userId: user.id, reason: suspensionReason)
|
||||||
|
await viewModel.pdsService.fetchUsers() // Refresh after suspension
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
if let user = selectedUser {
|
||||||
|
Text("Are you sure you want to suspend \(user.displayName)'s account? This will prevent them from using the service.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Reactivate User", isPresented: $showingReactivateAlert) {
|
||||||
|
Button("Cancel", role: .cancel) { }
|
||||||
|
Button("Reactivate") {
|
||||||
|
if let user = selectedUser {
|
||||||
|
Task {
|
||||||
|
await viewModel.pdsService.reactivateUser(userId: user.id)
|
||||||
|
await viewModel.pdsService.fetchUsers() // Refresh after reactivation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
if let user = selectedUser {
|
||||||
|
Text("Are you sure you want to reactivate \(user.displayName)'s account?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
// Only fetch if we don't already have data
|
||||||
|
if viewModel.users.isEmpty && !viewModel.isLoading {
|
||||||
|
await viewModel.pdsService.fetchUsers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Always attempt to refresh when view appears
|
||||||
|
if !viewModel.isLoading {
|
||||||
|
refreshUsers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshUsers() {
|
||||||
|
isRefreshing = true
|
||||||
|
Task {
|
||||||
|
await viewModel.pdsService.fetchUsers()
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserRow: View {
|
||||||
|
let user: PDSUser
|
||||||
|
|
||||||
|
var formattedDate: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
formatter.timeStyle = .none
|
||||||
|
return "Joined: \(formatter.string(from: user.joinedAt))"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if let avatarURL = user.avatar {
|
||||||
|
AsyncImage(url: avatarURL) { image in
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
} placeholder: {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
.frame(width: 60, height: 60)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "person.circle.fill")
|
||||||
|
.resizable()
|
||||||
|
.foregroundColor(.gray.opacity(0.7))
|
||||||
|
.frame(width: 60, height: 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
HStack {
|
||||||
|
Text(user.displayName)
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
if !user.isActive {
|
||||||
|
Text("SUSPENDED")
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 5)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.red)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("@\(user.handle)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.description.isEmpty {
|
||||||
|
Text(user.description)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.lineLimit(3)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(formattedDate)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(user.id)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
.frame(maxWidth: 160)
|
||||||
|
.opacity(0.7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.opacity(user.isActive ? 1.0 : 0.7)
|
||||||
|
}
|
||||||
|
}
|
6
README.md
Normal file
6
README.md
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
# PDSMan
|
||||||
|
|
||||||
|
Privacy Policy
|
||||||
|
|
||||||
|
This app does not collect any personal information. All data processing is performed locally on your device. The only data transmitted is crash logs sent to Apple for the purpose of improving the app.
|
||||||
|
|
Loading…
Add table
Reference in a new issue