Priv
This commit is contained in:
@ -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)
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user