@pylonsync/create-pylon 0.3.53 → 0.3.55

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.
Files changed (129) hide show
  1. package/bin/create-pylon.js +98 -42
  2. package/package.json +1 -1
  3. package/templates/backend/b2b/apps/api/functions/archiveProject.ts +15 -0
  4. package/templates/backend/b2b/apps/api/functions/createOrg.ts +43 -0
  5. package/templates/backend/b2b/apps/api/functions/createProject.ts +25 -0
  6. package/templates/backend/b2b/apps/api/functions/inviteMember.ts +49 -0
  7. package/templates/backend/b2b/apps/api/functions/myOrgs.ts +37 -0
  8. package/templates/backend/b2b/apps/api/functions/orgMembers.ts +13 -0
  9. package/templates/backend/b2b/apps/api/functions/orgProjects.ts +18 -0
  10. package/templates/backend/b2b/apps/api/functions/removeMember.ts +29 -0
  11. package/templates/backend/b2b/apps/api/functions/setMemberRole.ts +38 -0
  12. package/templates/backend/b2b/apps/api/package.json +20 -0
  13. package/templates/backend/b2b/apps/api/schema.ts +171 -0
  14. package/templates/backend/b2b/apps/api/tsconfig.json +13 -0
  15. package/templates/backend/chat/apps/api/functions/createRoom.ts +32 -0
  16. package/templates/backend/chat/apps/api/functions/listRooms.ts +15 -0
  17. package/templates/backend/chat/apps/api/functions/roomMessages.ts +20 -0
  18. package/templates/backend/chat/apps/api/functions/sendMessage.ts +37 -0
  19. package/templates/backend/chat/apps/api/package.json +20 -0
  20. package/templates/backend/chat/apps/api/schema.ts +93 -0
  21. package/templates/backend/chat/apps/api/tsconfig.json +13 -0
  22. package/templates/backend/consumer/apps/api/functions/createPost.ts +48 -0
  23. package/templates/backend/consumer/apps/api/functions/deletePost.ts +21 -0
  24. package/templates/backend/consumer/apps/api/functions/feed.ts +57 -0
  25. package/templates/backend/consumer/apps/api/functions/myProfile.ts +17 -0
  26. package/templates/backend/consumer/apps/api/functions/profilePosts.ts +17 -0
  27. package/templates/backend/consumer/apps/api/functions/toggleLike.ts +48 -0
  28. package/templates/backend/consumer/apps/api/functions/upsertProfile.ts +70 -0
  29. package/templates/backend/consumer/apps/api/package.json +20 -0
  30. package/templates/backend/consumer/apps/api/schema.ts +130 -0
  31. package/templates/backend/consumer/apps/api/tsconfig.json +13 -0
  32. package/templates/expo/chat/apps/expo/App.tsx +384 -0
  33. package/templates/expo/chat/apps/expo/app.json +25 -0
  34. package/templates/expo/chat/apps/expo/babel.config.js +6 -0
  35. package/templates/expo/chat/apps/expo/package.json +30 -0
  36. package/templates/expo/chat/apps/expo/tsconfig.json +16 -0
  37. package/templates/expo/consumer/apps/expo/App.tsx +392 -0
  38. package/templates/expo/consumer/apps/expo/app.json +25 -0
  39. package/templates/expo/consumer/apps/expo/babel.config.js +6 -0
  40. package/templates/expo/consumer/apps/expo/package.json +30 -0
  41. package/templates/expo/consumer/apps/expo/tsconfig.json +16 -0
  42. package/templates/ios/chat/apps/ios/Package.swift +24 -0
  43. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +116 -0
  44. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
  45. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/RoomView.swift +136 -0
  46. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +58 -0
  47. package/templates/ios/chat/apps/ios/project.yml +44 -0
  48. package/templates/ios/consumer/apps/ios/Package.swift +24 -0
  49. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +199 -0
  50. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +34 -0
  51. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +68 -0
  52. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +40 -0
  53. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +57 -0
  54. package/templates/ios/consumer/apps/ios/project.yml +44 -0
  55. package/templates/mac/b2b/apps/mac/Package.swift +22 -0
  56. package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +15 -0
  57. package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/OrgPickerView.swift +178 -0
  58. package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
  59. package/templates/mac/b2b/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  60. package/templates/mac/b2b/apps/mac/project.yml +34 -0
  61. package/templates/mac/barebones/apps/mac/Package.swift +33 -0
  62. package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/ContentView.swift +104 -0
  63. package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +17 -0
  64. package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
  65. package/templates/mac/barebones/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  66. package/templates/mac/barebones/apps/mac/project.yml +34 -0
  67. package/templates/mac/chat/apps/mac/Package.swift +34 -0
  68. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +143 -0
  69. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
  70. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/RoomView.swift +136 -0
  71. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +58 -0
  72. package/templates/mac/chat/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  73. package/templates/mac/chat/apps/mac/project.yml +36 -0
  74. package/templates/mac/consumer/apps/mac/Package.swift +34 -0
  75. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/FeedView.swift +199 -0
  76. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +34 -0
  77. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +68 -0
  78. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/RootView.swift +40 -0
  79. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +59 -0
  80. package/templates/mac/consumer/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  81. package/templates/mac/consumer/apps/mac/project.yml +36 -0
  82. package/templates/mac/todo/apps/mac/Package.swift +33 -0
  83. package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +19 -0
  84. package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/TodoListView.swift +244 -0
  85. package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
  86. package/templates/mac/todo/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  87. package/templates/mac/todo/apps/mac/project.yml +34 -0
  88. package/templates/web/b2b/apps/web/next-env.d.ts +2 -0
  89. package/templates/web/b2b/apps/web/next.config.ts +24 -0
  90. package/templates/web/b2b/apps/web/package.json +29 -0
  91. package/templates/web/b2b/apps/web/postcss.config.mjs +3 -0
  92. package/templates/web/b2b/apps/web/src/app/components/OrgPicker.tsx +171 -0
  93. package/templates/web/b2b/apps/web/src/app/globals.css +6 -0
  94. package/templates/web/b2b/apps/web/src/app/layout.tsx +21 -0
  95. package/templates/web/b2b/apps/web/src/app/page.tsx +39 -0
  96. package/templates/web/b2b/apps/web/src/lib/pylon.ts +5 -0
  97. package/templates/web/b2b/apps/web/tsconfig.json +26 -0
  98. package/templates/web/chat/apps/web/next-env.d.ts +2 -0
  99. package/templates/web/chat/apps/web/next.config.ts +24 -0
  100. package/templates/web/chat/apps/web/package.json +29 -0
  101. package/templates/web/chat/apps/web/postcss.config.mjs +3 -0
  102. package/templates/web/chat/apps/web/src/app/components/ChatRoom.tsx +231 -0
  103. package/templates/web/chat/apps/web/src/app/globals.css +6 -0
  104. package/templates/web/chat/apps/web/src/app/layout.tsx +22 -0
  105. package/templates/web/chat/apps/web/src/app/page.tsx +12 -0
  106. package/templates/web/chat/apps/web/src/app/providers.tsx +25 -0
  107. package/templates/web/chat/apps/web/src/lib/pylon.ts +5 -0
  108. package/templates/web/chat/apps/web/tsconfig.json +26 -0
  109. package/templates/web/consumer/apps/web/next-env.d.ts +2 -0
  110. package/templates/web/consumer/apps/web/next.config.ts +24 -0
  111. package/templates/web/consumer/apps/web/package.json +29 -0
  112. package/templates/web/consumer/apps/web/postcss.config.mjs +3 -0
  113. package/templates/web/consumer/apps/web/src/app/components/Feed.tsx +315 -0
  114. package/templates/web/consumer/apps/web/src/app/globals.css +6 -0
  115. package/templates/web/consumer/apps/web/src/app/layout.tsx +22 -0
  116. package/templates/web/consumer/apps/web/src/app/page.tsx +21 -0
  117. package/templates/web/consumer/apps/web/src/app/providers.tsx +25 -0
  118. package/templates/web/consumer/apps/web/src/lib/pylon.ts +5 -0
  119. package/templates/web/consumer/apps/web/tsconfig.json +26 -0
  120. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Package.swift +0 -0
  121. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/ContentView.swift +0 -0
  122. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/Models.swift +0 -0
  123. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +0 -0
  124. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/project.yml +0 -0
  125. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Package.swift +0 -0
  126. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/Models.swift +0 -0
  127. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/TodoListView.swift +0 -0
  128. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +0 -0
  129. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/project.yml +0 -0
@@ -0,0 +1,136 @@
1
+ import SwiftUI
2
+ import PylonClient
3
+ import PylonSync
4
+ import PylonSwiftUI
5
+
6
+ /// Live room view. PylonQuery<Message> with a `where` predicate that
7
+ /// matches `roomId` to the active room. The sync engine pushes diffs
8
+ /// over WebSocket; new messages from any client land in `messages.rows`
9
+ /// without us polling.
10
+ struct RoomView: View {
11
+ @EnvironmentObject var session: AppSession
12
+ let room: Room
13
+ let engine: SyncEngine
14
+ @StateObject private var messages: PylonQuery<Message>
15
+ @State private var draft = ""
16
+ @State private var sending = false
17
+ @State private var errorMessage: String?
18
+
19
+ init(room: Room, engine: SyncEngine) {
20
+ self.room = room
21
+ self.engine = engine
22
+ let roomId = room.id
23
+ _messages = StateObject(
24
+ wrappedValue: PylonQuery<Message>(
25
+ engine: engine,
26
+ entity: "Message",
27
+ where: { row in
28
+ row["roomId"]?.stringValue == roomId
29
+ },
30
+ ),
31
+ )
32
+ }
33
+
34
+ var body: some View {
35
+ VStack(spacing: 0) {
36
+ ScrollViewReader { proxy in
37
+ ScrollView {
38
+ LazyVStack(alignment: .leading, spacing: 12) {
39
+ ForEach(sortedMessages) { msg in
40
+ messageRow(msg).id(msg.id)
41
+ }
42
+ if messages.rows.isEmpty {
43
+ Text("No messages yet. Say hi.")
44
+ .foregroundStyle(.secondary)
45
+ .padding(.top, 32)
46
+ .frame(maxWidth: .infinity)
47
+ }
48
+ }
49
+ .padding(16)
50
+ }
51
+ .onChange(of: messages.rows.count) { _, _ in
52
+ if let last = sortedMessages.last {
53
+ withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
54
+ }
55
+ }
56
+ }
57
+
58
+ if let errorMessage {
59
+ Text(errorMessage)
60
+ .foregroundStyle(.red)
61
+ .font(.caption)
62
+ .padding(8)
63
+ }
64
+
65
+ HStack {
66
+ TextField("Message #\(room.slug)…", text: $draft, axis: .vertical)
67
+ .textFieldStyle(.roundedBorder)
68
+ .lineLimit(1...4)
69
+ .onSubmit { Task { await send() } }
70
+ Button("Send") { Task { await send() } }
71
+ .keyboardShortcut(.defaultAction)
72
+ .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || sending)
73
+ }
74
+ .padding(12)
75
+ .background(.thinMaterial)
76
+ }
77
+ .navigationTitle(room.name)
78
+ #if os(iOS)
79
+ .navigationBarTitleDisplayMode(.inline)
80
+ #endif
81
+ }
82
+
83
+ private var sortedMessages: [Message] {
84
+ messages.rows.sorted { $0.createdAt < $1.createdAt }
85
+ }
86
+
87
+ @ViewBuilder
88
+ private func messageRow(_ msg: Message) -> some View {
89
+ VStack(alignment: .leading, spacing: 2) {
90
+ HStack(alignment: .firstTextBaseline) {
91
+ Text(msg.authorName).font(.subheadline.weight(.medium))
92
+ Text(formatTime(msg.createdAt))
93
+ .font(.caption2)
94
+ .foregroundStyle(.tertiary)
95
+ }
96
+ Text(msg.body)
97
+ .font(.body)
98
+ .fixedSize(horizontal: false, vertical: true)
99
+ }
100
+ }
101
+
102
+ private func formatTime(_ iso: String) -> String {
103
+ let formatter = ISO8601DateFormatter()
104
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
105
+ guard let date = formatter.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) else {
106
+ return ""
107
+ }
108
+ let display = DateFormatter()
109
+ display.timeStyle = .short
110
+ return display.string(from: date)
111
+ }
112
+
113
+ private func send() async {
114
+ let body = draft.trimmingCharacters(in: .whitespaces)
115
+ guard !body.isEmpty else { return }
116
+ sending = true
117
+ defer { sending = false }
118
+ do {
119
+ // Server inserts via ctx.db.insert; the change_event flows
120
+ // back through the SyncEngine and our PylonQuery picks up
121
+ // the new row. We only clear the draft here.
122
+ let _: Message = try await session.client.callFn(
123
+ "sendMessage",
124
+ args: SendMessageArgs(
125
+ roomId: room.id,
126
+ body: body,
127
+ authorName: session.authorName,
128
+ ),
129
+ )
130
+ draft = ""
131
+ errorMessage = nil
132
+ } catch {
133
+ errorMessage = "Send failed: \(error.localizedDescription)"
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,58 @@
1
+ import SwiftUI
2
+ import PylonClient
3
+ import PylonSync
4
+
5
+ @main
6
+ struct __APP_NAME_PASCAL__App: App {
7
+ @StateObject private var session = AppSession()
8
+
9
+ var body: some Scene {
10
+ WindowGroup {
11
+ Group {
12
+ if let engine = session.engine {
13
+ ChatRootView(engine: engine)
14
+ .environmentObject(session)
15
+ } else {
16
+ ProgressView()
17
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
18
+ }
19
+ }
20
+ .task { await session.bootIfNeeded() }
21
+ }
22
+ }
23
+ }
24
+
25
+ @MainActor
26
+ final class AppSession: ObservableObject {
27
+ let client: PylonClient
28
+ @Published private(set) var engine: SyncEngine?
29
+ @Published var authorName: String
30
+
31
+ init() {
32
+ let baseURLString = ProcessInfo.processInfo.environment["PYLON_BASE_URL"]
33
+ ?? "http://localhost:4321"
34
+ guard let url = URL(string: baseURLString) else {
35
+ fatalError("Invalid PYLON_BASE_URL: \(baseURLString)")
36
+ }
37
+ self.client = PylonClient(baseURL: url, appName: "__APP_NAME_SNAKE__")
38
+ self.authorName = UserDefaults.standard.string(forKey: "authorName") ?? "anonymous"
39
+ }
40
+
41
+ /// Construct + start the SyncEngine. Idempotent — safe to call from
42
+ /// .task on every scene mount.
43
+ func bootIfNeeded() async {
44
+ guard engine == nil else { return }
45
+ let baseURLString = ProcessInfo.processInfo.environment["PYLON_BASE_URL"]
46
+ ?? "http://localhost:4321"
47
+ guard let url = URL(string: baseURLString) else { return }
48
+ let config = SyncEngineConfig(baseURL: url, appName: "__APP_NAME_SNAKE__")
49
+ let engine = await SyncEngine(config: config, client: client)
50
+ await engine.start()
51
+ self.engine = engine
52
+ }
53
+
54
+ func setAuthorName(_ name: String) {
55
+ authorName = name
56
+ UserDefaults.standard.set(name, forKey: "authorName")
57
+ }
58
+ }
@@ -0,0 +1,44 @@
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: PylonSync
43
+ - package: pylon
44
+ product: PylonSwiftUI
@@ -0,0 +1,24 @@
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: "PylonSync", package: "pylon"),
19
+ .product(name: "PylonSwiftUI", package: "pylon"),
20
+ ],
21
+ path: "Sources/__APP_NAME_PASCAL__"
22
+ ),
23
+ ]
24
+ )
@@ -0,0 +1,199 @@
1
+ import SwiftUI
2
+ import PylonClient
3
+ import PylonSync
4
+ import PylonSwiftUI
5
+
6
+ /// Live feed. Three subscriptions (Post / Like / Profile) joined
7
+ /// in-memory. Every new post + every like from any client renders
8
+ /// here without polling.
9
+ struct FeedView: View {
10
+ @EnvironmentObject var session: AppSession
11
+ let engine: SyncEngine
12
+ let me: Profile
13
+ let profiles: [Profile]
14
+
15
+ @StateObject private var posts: PylonQuery<Post>
16
+ @StateObject private var likes: PylonQuery<Like>
17
+ @State private var draft = ""
18
+ @State private var posting = false
19
+ @State private var errorMessage: String?
20
+
21
+ init(engine: SyncEngine, me: Profile, profiles: [Profile]) {
22
+ self.engine = engine
23
+ self.me = me
24
+ self.profiles = profiles
25
+ _posts = StateObject(
26
+ wrappedValue: PylonQuery<Post>(engine: engine, entity: "Post"),
27
+ )
28
+ _likes = StateObject(
29
+ wrappedValue: PylonQuery<Like>(engine: engine, entity: "Like"),
30
+ )
31
+ }
32
+
33
+ var body: some View {
34
+ NavigationStack {
35
+ List {
36
+ Section("Post") {
37
+ VStack(alignment: .leading, spacing: 8) {
38
+ TextEditor(text: $draft)
39
+ .frame(minHeight: 80)
40
+ .font(.body)
41
+ HStack {
42
+ Text("\(draft.count)/1000")
43
+ .font(.caption)
44
+ .foregroundStyle(.secondary)
45
+ Spacer()
46
+ Button(posting ? "Posting…" : "Post") {
47
+ Task { await post() }
48
+ }
49
+ .disabled(posting || draft.trimmingCharacters(in: .whitespaces).isEmpty)
50
+ }
51
+ }
52
+ }
53
+
54
+ Section("Feed") {
55
+ if items.isEmpty {
56
+ Text("No posts yet.")
57
+ .foregroundStyle(.secondary)
58
+ } else {
59
+ ForEach(items, id: \.post.id) { item in
60
+ row(item)
61
+ }
62
+ }
63
+ }
64
+
65
+ if let errorMessage {
66
+ Section {
67
+ Text(errorMessage)
68
+ .foregroundStyle(.red)
69
+ .font(.caption)
70
+ }
71
+ }
72
+ }
73
+ .navigationTitle("__APP_NAME__")
74
+ }
75
+ }
76
+
77
+ private struct FeedRow: Hashable {
78
+ let post: Post
79
+ let author: Profile?
80
+ let likeCount: Int
81
+ let likedByMe: Bool
82
+ }
83
+
84
+ private var profilesById: [String: Profile] {
85
+ Dictionary(uniqueKeysWithValues: profiles.map { ($0.id, $0) })
86
+ }
87
+
88
+ private var items: [FeedRow] {
89
+ posts.rows
90
+ .sorted { $0.createdAt > $1.createdAt }
91
+ .prefix(100)
92
+ .map { post in
93
+ let postLikes = likes.rows.filter { $0.postId == post.id }
94
+ return FeedRow(
95
+ post: post,
96
+ author: profilesById[post.authorId],
97
+ likeCount: postLikes.count,
98
+ likedByMe: postLikes.contains { $0.profileId == me.id },
99
+ )
100
+ }
101
+ }
102
+
103
+ @ViewBuilder
104
+ private func row(_ item: FeedRow) -> some View {
105
+ VStack(alignment: .leading, spacing: 6) {
106
+ HStack(alignment: .firstTextBaseline) {
107
+ Text(item.author?.displayName ?? "Unknown")
108
+ .font(.subheadline.weight(.medium))
109
+ Text("@\(item.author?.handle ?? "?")")
110
+ .font(.system(.caption, design: .monospaced))
111
+ .foregroundStyle(.secondary)
112
+ Spacer()
113
+ Text(item.post.createdAt)
114
+ .font(.caption2)
115
+ .foregroundStyle(.tertiary)
116
+ }
117
+ Text(item.post.body)
118
+ .font(.body)
119
+ .fixedSize(horizontal: false, vertical: true)
120
+ HStack {
121
+ Button {
122
+ Task { await toggleLike(item.post.id) }
123
+ } label: {
124
+ HStack(spacing: 4) {
125
+ Image(systemName: item.likedByMe ? "heart.fill" : "heart")
126
+ Text("\(item.likeCount)")
127
+ }
128
+ .font(.caption)
129
+ .foregroundStyle(item.likedByMe ? .pink : .secondary)
130
+ }
131
+ .buttonStyle(.plain)
132
+
133
+ if item.author?.id == me.id {
134
+ Spacer()
135
+ Button("Delete", role: .destructive) {
136
+ Task { await delete(item.post.id) }
137
+ }
138
+ .font(.caption)
139
+ }
140
+ }
141
+ }
142
+ .padding(.vertical, 4)
143
+ }
144
+
145
+ // MARK: - Mutations (writes flow through callFn; the engine receives
146
+ // the change_event and updates the local store, which re-renders the
147
+ // PylonQuery rows above).
148
+
149
+ private func post() async {
150
+ let body = draft.trimmingCharacters(in: .whitespaces)
151
+ guard !body.isEmpty else { return }
152
+ posting = true
153
+ defer { posting = false }
154
+ do {
155
+ // `createPost` returns the joined shape (Post + author),
156
+ // but we don't use the return value — the engine will
157
+ // surface the new Post row via the live subscription.
158
+ let _: PostCreatedResponse = try await session.client.callFn(
159
+ "createPost",
160
+ args: CreatePostArgs(body: body),
161
+ )
162
+ draft = ""
163
+ errorMessage = nil
164
+ } catch {
165
+ errorMessage = "Post failed: \(error.localizedDescription)"
166
+ }
167
+ }
168
+
169
+ private func toggleLike(_ postId: String) async {
170
+ do {
171
+ let _: ToggleLikeResponse = try await session.client.callFn(
172
+ "toggleLike",
173
+ args: ToggleLikeArgs(postId: postId),
174
+ )
175
+ } catch {
176
+ errorMessage = "Like failed: \(error.localizedDescription)"
177
+ }
178
+ }
179
+
180
+ private func delete(_ postId: String) async {
181
+ do {
182
+ let _: Post = try await session.client.callFn(
183
+ "deletePost",
184
+ args: DeletePostArgs(id: postId),
185
+ )
186
+ } catch {
187
+ errorMessage = "Delete failed: \(error.localizedDescription)"
188
+ }
189
+ }
190
+ }
191
+
192
+ private struct PostCreatedResponse: Decodable {
193
+ let id: String
194
+ }
195
+
196
+ private struct ToggleLikeResponse: Decodable {
197
+ let liked: Bool
198
+ let likeCount: Int
199
+ }
@@ -0,0 +1,34 @@
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 Post: Codable, Identifiable, Hashable {
13
+ let id: String
14
+ let authorId: String
15
+ let body: String
16
+ let createdAt: String
17
+ }
18
+
19
+ struct Like: Codable, Identifiable, Hashable {
20
+ let id: String
21
+ let postId: String
22
+ let profileId: String
23
+ let createdAt: String
24
+ }
25
+
26
+ struct UpsertProfileArgs: Encodable {
27
+ let handle: String
28
+ let displayName: String
29
+ let bio: String
30
+ }
31
+ struct CreatePostArgs: Encodable { let body: String }
32
+ struct DeletePostArgs: Encodable { let id: String }
33
+ struct ToggleLikeArgs: Encodable { let postId: String }
34
+ struct EmptyArgs: Encodable {}
@@ -0,0 +1,68 @@
1
+ import SwiftUI
2
+ import PylonClient
3
+ import PylonSync
4
+
5
+ struct ProfileSetupView: View {
6
+ @EnvironmentObject var session: AppSession
7
+ let engine: SyncEngine
8
+ let existingHandles: [String]
9
+
10
+ @State private var handle = ""
11
+ @State private var displayName = ""
12
+ @State private var bio = ""
13
+ @State private var saving = false
14
+ @State private var errorMessage: String?
15
+
16
+ var body: some View {
17
+ NavigationStack {
18
+ Form {
19
+ Section("Set up your profile") {
20
+ TextField("handle (lowercase, 2–20)", text: $handle)
21
+ .autocorrectionDisabled()
22
+ .textInputAutocapitalization(.never)
23
+ TextField("Display name", text: $displayName)
24
+ TextField("Bio (optional)", text: $bio)
25
+ }
26
+ if let errorMessage {
27
+ Section {
28
+ Text(errorMessage)
29
+ .foregroundStyle(.red)
30
+ .font(.caption)
31
+ }
32
+ }
33
+ Section {
34
+ Button(saving ? "Saving…" : "Save") {
35
+ Task { await save() }
36
+ }
37
+ .disabled(saving
38
+ || handle.trimmingCharacters(in: .whitespaces).isEmpty
39
+ || displayName.trimmingCharacters(in: .whitespaces).isEmpty)
40
+ }
41
+ }
42
+ .navigationTitle("__APP_NAME__")
43
+ }
44
+ }
45
+
46
+ private func save() async {
47
+ saving = true
48
+ defer { saving = false }
49
+ let lower = handle.trimmingCharacters(in: .whitespaces).lowercased()
50
+ if existingHandles.contains(lower) {
51
+ errorMessage = "@\(lower) is taken"
52
+ return
53
+ }
54
+ do {
55
+ let profile: Profile = try await session.client.callFn(
56
+ "upsertProfile",
57
+ args: UpsertProfileArgs(
58
+ handle: lower,
59
+ displayName: displayName.trimmingCharacters(in: .whitespaces),
60
+ bio: bio.trimmingCharacters(in: .whitespaces),
61
+ ),
62
+ )
63
+ session.setMyProfileId(profile.id)
64
+ } catch {
65
+ errorMessage = "Save failed: \(error.localizedDescription)"
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,40 @@
1
+ import SwiftUI
2
+ import PylonClient
3
+ import PylonSync
4
+ import PylonSwiftUI
5
+
6
+ /// Watches the live Profile collection. If we have a saved profileId
7
+ /// AND a Profile row in the local store with that id, render the
8
+ /// feed; otherwise, profile setup. The profile row appears in the
9
+ /// store via the SyncEngine, so a fresh device sees its own profile
10
+ /// pop in moments after `upsertProfile` returns.
11
+ struct RootView: View {
12
+ @EnvironmentObject var session: AppSession
13
+ let engine: SyncEngine
14
+ @StateObject private var profiles: PylonQuery<Profile>
15
+
16
+ init(engine: SyncEngine) {
17
+ self.engine = engine
18
+ _profiles = StateObject(
19
+ wrappedValue: PylonQuery<Profile>(engine: engine, entity: "Profile"),
20
+ )
21
+ }
22
+
23
+ var body: some View {
24
+ Group {
25
+ if let me = currentProfile {
26
+ FeedView(engine: engine, me: me, profiles: profiles.rows)
27
+ } else {
28
+ ProfileSetupView(
29
+ engine: engine,
30
+ existingHandles: profiles.rows.map { $0.handle },
31
+ )
32
+ }
33
+ }
34
+ }
35
+
36
+ private var currentProfile: Profile? {
37
+ guard let id = session.myProfileId else { return nil }
38
+ return profiles.rows.first { $0.id == id }
39
+ }
40
+ }
@@ -0,0 +1,57 @@
1
+ import SwiftUI
2
+ import PylonClient
3
+ import PylonSync
4
+
5
+ @main
6
+ struct __APP_NAME_PASCAL__App: App {
7
+ @StateObject private var session = AppSession()
8
+
9
+ var body: some Scene {
10
+ WindowGroup {
11
+ Group {
12
+ if let engine = session.engine {
13
+ RootView(engine: engine)
14
+ .environmentObject(session)
15
+ } else {
16
+ ProgressView()
17
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
18
+ }
19
+ }
20
+ .task { await session.bootIfNeeded() }
21
+ }
22
+ }
23
+ }
24
+
25
+ @MainActor
26
+ final class AppSession: ObservableObject {
27
+ let client: PylonClient
28
+ @Published private(set) var engine: SyncEngine?
29
+ @Published var myProfileId: String?
30
+
31
+ init() {
32
+ let baseURLString = ProcessInfo.processInfo.environment["PYLON_BASE_URL"]
33
+ ?? "http://localhost:4321"
34
+ guard let url = URL(string: baseURLString) else {
35
+ fatalError("Invalid PYLON_BASE_URL: \(baseURLString)")
36
+ }
37
+ self.client = PylonClient(baseURL: url, appName: "__APP_NAME_SNAKE__")
38
+ self.myProfileId = UserDefaults.standard.string(forKey: "myProfileId")
39
+ }
40
+
41
+ func bootIfNeeded() async {
42
+ guard engine == nil else { return }
43
+ let baseURLString = ProcessInfo.processInfo.environment["PYLON_BASE_URL"]
44
+ ?? "http://localhost:4321"
45
+ guard let url = URL(string: baseURLString) else { return }
46
+ let config = SyncEngineConfig(baseURL: url, appName: "__APP_NAME_SNAKE__")
47
+ let engine = await SyncEngine(config: config, client: client)
48
+ await engine.start()
49
+ self.engine = engine
50
+ }
51
+
52
+ func setMyProfileId(_ id: String?) {
53
+ myProfileId = id
54
+ if let id { UserDefaults.standard.set(id, forKey: "myProfileId") }
55
+ else { UserDefaults.standard.removeObject(forKey: "myProfileId") }
56
+ }
57
+ }
@@ -0,0 +1,44 @@
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: PylonSync
43
+ - package: pylon
44
+ product: PylonSwiftUI