@pplethai/cli 0.4.1 → 0.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pplethai/cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14,14 +14,14 @@ reinventing a design-system setup from `pple-erp` or other older apps.
14
14
  import "@pplethai/components/styles.css";
15
15
  import "./app.css";
16
16
 
17
- import { Button, Card, CardContent, Logo } from "@pplethai/components";
17
+ import { Button, Card, CardContent, Navbar } from "@pplethai/components";
18
18
  ```
19
19
 
20
20
  Package entry points:
21
21
 
22
22
  | Import | Contents |
23
23
  | ------ | -------- |
24
- | `@pplethai/components` | UI primitives, layout, brand (`Logo`, …), `cn()`, toasts |
24
+ | `@pplethai/components` | UI primitives, layout, brand (`Navbar`, `Logo`, …), `cn()`, toasts |
25
25
  | `@pplethai/components/form` | react-hook-form helpers when you need forms |
26
26
  | `@pplethai/components/styles.css` | Brand tokens — import once |
27
27
 
@@ -34,6 +34,11 @@ the bare `@pplethai/components` import to `dist/index.js`.
34
34
  - Put **page layout** in `web/app.css` (mobile-first, safe-area padding for
35
35
  the WebView). Do not fight the design-system stylesheet with a second
36
36
  full Tailwind stack unless you need it.
37
+ - Top-level chrome uses Shared `<Navbar>` (see `web/App.tsx`). Do not hand-roll
38
+ a header/app bar with `Logo`. With a client router, pass `renderHomeLink` /
39
+ `renderLink` (as in pple-erp); otherwise default `<a href>` links are fine.
40
+ Navbar is `position: fixed` below the `md` breakpoint — pad the page content
41
+ accordingly (the template's `.screen` already does).
37
42
  - Compose screens from design-system components (`Card`, `Button`, `Input`,
38
43
  `Textarea`, `Badge`, `Alert`, …) like the demo cards in `web/App.tsx`.
39
44
  - **Colors and type** come from CSS variables in `styles.css`
@@ -19,6 +19,7 @@
19
19
  "@pplethai/components": "^1.4.3",
20
20
  "@pplethai/pple-today-miniapp-sdk": "^0.1.4",
21
21
  "hono": "^4.12.30",
22
+ "lucide-react": "^0.469.0",
22
23
  "react": "^19.2.0",
23
24
  "react-dom": "^19.2.0",
24
25
  "react-hook-form": "^7.81.0",
@@ -1,20 +1,27 @@
1
1
  import {
2
- Badge,
3
2
  Button,
4
3
  Card,
5
4
  CardContent,
6
5
  CardDescription,
7
6
  CardHeader,
8
7
  CardTitle,
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuLabel,
12
+ DropdownMenuSeparator,
13
+ DropdownMenuTrigger,
14
+ Icon,
9
15
  Input,
10
- Logo,
16
+ Navbar,
11
17
  Spinner,
12
18
  Textarea,
13
19
  showToast,
14
20
  } from "@pplethai/components";
21
+ import { ChevronDown, LogOut, Plus, Send, Upload, User } from "lucide-react";
15
22
  import { useEffect, useRef, useState } from "react";
16
23
  import type { Me, Note, UploadedFile } from "../shared/api";
17
- import { api, apiJson } from "./pple";
24
+ import { api, apiJson, logout } from "./pple";
18
25
 
19
26
  function bytes(n: number): string {
20
27
  if (n < 1024) return `${n} B`;
@@ -26,35 +33,89 @@ function message(e: unknown): string {
26
33
  return e instanceof Error ? e.message : String(e);
27
34
  }
28
35
 
36
+ // Starter nav — replace with your routes. With a client router, pass
37
+ // renderHomeLink / renderLink (see pple-erp's Navbar wrapper).
38
+ const NAV_ITEMS = [{ href: "/", label: "หน้าหลัก", end: true }];
39
+
29
40
  export function App() {
30
41
  const [me, setMe] = useState<Me | null>(null);
31
42
 
32
43
  useEffect(() => {
33
44
  apiJson<Me>("/api/me").then(setMe).catch((e) => showToast({
34
- title: "Could not load your profile",
45
+ title: "โหลดโปรไฟล์ไม่สำเร็จ",
35
46
  description: message(e),
36
47
  variant: "error",
37
48
  }));
38
49
  }, []);
39
50
 
51
+ const displayName = me?.actor.name ?? me?.actor.sub ?? "";
52
+ const roleLabels = me
53
+ ? [me.effective.activeRole, ...me.effective.extraRoles]
54
+ : [];
55
+
40
56
  return (
41
- <main className="screen">
42
- <header className="appbar">
43
- <Logo className="appbar__logo" title="People's Party" />
44
- <span className="appbar__title">PPLE App</span>
45
- {me && <Badge>{me.actor.name ?? me.actor.sub}</Badge>}
46
- </header>
57
+ <>
58
+ <Navbar
59
+ title="PPLE App"
60
+ pathname={window.location.pathname}
61
+ items={NAV_ITEMS}
62
+ mobileMenuAriaLabel={{ open: "เปิดเมนู", close: "ปิดเมนู" }}
63
+ navAriaLabel="เมนูหลัก"
64
+ >
65
+ {me && (
66
+ <DropdownMenu>
67
+ <DropdownMenuTrigger asChild>
68
+ <Button variant="outline" size="default" className="gap-2">
69
+ <Icon icon={User} />
70
+ <span>{displayName}</span>
71
+ <Icon icon={ChevronDown} size="sm" />
72
+ </Button>
73
+ </DropdownMenuTrigger>
74
+ <DropdownMenuContent align="end" className="w-64">
75
+ <DropdownMenuLabel>
76
+ <div className="font-medium">{displayName}</div>
77
+ {me.actor.mobile && (
78
+ <div className="text-xs text-muted-foreground font-normal">
79
+ {me.actor.mobile}
80
+ </div>
81
+ )}
82
+ {me.effective.isDelegated && (
83
+ <div className="text-xs text-muted-foreground font-normal">
84
+ (ปฏิบัติงานแทน — สิทธิ์เป็นของหัวหน้า)
85
+ </div>
86
+ )}
87
+ </DropdownMenuLabel>
88
+ <DropdownMenuSeparator />
89
+ <DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
90
+ บทบาทของคุณ
91
+ </DropdownMenuLabel>
92
+ {roleLabels.map((label) => (
93
+ <DropdownMenuItem key={label} disabled className="opacity-70">
94
+ {label}
95
+ </DropdownMenuItem>
96
+ ))}
97
+ <DropdownMenuSeparator />
98
+ <DropdownMenuItem onClick={() => void logout()}>
99
+ <Icon icon={LogOut} />
100
+ ออกจากระบบ
101
+ </DropdownMenuItem>
102
+ </DropdownMenuContent>
103
+ </DropdownMenu>
104
+ )}
105
+ </Navbar>
47
106
 
48
- <div className="stack">
49
- <p className="muted">
50
- This is the golden-path template. Each card below exercises one
51
- platform capability. Replace them with your own app.
52
- </p>
53
- <NotesCard />
54
- <FilesCard />
55
- <NotifyCard />
56
- </div>
57
- </main>
107
+ <main className="screen">
108
+ <div className="stack">
109
+ <p className="muted">
110
+ นี่คือเทมเพลตเริ่มต้น แต่ละการ์ดด้านล่างแสดงความสามารถหนึ่งอย่างของ
111
+ แพลตฟอร์ม แทนที่ด้วยแอปของคุณเองได้เลย
112
+ </p>
113
+ <NotesCard />
114
+ <FilesCard />
115
+ <NotifyCard />
116
+ </div>
117
+ </main>
118
+ </>
58
119
  );
59
120
  }
60
121
 
@@ -68,7 +129,7 @@ function NotesCard() {
68
129
  apiJson<{ notes: Note[] }>("/api/notes")
69
130
  .then((r) => setNotes(r.notes))
70
131
  .catch((e) => {
71
- showToast({ title: "Could not load notes", description: message(e), variant: "error" });
132
+ showToast({ title: "โหลดบันทึกไม่สำเร็จ", description: message(e), variant: "error" });
72
133
  // Land on the empty state. Leaving this null would spin forever.
73
134
  setNotes([]);
74
135
  });
@@ -89,7 +150,7 @@ function NotesCard() {
89
150
  setBody("");
90
151
  await load();
91
152
  } catch (e) {
92
- showToast({ title: "Could not save note", description: message(e), variant: "error" });
153
+ showToast({ title: "บันทึกไม่สำเร็จ", description: message(e), variant: "error" });
93
154
  } finally {
94
155
  setSaving(false);
95
156
  }
@@ -98,32 +159,35 @@ function NotesCard() {
98
159
  return (
99
160
  <Card className="card">
100
161
  <CardHeader>
101
- <CardTitle>Notes</CardTitle>
102
- <CardDescription>Reads and writes your app's D1 database.</CardDescription>
162
+ <CardTitle>บันทึก</CardTitle>
163
+ <CardDescription>อ่านและเขียนฐานข้อมูล D1 ของแอป</CardDescription>
103
164
  </CardHeader>
104
165
  <CardContent>
105
166
  <div className="stack">
106
167
  <div className="row">
107
168
  <Input
108
169
  value={body}
109
- placeholder="Write a note…"
170
+ placeholder="เขียนบันทึก…"
110
171
  onChange={(e) => setBody(e.target.value)}
111
172
  onKeyDown={(e) => e.key === "Enter" && add()}
112
173
  />
113
174
  <Button onClick={add} disabled={saving || !body.trim()}>
114
- {saving ? <Spinner /> : "Add"}
175
+ {saving ? <Spinner /> : <Icon icon={Plus} />}
176
+ เพิ่ม
115
177
  </Button>
116
178
  </div>
117
179
  {notes === null ? (
118
180
  <Spinner />
119
181
  ) : notes.length === 0 ? (
120
- <p className="muted">No notes yet.</p>
182
+ <p className="muted">ยังไม่มีบันทึก</p>
121
183
  ) : (
122
184
  <ul className="list">
123
185
  {notes.map((n) => (
124
186
  <li key={n.id} className="list__item">
125
187
  <span>{n.body}</span>
126
- <span className="muted">{new Date(n.createdAt).toLocaleString()}</span>
188
+ <span className="muted">
189
+ {new Date(n.createdAt).toLocaleString("th-TH")}
190
+ </span>
127
191
  </li>
128
192
  ))}
129
193
  </ul>
@@ -144,7 +208,7 @@ function FilesCard() {
144
208
  apiJson<{ files: UploadedFile[] }>("/api/files")
145
209
  .then((r) => setFiles(r.files))
146
210
  .catch((e) => {
147
- showToast({ title: "Could not load files", description: message(e), variant: "error" });
211
+ showToast({ title: "โหลดไฟล์ไม่สำเร็จ", description: message(e), variant: "error" });
148
212
  // Land on the empty state. Leaving this null would spin forever.
149
213
  setFiles([]);
150
214
  });
@@ -159,11 +223,11 @@ function FilesCard() {
159
223
  const fd = new FormData();
160
224
  fd.append("file", file);
161
225
  const res = await api("/api/files", { method: "POST", body: fd });
162
- if (!res.ok) throw new Error(`upload failed (${res.status})`);
226
+ if (!res.ok) throw new Error(`อัปโหลดไม่สำเร็จ (${res.status})`);
163
227
  if (inputRef.current) inputRef.current.value = "";
164
228
  await load();
165
229
  } catch (e) {
166
- showToast({ title: "Could not upload file", description: message(e), variant: "error" });
230
+ showToast({ title: "อัปโหลดไฟล์ไม่สำเร็จ", description: message(e), variant: "error" });
167
231
  } finally {
168
232
  setUploading(false);
169
233
  }
@@ -172,27 +236,34 @@ function FilesCard() {
172
236
  return (
173
237
  <Card className="card">
174
238
  <CardHeader>
175
- <CardTitle>Files</CardTitle>
176
- <CardDescription>Uploads to your app's R2 bucket.</CardDescription>
239
+ <CardTitle>ไฟล์</CardTitle>
240
+ <CardDescription>อัปโหลดไปยังบัคเก็ต R2 ของแอป</CardDescription>
177
241
  </CardHeader>
178
242
  <CardContent>
179
243
  <div className="stack">
180
244
  <div className="row">
181
- {/* Disabled while uploading, so picking a second file can't start
182
- a concurrent upload. */}
245
+ {/* Hidden native picker; the design-system Button opens it. Disabled
246
+ while uploading so a second pick can't start a concurrent upload. */}
183
247
  <input
184
248
  ref={inputRef}
185
249
  type="file"
186
- className="file-input"
250
+ className="hidden"
187
251
  onChange={upload}
188
252
  disabled={uploading}
189
253
  />
190
- {uploading && <Spinner />}
254
+ <Button
255
+ variant="outline"
256
+ disabled={uploading}
257
+ onClick={() => inputRef.current?.click()}
258
+ >
259
+ {uploading ? <Spinner /> : <Icon icon={Upload} />}
260
+ เลือกไฟล์
261
+ </Button>
191
262
  </div>
192
263
  {files === null ? (
193
264
  <Spinner />
194
265
  ) : files.length === 0 ? (
195
- <p className="muted">No files yet.</p>
266
+ <p className="muted">ยังไม่มีไฟล์</p>
196
267
  ) : (
197
268
  <ul className="list">
198
269
  {files.map((f) => (
@@ -226,20 +297,20 @@ function NotifyCard() {
226
297
  });
227
298
  const data = (await res.json().catch(() => ({}))) as { error?: string };
228
299
  if (res.ok) {
229
- showToast({ title: "Notification sent", variant: "success" });
300
+ showToast({ title: "ส่งการแจ้งเตือนแล้ว", variant: "success" });
230
301
  setTitle("");
231
302
  setBody("");
232
303
  } else if (data.error === "notifications_not_configured") {
233
304
  showToast({
234
- title: "Notifications not configured",
235
- description: "Set NOTIFY_API_BASE and NOTIFY_API_KEY to send for real.",
305
+ title: "ยังไม่ได้ตั้งค่าการแจ้งเตือน",
306
+ description: "ตั้งค่า NOTIFY_API_BASE และ NOTIFY_API_KEY เพื่อส่งจริง",
236
307
  variant: "warning",
237
308
  });
238
309
  } else {
239
- showToast({ title: "Could not send", description: data.error, variant: "error" });
310
+ showToast({ title: "ส่งไม่สำเร็จ", description: data.error, variant: "error" });
240
311
  }
241
312
  } catch (e) {
242
- showToast({ title: "Could not send", description: message(e), variant: "error" });
313
+ showToast({ title: "ส่งไม่สำเร็จ", description: message(e), variant: "error" });
243
314
  } finally {
244
315
  setSending(false);
245
316
  }
@@ -248,18 +319,27 @@ function NotifyCard() {
248
319
  return (
249
320
  <Card className="card">
250
321
  <CardHeader>
251
- <CardTitle>Notify your users</CardTitle>
322
+ <CardTitle>แจ้งเตือนผู้ใช้</CardTitle>
252
323
  <CardDescription>
253
- Sends an audience-bound push to your App Users — content only, no
254
- phone numbers.
324
+ ส่งการแจ้งเตือนแบบผูกกับกลุ่มผู้ใช้แอป — ส่งเฉพาะเนื้อหา
325
+ ไม่ส่งหมายเลขโทรศัพท์
255
326
  </CardDescription>
256
327
  </CardHeader>
257
328
  <CardContent>
258
329
  <div className="stack">
259
- <Input value={title} placeholder="Title" onChange={(e) => setTitle(e.target.value)} />
260
- <Textarea value={body} placeholder="Message" onChange={(e) => setBody(e.target.value)} />
330
+ <Input
331
+ value={title}
332
+ placeholder="หัวข้อ"
333
+ onChange={(e) => setTitle(e.target.value)}
334
+ />
335
+ <Textarea
336
+ value={body}
337
+ placeholder="ข้อความ"
338
+ onChange={(e) => setBody(e.target.value)}
339
+ />
261
340
  <Button onClick={send} disabled={sending || !title.trim() || !body.trim()}>
262
- {sending ? <Spinner /> : "Send notification"}
341
+ {sending ? <Spinner /> : <Icon icon={Send} />}
342
+ ส่งการแจ้งเตือน
263
343
  </Button>
264
344
  </div>
265
345
  </CardContent>
@@ -17,34 +17,23 @@ body {
17
17
  max-width: 640px;
18
18
  margin: 0 auto;
19
19
  padding: 16px;
20
+ /* Navbar is position:fixed below the md breakpoint; give content room. */
21
+ padding-top: 4.5rem;
20
22
  padding-bottom: max(16px, env(safe-area-inset-bottom));
21
23
  min-height: 100vh;
22
24
  }
23
25
 
26
+ @media (min-width: 768px) {
27
+ .screen {
28
+ padding-top: 16px;
29
+ }
30
+ }
31
+
24
32
  .screen--center {
25
33
  display: grid;
26
34
  place-items: center;
27
35
  }
28
36
 
29
- .appbar {
30
- display: flex;
31
- align-items: center;
32
- gap: 12px;
33
- padding: 8px 4px 16px;
34
- }
35
-
36
- .appbar__logo {
37
- height: 28px;
38
- width: auto;
39
- color: #ff6a13; /* brand primary */
40
- }
41
-
42
- .appbar__title {
43
- font-weight: 600;
44
- font-size: 18px;
45
- margin-right: auto;
46
- }
47
-
48
37
  .stack {
49
38
  display: flex;
50
39
  flex-direction: column;
@@ -86,7 +75,9 @@ body {
86
75
  font-size: 14px;
87
76
  }
88
77
 
89
- .file-input {
90
- flex: 1;
91
- min-width: 0;
78
+ /* Icon defaults to text-foreground, which overrides the parent Button text
79
+ colour (primary-foreground on filled buttons, …). Inherit so the glyph
80
+ matches its label — including hover states on outline/ghost buttons. */
81
+ button svg.text-foreground {
82
+ color: inherit;
92
83
  }
@@ -26,7 +26,7 @@ function ErrorScreen({ message }: { message: string }) {
26
26
  return (
27
27
  <main className="screen screen--center">
28
28
  <Alert variant="destructive" className="card">
29
- <AlertTitle>Could not start</AlertTitle>
29
+ <AlertTitle>เริ่มต้นแอปไม่สำเร็จ</AlertTitle>
30
30
  <AlertDescription>{message}</AlertDescription>
31
31
  </Alert>
32
32
  </main>
@@ -30,6 +30,7 @@ export type Bootstrap =
30
30
  | { kind: "error"; message: string };
31
31
 
32
32
  let token: string | null = null;
33
+ let sdk: PPLEMiniApp | null = null;
33
34
 
34
35
  /** Establish the session. Call once at startup, before rendering the app. */
35
36
  export async function bootstrap(): Promise<Bootstrap> {
@@ -48,11 +49,11 @@ export async function bootstrap(): Promise<Bootstrap> {
48
49
  return {
49
50
  kind: "error",
50
51
  message:
51
- "This app is misconfigured: its PPLE ID sign-in settings were not set when it was built. Redeploy the app from the platform to fix it.",
52
+ "แอปตั้งค่าไม่ถูกต้อง: การตั้งค่าเข้าสู่ระบบ PPLE ID ไม่ได้ถูกตั้งตอนสร้างแอป โปรดดีพลอยใหม่จากแพลตฟอร์ม",
52
53
  };
53
54
  }
54
55
 
55
- const sdk = new PPLEMiniApp({
56
+ sdk = new PPLEMiniApp({
56
57
  oauthUrl: OAUTH_URL,
57
58
  oauthClientId: OAUTH_CLIENT_ID,
58
59
  oauthRedirectUri: OAUTH_REDIRECT_URI,
@@ -65,6 +66,16 @@ export async function bootstrap(): Promise<Bootstrap> {
65
66
  }
66
67
  }
67
68
 
69
+ /** Drop the session and return to the start (re-triggers PPLE ID login). */
70
+ export async function logout(): Promise<void> {
71
+ token = null;
72
+ if (sdk) {
73
+ await sdk.logout();
74
+ sdk = null;
75
+ }
76
+ window.location.href = "/";
77
+ }
78
+
68
79
  /**
69
80
  * Call your Worker API with the bearer token attached. Paths are relative
70
81
  * ("/api/notes"), since the SPA and the Worker share one origin.