@pylonsync/create-pylon 0.3.54 → 0.3.57
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 +28 -51
- package/package.json +1 -1
- package/templates/_root/turbo.json +18 -0
- package/templates/backend/b2b/apps/api/package.json +3 -3
- package/templates/backend/barebones/apps/api/package.json +3 -3
- package/templates/backend/chat/apps/api/package.json +3 -3
- package/templates/backend/consumer/apps/api/package.json +3 -3
- package/templates/backend/todo/apps/api/package.json +3 -3
- package/templates/expo/barebones/apps/expo/package.json +1 -0
- package/templates/expo/chat/apps/expo/App.tsx +126 -156
- package/templates/expo/chat/apps/expo/package.json +1 -0
- package/templates/expo/consumer/apps/expo/App.tsx +211 -179
- package/templates/expo/consumer/apps/expo/package.json +1 -0
- package/templates/expo/todo/apps/expo/package.json +1 -0
- package/templates/ios/chat/apps/ios/Package.swift +1 -11
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +26 -30
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/RoomView.swift +35 -36
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +27 -4
- package/templates/ios/chat/apps/ios/project.yml +2 -0
- package/templates/ios/consumer/apps/ios/Package.swift +1 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +81 -52
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +6 -14
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +14 -6
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +25 -15
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +33 -5
- package/templates/ios/consumer/apps/ios/project.yml +2 -0
- package/templates/mac/chat/apps/mac/Package.swift +1 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +30 -27
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/RoomView.swift +35 -36
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +26 -5
- package/templates/mac/chat/apps/mac/project.yml +2 -0
- package/templates/mac/consumer/apps/mac/Package.swift +1 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/FeedView.swift +81 -52
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +6 -14
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +14 -6
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/RootView.swift +25 -15
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +34 -6
- package/templates/mac/consumer/apps/mac/project.yml +2 -0
- package/templates/web/chat/apps/web/src/app/components/ChatRoom.tsx +52 -71
- package/templates/web/chat/apps/web/src/app/layout.tsx +3 -2
- package/templates/web/chat/apps/web/src/app/page.tsx +5 -44
- package/templates/web/chat/apps/web/src/app/providers.tsx +25 -0
- package/templates/web/consumer/apps/web/src/app/components/Feed.tsx +139 -119
- package/templates/web/consumer/apps/web/src/app/layout.tsx +3 -2
- package/templates/web/consumer/apps/web/src/app/page.tsx +8 -42
- package/templates/web/consumer/apps/web/src/app/providers.tsx +25 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
View,
|
|
4
4
|
Text,
|
|
@@ -9,14 +9,18 @@ import {
|
|
|
9
9
|
StyleSheet,
|
|
10
10
|
Platform,
|
|
11
11
|
Alert,
|
|
12
|
+
SafeAreaView,
|
|
12
13
|
} from "react-native";
|
|
13
14
|
import { StatusBar } from "expo-status-bar";
|
|
14
|
-
import { init, callFn } from "@pylonsync/react-native";
|
|
15
|
+
import { init, db, callFn } from "@pylonsync/react-native";
|
|
16
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
15
17
|
|
|
16
18
|
const PYLON_BASE_URL =
|
|
17
19
|
process.env.EXPO_PUBLIC_PYLON_BASE_URL ??
|
|
18
20
|
(Platform.OS === "android" ? "http://10.0.2.2:4321" : "http://localhost:4321");
|
|
19
21
|
|
|
22
|
+
const PROFILE_KEY = "__APP_NAME_SNAKE___profile_id";
|
|
23
|
+
|
|
20
24
|
type Profile = {
|
|
21
25
|
id: string;
|
|
22
26
|
userId: string;
|
|
@@ -26,13 +30,18 @@ type Profile = {
|
|
|
26
30
|
createdAt: string;
|
|
27
31
|
};
|
|
28
32
|
|
|
29
|
-
type
|
|
33
|
+
type Post = {
|
|
30
34
|
id: string;
|
|
35
|
+
authorId: string;
|
|
31
36
|
body: string;
|
|
32
37
|
createdAt: string;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type Like = {
|
|
41
|
+
id: string;
|
|
42
|
+
postId: string;
|
|
43
|
+
profileId: string;
|
|
44
|
+
createdAt: string;
|
|
36
45
|
};
|
|
37
46
|
|
|
38
47
|
let initPromise: Promise<void> | null = null;
|
|
@@ -48,20 +57,15 @@ function ensureInit() {
|
|
|
48
57
|
|
|
49
58
|
export default function App() {
|
|
50
59
|
const [ready, setReady] = useState(false);
|
|
51
|
-
const [
|
|
52
|
-
|
|
60
|
+
const [profileId, setProfileId] = useState<string | null>(null);
|
|
53
61
|
useEffect(() => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
} catch {
|
|
59
|
-
setMe(null);
|
|
60
|
-
}
|
|
62
|
+
(async () => {
|
|
63
|
+
await ensureInit();
|
|
64
|
+
const stored = await AsyncStorage.getItem(PROFILE_KEY);
|
|
65
|
+
setProfileId(stored);
|
|
61
66
|
setReady(true);
|
|
62
|
-
});
|
|
67
|
+
})();
|
|
63
68
|
}, []);
|
|
64
|
-
|
|
65
69
|
if (!ready) {
|
|
66
70
|
return (
|
|
67
71
|
<View style={[styles.screen, styles.center]}>
|
|
@@ -69,156 +73,86 @@ export default function App() {
|
|
|
69
73
|
</View>
|
|
70
74
|
);
|
|
71
75
|
}
|
|
76
|
+
return (
|
|
77
|
+
<Root
|
|
78
|
+
profileId={profileId}
|
|
79
|
+
onProfileChange={async (id) => {
|
|
80
|
+
if (id) await AsyncStorage.setItem(PROFILE_KEY, id);
|
|
81
|
+
else await AsyncStorage.removeItem(PROFILE_KEY);
|
|
82
|
+
setProfileId(id);
|
|
83
|
+
}}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function Root({
|
|
89
|
+
profileId,
|
|
90
|
+
onProfileChange,
|
|
91
|
+
}: {
|
|
92
|
+
profileId: string | null;
|
|
93
|
+
onProfileChange: (id: string | null) => void;
|
|
94
|
+
}) {
|
|
95
|
+
const { data: profiles = [] } = db.useQuery<Profile>("Profile", {});
|
|
96
|
+
const me = useMemo(
|
|
97
|
+
() => (profileId ? (profiles.find((p) => p.id === profileId) ?? null) : null),
|
|
98
|
+
[profileId, profiles],
|
|
99
|
+
);
|
|
100
|
+
|
|
72
101
|
if (!me) {
|
|
73
|
-
return
|
|
102
|
+
return (
|
|
103
|
+
<ProfileSetup
|
|
104
|
+
existingHandles={profiles.map((p) => p.handle)}
|
|
105
|
+
onSaved={(p) => onProfileChange(p.id)}
|
|
106
|
+
/>
|
|
107
|
+
);
|
|
74
108
|
}
|
|
75
|
-
return <Feed me={me} />;
|
|
109
|
+
return <Feed me={me} profiles={profiles} />;
|
|
76
110
|
}
|
|
77
111
|
|
|
78
|
-
function
|
|
79
|
-
const [
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
112
|
+
function Feed({ me, profiles }: { me: Profile; profiles: Profile[] }) {
|
|
113
|
+
const { data: posts = [] } = db.useQuery<Post>("Post", {
|
|
114
|
+
orderBy: { createdAt: "desc" },
|
|
115
|
+
limit: 100,
|
|
116
|
+
});
|
|
117
|
+
const { data: likes = [] } = db.useQuery<Like>("Like", {});
|
|
83
118
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
displayName: displayName.trim(),
|
|
90
|
-
bio: bio.trim(),
|
|
91
|
-
});
|
|
92
|
-
onSaved(p);
|
|
93
|
-
} catch (e) {
|
|
94
|
-
Alert.alert("Save failed", String(e));
|
|
95
|
-
} finally {
|
|
96
|
-
setSaving(false);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
119
|
+
const profilesById = useMemo(() => {
|
|
120
|
+
const map = new Map<string, Profile>();
|
|
121
|
+
for (const p of profiles) map.set(p.id, p);
|
|
122
|
+
return map;
|
|
123
|
+
}, [profiles]);
|
|
99
124
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
autoCorrect={false}
|
|
110
|
-
value={handle}
|
|
111
|
-
onChangeText={setHandle}
|
|
112
|
-
/>
|
|
113
|
-
<TextInput
|
|
114
|
-
style={styles.input}
|
|
115
|
-
placeholder="Display name"
|
|
116
|
-
value={displayName}
|
|
117
|
-
onChangeText={setDisplayName}
|
|
118
|
-
/>
|
|
119
|
-
<TextInput
|
|
120
|
-
style={styles.input}
|
|
121
|
-
placeholder="Bio (optional)"
|
|
122
|
-
value={bio}
|
|
123
|
-
onChangeText={setBio}
|
|
124
|
-
/>
|
|
125
|
-
<Pressable
|
|
126
|
-
onPress={save}
|
|
127
|
-
disabled={saving || !handle.trim() || !displayName.trim()}
|
|
128
|
-
style={({ pressed }) => [
|
|
129
|
-
styles.button,
|
|
130
|
-
(saving || !handle.trim() || !displayName.trim()) && styles.buttonDisabled,
|
|
131
|
-
pressed && styles.buttonPressed,
|
|
132
|
-
]}
|
|
133
|
-
>
|
|
134
|
-
<Text style={styles.buttonLabel}>{saving ? "Saving…" : "Save"}</Text>
|
|
135
|
-
</Pressable>
|
|
136
|
-
</View>
|
|
125
|
+
const items = useMemo(
|
|
126
|
+
() =>
|
|
127
|
+
posts.map((post) => {
|
|
128
|
+
const author = profilesById.get(post.authorId) ?? null;
|
|
129
|
+
const postLikes = likes.filter((l) => l.postId === post.id);
|
|
130
|
+
const likedByMe = postLikes.some((l) => l.profileId === me.id);
|
|
131
|
+
return { post, author, likeCount: postLikes.length, likedByMe };
|
|
132
|
+
}),
|
|
133
|
+
[posts, profilesById, likes, me.id],
|
|
137
134
|
);
|
|
138
|
-
}
|
|
139
135
|
|
|
140
|
-
function Feed({ me }: { me: Profile }) {
|
|
141
|
-
const [feed, setFeed] = useState<FeedItem[]>([]);
|
|
142
|
-
const [loading, setLoading] = useState(true);
|
|
143
136
|
const [draft, setDraft] = useState("");
|
|
144
137
|
const [posting, setPosting] = useState(false);
|
|
145
138
|
|
|
146
|
-
useEffect(() => {
|
|
147
|
-
void load();
|
|
148
|
-
}, []);
|
|
149
|
-
|
|
150
|
-
async function load() {
|
|
151
|
-
setLoading(true);
|
|
152
|
-
try {
|
|
153
|
-
setFeed(await callFn<FeedItem[]>("feed", {}));
|
|
154
|
-
} catch (e) {
|
|
155
|
-
Alert.alert("Load failed", String(e));
|
|
156
|
-
} finally {
|
|
157
|
-
setLoading(false);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
139
|
async function post() {
|
|
162
140
|
const body = draft.trim();
|
|
163
141
|
if (!body) return;
|
|
142
|
+
setDraft("");
|
|
164
143
|
setPosting(true);
|
|
165
144
|
try {
|
|
166
|
-
|
|
167
|
-
setFeed((prev) => [item, ...prev]);
|
|
168
|
-
setDraft("");
|
|
145
|
+
await callFn("createPost", { body });
|
|
169
146
|
} catch (e) {
|
|
170
147
|
Alert.alert("Post failed", String(e));
|
|
148
|
+
setDraft(body);
|
|
171
149
|
} finally {
|
|
172
150
|
setPosting(false);
|
|
173
151
|
}
|
|
174
152
|
}
|
|
175
153
|
|
|
176
|
-
async function toggleLike(item: FeedItem) {
|
|
177
|
-
setFeed((prev) =>
|
|
178
|
-
prev.map((p) =>
|
|
179
|
-
p.id === item.id
|
|
180
|
-
? {
|
|
181
|
-
...p,
|
|
182
|
-
likedByMe: !p.likedByMe,
|
|
183
|
-
likeCount: p.likeCount + (p.likedByMe ? -1 : 1),
|
|
184
|
-
}
|
|
185
|
-
: p,
|
|
186
|
-
),
|
|
187
|
-
);
|
|
188
|
-
try {
|
|
189
|
-
const result = await callFn<{ liked: boolean; likeCount: number }>(
|
|
190
|
-
"toggleLike",
|
|
191
|
-
{ postId: item.id },
|
|
192
|
-
);
|
|
193
|
-
setFeed((prev) =>
|
|
194
|
-
prev.map((p) =>
|
|
195
|
-
p.id === item.id
|
|
196
|
-
? { ...p, likedByMe: result.liked, likeCount: result.likeCount }
|
|
197
|
-
: p,
|
|
198
|
-
),
|
|
199
|
-
);
|
|
200
|
-
} catch {
|
|
201
|
-
setFeed((prev) =>
|
|
202
|
-
prev.map((p) =>
|
|
203
|
-
p.id === item.id
|
|
204
|
-
? { ...p, likedByMe: item.likedByMe, likeCount: item.likeCount }
|
|
205
|
-
: p,
|
|
206
|
-
),
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
async function remove(item: FeedItem) {
|
|
212
|
-
setFeed((prev) => prev.filter((p) => p.id !== item.id));
|
|
213
|
-
try {
|
|
214
|
-
await callFn("deletePost", { id: item.id });
|
|
215
|
-
} catch {
|
|
216
|
-
setFeed((prev) => [...prev, item]);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
154
|
return (
|
|
221
|
-
<
|
|
155
|
+
<SafeAreaView style={styles.screen}>
|
|
222
156
|
<StatusBar style="auto" />
|
|
223
157
|
<View style={styles.headerRow}>
|
|
224
158
|
<View>
|
|
@@ -252,57 +186,155 @@ function Feed({ me }: { me: Profile }) {
|
|
|
252
186
|
</View>
|
|
253
187
|
</View>
|
|
254
188
|
|
|
255
|
-
{
|
|
256
|
-
<ActivityIndicator style={{ marginTop: 24 }} />
|
|
257
|
-
) : feed.length === 0 ? (
|
|
189
|
+
{items.length === 0 ? (
|
|
258
190
|
<Text style={styles.empty}>No posts yet.</Text>
|
|
259
191
|
) : (
|
|
260
192
|
<FlatList
|
|
261
|
-
data={
|
|
262
|
-
keyExtractor={(
|
|
193
|
+
data={items}
|
|
194
|
+
keyExtractor={(it) => it.post.id}
|
|
263
195
|
contentContainerStyle={{ paddingTop: 8, paddingBottom: 32 }}
|
|
264
196
|
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
|
265
|
-
renderItem={({ item }) =>
|
|
266
|
-
<View style={styles.post}>
|
|
267
|
-
<View style={styles.postHead}>
|
|
268
|
-
<Text style={styles.postName}>
|
|
269
|
-
{item.author?.displayName ?? "Unknown"}
|
|
270
|
-
</Text>
|
|
271
|
-
<Text style={styles.postHandle}>
|
|
272
|
-
@{item.author?.handle ?? "?"}
|
|
273
|
-
</Text>
|
|
274
|
-
</View>
|
|
275
|
-
<Text style={styles.postBody}>{item.body}</Text>
|
|
276
|
-
<View style={styles.postFoot}>
|
|
277
|
-
<Pressable onPress={() => toggleLike(item)}>
|
|
278
|
-
<Text
|
|
279
|
-
style={[
|
|
280
|
-
styles.likeBtn,
|
|
281
|
-
item.likedByMe && styles.likeBtnActive,
|
|
282
|
-
]}
|
|
283
|
-
>
|
|
284
|
-
{item.likedByMe ? "♥" : "♡"} {item.likeCount}
|
|
285
|
-
</Text>
|
|
286
|
-
</Pressable>
|
|
287
|
-
{item.author?.id === me.id && (
|
|
288
|
-
<Pressable onPress={() => remove(item)}>
|
|
289
|
-
<Text style={styles.deleteBtn}>Delete</Text>
|
|
290
|
-
</Pressable>
|
|
291
|
-
)}
|
|
292
|
-
</View>
|
|
293
|
-
</View>
|
|
294
|
-
)}
|
|
197
|
+
renderItem={({ item }) => <Row item={item} myId={me.id} />}
|
|
295
198
|
/>
|
|
296
199
|
)}
|
|
200
|
+
</SafeAreaView>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function Row({
|
|
205
|
+
item,
|
|
206
|
+
myId,
|
|
207
|
+
}: {
|
|
208
|
+
item: {
|
|
209
|
+
post: Post;
|
|
210
|
+
author: Profile | null;
|
|
211
|
+
likeCount: number;
|
|
212
|
+
likedByMe: boolean;
|
|
213
|
+
};
|
|
214
|
+
myId: string;
|
|
215
|
+
}) {
|
|
216
|
+
async function toggleLike() {
|
|
217
|
+
try {
|
|
218
|
+
await callFn("toggleLike", { postId: item.post.id });
|
|
219
|
+
} catch (e) {
|
|
220
|
+
Alert.alert("Like failed", String(e));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async function remove() {
|
|
224
|
+
try {
|
|
225
|
+
await callFn("deletePost", { id: item.post.id });
|
|
226
|
+
} catch (e) {
|
|
227
|
+
Alert.alert("Delete failed", String(e));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return (
|
|
231
|
+
<View style={styles.post}>
|
|
232
|
+
<View style={styles.postHead}>
|
|
233
|
+
<Text style={styles.postName}>
|
|
234
|
+
{item.author?.displayName ?? "Unknown"}
|
|
235
|
+
</Text>
|
|
236
|
+
<Text style={styles.postHandle}>@{item.author?.handle ?? "?"}</Text>
|
|
237
|
+
</View>
|
|
238
|
+
<Text style={styles.postBody}>{item.post.body}</Text>
|
|
239
|
+
<View style={styles.postFoot}>
|
|
240
|
+
<Pressable onPress={toggleLike}>
|
|
241
|
+
<Text
|
|
242
|
+
style={[styles.likeBtn, item.likedByMe && styles.likeBtnActive]}
|
|
243
|
+
>
|
|
244
|
+
{item.likedByMe ? "♥" : "♡"} {item.likeCount}
|
|
245
|
+
</Text>
|
|
246
|
+
</Pressable>
|
|
247
|
+
{item.author?.id === myId && (
|
|
248
|
+
<Pressable onPress={remove}>
|
|
249
|
+
<Text style={styles.deleteBtn}>Delete</Text>
|
|
250
|
+
</Pressable>
|
|
251
|
+
)}
|
|
252
|
+
</View>
|
|
297
253
|
</View>
|
|
298
254
|
);
|
|
299
255
|
}
|
|
300
256
|
|
|
257
|
+
function ProfileSetup({
|
|
258
|
+
existingHandles,
|
|
259
|
+
onSaved,
|
|
260
|
+
}: {
|
|
261
|
+
existingHandles: string[];
|
|
262
|
+
onSaved: (p: Profile) => void;
|
|
263
|
+
}) {
|
|
264
|
+
const [handle, setHandle] = useState("");
|
|
265
|
+
const [displayName, setDisplayName] = useState("");
|
|
266
|
+
const [bio, setBio] = useState("");
|
|
267
|
+
const [saving, setSaving] = useState(false);
|
|
268
|
+
|
|
269
|
+
async function save() {
|
|
270
|
+
const lower = handle.trim().toLowerCase();
|
|
271
|
+
if (existingHandles.includes(lower)) {
|
|
272
|
+
Alert.alert("Handle taken", `@${lower} is already in use.`);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
setSaving(true);
|
|
276
|
+
try {
|
|
277
|
+
const profile = (await callFn("upsertProfile", {
|
|
278
|
+
handle: lower,
|
|
279
|
+
displayName: displayName.trim(),
|
|
280
|
+
bio: bio.trim(),
|
|
281
|
+
})) as Profile;
|
|
282
|
+
onSaved(profile);
|
|
283
|
+
} catch (e) {
|
|
284
|
+
Alert.alert("Save failed", String(e));
|
|
285
|
+
} finally {
|
|
286
|
+
setSaving(false);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<SafeAreaView style={styles.screen}>
|
|
292
|
+
<StatusBar style="auto" />
|
|
293
|
+
<View style={styles.content}>
|
|
294
|
+
<Text style={styles.title}>__APP_NAME__</Text>
|
|
295
|
+
<Text style={styles.subtitle}>Set up your profile</Text>
|
|
296
|
+
<TextInput
|
|
297
|
+
style={styles.input}
|
|
298
|
+
placeholder="handle (lowercase, 2–20)"
|
|
299
|
+
autoCapitalize="none"
|
|
300
|
+
autoCorrect={false}
|
|
301
|
+
value={handle}
|
|
302
|
+
onChangeText={setHandle}
|
|
303
|
+
/>
|
|
304
|
+
<TextInput
|
|
305
|
+
style={styles.input}
|
|
306
|
+
placeholder="Display name"
|
|
307
|
+
value={displayName}
|
|
308
|
+
onChangeText={setDisplayName}
|
|
309
|
+
/>
|
|
310
|
+
<TextInput
|
|
311
|
+
style={styles.input}
|
|
312
|
+
placeholder="Bio (optional)"
|
|
313
|
+
value={bio}
|
|
314
|
+
onChangeText={setBio}
|
|
315
|
+
/>
|
|
316
|
+
<Pressable
|
|
317
|
+
onPress={save}
|
|
318
|
+
disabled={saving || !handle.trim() || !displayName.trim()}
|
|
319
|
+
style={({ pressed }) => [
|
|
320
|
+
styles.button,
|
|
321
|
+
(saving || !handle.trim() || !displayName.trim()) && styles.buttonDisabled,
|
|
322
|
+
pressed && styles.buttonPressed,
|
|
323
|
+
]}
|
|
324
|
+
>
|
|
325
|
+
<Text style={styles.buttonLabel}>{saving ? "Saving…" : "Save"}</Text>
|
|
326
|
+
</Pressable>
|
|
327
|
+
</View>
|
|
328
|
+
</SafeAreaView>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
301
332
|
const styles = StyleSheet.create({
|
|
302
333
|
screen: { flex: 1, paddingTop: 64, paddingHorizontal: 20, backgroundColor: "#fff" },
|
|
303
334
|
center: { alignItems: "center", justifyContent: "center" },
|
|
335
|
+
content: { padding: 20, gap: 12 },
|
|
304
336
|
title: { fontSize: 28, fontWeight: "600" },
|
|
305
|
-
subtitle: { color: "#666",
|
|
337
|
+
subtitle: { color: "#666", marginBottom: 12 },
|
|
306
338
|
handle: { fontFamily: "Menlo", fontSize: 12, color: "#999" },
|
|
307
339
|
headerRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 16 },
|
|
308
340
|
input: {
|
|
@@ -1,17 +1,6 @@
|
|
|
1
1
|
// swift-tools-version:5.9
|
|
2
2
|
import PackageDescription
|
|
3
3
|
|
|
4
|
-
// SwiftPM package for the __APP_NAME__ mobile app.
|
|
5
|
-
//
|
|
6
|
-
// The executable target runs on macOS via `swift run`. For an iOS
|
|
7
|
-
// build, generate an Xcode project from `project.yml`:
|
|
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 iOS + macOS share one source set.
|
|
15
4
|
let package = Package(
|
|
16
5
|
name: "__APP_NAME_PASCAL__",
|
|
17
6
|
platforms: [
|
|
@@ -26,6 +15,7 @@ let package = Package(
|
|
|
26
15
|
name: "__APP_NAME_PASCAL__",
|
|
27
16
|
dependencies: [
|
|
28
17
|
.product(name: "PylonClient", package: "pylon"),
|
|
18
|
+
.product(name: "PylonSync", package: "pylon"),
|
|
29
19
|
.product(name: "PylonSwiftUI", package: "pylon"),
|
|
30
20
|
],
|
|
31
21
|
path: "Sources/__APP_NAME_PASCAL__"
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
2
|
import PylonClient
|
|
3
|
+
import PylonSync
|
|
4
|
+
import PylonSwiftUI
|
|
3
5
|
|
|
4
|
-
/// Two-pane chat: rooms list → room view.
|
|
5
|
-
///
|
|
6
|
-
///
|
|
6
|
+
/// Two-pane chat: rooms list → room view. Subscribes to Room
|
|
7
|
+
/// inserts/deletes via PylonQuery — every new room created on any
|
|
8
|
+
/// device shows up here within ~ms over the WebSocket sync channel.
|
|
7
9
|
struct ChatRootView: View {
|
|
8
10
|
@EnvironmentObject var session: AppSession
|
|
9
|
-
|
|
10
|
-
@
|
|
11
|
+
let engine: SyncEngine
|
|
12
|
+
@StateObject private var rooms: PylonQuery<Room>
|
|
11
13
|
@State private var errorMessage: String?
|
|
12
14
|
|
|
15
|
+
init(engine: SyncEngine) {
|
|
16
|
+
self.engine = engine
|
|
17
|
+
_rooms = StateObject(
|
|
18
|
+
wrappedValue: PylonQuery<Room>(engine: engine, entity: "Room"),
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
13
22
|
var body: some View {
|
|
14
23
|
NavigationStack {
|
|
15
24
|
List {
|
|
@@ -21,13 +30,11 @@ struct ChatRootView: View {
|
|
|
21
30
|
.autocorrectionDisabled()
|
|
22
31
|
}
|
|
23
32
|
Section("Rooms") {
|
|
24
|
-
if
|
|
25
|
-
|
|
26
|
-
} else if rooms.isEmpty {
|
|
27
|
-
Text("No rooms yet. Create one below.")
|
|
33
|
+
if rooms.rows.isEmpty {
|
|
34
|
+
Text("No rooms yet — create one below.")
|
|
28
35
|
.foregroundStyle(.secondary)
|
|
29
36
|
} else {
|
|
30
|
-
ForEach(
|
|
37
|
+
ForEach(sortedRooms) { r in
|
|
31
38
|
NavigationLink(value: r) {
|
|
32
39
|
VStack(alignment: .leading) {
|
|
33
40
|
Text(r.name)
|
|
@@ -54,38 +61,31 @@ struct ChatRootView: View {
|
|
|
54
61
|
}
|
|
55
62
|
.navigationTitle("__APP_NAME__")
|
|
56
63
|
.navigationDestination(for: Room.self) { room in
|
|
57
|
-
RoomView(room: room)
|
|
64
|
+
RoomView(room: room, engine: engine)
|
|
58
65
|
}
|
|
59
|
-
.task { await loadRooms() }
|
|
60
|
-
.refreshable { await loadRooms() }
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
private
|
|
65
|
-
|
|
66
|
-
defer { loadingRooms = false }
|
|
67
|
-
do {
|
|
68
|
-
rooms = try await session.pylon.callFn("listRooms", args: EmptyArgs())
|
|
69
|
-
errorMessage = nil
|
|
70
|
-
} catch {
|
|
71
|
-
errorMessage = "Load failed: \(error.localizedDescription)"
|
|
72
|
-
}
|
|
69
|
+
private var sortedRooms: [Room] {
|
|
70
|
+
rooms.rows.sorted { $0.createdAt < $1.createdAt }
|
|
73
71
|
}
|
|
74
72
|
|
|
75
73
|
private func createRoom() async {
|
|
76
|
-
let
|
|
77
|
-
guard let name =
|
|
74
|
+
let name = await prompt(title: "Create room", message: "Room name?")
|
|
75
|
+
guard let name = name?.trimmingCharacters(in: .whitespaces),
|
|
78
76
|
!name.isEmpty
|
|
79
77
|
else { return }
|
|
80
78
|
let slug = name.lowercased()
|
|
81
79
|
.replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression)
|
|
82
80
|
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
|
|
83
81
|
do {
|
|
84
|
-
|
|
82
|
+
// Server validates + writes via ctx.db.insert. The change_event
|
|
83
|
+
// flows back through the SyncEngine and PylonQuery picks up
|
|
84
|
+
// the new Room without us touching local state.
|
|
85
|
+
let _: Room = try await session.client.callFn(
|
|
85
86
|
"createRoom",
|
|
86
87
|
args: CreateRoomArgs(slug: slug, name: name),
|
|
87
88
|
)
|
|
88
|
-
rooms.append(room)
|
|
89
89
|
} catch {
|
|
90
90
|
errorMessage = "Create failed: \(error.localizedDescription)"
|
|
91
91
|
}
|
|
@@ -93,10 +93,6 @@ struct ChatRootView: View {
|
|
|
93
93
|
|
|
94
94
|
@MainActor
|
|
95
95
|
private func prompt(title: String, message: String) async -> String? {
|
|
96
|
-
// Minimal SwiftUI prompt — for production replace with an
|
|
97
|
-
// in-tree alert + TextField sheet. The scaffold uses a tiny
|
|
98
|
-
// UIKit detour on iOS so the demo works without a custom
|
|
99
|
-
// modal implementation.
|
|
100
96
|
#if canImport(UIKit)
|
|
101
97
|
await withCheckedContinuation { (cont: CheckedContinuation<String?, Never>) in
|
|
102
98
|
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|