@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,143 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import PylonClient
|
|
3
|
+
import PylonSync
|
|
4
|
+
import PylonSwiftUI
|
|
5
|
+
|
|
6
|
+
/// macOS chat: rooms in a sidebar, room view in the detail pane.
|
|
7
|
+
/// PylonQuery<Room> subscribes via the SyncEngine — new rooms from
|
|
8
|
+
/// any client appear without polling. Same story inside RoomView for
|
|
9
|
+
/// messages.
|
|
10
|
+
struct ChatRootView: View {
|
|
11
|
+
@EnvironmentObject var session: AppSession
|
|
12
|
+
let engine: SyncEngine
|
|
13
|
+
@StateObject private var rooms: PylonQuery<Room>
|
|
14
|
+
@State private var selected: Room.ID?
|
|
15
|
+
@State private var showingCreate = false
|
|
16
|
+
@State private var draftSlug = ""
|
|
17
|
+
@State private var draftName = ""
|
|
18
|
+
@State private var errorMessage: String?
|
|
19
|
+
|
|
20
|
+
init(engine: SyncEngine) {
|
|
21
|
+
self.engine = engine
|
|
22
|
+
_rooms = StateObject(
|
|
23
|
+
wrappedValue: PylonQuery<Room>(engine: engine, entity: "Room"),
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
var body: some View {
|
|
28
|
+
NavigationSplitView {
|
|
29
|
+
VStack(spacing: 0) {
|
|
30
|
+
List(selection: $selected) {
|
|
31
|
+
Section("Rooms") {
|
|
32
|
+
ForEach(sortedRooms) { r in
|
|
33
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
34
|
+
Text(r.name)
|
|
35
|
+
Text("#\(r.slug)")
|
|
36
|
+
.font(.system(.caption, design: .monospaced))
|
|
37
|
+
.foregroundStyle(.secondary)
|
|
38
|
+
}
|
|
39
|
+
.tag(r.id)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
Divider()
|
|
44
|
+
HStack(spacing: 8) {
|
|
45
|
+
TextField("display name", text: Binding(
|
|
46
|
+
get: { session.authorName },
|
|
47
|
+
set: { session.setAuthorName($0) },
|
|
48
|
+
))
|
|
49
|
+
.textFieldStyle(.roundedBorder)
|
|
50
|
+
.font(.caption)
|
|
51
|
+
}
|
|
52
|
+
.padding(8)
|
|
53
|
+
}
|
|
54
|
+
.toolbar {
|
|
55
|
+
ToolbarItem(placement: .primaryAction) {
|
|
56
|
+
Button {
|
|
57
|
+
showingCreate.toggle()
|
|
58
|
+
} label: {
|
|
59
|
+
Image(systemName: "plus")
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
.sheet(isPresented: $showingCreate) {
|
|
64
|
+
createSheet
|
|
65
|
+
}
|
|
66
|
+
} detail: {
|
|
67
|
+
if let id = selected, let room = sortedRooms.first(where: { $0.id == id }) {
|
|
68
|
+
RoomView(room: room, engine: engine)
|
|
69
|
+
} else {
|
|
70
|
+
placeholder
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
.onAppear {
|
|
74
|
+
if selected == nil { selected = sortedRooms.first?.id }
|
|
75
|
+
}
|
|
76
|
+
.onChange(of: rooms.rows.count) { _, _ in
|
|
77
|
+
if selected == nil { selected = sortedRooms.first?.id }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private var sortedRooms: [Room] {
|
|
82
|
+
rooms.rows.sorted { $0.createdAt < $1.createdAt }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private var createSheet: some View {
|
|
86
|
+
VStack(alignment: .leading, spacing: 12) {
|
|
87
|
+
Text("Create a room").font(.headline)
|
|
88
|
+
TextField("Name", text: $draftName)
|
|
89
|
+
.textFieldStyle(.roundedBorder)
|
|
90
|
+
TextField("Slug (e.g. general)", text: $draftSlug)
|
|
91
|
+
.textFieldStyle(.roundedBorder)
|
|
92
|
+
.autocorrectionDisabled()
|
|
93
|
+
HStack {
|
|
94
|
+
Spacer()
|
|
95
|
+
Button("Cancel") { showingCreate = false }
|
|
96
|
+
.buttonStyle(.bordered)
|
|
97
|
+
Button("Create") { Task { await create() } }
|
|
98
|
+
.keyboardShortcut(.defaultAction)
|
|
99
|
+
.disabled(draftName.trimmingCharacters(in: .whitespaces).isEmpty
|
|
100
|
+
|| draftSlug.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
101
|
+
}
|
|
102
|
+
if let errorMessage {
|
|
103
|
+
Text(errorMessage).foregroundStyle(.red).font(.caption)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
.padding(20)
|
|
107
|
+
.frame(width: 360)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private var placeholder: some View {
|
|
111
|
+
VStack(spacing: 8) {
|
|
112
|
+
if rooms.rows.isEmpty {
|
|
113
|
+
Text("No rooms yet.")
|
|
114
|
+
.foregroundStyle(.secondary)
|
|
115
|
+
Button("Create your first room") { showingCreate = true }
|
|
116
|
+
.buttonStyle(.bordered)
|
|
117
|
+
} else {
|
|
118
|
+
Text("Pick a room from the sidebar.")
|
|
119
|
+
.foregroundStyle(.secondary)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private func create() async {
|
|
126
|
+
let name = draftName.trimmingCharacters(in: .whitespaces)
|
|
127
|
+
let slug = draftSlug.trimmingCharacters(in: .whitespaces).lowercased()
|
|
128
|
+
guard !name.isEmpty, !slug.isEmpty else { return }
|
|
129
|
+
do {
|
|
130
|
+
let room: Room = try await session.client.callFn(
|
|
131
|
+
"createRoom",
|
|
132
|
+
args: CreateRoomArgs(slug: slug, name: name),
|
|
133
|
+
)
|
|
134
|
+
selected = room.id
|
|
135
|
+
showingCreate = false
|
|
136
|
+
draftName = ""
|
|
137
|
+
draftSlug = ""
|
|
138
|
+
errorMessage = nil
|
|
139
|
+
} catch {
|
|
140
|
+
errorMessage = "Create failed: \(error.localizedDescription)"
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -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,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
|
+
Window("__APP_NAME__", id: "main") {
|
|
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
|
+
.frame(minWidth: 700, minHeight: 500)
|
|
22
|
+
}
|
|
23
|
+
.windowResizability(.contentMinSize)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@MainActor
|
|
28
|
+
final class AppSession: ObservableObject {
|
|
29
|
+
let client: PylonClient
|
|
30
|
+
@Published private(set) var engine: SyncEngine?
|
|
31
|
+
@Published var authorName: String
|
|
32
|
+
|
|
33
|
+
init() {
|
|
34
|
+
let baseURLString = ProcessInfo.processInfo.environment["PYLON_BASE_URL"]
|
|
35
|
+
?? "http://localhost:4321"
|
|
36
|
+
guard let url = URL(string: baseURLString) else {
|
|
37
|
+
fatalError("Invalid PYLON_BASE_URL: \(baseURLString)")
|
|
38
|
+
}
|
|
39
|
+
self.client = PylonClient(baseURL: url, appName: "__APP_NAME_SNAKE__")
|
|
40
|
+
self.authorName = UserDefaults.standard.string(forKey: "authorName") ?? "anonymous"
|
|
41
|
+
}
|
|
42
|
+
|
|
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,13 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<!-- App Sandbox + outgoing-network client. Without these the
|
|
6
|
+
scaffolded app can't reach localhost:4321 during dev or
|
|
7
|
+
a remote Pylon Cloud URL in production. -->
|
|
8
|
+
<key>com.apple.security.app-sandbox</key>
|
|
9
|
+
<true/>
|
|
10
|
+
<key>com.apple.security.network.client</key>
|
|
11
|
+
<true/>
|
|
12
|
+
</dict>
|
|
13
|
+
</plist>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: __APP_NAME_PASCAL__
|
|
2
|
+
options:
|
|
3
|
+
bundleIdPrefix: com.example
|
|
4
|
+
deploymentTarget:
|
|
5
|
+
macOS: "13.0"
|
|
6
|
+
|
|
7
|
+
packages:
|
|
8
|
+
pylon:
|
|
9
|
+
url: https://github.com/pylonsync/pylon.git
|
|
10
|
+
from: "0.3.0"
|
|
11
|
+
|
|
12
|
+
targets:
|
|
13
|
+
__APP_NAME_PASCAL__:
|
|
14
|
+
type: application
|
|
15
|
+
platform: macOS
|
|
16
|
+
sources:
|
|
17
|
+
- path: Sources/__APP_NAME_PASCAL__
|
|
18
|
+
settings:
|
|
19
|
+
base:
|
|
20
|
+
GENERATE_INFOPLIST_FILE: YES
|
|
21
|
+
INFOPLIST_KEY_CFBundleDisplayName: __APP_NAME__
|
|
22
|
+
INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.developer-tools"
|
|
23
|
+
INFOPLIST_KEY_NSHumanReadableCopyright: ""
|
|
24
|
+
SWIFT_VERSION: "5.9"
|
|
25
|
+
ENABLE_PREVIEWS: YES
|
|
26
|
+
# The mac app talks to the local Pylon control plane during dev.
|
|
27
|
+
# App Sandbox needs the network client entitlement to reach
|
|
28
|
+
# localhost; we declare it via codesign entitlements at build time.
|
|
29
|
+
CODE_SIGN_ENTITLEMENTS: __APP_NAME_PASCAL__.entitlements
|
|
30
|
+
dependencies:
|
|
31
|
+
- package: pylon
|
|
32
|
+
product: PylonClient
|
|
33
|
+
- package: pylon
|
|
34
|
+
product: PylonSync
|
|
35
|
+
- package: pylon
|
|
36
|
+
product: PylonSwiftUI
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// swift-tools-version:5.9
|
|
2
|
+
import PackageDescription
|
|
3
|
+
|
|
4
|
+
// SwiftPM package for the __APP_NAME__ macOS app.
|
|
5
|
+
//
|
|
6
|
+
// The executable target runs locally with `swift run`. For a proper
|
|
7
|
+
// signed `.app` bundle:
|
|
8
|
+
//
|
|
9
|
+
// brew install xcodegen
|
|
10
|
+
// xcodegen generate
|
|
11
|
+
// open __APP_NAME_PASCAL__.xcodeproj
|
|
12
|
+
//
|
|
13
|
+
// The Xcode project pulls the same Sources/__APP_NAME_PASCAL__/ tree
|
|
14
|
+
// as `swift build`, so SwiftPM and Xcode share one source set.
|
|
15
|
+
let package = Package(
|
|
16
|
+
name: "__APP_NAME_PASCAL__",
|
|
17
|
+
platforms: [
|
|
18
|
+
.macOS(.v13),
|
|
19
|
+
],
|
|
20
|
+
dependencies: [
|
|
21
|
+
.package(url: "https://github.com/pylonsync/pylon.git", from: "0.3.0"),
|
|
22
|
+
],
|
|
23
|
+
targets: [
|
|
24
|
+
.executableTarget(
|
|
25
|
+
name: "__APP_NAME_PASCAL__",
|
|
26
|
+
dependencies: [
|
|
27
|
+
.product(name: "PylonClient", package: "pylon"),
|
|
28
|
+
.product(name: "PylonSync", package: "pylon"),
|
|
29
|
+
.product(name: "PylonSwiftUI", package: "pylon"),
|
|
30
|
+
],
|
|
31
|
+
path: "Sources/__APP_NAME_PASCAL__"
|
|
32
|
+
),
|
|
33
|
+
]
|
|
34
|
+
)
|
|
@@ -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 {}
|