@pylonsync/create-pylon 0.3.53 → 0.3.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-pylon.js +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 +414 -0
- package/templates/expo/chat/apps/expo/app.json +25 -0
- package/templates/expo/chat/apps/expo/babel.config.js +6 -0
- package/templates/expo/chat/apps/expo/package.json +30 -0
- package/templates/expo/chat/apps/expo/tsconfig.json +16 -0
- package/templates/expo/consumer/apps/expo/App.tsx +360 -0
- package/templates/expo/consumer/apps/expo/app.json +25 -0
- package/templates/expo/consumer/apps/expo/babel.config.js +6 -0
- package/templates/expo/consumer/apps/expo/package.json +30 -0
- package/templates/expo/consumer/apps/expo/tsconfig.json +16 -0
- package/templates/ios/chat/apps/ios/Package.swift +34 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +120 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/RoomView.swift +137 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +35 -0
- package/templates/ios/chat/apps/ios/project.yml +42 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +170 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +42 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +60 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +30 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +29 -0
- package/templates/ios/consumer/apps/ios/project.yml +42 -0
- package/templates/ios/todo/apps/ios/Package.swift +23 -0
- package/templates/mac/b2b/apps/mac/Package.swift +22 -0
- package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +15 -0
- package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/OrgPickerView.swift +178 -0
- package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
- package/templates/mac/b2b/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/b2b/apps/mac/project.yml +34 -0
- package/templates/mac/barebones/apps/mac/Package.swift +33 -0
- package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/ContentView.swift +104 -0
- package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +17 -0
- package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
- package/templates/mac/barebones/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/barebones/apps/mac/project.yml +34 -0
- package/templates/mac/chat/apps/mac/Package.swift +33 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +140 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/RoomView.swift +137 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +37 -0
- package/templates/mac/chat/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/chat/apps/mac/project.yml +34 -0
- package/templates/mac/consumer/apps/mac/Package.swift +33 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/FeedView.swift +170 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +42 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +60 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/RootView.swift +30 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +31 -0
- package/templates/mac/consumer/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/consumer/apps/mac/project.yml +34 -0
- package/templates/mac/todo/apps/mac/Package.swift +33 -0
- package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +19 -0
- package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/TodoListView.swift +244 -0
- package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
- package/templates/mac/todo/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/todo/apps/mac/project.yml +34 -0
- package/templates/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 +250 -0
- package/templates/web/chat/apps/web/src/app/globals.css +6 -0
- package/templates/web/chat/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/chat/apps/web/src/app/page.tsx +51 -0
- package/templates/web/chat/apps/web/src/lib/pylon.ts +5 -0
- package/templates/web/chat/apps/web/tsconfig.json +26 -0
- package/templates/web/consumer/apps/web/next-env.d.ts +2 -0
- package/templates/web/consumer/apps/web/next.config.ts +24 -0
- package/templates/web/consumer/apps/web/package.json +29 -0
- package/templates/web/consumer/apps/web/postcss.config.mjs +3 -0
- package/templates/web/consumer/apps/web/src/app/components/Feed.tsx +295 -0
- package/templates/web/consumer/apps/web/src/app/globals.css +6 -0
- package/templates/web/consumer/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/consumer/apps/web/src/app/page.tsx +55 -0
- package/templates/web/consumer/apps/web/src/lib/pylon.ts +5 -0
- package/templates/web/consumer/apps/web/tsconfig.json +26 -0
- /package/templates/{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/consumer/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,244 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import PylonClient
|
|
3
|
+
|
|
4
|
+
/// macOS Todo list. List with inline checkbox + edit-on-double-click +
|
|
5
|
+
/// delete on hover. macOS doesn't ship `EditButton`, so reordering is
|
|
6
|
+
/// drag-and-drop via `.onMove(perform:)` which works in editable List.
|
|
7
|
+
struct TodoListView: View {
|
|
8
|
+
@EnvironmentObject var session: AppSession
|
|
9
|
+
@State private var todos: [Todo] = []
|
|
10
|
+
@State private var draftTitle: String = ""
|
|
11
|
+
@State private var loading = true
|
|
12
|
+
@State private var pending = false
|
|
13
|
+
@State private var errorMessage: String?
|
|
14
|
+
@State private var editingId: String?
|
|
15
|
+
@State private var editingDraft: String = ""
|
|
16
|
+
@State private var hoveredId: String?
|
|
17
|
+
|
|
18
|
+
var body: some View {
|
|
19
|
+
VStack(spacing: 0) {
|
|
20
|
+
HStack(spacing: 8) {
|
|
21
|
+
TextField("What needs doing?", text: $draftTitle)
|
|
22
|
+
.textFieldStyle(.roundedBorder)
|
|
23
|
+
.onSubmit { Task { await add() } }
|
|
24
|
+
Button("Add") { Task { await add() } }
|
|
25
|
+
.keyboardShortcut(.defaultAction)
|
|
26
|
+
.disabled(draftTitle.trimmingCharacters(in: .whitespaces).isEmpty || pending)
|
|
27
|
+
}
|
|
28
|
+
.padding(16)
|
|
29
|
+
|
|
30
|
+
Divider()
|
|
31
|
+
|
|
32
|
+
if loading {
|
|
33
|
+
ProgressView().padding()
|
|
34
|
+
Spacer()
|
|
35
|
+
} else if todos.isEmpty {
|
|
36
|
+
Spacer()
|
|
37
|
+
Text("No todos yet.")
|
|
38
|
+
.foregroundStyle(.secondary)
|
|
39
|
+
Spacer()
|
|
40
|
+
} else {
|
|
41
|
+
List {
|
|
42
|
+
ForEach(todos) { todo in
|
|
43
|
+
row(todo)
|
|
44
|
+
}
|
|
45
|
+
.onMove { source, destination in
|
|
46
|
+
Task { await reorder(from: source, to: destination) }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
.listStyle(.inset)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if let errorMessage {
|
|
53
|
+
Divider()
|
|
54
|
+
Text(errorMessage)
|
|
55
|
+
.foregroundStyle(.red)
|
|
56
|
+
.font(.caption)
|
|
57
|
+
.padding(8)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
.task { await load() }
|
|
61
|
+
.toolbar {
|
|
62
|
+
ToolbarItem(placement: .primaryAction) {
|
|
63
|
+
Button { Task { await load() } } label: {
|
|
64
|
+
Image(systemName: "arrow.clockwise")
|
|
65
|
+
}
|
|
66
|
+
.disabled(loading)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@ViewBuilder
|
|
72
|
+
private func row(_ todo: Todo) -> some View {
|
|
73
|
+
HStack(spacing: 10) {
|
|
74
|
+
Button { Task { await toggle(todo) } } label: {
|
|
75
|
+
Image(systemName: todo.done ? "checkmark.circle.fill" : "circle")
|
|
76
|
+
.foregroundStyle(todo.done ? .green : .secondary)
|
|
77
|
+
.imageScale(.large)
|
|
78
|
+
}
|
|
79
|
+
.buttonStyle(.plain)
|
|
80
|
+
|
|
81
|
+
if editingId == todo.id {
|
|
82
|
+
TextField("Title", text: $editingDraft)
|
|
83
|
+
.textFieldStyle(.roundedBorder)
|
|
84
|
+
.onSubmit { Task { await commitEdit(todo) } }
|
|
85
|
+
Button("Save") { Task { await commitEdit(todo) } }
|
|
86
|
+
.buttonStyle(.bordered)
|
|
87
|
+
Button("Cancel") {
|
|
88
|
+
editingId = nil
|
|
89
|
+
editingDraft = ""
|
|
90
|
+
}
|
|
91
|
+
.buttonStyle(.bordered)
|
|
92
|
+
} else {
|
|
93
|
+
Text(todo.title)
|
|
94
|
+
.strikethrough(todo.done, color: .secondary)
|
|
95
|
+
.foregroundStyle(todo.done ? .secondary : .primary)
|
|
96
|
+
.onTapGesture(count: 2) {
|
|
97
|
+
editingId = todo.id
|
|
98
|
+
editingDraft = todo.title
|
|
99
|
+
}
|
|
100
|
+
Spacer()
|
|
101
|
+
if hoveredId == todo.id {
|
|
102
|
+
Button("Edit") {
|
|
103
|
+
editingId = todo.id
|
|
104
|
+
editingDraft = todo.title
|
|
105
|
+
}
|
|
106
|
+
.buttonStyle(.borderless)
|
|
107
|
+
.font(.caption)
|
|
108
|
+
Button("Delete") { Task { await delete(todo) } }
|
|
109
|
+
.buttonStyle(.borderless)
|
|
110
|
+
.font(.caption)
|
|
111
|
+
.foregroundStyle(.red)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
.padding(.vertical, 4)
|
|
116
|
+
.contentShape(Rectangle())
|
|
117
|
+
.onHover { hovering in
|
|
118
|
+
hoveredId = hovering ? todo.id : (hoveredId == todo.id ? nil : hoveredId)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// MARK: - Network
|
|
123
|
+
|
|
124
|
+
private func load() async {
|
|
125
|
+
loading = true
|
|
126
|
+
defer { loading = false }
|
|
127
|
+
do {
|
|
128
|
+
let rows: [Todo] = try await session.pylon.callFn("listTodos", args: EmptyArgs())
|
|
129
|
+
todos = rows
|
|
130
|
+
errorMessage = nil
|
|
131
|
+
} catch {
|
|
132
|
+
errorMessage = "Load failed: \(error.localizedDescription)"
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private func add() async {
|
|
137
|
+
let trimmed = draftTitle.trimmingCharacters(in: .whitespaces)
|
|
138
|
+
guard !trimmed.isEmpty else { return }
|
|
139
|
+
pending = true
|
|
140
|
+
defer { pending = false }
|
|
141
|
+
do {
|
|
142
|
+
let todo: Todo = try await session.pylon.callFn(
|
|
143
|
+
"addTodo",
|
|
144
|
+
args: AddTodoArgs(title: trimmed),
|
|
145
|
+
)
|
|
146
|
+
todos.append(todo)
|
|
147
|
+
draftTitle = ""
|
|
148
|
+
} catch {
|
|
149
|
+
errorMessage = "Add failed: \(error.localizedDescription)"
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private func toggle(_ todo: Todo) async {
|
|
154
|
+
let nextDone = !todo.done
|
|
155
|
+
if let i = todos.firstIndex(where: { $0.id == todo.id }) {
|
|
156
|
+
todos[i].done = nextDone
|
|
157
|
+
}
|
|
158
|
+
do {
|
|
159
|
+
let _: Todo = try await session.pylon.callFn(
|
|
160
|
+
"toggleTodo",
|
|
161
|
+
args: ToggleTodoArgs(id: todo.id, done: nextDone),
|
|
162
|
+
)
|
|
163
|
+
} catch {
|
|
164
|
+
if let i = todos.firstIndex(where: { $0.id == todo.id }) {
|
|
165
|
+
todos[i].done = todo.done
|
|
166
|
+
}
|
|
167
|
+
errorMessage = "Toggle failed: \(error.localizedDescription)"
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private func commitEdit(_ todo: Todo) async {
|
|
172
|
+
let trimmed = editingDraft.trimmingCharacters(in: .whitespaces)
|
|
173
|
+
guard !trimmed.isEmpty, trimmed != todo.title else {
|
|
174
|
+
editingId = nil
|
|
175
|
+
editingDraft = ""
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
let originalTitle = todo.title
|
|
179
|
+
if let i = todos.firstIndex(where: { $0.id == todo.id }) {
|
|
180
|
+
todos[i].title = trimmed
|
|
181
|
+
}
|
|
182
|
+
editingId = nil
|
|
183
|
+
editingDraft = ""
|
|
184
|
+
do {
|
|
185
|
+
let _: Todo = try await session.pylon.callFn(
|
|
186
|
+
"editTodo",
|
|
187
|
+
args: EditTodoArgs(id: todo.id, title: trimmed),
|
|
188
|
+
)
|
|
189
|
+
} catch {
|
|
190
|
+
if let i = todos.firstIndex(where: { $0.id == todo.id }) {
|
|
191
|
+
todos[i].title = originalTitle
|
|
192
|
+
}
|
|
193
|
+
errorMessage = "Rename failed: \(error.localizedDescription)"
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private func delete(_ todo: Todo) async {
|
|
198
|
+
let snapshot = todos
|
|
199
|
+
todos.removeAll { $0.id == todo.id }
|
|
200
|
+
do {
|
|
201
|
+
let _: Todo = try await session.pylon.callFn(
|
|
202
|
+
"deleteTodo",
|
|
203
|
+
args: DeleteTodoArgs(id: todo.id),
|
|
204
|
+
)
|
|
205
|
+
} catch {
|
|
206
|
+
todos = snapshot
|
|
207
|
+
errorMessage = "Delete failed: \(error.localizedDescription)"
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private func reorder(from source: IndexSet, to destination: Int) async {
|
|
212
|
+
var moved = todos
|
|
213
|
+
moved.move(fromOffsets: source, toOffset: destination)
|
|
214
|
+
guard let movedIndex = source.first else { return }
|
|
215
|
+
let newIndex = destination > movedIndex ? destination - 1 : destination
|
|
216
|
+
guard newIndex < moved.count else { return }
|
|
217
|
+
let movedTodo = moved[newIndex]
|
|
218
|
+
let prev = newIndex > 0 ? moved[newIndex - 1] : nil
|
|
219
|
+
let next = newIndex + 1 < moved.count ? moved[newIndex + 1] : nil
|
|
220
|
+
let prevPos = prev?.position ?? 0
|
|
221
|
+
let nextPos = next?.position ?? 0
|
|
222
|
+
let position: Double
|
|
223
|
+
if prev != nil && next != nil {
|
|
224
|
+
position = (prevPos + nextPos) / 2
|
|
225
|
+
} else if prev != nil {
|
|
226
|
+
position = prevPos + 1024
|
|
227
|
+
} else if next != nil {
|
|
228
|
+
position = nextPos - 1024
|
|
229
|
+
} else {
|
|
230
|
+
position = 1024
|
|
231
|
+
}
|
|
232
|
+
let snapshot = todos
|
|
233
|
+
todos = moved
|
|
234
|
+
do {
|
|
235
|
+
let _: Todo = try await session.pylon.callFn(
|
|
236
|
+
"reorderTodo",
|
|
237
|
+
args: ReorderTodoArgs(id: movedTodo.id, position: position),
|
|
238
|
+
)
|
|
239
|
+
} catch {
|
|
240
|
+
todos = snapshot
|
|
241
|
+
errorMessage = "Reorder failed: \(error.localizedDescription)"
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import PylonClient
|
|
3
|
+
|
|
4
|
+
@main
|
|
5
|
+
struct __APP_NAME_PASCAL__App: App {
|
|
6
|
+
@StateObject private var session = AppSession()
|
|
7
|
+
|
|
8
|
+
var body: some Scene {
|
|
9
|
+
Window("__APP_NAME__", id: "main") {
|
|
10
|
+
TodoListView()
|
|
11
|
+
.environmentObject(session)
|
|
12
|
+
.frame(minWidth: 480, minHeight: 360)
|
|
13
|
+
}
|
|
14
|
+
.windowResizability(.contentMinSize)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@MainActor
|
|
19
|
+
final class AppSession: ObservableObject {
|
|
20
|
+
let pylon: PylonClient
|
|
21
|
+
|
|
22
|
+
init() {
|
|
23
|
+
let baseURLString = ProcessInfo.processInfo.environment["PYLON_BASE_URL"]
|
|
24
|
+
?? "http://localhost:4321"
|
|
25
|
+
guard let url = URL(string: baseURLString) else {
|
|
26
|
+
fatalError("Invalid PYLON_BASE_URL: \(baseURLString)")
|
|
27
|
+
}
|
|
28
|
+
self.pylon = PylonClient(baseURL: url, appName: "__APP_NAME_SNAKE__")
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -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,34 @@
|
|
|
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: PylonSwiftUI
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { NextConfig } from "next";
|
|
2
|
+
|
|
3
|
+
const PYLON_API_URL = process.env.PYLON_API_URL ?? "http://localhost:4321";
|
|
4
|
+
|
|
5
|
+
const config: NextConfig = {
|
|
6
|
+
transpilePackages: [
|
|
7
|
+
"@__APP_NAME_KEBAB__/ui",
|
|
8
|
+
"@pylonsync/sdk",
|
|
9
|
+
"@pylonsync/react",
|
|
10
|
+
"@pylonsync/next",
|
|
11
|
+
"@pylonsync/functions",
|
|
12
|
+
"@pylonsync/sync",
|
|
13
|
+
],
|
|
14
|
+
async rewrites() {
|
|
15
|
+
return [
|
|
16
|
+
{ source: "/api/fn/:path*", destination: `${PYLON_API_URL}/api/fn/:path*` },
|
|
17
|
+
{ source: "/api/auth/:path*", destination: `${PYLON_API_URL}/api/auth/:path*` },
|
|
18
|
+
{ source: "/api/sync/:path*", destination: `${PYLON_API_URL}/api/sync/:path*` },
|
|
19
|
+
{ source: "/api/:path*", destination: `${PYLON_API_URL}/api/:path*` },
|
|
20
|
+
];
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default config;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@__APP_NAME_KEBAB__/web",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "next dev --port 3000",
|
|
8
|
+
"build": "next build",
|
|
9
|
+
"start": "next start",
|
|
10
|
+
"lint": "next lint"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@__APP_NAME_KEBAB__/ui": "__WORKSPACE_DEP__",
|
|
14
|
+
"@pylonsync/sdk": "^__PYLON_VERSION__",
|
|
15
|
+
"@pylonsync/react": "^__PYLON_VERSION__",
|
|
16
|
+
"@pylonsync/next": "^__PYLON_VERSION__",
|
|
17
|
+
"next": "^16.0.0",
|
|
18
|
+
"react": "^19.0.0",
|
|
19
|
+
"react-dom": "^19.0.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^20.0.0",
|
|
23
|
+
"@types/react": "^19.0.0",
|
|
24
|
+
"@types/react-dom": "^19.0.0",
|
|
25
|
+
"@tailwindcss/postcss": "^4.0.0",
|
|
26
|
+
"tailwindcss": "^4.0.0",
|
|
27
|
+
"typescript": "^5.5.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useTransition } from "react";
|
|
4
|
+
import { Button, Input, Card, CardHeader, CardContent } from "@__APP_NAME_KEBAB__/ui";
|
|
5
|
+
|
|
6
|
+
type Org = {
|
|
7
|
+
id: string;
|
|
8
|
+
slug: string;
|
|
9
|
+
name: string;
|
|
10
|
+
role: string;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function OrgPicker({ initialOrgs }: { initialOrgs: Org[] }) {
|
|
15
|
+
const [orgs, setOrgs] = useState(initialOrgs);
|
|
16
|
+
const [activeId, setActiveId] = useState<string | null>(
|
|
17
|
+
initialOrgs[0]?.id ?? null,
|
|
18
|
+
);
|
|
19
|
+
const [creating, setCreating] = useState(false);
|
|
20
|
+
const [name, setName] = useState("");
|
|
21
|
+
const [slug, setSlug] = useState("");
|
|
22
|
+
const [pending, startTransition] = useTransition();
|
|
23
|
+
const [error, setError] = useState<string | null>(null);
|
|
24
|
+
|
|
25
|
+
async function create(e: React.FormEvent) {
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
setError(null);
|
|
28
|
+
startTransition(async () => {
|
|
29
|
+
const res = await fetch("/api/fn/createOrg", {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "Content-Type": "application/json" },
|
|
32
|
+
body: JSON.stringify({ slug, name }),
|
|
33
|
+
});
|
|
34
|
+
if (res.ok) {
|
|
35
|
+
const org = (await res.json()) as Org;
|
|
36
|
+
setOrgs((prev) => [
|
|
37
|
+
{ ...org, role: "owner" },
|
|
38
|
+
...prev,
|
|
39
|
+
]);
|
|
40
|
+
setActiveId(org.id);
|
|
41
|
+
setName("");
|
|
42
|
+
setSlug("");
|
|
43
|
+
setCreating(false);
|
|
44
|
+
} else {
|
|
45
|
+
const body = await res.json().catch(() => ({}));
|
|
46
|
+
setError(body?.message ?? "create failed");
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const active = orgs.find((o) => o.id === activeId) ?? null;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="space-y-6">
|
|
55
|
+
<Card>
|
|
56
|
+
<CardHeader>
|
|
57
|
+
<div className="flex items-center justify-between">
|
|
58
|
+
<h2 className="text-sm font-medium">Your organizations</h2>
|
|
59
|
+
<Button
|
|
60
|
+
size="sm"
|
|
61
|
+
variant={creating ? "ghost" : "default"}
|
|
62
|
+
onClick={() => setCreating((v) => !v)}
|
|
63
|
+
>
|
|
64
|
+
{creating ? "Cancel" : "New org"}
|
|
65
|
+
</Button>
|
|
66
|
+
</div>
|
|
67
|
+
</CardHeader>
|
|
68
|
+
<CardContent>
|
|
69
|
+
{creating && (
|
|
70
|
+
<form onSubmit={create} className="grid grid-cols-2 gap-2 mb-4">
|
|
71
|
+
<Input
|
|
72
|
+
placeholder="Org name"
|
|
73
|
+
value={name}
|
|
74
|
+
onChange={(e) => setName(e.target.value)}
|
|
75
|
+
disabled={pending}
|
|
76
|
+
required
|
|
77
|
+
/>
|
|
78
|
+
<Input
|
|
79
|
+
placeholder="acme-corp"
|
|
80
|
+
value={slug}
|
|
81
|
+
onChange={(e) => setSlug(e.target.value.toLowerCase())}
|
|
82
|
+
disabled={pending}
|
|
83
|
+
pattern="[a-z0-9][a-z0-9-]{1,30}[a-z0-9]"
|
|
84
|
+
required
|
|
85
|
+
/>
|
|
86
|
+
<Button
|
|
87
|
+
type="submit"
|
|
88
|
+
variant="primary"
|
|
89
|
+
disabled={pending || !name.trim() || !slug.trim()}
|
|
90
|
+
className="col-span-2"
|
|
91
|
+
>
|
|
92
|
+
{pending ? "Creating…" : "Create org"}
|
|
93
|
+
</Button>
|
|
94
|
+
{error && (
|
|
95
|
+
<p className="text-xs text-red-500 col-span-2">{error}</p>
|
|
96
|
+
)}
|
|
97
|
+
</form>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{orgs.length === 0 ? (
|
|
101
|
+
<p className="text-sm text-neutral-500 py-2">
|
|
102
|
+
You're not in any orgs yet. Create one above to get started.
|
|
103
|
+
</p>
|
|
104
|
+
) : (
|
|
105
|
+
<ul className="divide-y divide-neutral-200 dark:divide-neutral-800 -mx-5">
|
|
106
|
+
{orgs.map((o) => (
|
|
107
|
+
<li
|
|
108
|
+
key={o.id}
|
|
109
|
+
className="flex items-center justify-between px-5 py-2.5 cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-900"
|
|
110
|
+
data-active={o.id === activeId || undefined}
|
|
111
|
+
onClick={() => setActiveId(o.id)}
|
|
112
|
+
>
|
|
113
|
+
<div>
|
|
114
|
+
<div className="text-sm font-medium">{o.name}</div>
|
|
115
|
+
<div className="text-xs text-neutral-400 font-mono">
|
|
116
|
+
{o.slug}
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
<span
|
|
120
|
+
className={`text-xs font-mono uppercase tracking-wide px-2 py-0.5 rounded ${
|
|
121
|
+
o.role === "owner"
|
|
122
|
+
? "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300"
|
|
123
|
+
: o.role === "admin"
|
|
124
|
+
? "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300"
|
|
125
|
+
: "bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400"
|
|
126
|
+
}`}
|
|
127
|
+
>
|
|
128
|
+
{o.role}
|
|
129
|
+
</span>
|
|
130
|
+
</li>
|
|
131
|
+
))}
|
|
132
|
+
</ul>
|
|
133
|
+
)}
|
|
134
|
+
</CardContent>
|
|
135
|
+
</Card>
|
|
136
|
+
|
|
137
|
+
{active && <OrgDetail org={active} />}
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function OrgDetail({ org }: { org: Org }) {
|
|
143
|
+
return (
|
|
144
|
+
<Card>
|
|
145
|
+
<CardHeader>
|
|
146
|
+
<div className="flex items-baseline justify-between">
|
|
147
|
+
<h2 className="text-sm font-medium">
|
|
148
|
+
{org.name}{" "}
|
|
149
|
+
<span className="text-xs text-neutral-400 font-mono ml-1">
|
|
150
|
+
/{org.slug}
|
|
151
|
+
</span>
|
|
152
|
+
</h2>
|
|
153
|
+
<span className="text-xs text-neutral-500">your role: {org.role}</span>
|
|
154
|
+
</div>
|
|
155
|
+
</CardHeader>
|
|
156
|
+
<CardContent className="space-y-4">
|
|
157
|
+
<p className="text-sm text-neutral-500">
|
|
158
|
+
Wire member management and project lists into this panel by calling
|
|
159
|
+
<code className="font-mono text-xs mx-1">/api/fn/orgMembers</code>
|
|
160
|
+
and
|
|
161
|
+
<code className="font-mono text-xs mx-1">/api/fn/orgProjects</code>
|
|
162
|
+
with this org's id. Both queries enforce membership; you only
|
|
163
|
+
see what your role allows.
|
|
164
|
+
</p>
|
|
165
|
+
<div className="text-xs font-mono text-neutral-400">
|
|
166
|
+
orgId: {org.id}
|
|
167
|
+
</div>
|
|
168
|
+
</CardContent>
|
|
169
|
+
</Card>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import "./globals.css";
|
|
3
|
+
|
|
4
|
+
export const metadata: Metadata = {
|
|
5
|
+
title: "__APP_NAME__",
|
|
6
|
+
description: "Multi-tenant SaaS scaffold powered by Pylon",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default function RootLayout({
|
|
10
|
+
children,
|
|
11
|
+
}: {
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<html lang="en">
|
|
16
|
+
<body className="antialiased min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
|
|
17
|
+
{children}
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { pylon } from "@/lib/pylon";
|
|
2
|
+
import { OrgPicker } from "./components/OrgPicker";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
|
|
6
|
+
type Org = {
|
|
7
|
+
id: string;
|
|
8
|
+
slug: string;
|
|
9
|
+
name: string;
|
|
10
|
+
role: string;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default async function HomePage() {
|
|
15
|
+
const orgs = await pylon
|
|
16
|
+
.json<Org[]>("/api/fn/myOrgs", {
|
|
17
|
+
method: "POST",
|
|
18
|
+
body: "{}",
|
|
19
|
+
headers: { "Content-Type": "application/json" },
|
|
20
|
+
})
|
|
21
|
+
.catch(() => [] as Org[]);
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<main className="mx-auto max-w-3xl px-6 py-12 space-y-8">
|
|
25
|
+
<header className="space-y-2">
|
|
26
|
+
<h1 className="text-3xl font-semibold tracking-tight">__APP_NAME__</h1>
|
|
27
|
+
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
|
28
|
+
Multi-tenant SaaS scaffold. Pick an org to manage its members and
|
|
29
|
+
projects, or create a new one. Tenant isolation is enforced
|
|
30
|
+
server-side by Pylon row-level policies — clients can't leak
|
|
31
|
+
data across orgs even if they fabricate <code className="font-mono text-xs">orgId</code>{" "}
|
|
32
|
+
in a request.
|
|
33
|
+
</p>
|
|
34
|
+
</header>
|
|
35
|
+
|
|
36
|
+
<OrgPicker initialOrgs={orgs} />
|
|
37
|
+
</main>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "preserve",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"plugins": [{ "name": "next" }],
|
|
17
|
+
"paths": { "@/*": ["./src/*"] }
|
|
18
|
+
},
|
|
19
|
+
"include": [
|
|
20
|
+
"next-env.d.ts",
|
|
21
|
+
"src/**/*.ts",
|
|
22
|
+
"src/**/*.tsx",
|
|
23
|
+
".next/types/**/*.ts"
|
|
24
|
+
],
|
|
25
|
+
"exclude": ["node_modules"]
|
|
26
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { NextConfig } from "next";
|
|
2
|
+
|
|
3
|
+
const PYLON_API_URL = process.env.PYLON_API_URL ?? "http://localhost:4321";
|
|
4
|
+
|
|
5
|
+
const config: NextConfig = {
|
|
6
|
+
transpilePackages: [
|
|
7
|
+
"@__APP_NAME_KEBAB__/ui",
|
|
8
|
+
"@pylonsync/sdk",
|
|
9
|
+
"@pylonsync/react",
|
|
10
|
+
"@pylonsync/next",
|
|
11
|
+
"@pylonsync/functions",
|
|
12
|
+
"@pylonsync/sync",
|
|
13
|
+
],
|
|
14
|
+
async rewrites() {
|
|
15
|
+
return [
|
|
16
|
+
{ source: "/api/fn/:path*", destination: `${PYLON_API_URL}/api/fn/:path*` },
|
|
17
|
+
{ source: "/api/auth/:path*", destination: `${PYLON_API_URL}/api/auth/:path*` },
|
|
18
|
+
{ source: "/api/sync/:path*", destination: `${PYLON_API_URL}/api/sync/:path*` },
|
|
19
|
+
{ source: "/api/:path*", destination: `${PYLON_API_URL}/api/:path*` },
|
|
20
|
+
];
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default config;
|