@pylonsync/create-pylon 0.3.51 → 0.3.53
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 +290 -1155
- package/package.json +4 -3
- package/templates/_root/.env.example +9 -0
- package/templates/_root/README.md +43 -0
- package/templates/backend/barebones/apps/api/functions/createWidget.ts +22 -0
- package/templates/backend/barebones/apps/api/functions/listWidgets.ts +18 -0
- package/templates/backend/barebones/apps/api/package.json +20 -0
- package/templates/backend/barebones/apps/api/schema.ts +61 -0
- package/templates/backend/barebones/apps/api/tsconfig.json +13 -0
- package/templates/backend/todo/apps/api/functions/addTodo.ts +27 -0
- package/templates/backend/todo/apps/api/functions/deleteTodo.ts +14 -0
- package/templates/backend/todo/apps/api/functions/editTodo.ts +16 -0
- package/templates/backend/todo/apps/api/functions/listTodos.ts +24 -0
- package/templates/backend/todo/apps/api/functions/reorderTodo.ts +14 -0
- package/templates/backend/todo/apps/api/functions/toggleTodo.ts +13 -0
- package/templates/backend/todo/apps/api/package.json +20 -0
- package/templates/backend/todo/apps/api/schema.ts +85 -0
- package/templates/backend/todo/apps/api/tsconfig.json +13 -0
- package/templates/expo/barebones/apps/expo/App.tsx +166 -0
- package/templates/expo/barebones/apps/expo/app.json +31 -0
- package/templates/expo/barebones/apps/expo/babel.config.js +6 -0
- package/templates/expo/barebones/apps/expo/package.json +30 -0
- package/templates/expo/barebones/apps/expo/tsconfig.json +16 -0
- package/templates/expo/todo/apps/expo/App.tsx +287 -0
- package/templates/expo/todo/apps/expo/app.json +25 -0
- package/templates/expo/todo/apps/expo/babel.config.js +6 -0
- package/templates/expo/todo/apps/expo/package.json +30 -0
- package/templates/expo/todo/apps/expo/tsconfig.json +16 -0
- package/templates/mobile/barebones/apps/mobile/Package.swift +34 -0
- package/templates/mobile/barebones/apps/mobile/Sources/__APP_NAME_PASCAL__/ContentView.swift +98 -0
- package/templates/mobile/barebones/apps/mobile/Sources/__APP_NAME_PASCAL__/Models.swift +17 -0
- package/templates/mobile/barebones/apps/mobile/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +34 -0
- package/templates/mobile/barebones/apps/mobile/project.yml +42 -0
- package/templates/mobile/todo/apps/mobile/Package.swift +23 -0
- package/templates/mobile/todo/apps/mobile/Sources/__APP_NAME_PASCAL__/Models.swift +18 -0
- package/templates/mobile/todo/apps/mobile/Sources/__APP_NAME_PASCAL__/TodoListView.swift +230 -0
- package/templates/mobile/todo/apps/mobile/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +28 -0
- package/templates/mobile/todo/apps/mobile/project.yml +32 -0
- package/templates/ui/packages/ui/package.json +26 -0
- package/templates/ui/packages/ui/src/button.tsx +44 -0
- package/templates/ui/packages/ui/src/card.tsx +39 -0
- package/templates/ui/packages/ui/src/cn.ts +12 -0
- package/templates/ui/packages/ui/src/index.ts +4 -0
- package/templates/ui/packages/ui/src/input.tsx +19 -0
- package/templates/ui/packages/ui/tsconfig.json +15 -0
- package/templates/web/barebones/apps/web/next-env.d.ts +2 -0
- package/templates/web/barebones/apps/web/next.config.ts +40 -0
- package/templates/web/barebones/apps/web/package.json +29 -0
- package/templates/web/barebones/apps/web/postcss.config.mjs +4 -0
- package/templates/web/barebones/apps/web/src/app/components/WidgetList.tsx +81 -0
- package/templates/web/barebones/apps/web/src/app/globals.css +9 -0
- package/templates/web/barebones/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/barebones/apps/web/src/app/page.tsx +43 -0
- package/templates/web/barebones/apps/web/src/lib/pylon.ts +14 -0
- package/templates/web/barebones/apps/web/tsconfig.json +26 -0
- package/templates/web/todo/apps/web/next-env.d.ts +2 -0
- package/templates/web/todo/apps/web/next.config.ts +24 -0
- package/templates/web/todo/apps/web/package.json +32 -0
- package/templates/web/todo/apps/web/postcss.config.mjs +3 -0
- package/templates/web/todo/apps/web/src/app/components/TodoList.tsx +310 -0
- package/templates/web/todo/apps/web/src/app/globals.css +6 -0
- package/templates/web/todo/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/todo/apps/web/src/app/page.tsx +36 -0
- package/templates/web/todo/apps/web/src/lib/pylon.ts +5 -0
- package/templates/web/todo/apps/web/tsconfig.json +26 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import PylonClient
|
|
3
|
+
|
|
4
|
+
/// Live Todo list with add, toggle, edit, drag-reorder, and delete.
|
|
5
|
+
/// Uses PylonClient's HTTP API directly. Drop-in upgrade path:
|
|
6
|
+
/// swap `load()` for a `PylonQuery<Todo>` ObservableObject from
|
|
7
|
+
/// PylonSwiftUI to get realtime updates without polling.
|
|
8
|
+
struct TodoListView: View {
|
|
9
|
+
@EnvironmentObject var session: AppSession
|
|
10
|
+
|
|
11
|
+
@State private var todos: [Todo] = []
|
|
12
|
+
@State private var draftTitle: String = ""
|
|
13
|
+
@State private var loading = true
|
|
14
|
+
@State private var pending = false
|
|
15
|
+
@State private var errorMessage: String?
|
|
16
|
+
@State private var editingId: String?
|
|
17
|
+
@State private var editingDraft: String = ""
|
|
18
|
+
|
|
19
|
+
var body: some View {
|
|
20
|
+
NavigationStack {
|
|
21
|
+
List {
|
|
22
|
+
Section("Add") {
|
|
23
|
+
HStack {
|
|
24
|
+
TextField("What needs doing?", text: $draftTitle)
|
|
25
|
+
.textFieldStyle(.roundedBorder)
|
|
26
|
+
.onSubmit { Task { await add() } }
|
|
27
|
+
Button("Add") { Task { await add() } }
|
|
28
|
+
.buttonStyle(.borderedProminent)
|
|
29
|
+
.disabled(draftTitle.trimmingCharacters(in: .whitespaces).isEmpty || pending)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
Section("Todos") {
|
|
34
|
+
if loading {
|
|
35
|
+
ProgressView()
|
|
36
|
+
} else if todos.isEmpty {
|
|
37
|
+
Text("No todos yet.").foregroundStyle(.secondary)
|
|
38
|
+
} else {
|
|
39
|
+
ForEach(todos) { todo in
|
|
40
|
+
row(todo)
|
|
41
|
+
}
|
|
42
|
+
.onMove { source, destination in
|
|
43
|
+
Task { await reorder(from: source, to: destination) }
|
|
44
|
+
}
|
|
45
|
+
.onDelete { offsets in
|
|
46
|
+
Task { await delete(at: offsets) }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if let errorMessage {
|
|
52
|
+
Section {
|
|
53
|
+
Text(errorMessage)
|
|
54
|
+
.foregroundStyle(.red)
|
|
55
|
+
.font(.caption)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
.navigationTitle("__APP_NAME__")
|
|
60
|
+
.toolbar { EditButton() }
|
|
61
|
+
.task { await load() }
|
|
62
|
+
.refreshable { await load() }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@ViewBuilder
|
|
67
|
+
private func row(_ todo: Todo) -> some View {
|
|
68
|
+
HStack(spacing: 12) {
|
|
69
|
+
Button {
|
|
70
|
+
Task { await toggle(todo) }
|
|
71
|
+
} label: {
|
|
72
|
+
Image(systemName: todo.done ? "checkmark.circle.fill" : "circle")
|
|
73
|
+
.foregroundStyle(todo.done ? .green : .secondary)
|
|
74
|
+
}
|
|
75
|
+
.buttonStyle(.plain)
|
|
76
|
+
|
|
77
|
+
if editingId == todo.id {
|
|
78
|
+
TextField("Title", text: $editingDraft)
|
|
79
|
+
.textFieldStyle(.roundedBorder)
|
|
80
|
+
.onSubmit { Task { await commitEdit(todo) } }
|
|
81
|
+
Button("Save") { Task { await commitEdit(todo) } }
|
|
82
|
+
.buttonStyle(.bordered)
|
|
83
|
+
Button("Cancel") {
|
|
84
|
+
editingId = nil
|
|
85
|
+
editingDraft = ""
|
|
86
|
+
}
|
|
87
|
+
.buttonStyle(.bordered)
|
|
88
|
+
} else {
|
|
89
|
+
Text(todo.title)
|
|
90
|
+
.strikethrough(todo.done, color: .secondary)
|
|
91
|
+
.foregroundStyle(todo.done ? .secondary : .primary)
|
|
92
|
+
Spacer()
|
|
93
|
+
Button("Edit") {
|
|
94
|
+
editingId = todo.id
|
|
95
|
+
editingDraft = todo.title
|
|
96
|
+
}
|
|
97
|
+
.buttonStyle(.borderless)
|
|
98
|
+
.font(.caption)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// MARK: - Network
|
|
104
|
+
|
|
105
|
+
private func load() async {
|
|
106
|
+
loading = true
|
|
107
|
+
defer { loading = false }
|
|
108
|
+
do {
|
|
109
|
+
let rows: [Todo] = try await session.pylon.callFn("listTodos", args: EmptyArgs())
|
|
110
|
+
todos = rows
|
|
111
|
+
errorMessage = nil
|
|
112
|
+
} catch {
|
|
113
|
+
errorMessage = "Load failed: \(error.localizedDescription)"
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private func add() async {
|
|
118
|
+
let trimmed = draftTitle.trimmingCharacters(in: .whitespaces)
|
|
119
|
+
guard !trimmed.isEmpty else { return }
|
|
120
|
+
pending = true
|
|
121
|
+
defer { pending = false }
|
|
122
|
+
do {
|
|
123
|
+
let todo: Todo = try await session.pylon.callFn(
|
|
124
|
+
"addTodo",
|
|
125
|
+
args: AddTodoArgs(title: trimmed),
|
|
126
|
+
)
|
|
127
|
+
todos.append(todo)
|
|
128
|
+
draftTitle = ""
|
|
129
|
+
} catch {
|
|
130
|
+
errorMessage = "Add failed: \(error.localizedDescription)"
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private func toggle(_ todo: Todo) async {
|
|
135
|
+
let nextDone = !todo.done
|
|
136
|
+
// Optimistic update
|
|
137
|
+
if let i = todos.firstIndex(where: { $0.id == todo.id }) {
|
|
138
|
+
todos[i].done = nextDone
|
|
139
|
+
}
|
|
140
|
+
do {
|
|
141
|
+
let _: Todo = try await session.pylon.callFn(
|
|
142
|
+
"toggleTodo",
|
|
143
|
+
args: ToggleTodoArgs(id: todo.id, done: nextDone),
|
|
144
|
+
)
|
|
145
|
+
} catch {
|
|
146
|
+
// Revert
|
|
147
|
+
if let i = todos.firstIndex(where: { $0.id == todo.id }) {
|
|
148
|
+
todos[i].done = todo.done
|
|
149
|
+
}
|
|
150
|
+
errorMessage = "Toggle failed: \(error.localizedDescription)"
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private func commitEdit(_ todo: Todo) async {
|
|
155
|
+
let trimmed = editingDraft.trimmingCharacters(in: .whitespaces)
|
|
156
|
+
guard !trimmed.isEmpty, trimmed != todo.title else {
|
|
157
|
+
editingId = nil
|
|
158
|
+
editingDraft = ""
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
let originalTitle = todo.title
|
|
162
|
+
if let i = todos.firstIndex(where: { $0.id == todo.id }) {
|
|
163
|
+
todos[i].title = trimmed
|
|
164
|
+
}
|
|
165
|
+
editingId = nil
|
|
166
|
+
editingDraft = ""
|
|
167
|
+
do {
|
|
168
|
+
let _: Todo = try await session.pylon.callFn(
|
|
169
|
+
"editTodo",
|
|
170
|
+
args: EditTodoArgs(id: todo.id, title: trimmed),
|
|
171
|
+
)
|
|
172
|
+
} catch {
|
|
173
|
+
if let i = todos.firstIndex(where: { $0.id == todo.id }) {
|
|
174
|
+
todos[i].title = originalTitle
|
|
175
|
+
}
|
|
176
|
+
errorMessage = "Rename failed: \(error.localizedDescription)"
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private func delete(at offsets: IndexSet) async {
|
|
181
|
+
let removed = offsets.map { todos[$0] }
|
|
182
|
+
todos.remove(atOffsets: offsets)
|
|
183
|
+
for todo in removed {
|
|
184
|
+
do {
|
|
185
|
+
let _: Todo = try await session.pylon.callFn(
|
|
186
|
+
"deleteTodo",
|
|
187
|
+
args: DeleteTodoArgs(id: todo.id),
|
|
188
|
+
)
|
|
189
|
+
} catch {
|
|
190
|
+
todos.append(todo)
|
|
191
|
+
errorMessage = "Delete failed: \(error.localizedDescription)"
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private func reorder(from source: IndexSet, to destination: Int) async {
|
|
197
|
+
var moved = todos
|
|
198
|
+
moved.move(fromOffsets: source, toOffset: destination)
|
|
199
|
+
guard let movedIndex = source.first else { return }
|
|
200
|
+
// Compute the moved row's new index after the move
|
|
201
|
+
let newIndex = destination > movedIndex ? destination - 1 : destination
|
|
202
|
+
guard newIndex < moved.count else { return }
|
|
203
|
+
let movedTodo = moved[newIndex]
|
|
204
|
+
let prev = newIndex > 0 ? moved[newIndex - 1] : nil
|
|
205
|
+
let next = newIndex + 1 < moved.count ? moved[newIndex + 1] : nil
|
|
206
|
+
let prevPos = prev?.position ?? 0
|
|
207
|
+
let nextPos = next?.position ?? 0
|
|
208
|
+
let position: Double
|
|
209
|
+
if prev != nil && next != nil {
|
|
210
|
+
position = (prevPos + nextPos) / 2
|
|
211
|
+
} else if prev != nil {
|
|
212
|
+
position = prevPos + 1024
|
|
213
|
+
} else if next != nil {
|
|
214
|
+
position = nextPos - 1024
|
|
215
|
+
} else {
|
|
216
|
+
position = 1024
|
|
217
|
+
}
|
|
218
|
+
let snapshot = todos
|
|
219
|
+
todos = moved
|
|
220
|
+
do {
|
|
221
|
+
let _: Todo = try await session.pylon.callFn(
|
|
222
|
+
"reorderTodo",
|
|
223
|
+
args: ReorderTodoArgs(id: movedTodo.id, position: position),
|
|
224
|
+
)
|
|
225
|
+
} catch {
|
|
226
|
+
todos = snapshot
|
|
227
|
+
errorMessage = "Reorder failed: \(error.localizedDescription)"
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
package/templates/mobile/todo/apps/mobile/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import PylonClient
|
|
3
|
+
|
|
4
|
+
@main
|
|
5
|
+
struct __APP_NAME_PASCAL__App: App {
|
|
6
|
+
@StateObject private var session = AppSession()
|
|
7
|
+
|
|
8
|
+
var body: some Scene {
|
|
9
|
+
WindowGroup {
|
|
10
|
+
TodoListView()
|
|
11
|
+
.environmentObject(session)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@MainActor
|
|
17
|
+
final class AppSession: ObservableObject {
|
|
18
|
+
let pylon: PylonClient
|
|
19
|
+
|
|
20
|
+
init() {
|
|
21
|
+
let baseURLString = ProcessInfo.processInfo.environment["PYLON_BASE_URL"]
|
|
22
|
+
?? "http://localhost:4321"
|
|
23
|
+
guard let url = URL(string: baseURLString) else {
|
|
24
|
+
fatalError("Invalid PYLON_BASE_URL: \(baseURLString)")
|
|
25
|
+
}
|
|
26
|
+
self.pylon = PylonClient(baseURL: url, appName: "__APP_NAME_SNAKE__")
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: __APP_NAME_PASCAL__
|
|
2
|
+
options:
|
|
3
|
+
bundleIdPrefix: com.example
|
|
4
|
+
deploymentTarget:
|
|
5
|
+
iOS: "16.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: iOS
|
|
16
|
+
sources:
|
|
17
|
+
- path: Sources/__APP_NAME_PASCAL__
|
|
18
|
+
settings:
|
|
19
|
+
base:
|
|
20
|
+
GENERATE_INFOPLIST_FILE: YES
|
|
21
|
+
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
|
|
22
|
+
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
|
23
|
+
INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
|
|
24
|
+
TARGETED_DEVICE_FAMILY: "1,2"
|
|
25
|
+
SWIFT_VERSION: "5.9"
|
|
26
|
+
ENABLE_PREVIEWS: YES
|
|
27
|
+
INFOPLIST_KEY_NSAppTransportSecurity: "{NSAllowsLocalNetworking = YES;}"
|
|
28
|
+
dependencies:
|
|
29
|
+
- package: pylon
|
|
30
|
+
product: PylonClient
|
|
31
|
+
- package: pylon
|
|
32
|
+
product: PylonSwiftUI
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@__APP_NAME_KEBAB__/ui",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./button": "./src/button.tsx",
|
|
11
|
+
"./input": "./src/input.tsx",
|
|
12
|
+
"./card": "./src/card.tsx",
|
|
13
|
+
"./cn": "./src/cn.ts"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"clsx": "^2.1.0",
|
|
17
|
+
"tailwind-merge": "^2.5.0"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"react": "^19.0.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/react": "^19.0.0",
|
|
24
|
+
"typescript": "^5.5.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "./cn";
|
|
3
|
+
|
|
4
|
+
type Variant = "default" | "primary" | "ghost";
|
|
5
|
+
type Size = "sm" | "md";
|
|
6
|
+
|
|
7
|
+
const variants: Record<Variant, string> = {
|
|
8
|
+
default:
|
|
9
|
+
"bg-neutral-100 hover:bg-neutral-200 text-neutral-900 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:text-neutral-100",
|
|
10
|
+
primary:
|
|
11
|
+
"bg-neutral-900 hover:bg-neutral-800 text-white dark:bg-white dark:hover:bg-neutral-200 dark:text-neutral-900",
|
|
12
|
+
ghost:
|
|
13
|
+
"bg-transparent hover:bg-neutral-100 text-neutral-700 dark:hover:bg-neutral-800 dark:text-neutral-300",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const sizes: Record<Size, string> = {
|
|
17
|
+
sm: "h-8 px-3 text-[13px]",
|
|
18
|
+
md: "h-9 px-4 text-sm",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export interface ButtonProps
|
|
22
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
23
|
+
variant?: Variant;
|
|
24
|
+
size?: Size;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function Button({
|
|
28
|
+
className,
|
|
29
|
+
variant = "default",
|
|
30
|
+
size = "md",
|
|
31
|
+
...props
|
|
32
|
+
}: ButtonProps) {
|
|
33
|
+
return (
|
|
34
|
+
<button
|
|
35
|
+
className={cn(
|
|
36
|
+
"inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors disabled:opacity-50 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
|
|
37
|
+
variants[variant],
|
|
38
|
+
sizes[size],
|
|
39
|
+
className,
|
|
40
|
+
)}
|
|
41
|
+
{...props}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "./cn";
|
|
3
|
+
|
|
4
|
+
export function Card({
|
|
5
|
+
className,
|
|
6
|
+
...props
|
|
7
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
8
|
+
return (
|
|
9
|
+
<div
|
|
10
|
+
className={cn(
|
|
11
|
+
"rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900",
|
|
12
|
+
className,
|
|
13
|
+
)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function CardHeader({
|
|
20
|
+
className,
|
|
21
|
+
...props
|
|
22
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
className={cn(
|
|
26
|
+
"p-5 border-b border-neutral-200 dark:border-neutral-800",
|
|
27
|
+
className,
|
|
28
|
+
)}
|
|
29
|
+
{...props}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function CardContent({
|
|
35
|
+
className,
|
|
36
|
+
...props
|
|
37
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
38
|
+
return <div className={cn("p-5", className)} {...props} />;
|
|
39
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { clsx, type ClassValue } from "clsx";
|
|
2
|
+
import { twMerge } from "tailwind-merge";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tailwind-aware class merger. Last-class-wins semantics so a
|
|
6
|
+
* caller's `className` reliably overrides a default in a UI
|
|
7
|
+
* primitive (e.g. <Button className="bg-red-500"> beats the
|
|
8
|
+
* primitive's bg-neutral-900 base).
|
|
9
|
+
*/
|
|
10
|
+
export function cn(...inputs: ClassValue[]): string {
|
|
11
|
+
return twMerge(clsx(inputs));
|
|
12
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "./cn";
|
|
3
|
+
|
|
4
|
+
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
|
5
|
+
|
|
6
|
+
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
7
|
+
function Input({ className, ...props }, ref) {
|
|
8
|
+
return (
|
|
9
|
+
<input
|
|
10
|
+
ref={ref}
|
|
11
|
+
className={cn(
|
|
12
|
+
"flex h-9 w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm placeholder:text-neutral-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-50",
|
|
13
|
+
className,
|
|
14
|
+
)}
|
|
15
|
+
{...props}
|
|
16
|
+
/>
|
|
17
|
+
);
|
|
18
|
+
},
|
|
19
|
+
);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["dom", "esnext"],
|
|
5
|
+
"jsx": "preserve",
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"moduleResolution": "Bundler",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"allowSyntheticDefaultImports": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { NextConfig } from "next";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pylon's typed client + functions packages re-export across the
|
|
5
|
+
* server/client boundary AND the workspace UI package ships TSX.
|
|
6
|
+
* `transpilePackages` makes Next bundle them cleanly.
|
|
7
|
+
*
|
|
8
|
+
* `rewrites` proxies every Pylon-owned path (`/api/fn/*`,
|
|
9
|
+
* `/api/auth/*`, `/api/sync/*`, …) to the Pylon binary running on
|
|
10
|
+
* `PYLON_API_URL` (default http://localhost:4321). Without this,
|
|
11
|
+
* Next.js sees `/api/fn/createWidget` as a missing route and 404s
|
|
12
|
+
* before the request reaches Pylon.
|
|
13
|
+
*
|
|
14
|
+
* In production set `PYLON_API_URL` to wherever you've deployed the
|
|
15
|
+
* Pylon binary (Fly, Render, Railway, your own box). The browser
|
|
16
|
+
* still hits same-origin paths under your Next deployment, and Next
|
|
17
|
+
* forwards them server-side — no CORS, no extra DNS.
|
|
18
|
+
*/
|
|
19
|
+
const PYLON_API_URL = process.env.PYLON_API_URL ?? "http://localhost:4321";
|
|
20
|
+
|
|
21
|
+
const config: NextConfig = {
|
|
22
|
+
transpilePackages: [
|
|
23
|
+
"@__APP_NAME_KEBAB__/ui",
|
|
24
|
+
"@pylonsync/sdk",
|
|
25
|
+
"@pylonsync/react",
|
|
26
|
+
"@pylonsync/next",
|
|
27
|
+
"@pylonsync/functions",
|
|
28
|
+
"@pylonsync/sync",
|
|
29
|
+
],
|
|
30
|
+
async rewrites() {
|
|
31
|
+
return [
|
|
32
|
+
{ source: "/api/fn/:path*", destination: `${PYLON_API_URL}/api/fn/:path*` },
|
|
33
|
+
{ source: "/api/auth/:path*", destination: `${PYLON_API_URL}/api/auth/:path*` },
|
|
34
|
+
{ source: "/api/sync/:path*", destination: `${PYLON_API_URL}/api/sync/:path*` },
|
|
35
|
+
{ source: "/api/:path*", destination: `${PYLON_API_URL}/api/:path*` },
|
|
36
|
+
];
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
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,81 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useTransition } from "react";
|
|
4
|
+
import { Button, Input } from "@__APP_NAME_KEBAB__/ui";
|
|
5
|
+
|
|
6
|
+
type Widget = {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
count: number;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function WidgetList({ initialWidgets }: { initialWidgets: Widget[] }) {
|
|
14
|
+
const [widgets, setWidgets] = useState(initialWidgets);
|
|
15
|
+
const [name, setName] = useState("");
|
|
16
|
+
const [pending, startTransition] = useTransition();
|
|
17
|
+
|
|
18
|
+
async function add() {
|
|
19
|
+
const trimmed = name.trim();
|
|
20
|
+
if (!trimmed) return;
|
|
21
|
+
setName("");
|
|
22
|
+
startTransition(async () => {
|
|
23
|
+
const res = await fetch("/api/fn/createWidget", {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { "Content-Type": "application/json" },
|
|
26
|
+
body: JSON.stringify({ name: trimmed }),
|
|
27
|
+
});
|
|
28
|
+
if (res.ok) {
|
|
29
|
+
const widget = (await res.json()) as Widget;
|
|
30
|
+
setWidgets((prev) => [widget, ...prev]);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="space-y-4">
|
|
37
|
+
<form
|
|
38
|
+
onSubmit={(e) => {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
add();
|
|
41
|
+
}}
|
|
42
|
+
className="flex gap-2"
|
|
43
|
+
>
|
|
44
|
+
<Input
|
|
45
|
+
value={name}
|
|
46
|
+
onChange={(e) => setName(e.target.value)}
|
|
47
|
+
placeholder="Name a widget…"
|
|
48
|
+
disabled={pending}
|
|
49
|
+
className="flex-1"
|
|
50
|
+
/>
|
|
51
|
+
<Button
|
|
52
|
+
type="submit"
|
|
53
|
+
variant="primary"
|
|
54
|
+
disabled={pending || !name.trim()}
|
|
55
|
+
>
|
|
56
|
+
Create
|
|
57
|
+
</Button>
|
|
58
|
+
</form>
|
|
59
|
+
|
|
60
|
+
{widgets.length === 0 ? (
|
|
61
|
+
<p className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
|
|
62
|
+
No widgets yet. Create one above.
|
|
63
|
+
</p>
|
|
64
|
+
) : (
|
|
65
|
+
<ul className="divide-y divide-neutral-200 dark:divide-neutral-800 rounded-md border border-neutral-200 dark:border-neutral-800">
|
|
66
|
+
{widgets.map((w) => (
|
|
67
|
+
<li
|
|
68
|
+
key={w.id}
|
|
69
|
+
className="flex items-center justify-between gap-3 px-4 py-3 text-sm bg-white dark:bg-neutral-950"
|
|
70
|
+
>
|
|
71
|
+
<span className="font-medium">{w.name}</span>
|
|
72
|
+
<span className="font-mono text-xs text-neutral-400">
|
|
73
|
+
count: {w.count}
|
|
74
|
+
</span>
|
|
75
|
+
</li>
|
|
76
|
+
))}
|
|
77
|
+
</ul>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -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: "Realtime app 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
|
+
}
|