@pylonsync/create-pylon 0.3.51 → 0.3.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-pylon.js +347 -1156
- package/package.json +4 -3
- package/templates/_root/.env.example +9 -0
- package/templates/_root/README.md +43 -0
- package/templates/backend/b2b/apps/api/functions/archiveProject.ts +15 -0
- package/templates/backend/b2b/apps/api/functions/createOrg.ts +43 -0
- package/templates/backend/b2b/apps/api/functions/createProject.ts +25 -0
- package/templates/backend/b2b/apps/api/functions/inviteMember.ts +49 -0
- package/templates/backend/b2b/apps/api/functions/myOrgs.ts +37 -0
- package/templates/backend/b2b/apps/api/functions/orgMembers.ts +13 -0
- package/templates/backend/b2b/apps/api/functions/orgProjects.ts +18 -0
- package/templates/backend/b2b/apps/api/functions/removeMember.ts +29 -0
- package/templates/backend/b2b/apps/api/functions/setMemberRole.ts +38 -0
- package/templates/backend/b2b/apps/api/package.json +20 -0
- package/templates/backend/b2b/apps/api/schema.ts +171 -0
- package/templates/backend/b2b/apps/api/tsconfig.json +13 -0
- package/templates/backend/barebones/apps/api/functions/createWidget.ts +22 -0
- package/templates/backend/barebones/apps/api/functions/listWidgets.ts +18 -0
- package/templates/backend/barebones/apps/api/package.json +20 -0
- package/templates/backend/barebones/apps/api/schema.ts +61 -0
- package/templates/backend/barebones/apps/api/tsconfig.json +13 -0
- package/templates/backend/chat/apps/api/functions/createRoom.ts +32 -0
- package/templates/backend/chat/apps/api/functions/listRooms.ts +15 -0
- package/templates/backend/chat/apps/api/functions/roomMessages.ts +20 -0
- package/templates/backend/chat/apps/api/functions/sendMessage.ts +37 -0
- package/templates/backend/chat/apps/api/package.json +20 -0
- package/templates/backend/chat/apps/api/schema.ts +93 -0
- package/templates/backend/chat/apps/api/tsconfig.json +13 -0
- package/templates/backend/consumer/apps/api/functions/createPost.ts +48 -0
- package/templates/backend/consumer/apps/api/functions/deletePost.ts +21 -0
- package/templates/backend/consumer/apps/api/functions/feed.ts +57 -0
- package/templates/backend/consumer/apps/api/functions/myProfile.ts +17 -0
- package/templates/backend/consumer/apps/api/functions/profilePosts.ts +17 -0
- package/templates/backend/consumer/apps/api/functions/toggleLike.ts +48 -0
- package/templates/backend/consumer/apps/api/functions/upsertProfile.ts +70 -0
- package/templates/backend/consumer/apps/api/package.json +20 -0
- package/templates/backend/consumer/apps/api/schema.ts +130 -0
- package/templates/backend/consumer/apps/api/tsconfig.json +13 -0
- package/templates/backend/todo/apps/api/functions/addTodo.ts +27 -0
- package/templates/backend/todo/apps/api/functions/deleteTodo.ts +14 -0
- package/templates/backend/todo/apps/api/functions/editTodo.ts +16 -0
- package/templates/backend/todo/apps/api/functions/listTodos.ts +24 -0
- package/templates/backend/todo/apps/api/functions/reorderTodo.ts +14 -0
- package/templates/backend/todo/apps/api/functions/toggleTodo.ts +13 -0
- package/templates/backend/todo/apps/api/package.json +20 -0
- package/templates/backend/todo/apps/api/schema.ts +85 -0
- package/templates/backend/todo/apps/api/tsconfig.json +13 -0
- package/templates/expo/barebones/apps/expo/App.tsx +166 -0
- package/templates/expo/barebones/apps/expo/app.json +31 -0
- package/templates/expo/barebones/apps/expo/babel.config.js +6 -0
- package/templates/expo/barebones/apps/expo/package.json +30 -0
- package/templates/expo/barebones/apps/expo/tsconfig.json +16 -0
- package/templates/expo/chat/apps/expo/App.tsx +414 -0
- package/templates/expo/chat/apps/expo/app.json +25 -0
- package/templates/expo/chat/apps/expo/babel.config.js +6 -0
- package/templates/expo/chat/apps/expo/package.json +30 -0
- package/templates/expo/chat/apps/expo/tsconfig.json +16 -0
- package/templates/expo/consumer/apps/expo/App.tsx +360 -0
- package/templates/expo/consumer/apps/expo/app.json +25 -0
- package/templates/expo/consumer/apps/expo/babel.config.js +6 -0
- package/templates/expo/consumer/apps/expo/package.json +30 -0
- package/templates/expo/consumer/apps/expo/tsconfig.json +16 -0
- package/templates/expo/todo/apps/expo/App.tsx +287 -0
- package/templates/expo/todo/apps/expo/app.json +25 -0
- package/templates/expo/todo/apps/expo/babel.config.js +6 -0
- package/templates/expo/todo/apps/expo/package.json +30 -0
- package/templates/expo/todo/apps/expo/tsconfig.json +16 -0
- package/templates/ios/barebones/apps/ios/Package.swift +34 -0
- package/templates/ios/barebones/apps/ios/Sources/__APP_NAME_PASCAL__/ContentView.swift +98 -0
- package/templates/ios/barebones/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +17 -0
- package/templates/ios/barebones/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +34 -0
- package/templates/ios/barebones/apps/ios/project.yml +42 -0
- package/templates/ios/chat/apps/ios/Package.swift +34 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +120 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/RoomView.swift +137 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +35 -0
- package/templates/ios/chat/apps/ios/project.yml +42 -0
- package/templates/ios/consumer/apps/ios/Package.swift +23 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +170 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +42 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +60 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +30 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +29 -0
- package/templates/ios/consumer/apps/ios/project.yml +42 -0
- package/templates/ios/todo/apps/ios/Package.swift +23 -0
- package/templates/ios/todo/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +18 -0
- package/templates/ios/todo/apps/ios/Sources/__APP_NAME_PASCAL__/TodoListView.swift +230 -0
- package/templates/ios/todo/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +28 -0
- package/templates/ios/todo/apps/ios/project.yml +32 -0
- package/templates/mac/b2b/apps/mac/Package.swift +22 -0
- package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +15 -0
- package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/OrgPickerView.swift +178 -0
- package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
- package/templates/mac/b2b/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/b2b/apps/mac/project.yml +34 -0
- package/templates/mac/barebones/apps/mac/Package.swift +33 -0
- package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/ContentView.swift +104 -0
- package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +17 -0
- package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
- package/templates/mac/barebones/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/barebones/apps/mac/project.yml +34 -0
- package/templates/mac/chat/apps/mac/Package.swift +33 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +140 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/RoomView.swift +137 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +37 -0
- package/templates/mac/chat/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/chat/apps/mac/project.yml +34 -0
- package/templates/mac/consumer/apps/mac/Package.swift +33 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/FeedView.swift +170 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +42 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +60 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/RootView.swift +30 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +31 -0
- package/templates/mac/consumer/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/consumer/apps/mac/project.yml +34 -0
- package/templates/mac/todo/apps/mac/Package.swift +33 -0
- package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +19 -0
- package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/TodoListView.swift +244 -0
- package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
- package/templates/mac/todo/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/todo/apps/mac/project.yml +34 -0
- package/templates/ui/packages/ui/package.json +26 -0
- package/templates/ui/packages/ui/src/button.tsx +44 -0
- package/templates/ui/packages/ui/src/card.tsx +39 -0
- package/templates/ui/packages/ui/src/cn.ts +12 -0
- package/templates/ui/packages/ui/src/index.ts +4 -0
- package/templates/ui/packages/ui/src/input.tsx +19 -0
- package/templates/ui/packages/ui/tsconfig.json +15 -0
- package/templates/web/b2b/apps/web/next-env.d.ts +2 -0
- package/templates/web/b2b/apps/web/next.config.ts +24 -0
- package/templates/web/b2b/apps/web/package.json +29 -0
- package/templates/web/b2b/apps/web/postcss.config.mjs +3 -0
- package/templates/web/b2b/apps/web/src/app/components/OrgPicker.tsx +171 -0
- package/templates/web/b2b/apps/web/src/app/globals.css +6 -0
- package/templates/web/b2b/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/b2b/apps/web/src/app/page.tsx +39 -0
- package/templates/web/b2b/apps/web/src/lib/pylon.ts +5 -0
- package/templates/web/b2b/apps/web/tsconfig.json +26 -0
- package/templates/web/barebones/apps/web/next-env.d.ts +2 -0
- package/templates/web/barebones/apps/web/next.config.ts +40 -0
- package/templates/web/barebones/apps/web/package.json +29 -0
- package/templates/web/barebones/apps/web/postcss.config.mjs +4 -0
- package/templates/web/barebones/apps/web/src/app/components/WidgetList.tsx +81 -0
- package/templates/web/barebones/apps/web/src/app/globals.css +9 -0
- package/templates/web/barebones/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/barebones/apps/web/src/app/page.tsx +43 -0
- package/templates/web/barebones/apps/web/src/lib/pylon.ts +14 -0
- package/templates/web/barebones/apps/web/tsconfig.json +26 -0
- package/templates/web/chat/apps/web/next-env.d.ts +2 -0
- package/templates/web/chat/apps/web/next.config.ts +24 -0
- package/templates/web/chat/apps/web/package.json +29 -0
- package/templates/web/chat/apps/web/postcss.config.mjs +3 -0
- package/templates/web/chat/apps/web/src/app/components/ChatRoom.tsx +250 -0
- package/templates/web/chat/apps/web/src/app/globals.css +6 -0
- package/templates/web/chat/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/chat/apps/web/src/app/page.tsx +51 -0
- package/templates/web/chat/apps/web/src/lib/pylon.ts +5 -0
- package/templates/web/chat/apps/web/tsconfig.json +26 -0
- package/templates/web/consumer/apps/web/next-env.d.ts +2 -0
- package/templates/web/consumer/apps/web/next.config.ts +24 -0
- package/templates/web/consumer/apps/web/package.json +29 -0
- package/templates/web/consumer/apps/web/postcss.config.mjs +3 -0
- package/templates/web/consumer/apps/web/src/app/components/Feed.tsx +295 -0
- package/templates/web/consumer/apps/web/src/app/globals.css +6 -0
- package/templates/web/consumer/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/consumer/apps/web/src/app/page.tsx +55 -0
- package/templates/web/consumer/apps/web/src/lib/pylon.ts +5 -0
- package/templates/web/consumer/apps/web/tsconfig.json +26 -0
- package/templates/web/todo/apps/web/next-env.d.ts +2 -0
- package/templates/web/todo/apps/web/next.config.ts +24 -0
- package/templates/web/todo/apps/web/package.json +32 -0
- package/templates/web/todo/apps/web/postcss.config.mjs +3 -0
- package/templates/web/todo/apps/web/src/app/components/TodoList.tsx +310 -0
- package/templates/web/todo/apps/web/src/app/globals.css +6 -0
- package/templates/web/todo/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/todo/apps/web/src/app/page.tsx +36 -0
- package/templates/web/todo/apps/web/src/lib/pylon.ts +5 -0
- package/templates/web/todo/apps/web/tsconfig.json +26 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import PylonClient
|
|
3
|
+
|
|
4
|
+
/// Two-pane chat: rooms list → room view. Polls the active room
|
|
5
|
+
/// every 1.5s. For realtime, swap `pollMessages()` out for a
|
|
6
|
+
/// PylonQuery<Message> from PylonSwiftUI subscribed by roomId.
|
|
7
|
+
struct ChatRootView: View {
|
|
8
|
+
@EnvironmentObject var session: AppSession
|
|
9
|
+
@State private var rooms: [Room] = []
|
|
10
|
+
@State private var loadingRooms = true
|
|
11
|
+
@State private var errorMessage: String?
|
|
12
|
+
|
|
13
|
+
var body: some View {
|
|
14
|
+
NavigationStack {
|
|
15
|
+
List {
|
|
16
|
+
Section("Your name") {
|
|
17
|
+
TextField("display name", text: Binding(
|
|
18
|
+
get: { session.authorName },
|
|
19
|
+
set: { session.setAuthorName($0) },
|
|
20
|
+
))
|
|
21
|
+
.autocorrectionDisabled()
|
|
22
|
+
}
|
|
23
|
+
Section("Rooms") {
|
|
24
|
+
if loadingRooms {
|
|
25
|
+
ProgressView()
|
|
26
|
+
} else if rooms.isEmpty {
|
|
27
|
+
Text("No rooms yet. Create one below.")
|
|
28
|
+
.foregroundStyle(.secondary)
|
|
29
|
+
} else {
|
|
30
|
+
ForEach(rooms) { r in
|
|
31
|
+
NavigationLink(value: r) {
|
|
32
|
+
VStack(alignment: .leading) {
|
|
33
|
+
Text(r.name)
|
|
34
|
+
Text("#\(r.slug)")
|
|
35
|
+
.font(.system(.caption, design: .monospaced))
|
|
36
|
+
.foregroundStyle(.secondary)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
Section {
|
|
43
|
+
Button("Create room") {
|
|
44
|
+
Task { await createRoom() }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if let errorMessage {
|
|
48
|
+
Section {
|
|
49
|
+
Text(errorMessage)
|
|
50
|
+
.foregroundStyle(.red)
|
|
51
|
+
.font(.caption)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
.navigationTitle("__APP_NAME__")
|
|
56
|
+
.navigationDestination(for: Room.self) { room in
|
|
57
|
+
RoomView(room: room)
|
|
58
|
+
}
|
|
59
|
+
.task { await loadRooms() }
|
|
60
|
+
.refreshable { await loadRooms() }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private func loadRooms() async {
|
|
65
|
+
loadingRooms = true
|
|
66
|
+
defer { loadingRooms = false }
|
|
67
|
+
do {
|
|
68
|
+
rooms = try await session.pylon.callFn("listRooms", args: EmptyArgs())
|
|
69
|
+
errorMessage = nil
|
|
70
|
+
} catch {
|
|
71
|
+
errorMessage = "Load failed: \(error.localizedDescription)"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private func createRoom() async {
|
|
76
|
+
let alert = await prompt(title: "Create room", message: "Room name?")
|
|
77
|
+
guard let name = alert?.trimmingCharacters(in: .whitespaces),
|
|
78
|
+
!name.isEmpty
|
|
79
|
+
else { return }
|
|
80
|
+
let slug = name.lowercased()
|
|
81
|
+
.replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression)
|
|
82
|
+
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
|
|
83
|
+
do {
|
|
84
|
+
let room: Room = try await session.pylon.callFn(
|
|
85
|
+
"createRoom",
|
|
86
|
+
args: CreateRoomArgs(slug: slug, name: name),
|
|
87
|
+
)
|
|
88
|
+
rooms.append(room)
|
|
89
|
+
} catch {
|
|
90
|
+
errorMessage = "Create failed: \(error.localizedDescription)"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@MainActor
|
|
95
|
+
private func prompt(title: String, message: String) async -> String? {
|
|
96
|
+
// Minimal SwiftUI prompt — for production replace with an
|
|
97
|
+
// in-tree alert + TextField sheet. The scaffold uses a tiny
|
|
98
|
+
// UIKit detour on iOS so the demo works without a custom
|
|
99
|
+
// modal implementation.
|
|
100
|
+
#if canImport(UIKit)
|
|
101
|
+
await withCheckedContinuation { (cont: CheckedContinuation<String?, Never>) in
|
|
102
|
+
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
|
103
|
+
alert.addTextField()
|
|
104
|
+
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in cont.resume(returning: nil) })
|
|
105
|
+
alert.addAction(UIAlertAction(title: "Create", style: .default) { _ in cont.resume(returning: alert.textFields?.first?.text) })
|
|
106
|
+
UIApplication.shared.connectedScenes
|
|
107
|
+
.compactMap { ($0 as? UIWindowScene)?.windows.first }
|
|
108
|
+
.first?
|
|
109
|
+
.rootViewController?
|
|
110
|
+
.present(alert, animated: true)
|
|
111
|
+
}
|
|
112
|
+
#else
|
|
113
|
+
return nil
|
|
114
|
+
#endif
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#if canImport(UIKit)
|
|
119
|
+
import UIKit
|
|
120
|
+
#endif
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
struct Room: Codable, Identifiable, Hashable {
|
|
4
|
+
let id: String
|
|
5
|
+
let slug: String
|
|
6
|
+
let name: String
|
|
7
|
+
let createdAt: String
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
struct Message: Codable, Identifiable, Hashable {
|
|
11
|
+
let id: String
|
|
12
|
+
let roomId: String
|
|
13
|
+
let authorId: String
|
|
14
|
+
let authorName: String
|
|
15
|
+
let body: String
|
|
16
|
+
let createdAt: String
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
struct CreateRoomArgs: Encodable { let slug: String; let name: String }
|
|
20
|
+
struct RoomMessagesArgs: Encodable { let roomId: String }
|
|
21
|
+
struct SendMessageArgs: Encodable {
|
|
22
|
+
let roomId: String
|
|
23
|
+
let body: String
|
|
24
|
+
let authorName: String
|
|
25
|
+
}
|
|
26
|
+
struct EmptyArgs: Encodable {}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import PylonClient
|
|
3
|
+
|
|
4
|
+
struct RoomView: View {
|
|
5
|
+
@EnvironmentObject var session: AppSession
|
|
6
|
+
let room: Room
|
|
7
|
+
@State private var messages: [Message] = []
|
|
8
|
+
@State private var draft = ""
|
|
9
|
+
@State private var sending = false
|
|
10
|
+
@State private var errorMessage: String?
|
|
11
|
+
@State private var pollTimer: Task<Void, Never>?
|
|
12
|
+
|
|
13
|
+
var body: some View {
|
|
14
|
+
VStack(spacing: 0) {
|
|
15
|
+
ScrollViewReader { proxy in
|
|
16
|
+
ScrollView {
|
|
17
|
+
LazyVStack(alignment: .leading, spacing: 12) {
|
|
18
|
+
ForEach(messages) { msg in
|
|
19
|
+
messageRow(msg).id(msg.id)
|
|
20
|
+
}
|
|
21
|
+
if messages.isEmpty {
|
|
22
|
+
Text("No messages yet. Say hi.")
|
|
23
|
+
.foregroundStyle(.secondary)
|
|
24
|
+
.padding(.top, 32)
|
|
25
|
+
.frame(maxWidth: .infinity)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
.padding(16)
|
|
29
|
+
}
|
|
30
|
+
.onChange(of: messages.count) { _, _ in
|
|
31
|
+
if let last = messages.last {
|
|
32
|
+
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if let errorMessage {
|
|
38
|
+
Text(errorMessage)
|
|
39
|
+
.foregroundStyle(.red)
|
|
40
|
+
.font(.caption)
|
|
41
|
+
.padding(8)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
HStack {
|
|
45
|
+
TextField("Message #\(room.slug)…", text: $draft, axis: .vertical)
|
|
46
|
+
.textFieldStyle(.roundedBorder)
|
|
47
|
+
.lineLimit(1...4)
|
|
48
|
+
.onSubmit { Task { await send() } }
|
|
49
|
+
Button("Send") { Task { await send() } }
|
|
50
|
+
.keyboardShortcut(.defaultAction)
|
|
51
|
+
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || sending)
|
|
52
|
+
}
|
|
53
|
+
.padding(12)
|
|
54
|
+
.background(.thinMaterial)
|
|
55
|
+
}
|
|
56
|
+
.navigationTitle(room.name)
|
|
57
|
+
#if os(iOS)
|
|
58
|
+
.navigationBarTitleDisplayMode(.inline)
|
|
59
|
+
#endif
|
|
60
|
+
.task {
|
|
61
|
+
await loadMessages()
|
|
62
|
+
startPolling()
|
|
63
|
+
}
|
|
64
|
+
.onDisappear { pollTimer?.cancel() }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@ViewBuilder
|
|
68
|
+
private func messageRow(_ msg: Message) -> some View {
|
|
69
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
70
|
+
HStack(alignment: .firstTextBaseline) {
|
|
71
|
+
Text(msg.authorName).font(.subheadline.weight(.medium))
|
|
72
|
+
Text(formatTime(msg.createdAt))
|
|
73
|
+
.font(.caption2)
|
|
74
|
+
.foregroundStyle(.tertiary)
|
|
75
|
+
}
|
|
76
|
+
Text(msg.body)
|
|
77
|
+
.font(.body)
|
|
78
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private func formatTime(_ iso: String) -> String {
|
|
83
|
+
let formatter = ISO8601DateFormatter()
|
|
84
|
+
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
85
|
+
guard let date = formatter.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) else {
|
|
86
|
+
return ""
|
|
87
|
+
}
|
|
88
|
+
let display = DateFormatter()
|
|
89
|
+
display.timeStyle = .short
|
|
90
|
+
return display.string(from: date)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private func loadMessages() async {
|
|
94
|
+
do {
|
|
95
|
+
messages = try await session.pylon.callFn(
|
|
96
|
+
"roomMessages",
|
|
97
|
+
args: RoomMessagesArgs(roomId: room.id),
|
|
98
|
+
)
|
|
99
|
+
errorMessage = nil
|
|
100
|
+
} catch {
|
|
101
|
+
errorMessage = "Load failed: \(error.localizedDescription)"
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private func startPolling() {
|
|
106
|
+
pollTimer?.cancel()
|
|
107
|
+
pollTimer = Task {
|
|
108
|
+
while !Task.isCancelled {
|
|
109
|
+
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
|
110
|
+
if Task.isCancelled { break }
|
|
111
|
+
await loadMessages()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private func send() async {
|
|
117
|
+
let body = draft.trimmingCharacters(in: .whitespaces)
|
|
118
|
+
guard !body.isEmpty else { return }
|
|
119
|
+
sending = true
|
|
120
|
+
defer { sending = false }
|
|
121
|
+
do {
|
|
122
|
+
let msg: Message = try await session.pylon.callFn(
|
|
123
|
+
"sendMessage",
|
|
124
|
+
args: SendMessageArgs(
|
|
125
|
+
roomId: room.id,
|
|
126
|
+
body: body,
|
|
127
|
+
authorName: session.authorName,
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
messages.append(msg)
|
|
131
|
+
draft = ""
|
|
132
|
+
errorMessage = nil
|
|
133
|
+
} catch {
|
|
134
|
+
errorMessage = "Send failed: \(error.localizedDescription)"
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import PylonClient
|
|
3
|
+
|
|
4
|
+
@main
|
|
5
|
+
struct __APP_NAME_PASCAL__App: App {
|
|
6
|
+
@StateObject private var session = AppSession()
|
|
7
|
+
|
|
8
|
+
var body: some Scene {
|
|
9
|
+
WindowGroup {
|
|
10
|
+
ChatRootView()
|
|
11
|
+
.environmentObject(session)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@MainActor
|
|
17
|
+
final class AppSession: ObservableObject {
|
|
18
|
+
let pylon: PylonClient
|
|
19
|
+
@Published var authorName: String
|
|
20
|
+
|
|
21
|
+
init() {
|
|
22
|
+
let baseURLString = ProcessInfo.processInfo.environment["PYLON_BASE_URL"]
|
|
23
|
+
?? "http://localhost:4321"
|
|
24
|
+
guard let url = URL(string: baseURLString) else {
|
|
25
|
+
fatalError("Invalid PYLON_BASE_URL: \(baseURLString)")
|
|
26
|
+
}
|
|
27
|
+
self.pylon = PylonClient(baseURL: url, appName: "__APP_NAME_SNAKE__")
|
|
28
|
+
self.authorName = UserDefaults.standard.string(forKey: "authorName") ?? "anonymous"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func setAuthorName(_ name: String) {
|
|
32
|
+
authorName = name
|
|
33
|
+
UserDefaults.standard.set(name, forKey: "authorName")
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# xcodegen spec for the iOS build of __APP_NAME__.
|
|
2
|
+
#
|
|
3
|
+
# Materialize the Xcode project once with:
|
|
4
|
+
# brew install xcodegen
|
|
5
|
+
# xcodegen generate
|
|
6
|
+
# Then open __APP_NAME_PASCAL__.xcodeproj.
|
|
7
|
+
|
|
8
|
+
name: __APP_NAME_PASCAL__
|
|
9
|
+
options:
|
|
10
|
+
bundleIdPrefix: com.example
|
|
11
|
+
deploymentTarget:
|
|
12
|
+
iOS: "16.0"
|
|
13
|
+
|
|
14
|
+
packages:
|
|
15
|
+
pylon:
|
|
16
|
+
url: https://github.com/pylonsync/pylon.git
|
|
17
|
+
from: "0.3.0"
|
|
18
|
+
|
|
19
|
+
targets:
|
|
20
|
+
__APP_NAME_PASCAL__:
|
|
21
|
+
type: application
|
|
22
|
+
platform: iOS
|
|
23
|
+
sources:
|
|
24
|
+
- path: Sources/__APP_NAME_PASCAL__
|
|
25
|
+
settings:
|
|
26
|
+
base:
|
|
27
|
+
GENERATE_INFOPLIST_FILE: YES
|
|
28
|
+
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
|
|
29
|
+
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
|
30
|
+
INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
|
|
31
|
+
TARGETED_DEVICE_FAMILY: "1,2"
|
|
32
|
+
SWIFT_VERSION: "5.9"
|
|
33
|
+
ENABLE_PREVIEWS: YES
|
|
34
|
+
# __APP_NAME__ talks to a Pylon backend on localhost during dev.
|
|
35
|
+
# The simulator can reach localhost directly; on a physical device
|
|
36
|
+
# set PYLON_BASE_URL to your machine's LAN IP.
|
|
37
|
+
INFOPLIST_KEY_NSAppTransportSecurity: "{NSAllowsLocalNetworking = YES;}"
|
|
38
|
+
dependencies:
|
|
39
|
+
- package: pylon
|
|
40
|
+
product: PylonClient
|
|
41
|
+
- package: pylon
|
|
42
|
+
product: PylonSwiftUI
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// swift-tools-version:5.9
|
|
2
|
+
import PackageDescription
|
|
3
|
+
|
|
4
|
+
let package = Package(
|
|
5
|
+
name: "__APP_NAME_PASCAL__",
|
|
6
|
+
platforms: [
|
|
7
|
+
.iOS(.v16),
|
|
8
|
+
.macOS(.v13),
|
|
9
|
+
],
|
|
10
|
+
dependencies: [
|
|
11
|
+
.package(url: "https://github.com/pylonsync/pylon.git", from: "0.3.0"),
|
|
12
|
+
],
|
|
13
|
+
targets: [
|
|
14
|
+
.executableTarget(
|
|
15
|
+
name: "__APP_NAME_PASCAL__",
|
|
16
|
+
dependencies: [
|
|
17
|
+
.product(name: "PylonClient", package: "pylon"),
|
|
18
|
+
.product(name: "PylonSwiftUI", package: "pylon"),
|
|
19
|
+
],
|
|
20
|
+
path: "Sources/__APP_NAME_PASCAL__"
|
|
21
|
+
),
|
|
22
|
+
]
|
|
23
|
+
)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import PylonClient
|
|
3
|
+
|
|
4
|
+
struct FeedView: View {
|
|
5
|
+
@EnvironmentObject var session: AppSession
|
|
6
|
+
@State private var feed: [FeedItem] = []
|
|
7
|
+
@State private var loading = true
|
|
8
|
+
@State private var posting = false
|
|
9
|
+
@State private var draft = ""
|
|
10
|
+
@State private var errorMessage: String?
|
|
11
|
+
|
|
12
|
+
var body: some View {
|
|
13
|
+
NavigationStack {
|
|
14
|
+
List {
|
|
15
|
+
Section("Post") {
|
|
16
|
+
VStack(alignment: .leading, spacing: 8) {
|
|
17
|
+
TextEditor(text: $draft)
|
|
18
|
+
.frame(minHeight: 80)
|
|
19
|
+
.font(.body)
|
|
20
|
+
HStack {
|
|
21
|
+
Text("\(draft.count)/1000")
|
|
22
|
+
.font(.caption)
|
|
23
|
+
.foregroundStyle(.secondary)
|
|
24
|
+
Spacer()
|
|
25
|
+
Button(posting ? "Posting…" : "Post") {
|
|
26
|
+
Task { await post() }
|
|
27
|
+
}
|
|
28
|
+
.disabled(posting || draft.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
Section("Feed") {
|
|
34
|
+
if loading {
|
|
35
|
+
ProgressView()
|
|
36
|
+
} else if feed.isEmpty {
|
|
37
|
+
Text("No posts yet.")
|
|
38
|
+
.foregroundStyle(.secondary)
|
|
39
|
+
} else {
|
|
40
|
+
ForEach(feed) { item in
|
|
41
|
+
row(item)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if let errorMessage {
|
|
47
|
+
Section {
|
|
48
|
+
Text(errorMessage)
|
|
49
|
+
.foregroundStyle(.red)
|
|
50
|
+
.font(.caption)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
.navigationTitle("__APP_NAME__")
|
|
55
|
+
.task { await load() }
|
|
56
|
+
.refreshable { await load() }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@ViewBuilder
|
|
61
|
+
private func row(_ item: FeedItem) -> some View {
|
|
62
|
+
VStack(alignment: .leading, spacing: 6) {
|
|
63
|
+
HStack(alignment: .firstTextBaseline) {
|
|
64
|
+
Text(item.author?.displayName ?? "Unknown")
|
|
65
|
+
.font(.subheadline.weight(.medium))
|
|
66
|
+
Text("@\(item.author?.handle ?? "?")")
|
|
67
|
+
.font(.system(.caption, design: .monospaced))
|
|
68
|
+
.foregroundStyle(.secondary)
|
|
69
|
+
Spacer()
|
|
70
|
+
Text(item.createdAt)
|
|
71
|
+
.font(.caption2)
|
|
72
|
+
.foregroundStyle(.tertiary)
|
|
73
|
+
}
|
|
74
|
+
Text(item.body)
|
|
75
|
+
.font(.body)
|
|
76
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
77
|
+
HStack {
|
|
78
|
+
Button {
|
|
79
|
+
Task { await toggleLike(item) }
|
|
80
|
+
} label: {
|
|
81
|
+
HStack(spacing: 4) {
|
|
82
|
+
Image(systemName: item.likedByMe ? "heart.fill" : "heart")
|
|
83
|
+
Text("\(item.likeCount)")
|
|
84
|
+
}
|
|
85
|
+
.font(.caption)
|
|
86
|
+
.foregroundStyle(item.likedByMe ? .pink : .secondary)
|
|
87
|
+
}
|
|
88
|
+
.buttonStyle(.plain)
|
|
89
|
+
|
|
90
|
+
if item.author?.id == session.me?.id {
|
|
91
|
+
Spacer()
|
|
92
|
+
Button("Delete", role: .destructive) {
|
|
93
|
+
Task { await delete(item) }
|
|
94
|
+
}
|
|
95
|
+
.font(.caption)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
.padding(.vertical, 4)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// MARK: - Network
|
|
103
|
+
|
|
104
|
+
private func load() async {
|
|
105
|
+
loading = true
|
|
106
|
+
defer { loading = false }
|
|
107
|
+
do {
|
|
108
|
+
feed = try await session.pylon.callFn("feed", args: EmptyArgs())
|
|
109
|
+
errorMessage = nil
|
|
110
|
+
} catch {
|
|
111
|
+
errorMessage = "Load failed: \(error.localizedDescription)"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private func post() async {
|
|
116
|
+
let body = draft.trimmingCharacters(in: .whitespaces)
|
|
117
|
+
guard !body.isEmpty else { return }
|
|
118
|
+
posting = true
|
|
119
|
+
defer { posting = false }
|
|
120
|
+
do {
|
|
121
|
+
let item: FeedItem = try await session.pylon.callFn(
|
|
122
|
+
"createPost",
|
|
123
|
+
args: CreatePostArgs(body: body),
|
|
124
|
+
)
|
|
125
|
+
feed.insert(item, at: 0)
|
|
126
|
+
draft = ""
|
|
127
|
+
} catch {
|
|
128
|
+
errorMessage = "Post failed: \(error.localizedDescription)"
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private func toggleLike(_ item: FeedItem) async {
|
|
133
|
+
// Optimistic
|
|
134
|
+
if let i = feed.firstIndex(where: { $0.id == item.id }) {
|
|
135
|
+
feed[i].likedByMe.toggle()
|
|
136
|
+
feed[i].likeCount += feed[i].likedByMe ? 1 : -1
|
|
137
|
+
}
|
|
138
|
+
do {
|
|
139
|
+
let result: ToggleLikeResult = try await session.pylon.callFn(
|
|
140
|
+
"toggleLike",
|
|
141
|
+
args: ToggleLikeArgs(postId: item.id),
|
|
142
|
+
)
|
|
143
|
+
if let i = feed.firstIndex(where: { $0.id == item.id }) {
|
|
144
|
+
feed[i].likedByMe = result.liked
|
|
145
|
+
feed[i].likeCount = result.likeCount
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// Revert
|
|
149
|
+
if let i = feed.firstIndex(where: { $0.id == item.id }) {
|
|
150
|
+
feed[i].likedByMe = item.likedByMe
|
|
151
|
+
feed[i].likeCount = item.likeCount
|
|
152
|
+
}
|
|
153
|
+
errorMessage = "Like failed: \(error.localizedDescription)"
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private func delete(_ item: FeedItem) async {
|
|
158
|
+
let snapshot = feed
|
|
159
|
+
feed.removeAll { $0.id == item.id }
|
|
160
|
+
do {
|
|
161
|
+
let _: FeedItem = try await session.pylon.callFn(
|
|
162
|
+
"deletePost",
|
|
163
|
+
args: DeletePostArgs(id: item.id),
|
|
164
|
+
)
|
|
165
|
+
} catch {
|
|
166
|
+
feed = snapshot
|
|
167
|
+
errorMessage = "Delete failed: \(error.localizedDescription)"
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
struct Profile: Codable, Identifiable, Hashable {
|
|
4
|
+
let id: String
|
|
5
|
+
let userId: String
|
|
6
|
+
let handle: String
|
|
7
|
+
let displayName: String
|
|
8
|
+
let bio: String?
|
|
9
|
+
let createdAt: String
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
struct FeedItem: Codable, Identifiable, Hashable {
|
|
13
|
+
let id: String
|
|
14
|
+
let body: String
|
|
15
|
+
let createdAt: String
|
|
16
|
+
let author: AuthorCard?
|
|
17
|
+
var likeCount: Int
|
|
18
|
+
var likedByMe: Bool
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
struct AuthorCard: Codable, Hashable {
|
|
22
|
+
let id: String
|
|
23
|
+
let handle: String
|
|
24
|
+
let displayName: String
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
struct UpsertProfileArgs: Encodable {
|
|
28
|
+
let handle: String
|
|
29
|
+
let displayName: String
|
|
30
|
+
let bio: String
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
struct CreatePostArgs: Encodable { let body: String }
|
|
34
|
+
struct DeletePostArgs: Encodable { let id: String }
|
|
35
|
+
struct ToggleLikeArgs: Encodable { let postId: String }
|
|
36
|
+
|
|
37
|
+
struct ToggleLikeResult: Codable {
|
|
38
|
+
let liked: Bool
|
|
39
|
+
let likeCount: Int
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
struct EmptyArgs: Encodable {}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import PylonClient
|
|
3
|
+
|
|
4
|
+
struct ProfileSetupView: View {
|
|
5
|
+
@EnvironmentObject var session: AppSession
|
|
6
|
+
@State private var handle = ""
|
|
7
|
+
@State private var displayName = ""
|
|
8
|
+
@State private var bio = ""
|
|
9
|
+
@State private var saving = false
|
|
10
|
+
@State private var errorMessage: String?
|
|
11
|
+
|
|
12
|
+
var body: some View {
|
|
13
|
+
NavigationStack {
|
|
14
|
+
Form {
|
|
15
|
+
Section("Set up your profile") {
|
|
16
|
+
TextField("handle (lowercase, 2–20)", text: $handle)
|
|
17
|
+
.autocorrectionDisabled()
|
|
18
|
+
.textInputAutocapitalization(.never)
|
|
19
|
+
TextField("Display name", text: $displayName)
|
|
20
|
+
TextField("Bio (optional)", text: $bio)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if let errorMessage {
|
|
24
|
+
Section {
|
|
25
|
+
Text(errorMessage)
|
|
26
|
+
.foregroundStyle(.red)
|
|
27
|
+
.font(.caption)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
Section {
|
|
32
|
+
Button(saving ? "Saving…" : "Save") {
|
|
33
|
+
Task { await save() }
|
|
34
|
+
}
|
|
35
|
+
.disabled(saving || handle.trimmingCharacters(in: .whitespaces).isEmpty
|
|
36
|
+
|| displayName.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
.navigationTitle("__APP_NAME__")
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private func save() async {
|
|
44
|
+
saving = true
|
|
45
|
+
defer { saving = false }
|
|
46
|
+
do {
|
|
47
|
+
let profile: Profile = try await session.pylon.callFn(
|
|
48
|
+
"upsertProfile",
|
|
49
|
+
args: UpsertProfileArgs(
|
|
50
|
+
handle: handle.trimmingCharacters(in: .whitespaces).lowercased(),
|
|
51
|
+
displayName: displayName.trimmingCharacters(in: .whitespaces),
|
|
52
|
+
bio: bio.trimmingCharacters(in: .whitespaces),
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
session.me = profile
|
|
56
|
+
} catch {
|
|
57
|
+
errorMessage = "Save failed: \(error.localizedDescription)"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import PylonClient
|
|
3
|
+
|
|
4
|
+
struct RootView: View {
|
|
5
|
+
@EnvironmentObject var session: AppSession
|
|
6
|
+
@State private var loading = true
|
|
7
|
+
|
|
8
|
+
var body: some View {
|
|
9
|
+
Group {
|
|
10
|
+
if loading {
|
|
11
|
+
ProgressView()
|
|
12
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
13
|
+
} else if session.me == nil {
|
|
14
|
+
ProfileSetupView()
|
|
15
|
+
} else {
|
|
16
|
+
FeedView()
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
.task { await load() }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private func load() async {
|
|
23
|
+
do {
|
|
24
|
+
session.me = try await session.pylon.callFn("myProfile", args: EmptyArgs())
|
|
25
|
+
} catch {
|
|
26
|
+
session.me = nil
|
|
27
|
+
}
|
|
28
|
+
loading = false
|
|
29
|
+
}
|
|
30
|
+
}
|