This commit is contained in:
2025-03-19 01:25:06 -06:00
parent be162d062b
commit 292180b204
12 changed files with 1897 additions and 13 deletions

View File

@ -9,13 +9,8 @@ import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
MainAppView()
.accentColor(.blue)
}
}

View 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
View 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")
}
}
}
}

View File

@ -7,11 +7,11 @@
import SwiftUI
@main
// This app entry point is replaced by PDSApp.swift
struct PDSManApp: App {
var body: some Scene {
WindowGroup {
ContentView()
Text("This app entry point is not used.")
}
}
}

File diff suppressed because it is too large Load Diff

View 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
}

View 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)
}
}

View 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!")
}
}
}
}

View 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")
}
}
}

View 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)
}
}