@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.
- package/bin/create-pylon.js +98 -42
- package/package.json +1 -1
- 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/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/expo/chat/apps/expo/App.tsx +384 -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 +392 -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/ios/chat/apps/ios/Package.swift +24 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +116 -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 +136 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +58 -0
- package/templates/ios/chat/apps/ios/project.yml +44 -0
- package/templates/ios/consumer/apps/ios/Package.swift +24 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +199 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +34 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +68 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +40 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +57 -0
- package/templates/ios/consumer/apps/ios/project.yml +44 -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 +34 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +143 -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 +136 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +58 -0
- package/templates/mac/chat/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/chat/apps/mac/project.yml +36 -0
- package/templates/mac/consumer/apps/mac/Package.swift +34 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/FeedView.swift +199 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +34 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +68 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/RootView.swift +40 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +59 -0
- package/templates/mac/consumer/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/consumer/apps/mac/project.yml +36 -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/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/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 +231 -0
- package/templates/web/chat/apps/web/src/app/globals.css +6 -0
- package/templates/web/chat/apps/web/src/app/layout.tsx +22 -0
- package/templates/web/chat/apps/web/src/app/page.tsx +12 -0
- package/templates/web/chat/apps/web/src/app/providers.tsx +25 -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 +315 -0
- package/templates/web/consumer/apps/web/src/app/globals.css +6 -0
- package/templates/web/consumer/apps/web/src/app/layout.tsx +22 -0
- package/templates/web/consumer/apps/web/src/app/page.tsx +21 -0
- package/templates/web/consumer/apps/web/src/app/providers.tsx +25 -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/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Package.swift +0 -0
- /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/ContentView.swift +0 -0
- /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/Models.swift +0 -0
- /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +0 -0
- /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/project.yml +0 -0
- /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Package.swift +0 -0
- /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/Models.swift +0 -0
- /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/TodoListView.swift +0 -0
- /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +0 -0
- /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
|
+
}
|
package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift
ADDED
|
@@ -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
|