@finesoft/create-app 0.1.34 → 0.1.36
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/dist/index.mjs +22 -26
- package/package.json +14 -5
- package/templates/react/README.md +35 -0
- package/templates/react/index.html +2 -3
- package/templates/react/package.json +12 -8
- package/templates/react/src/App.tsx +7 -15
- package/templates/react/src/app-definition.ts +35 -0
- package/templates/react/src/components/Layout.tsx +5 -52
- package/templates/react/src/components/Loading.tsx +1 -1
- package/templates/react/src/components/Navigation.tsx +10 -37
- package/templates/react/src/components/ProductCard.tsx +5 -23
- package/templates/react/src/config.ts +2 -0
- package/templates/react/src/lib/controllers/about.ts +18 -13
- package/templates/react/src/lib/controllers/error.ts +11 -7
- package/templates/react/src/lib/controllers/home.ts +36 -41
- package/templates/react/src/lib/controllers/product-detail.ts +71 -40
- package/templates/react/src/lib/controllers/search.ts +33 -54
- package/templates/react/src/lib/data/products.ts +36 -0
- package/templates/react/src/lib/mappers/product.ts +3 -3
- package/templates/react/src/lib/models/product.ts +11 -1
- package/templates/react/src/lib/services/api-client.ts +7 -4
- package/templates/react/src/main.ts +34 -0
- package/templates/react/src/pages/About.tsx +2 -4
- package/templates/react/src/pages/Home.tsx +6 -15
- package/templates/react/src/pages/NotFound.tsx +4 -14
- package/templates/react/src/pages/ProductDetail.tsx +5 -23
- package/templates/react/src/pages/Search.tsx +5 -7
- package/templates/react/src/ssr.ts +11 -0
- package/templates/react/src/styles.css +71 -0
- package/templates/react/src/views.ts +12 -0
- package/templates/react/tsconfig.json +14 -9
- package/templates/react/vite.config.ts +9 -13
- package/templates/react-minimal/README.md +39 -0
- package/templates/react-minimal/index.html +2 -3
- package/templates/react-minimal/package.json +12 -8
- package/templates/react-minimal/src/App.tsx +40 -85
- package/templates/react-minimal/src/app-definition.ts +48 -0
- package/templates/react-minimal/src/config.ts +2 -0
- package/templates/react-minimal/src/lib/controllers/detail.ts +19 -18
- package/templates/react-minimal/src/lib/controllers/error.ts +15 -0
- package/templates/react-minimal/src/lib/controllers/home.ts +19 -24
- package/templates/react-minimal/src/lib/controllers/notes.ts +7 -5
- package/templates/react-minimal/src/lib/locale.ts +16 -0
- package/templates/react-minimal/src/lib/models/page.ts +27 -0
- package/templates/react-minimal/src/lib/navigation.ts +11 -0
- package/templates/react-minimal/src/locales/en-US.json +5 -0
- package/templates/react-minimal/src/locales/zh-Hans.json +5 -0
- package/templates/react-minimal/src/main.ts +43 -0
- package/templates/react-minimal/src/pages/Detail.tsx +20 -0
- package/templates/react-minimal/src/pages/Home.tsx +38 -0
- package/templates/react-minimal/src/pages/NotFound.tsx +11 -0
- package/templates/react-minimal/src/pages/Notes.tsx +21 -0
- package/templates/react-minimal/src/ssr.ts +11 -0
- package/templates/react-minimal/src/styles.css +74 -0
- package/templates/react-minimal/src/views.ts +6 -0
- package/templates/react-minimal/src/vite-env.d.ts +3 -0
- package/templates/react-minimal/tsconfig.json +14 -9
- package/templates/react-minimal/vite.config.ts +9 -7
- package/templates/svelte/README.md +35 -0
- package/templates/svelte/index.html +1 -2
- package/templates/svelte/package.json +9 -5
- package/templates/svelte/src/App.svelte +8 -80
- package/templates/svelte/src/app-definition.ts +35 -0
- package/templates/svelte/src/components/Layout.svelte +2 -2
- package/templates/svelte/src/components/Loading.svelte +1 -1
- package/templates/svelte/src/components/Navigation.svelte +5 -66
- package/templates/svelte/src/components/ProductCard.svelte +3 -24
- package/templates/svelte/src/config.ts +2 -0
- package/templates/svelte/src/lib/controllers/about.ts +18 -13
- package/templates/svelte/src/lib/controllers/error.ts +11 -7
- package/templates/svelte/src/lib/controllers/home.ts +36 -41
- package/templates/svelte/src/lib/controllers/product-detail.ts +71 -40
- package/templates/svelte/src/lib/controllers/search.ts +33 -54
- package/templates/svelte/src/lib/data/products.ts +36 -0
- package/templates/svelte/src/lib/mappers/product.ts +3 -3
- package/templates/svelte/src/lib/models/product.ts +12 -1
- package/templates/svelte/src/lib/services/api-client.ts +7 -4
- package/templates/svelte/src/main.ts +28 -24
- package/templates/svelte/src/pages/About.svelte +2 -2
- package/templates/svelte/src/pages/Home.svelte +3 -15
- package/templates/svelte/src/pages/NotFound.svelte +3 -12
- package/templates/svelte/src/pages/ProductDetail.svelte +4 -21
- package/templates/svelte/src/pages/Search.svelte +2 -10
- package/templates/svelte/src/ssr.ts +10 -9
- package/templates/svelte/src/styles.css +71 -0
- package/templates/svelte/src/views.ts +12 -0
- package/templates/svelte/svelte.config.js +2 -0
- package/templates/svelte/tsconfig.json +13 -8
- package/templates/svelte/vite.config.ts +9 -13
- package/templates/svelte-minimal/README.md +39 -0
- package/templates/svelte-minimal/index.html +1 -2
- package/templates/svelte-minimal/package.json +9 -5
- package/templates/svelte-minimal/src/App.svelte +49 -61
- package/templates/svelte-minimal/src/app-definition.ts +48 -0
- package/templates/svelte-minimal/src/config.ts +2 -0
- package/templates/svelte-minimal/src/lib/controllers/detail.ts +23 -0
- package/templates/svelte-minimal/src/lib/controllers/error.ts +15 -0
- package/templates/svelte-minimal/src/lib/controllers/home.ts +24 -23
- package/templates/svelte-minimal/src/lib/controllers/notes.ts +18 -0
- package/templates/svelte-minimal/src/lib/locale.ts +16 -0
- package/templates/svelte-minimal/src/lib/models/page.ts +27 -0
- package/templates/svelte-minimal/src/lib/navigation.ts +11 -0
- package/templates/svelte-minimal/src/locales/en-US.json +1 -3
- package/templates/svelte-minimal/src/locales/zh-Hans.json +1 -3
- package/templates/svelte-minimal/src/main.ts +37 -19
- package/templates/svelte-minimal/src/pages/Detail.svelte +11 -0
- package/templates/svelte-minimal/src/pages/Home.svelte +17 -21
- package/templates/svelte-minimal/src/pages/NotFound.svelte +10 -0
- package/templates/svelte-minimal/src/pages/Notes.svelte +11 -0
- package/templates/svelte-minimal/src/ssr.ts +9 -17
- package/templates/svelte-minimal/src/styles.css +74 -0
- package/templates/svelte-minimal/src/views.ts +6 -0
- package/templates/svelte-minimal/src/vite-env.d.ts +3 -0
- package/templates/svelte-minimal/svelte.config.js +2 -0
- package/templates/svelte-minimal/tsconfig.json +13 -8
- package/templates/svelte-minimal/vite.config.ts +9 -10
- package/templates/vue/README.md +35 -0
- package/templates/vue/index.html +1 -2
- package/templates/vue/package.json +9 -5
- package/templates/vue/src/App.vue +7 -22
- package/templates/vue/src/app-definition.ts +35 -0
- package/templates/vue/src/components/Layout.vue +4 -6
- package/templates/vue/src/components/Loading.vue +1 -1
- package/templates/vue/src/components/Navigation.vue +10 -43
- package/templates/vue/src/components/ProductCard.vue +4 -28
- package/templates/vue/src/config.ts +2 -0
- package/templates/vue/src/lib/controllers/about.ts +18 -13
- package/templates/vue/src/lib/controllers/error.ts +11 -7
- package/templates/vue/src/lib/controllers/home.ts +36 -41
- package/templates/vue/src/lib/controllers/product-detail.ts +71 -40
- package/templates/vue/src/lib/controllers/search.ts +33 -54
- package/templates/vue/src/lib/data/products.ts +36 -0
- package/templates/vue/src/lib/mappers/product.ts +3 -3
- package/templates/vue/src/lib/models/product.ts +12 -1
- package/templates/vue/src/lib/services/api-client.ts +7 -7
- package/templates/vue/src/main.ts +28 -43
- package/templates/vue/src/pages/About.vue +3 -5
- package/templates/vue/src/pages/Home.vue +5 -23
- package/templates/vue/src/pages/NotFound.vue +4 -13
- package/templates/vue/src/pages/ProductDetail.vue +5 -23
- package/templates/vue/src/pages/Search.vue +4 -18
- package/templates/vue/src/ssr.ts +5 -18
- package/templates/vue/src/styles.css +71 -0
- package/templates/vue/src/views.ts +12 -0
- package/templates/vue/tsconfig.json +14 -9
- package/templates/vue/vite.config.ts +9 -13
- package/templates/vue-minimal/README.md +39 -0
- package/templates/vue-minimal/index.html +1 -2
- package/templates/vue-minimal/package.json +9 -5
- package/templates/vue-minimal/src/App.vue +46 -43
- package/templates/vue-minimal/src/app-definition.ts +48 -0
- package/templates/vue-minimal/src/config.ts +2 -0
- package/templates/vue-minimal/src/lib/controllers/detail.ts +19 -18
- package/templates/vue-minimal/src/lib/controllers/error.ts +15 -0
- package/templates/vue-minimal/src/lib/controllers/home.ts +19 -24
- package/templates/vue-minimal/src/lib/controllers/notes.ts +7 -5
- package/templates/vue-minimal/src/lib/locale.ts +16 -0
- package/templates/vue-minimal/src/lib/models/page.ts +27 -0
- package/templates/vue-minimal/src/lib/navigation.ts +11 -0
- package/templates/vue-minimal/src/locales/en-US.json +5 -0
- package/templates/vue-minimal/src/locales/zh-Hans.json +5 -0
- package/templates/vue-minimal/src/main.ts +35 -70
- package/templates/vue-minimal/src/pages/Detail.vue +20 -0
- package/templates/vue-minimal/src/pages/Home.vue +34 -0
- package/templates/vue-minimal/src/pages/NotFound.vue +13 -0
- package/templates/vue-minimal/src/pages/Notes.vue +22 -0
- package/templates/vue-minimal/src/ssr.ts +7 -46
- package/templates/vue-minimal/src/styles.css +74 -0
- package/templates/vue-minimal/src/views.ts +6 -0
- package/templates/vue-minimal/src/vite-env.d.ts +3 -0
- package/templates/vue-minimal/tsconfig.json +14 -9
- package/templates/vue-minimal/vite.config.ts +9 -7
- package/templates/react/src/actions.ts +0 -14
- package/templates/react/src/bootstrap.ts +0 -36
- package/templates/react/src/components/PageRenderer.tsx +0 -41
- package/templates/react/src/main.tsx +0 -49
- package/templates/react/src/ssr.tsx +0 -23
- package/templates/react-minimal/src/bootstrap.ts +0 -39
- package/templates/react-minimal/src/main.tsx +0 -99
- package/templates/react-minimal/src/ssr.tsx +0 -51
- package/templates/react-minimal/src/views/DetailView.tsx +0 -28
- package/templates/react-minimal/src/views/HomeView.tsx +0 -27
- package/templates/react-minimal/src/views/NotesView.tsx +0 -11
- package/templates/svelte/src/actions.ts +0 -14
- package/templates/svelte/src/bootstrap.ts +0 -36
- package/templates/svelte/src/components/PageRenderer.svelte +0 -22
- package/templates/svelte/src/lib/framework-svelte.ts +0 -34
- package/templates/svelte/src/lib/render.ts +0 -15
- package/templates/svelte-minimal/src/bootstrap.ts +0 -6
- package/templates/svelte-minimal/src/lib/framework-svelte.ts +0 -34
- package/templates/svelte-minimal/src/lib/render.ts +0 -12
- package/templates/vue/src/actions.ts +0 -14
- package/templates/vue/src/bootstrap.ts +0 -42
- package/templates/vue/src/components/PageRenderer.vue +0 -26
- package/templates/vue-minimal/src/bootstrap.ts +0 -39
- package/templates/vue-minimal/src/views/DetailView.vue +0 -29
- package/templates/vue-minimal/src/views/HomeView.vue +0 -26
- package/templates/vue-minimal/src/views/NotesView.vue +0 -12
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
import { finesoftFrontViteConfig } from "@finesoft/front";
|
|
2
2
|
import react from "@vitejs/plugin-react";
|
|
3
|
-
import { defineConfig } from "vite-plus";
|
|
3
|
+
import { defineConfig, lazyPlugins } from "vite-plus";
|
|
4
|
+
|
|
5
|
+
const front = finesoftFrontViteConfig({
|
|
6
|
+
controllerTypes: { root: import.meta.dirname },
|
|
7
|
+
ssr: { entry: "src/ssr.ts" },
|
|
8
|
+
proxies: [{ prefix: "/api", target: "https://jsonplaceholder.typicode.com" }],
|
|
9
|
+
});
|
|
4
10
|
|
|
5
11
|
export default defineConfig({
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
finesoftFrontViteConfig({
|
|
9
|
-
ssr: { entry: "src/ssr.tsx" },
|
|
10
|
-
proxies: [
|
|
11
|
-
{
|
|
12
|
-
prefix: "/api",
|
|
13
|
-
target: "https://jsonplaceholder.typicode.com",
|
|
14
|
-
},
|
|
15
|
-
],
|
|
16
|
-
}),
|
|
17
|
-
],
|
|
12
|
+
lint: { options: { typeAware: true, typeCheck: true } },
|
|
13
|
+
plugins: lazyPlugins(() => [front, react()]),
|
|
18
14
|
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Finesoft Front minimal application
|
|
2
|
+
|
|
3
|
+
Feed, detail and Notes demonstrate tabs/stacks, retained drafts, a per-application profile and session restoration. All three native frameworks have the same example.
|
|
4
|
+
|
|
5
|
+
## Run
|
|
6
|
+
|
|
7
|
+
Node must satisfy `^22.18.0 || >=24.11.0`. Use Vite+ for tooling.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
vp install
|
|
11
|
+
vp run dev
|
|
12
|
+
vp run build
|
|
13
|
+
vp run preview
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Visit `/`, open a Feed item, enter a detail note, switch to Notes and type a draft. Switch tabs and reload to see both drafts restored. Use Back to remove the detail entry; opening it again starts a fresh draft. Your name is global to this application and is saved on blur.
|
|
17
|
+
|
|
18
|
+
## Server controller example
|
|
19
|
+
|
|
20
|
+
`src/lib/controllers/detail.ts` extends `BaseServerController` from `@finesoft/front`. Open `/item/2` and inspect the response header `x-demo-request-method: GET`. Return to Feed and open another item: browser navigation loads the controller remotely using POST, whose response contains `x-demo-request-method: POST`. This demonstrates reading `context.request` and explicitly writing `context.responseHeaders` without adding an authentication policy.
|
|
21
|
+
|
|
22
|
+
SSR executes the controller directly, and hydration reuses the result. Its implementation stays on the server; `markPublic` selects the data sent to the native view. Home and Notes remain ordinary `BaseController` examples. Route types come from `src/app-definition.ts`; the Vite plugin maintains the generated `Input` type with the appropriate server context.
|
|
23
|
+
|
|
24
|
+
Keep the configured SSR entry and deploy to a request host such as Node or Worker. Serving only `dist/client` cannot execute this controller. Login state and any credential forwarding remain application-owned.
|
|
25
|
+
|
|
26
|
+
## Edit
|
|
27
|
+
|
|
28
|
+
- `src/config.ts`: application identity and persistence namespace.
|
|
29
|
+
- `src/app-definition.ts`: pages, routes, guards and navigation.
|
|
30
|
+
- `src/lib/controllers/` and `src/lib/models/`: page loading and typed public data.
|
|
31
|
+
- `src/pages/`, `src/App.*`, `src/styles.css`: native UI and presentation.
|
|
32
|
+
- `src/views.ts`: the view registry shared by browser and server.
|
|
33
|
+
- `src/main.ts` and `src/ssr.ts`: standard browser and SSR startup.
|
|
34
|
+
|
|
35
|
+
`src/instance.ts` creates one profile store/provider per mount. For an embedded application, call `mountApplication(target, uniquePersistenceKey, "memory", "/notes")`. The `data-restore-root` fields belong to their page entries.
|
|
36
|
+
|
|
37
|
+
`src/locales/` contains English and Chinese JSON messages. Set `frameworkConfig.locale` in `src/app-definition.ts` to `en-US` or `zh-Hans` (default), then restart/rebuild. Navigation labels and sample items remain English.
|
|
38
|
+
|
|
39
|
+
All framework APIs import from `@finesoft/front`. Select the native component with `Outlet("react")`, `Outlet("vue")`, or `Outlet("svelte")`, and pass the same renderer to `useSnapshot`. The plugin maintains `.finesoft/front.d.ts` and the exact TypeScript import mapping; install only the chosen renderer. For standalone TypeScript checks, first run `vp exec finesoft-types`. The project is standalone and requires no sibling templates or private runtime packages.
|
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
<!--ssr-head-->
|
|
7
7
|
</head>
|
|
8
8
|
<body>
|
|
9
|
-
<div id="app"><!--ssr-body--></div>
|
|
10
|
-
|
|
11
|
-
<script type="module" src="/src/main.tsx"></script>
|
|
9
|
+
<div id="app"><!--ssr-body--><!--ssr-data--></div>
|
|
10
|
+
<script type="module" src="/src/main.ts"></script>
|
|
12
11
|
</body>
|
|
13
12
|
</html>
|
|
@@ -8,15 +8,19 @@
|
|
|
8
8
|
"preview": "vp preview"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@finesoft/front": "^0.5.
|
|
12
|
-
"react": "^19.
|
|
13
|
-
"react-dom": "^19.
|
|
11
|
+
"@finesoft/front": "^0.5.3",
|
|
12
|
+
"react": "^19.2.8",
|
|
13
|
+
"react-dom": "^19.2.8"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
|
-
"@
|
|
17
|
-
"@types/
|
|
18
|
-
"@
|
|
19
|
-
"
|
|
20
|
-
"
|
|
16
|
+
"@hono/node-server": "^2.1.0",
|
|
17
|
+
"@types/node": "^22.20.1",
|
|
18
|
+
"@types/react": "^19.2.18",
|
|
19
|
+
"@types/react-dom": "^19.2.4",
|
|
20
|
+
"@vitejs/plugin-react": "^6.0.5",
|
|
21
|
+
"hono": "^4.13.1",
|
|
22
|
+
"typescript": "^6.0.3",
|
|
23
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.8",
|
|
24
|
+
"vite-plus": "0.2.8"
|
|
21
25
|
}
|
|
22
26
|
}
|
|
@@ -1,108 +1,63 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
/** name 全局切片的极小外部 store 接口(main 创建并注入;App 只读它 + 订阅)。 */
|
|
11
|
-
export interface NameStore {
|
|
12
|
-
get(): string;
|
|
13
|
-
set(value: string): void;
|
|
14
|
-
subscribe(listener: () => void): () => void;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface AppProps {
|
|
18
|
-
/** 首屏快照:SSR 与客户端 hydrate 时一致(URL 推导,tree 相同)→ 无水合失配。 */
|
|
19
|
-
initialSnapshot: NavigationSnapshot | null;
|
|
20
|
-
/** 导航 handle(客户端有;SSR 无)。提供后订阅 snapshot 变更驱动重渲。 */
|
|
21
|
-
nav?: NavigationHandle;
|
|
22
|
-
/** 框架统一句柄(selectTab / pop / save)。SSR 无(渲染不依赖,仅事件处理用)。 */
|
|
23
|
-
controller?: AppHandle;
|
|
24
|
-
/** name 切片 store(客户端注入;SSR 无 → name 恒 "")。 */
|
|
25
|
-
nameStore?: NameStore;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const TAB_LABELS: Record<string, string> = { home: "Feed", notes: "Notes" };
|
|
29
|
-
|
|
30
|
-
export default function App({ initialSnapshot, nav, controller, nameStore }: AppProps) {
|
|
31
|
-
// 首渲用 initialSnapshot(= SSR 快照,tree 一致)→ 水合 DOM 一致;effect 仅客户端跑,订阅后续提交。
|
|
32
|
-
const [snapshot, setSnapshot] = useState(initialSnapshot);
|
|
33
|
-
useEffect(() => {
|
|
34
|
-
if (!nav) return;
|
|
35
|
-
setSnapshot(nav.getSnapshot()); // 追平 render→effect 间隙可能的提交
|
|
36
|
-
return nav.subscribe(setSnapshot);
|
|
37
|
-
}, [nav]);
|
|
38
|
-
|
|
39
|
-
// name 恒 "" 首渲(SSR + 水合一致);会话恢复在水合后经 store 落,订阅驱动重渲。
|
|
1
|
+
import { Outlet as selectOutlet, useSnapshot, type WebAppView } from "@finesoft/front";
|
|
2
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { TAB_LABELS } from "./lib/navigation";
|
|
4
|
+
import { views } from "./views";
|
|
5
|
+
const Outlet = selectOutlet("react");
|
|
6
|
+
export default function App({ app }: { readonly app: WebAppView }) {
|
|
7
|
+
const snapshot = useSnapshot("react", app);
|
|
8
|
+
// Start empty on both server and client; restore the profile after hydration.
|
|
40
9
|
const [name, setName] = useState("");
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
10
|
+
const current = useRef(name);
|
|
11
|
+
current.current = name;
|
|
12
|
+
useLayoutEffect(() => {
|
|
13
|
+
return app.session?.register({
|
|
14
|
+
key: "profile",
|
|
15
|
+
version: 1,
|
|
16
|
+
decode: (data) => {
|
|
17
|
+
if (
|
|
18
|
+
!data ||
|
|
19
|
+
typeof data !== "object" ||
|
|
20
|
+
typeof (data as { name?: unknown }).name !== "string"
|
|
21
|
+
)
|
|
22
|
+
throw Error("invalid-profile-state");
|
|
23
|
+
return data as { name: string };
|
|
24
|
+
},
|
|
25
|
+
capture: () => ({ name: current.current }),
|
|
26
|
+
restore: (data) => setName(data.name),
|
|
27
|
+
});
|
|
28
|
+
}, [app]);
|
|
51
29
|
|
|
52
30
|
return (
|
|
53
|
-
<div
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
margin: "0 auto",
|
|
57
|
-
padding: "1rem",
|
|
58
|
-
fontFamily: "system-ui",
|
|
59
|
-
}}
|
|
60
|
-
>
|
|
61
|
-
{/* 全局切片:名字 */}
|
|
62
|
-
<header
|
|
63
|
-
style={{
|
|
64
|
-
display: "flex",
|
|
65
|
-
gap: "0.5rem",
|
|
66
|
-
alignItems: "center",
|
|
67
|
-
marginBottom: "1rem",
|
|
68
|
-
}}
|
|
69
|
-
>
|
|
70
|
-
<label style={{ flex: 1 }}>
|
|
31
|
+
<div className="app-chrome">
|
|
32
|
+
<header className="profile">
|
|
33
|
+
<label>
|
|
71
34
|
Your name (global):
|
|
72
35
|
<input
|
|
73
36
|
value={name}
|
|
74
37
|
placeholder="anon"
|
|
75
|
-
onChange={(
|
|
76
|
-
|
|
77
|
-
nameStore?.set(e.target.value);
|
|
78
|
-
}}
|
|
79
|
-
onBlur={() => void controller?.save()}
|
|
38
|
+
onChange={(event) => setName(event.target.value)}
|
|
39
|
+
onBlur={() => void app.session?.save()}
|
|
80
40
|
/>
|
|
81
41
|
</label>
|
|
82
42
|
{name && <span>👋 {name}</span>}
|
|
83
43
|
</header>
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<nav style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}>
|
|
88
|
-
{tabBar.order.map((key) => (
|
|
44
|
+
{snapshot.navigation.tabs && (
|
|
45
|
+
<nav className="tabs" aria-label="Pages">
|
|
46
|
+
{snapshot.navigation.tabs.order.map((key) => (
|
|
89
47
|
<button
|
|
90
48
|
key={key}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
onClick={() => void controller?.selectTab(key)}
|
|
49
|
+
aria-current={key === snapshot.navigation.tabs?.active}
|
|
50
|
+
onClick={() => void app.perform({ kind: "selectTab", key: key })}
|
|
94
51
|
>
|
|
95
52
|
{TAB_LABELS[key] ?? key}
|
|
96
53
|
</button>
|
|
97
54
|
))}
|
|
98
55
|
</nav>
|
|
99
56
|
)}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
<button style={{ marginBottom: "0.5rem" }} onClick={() => void controller?.pop()}>
|
|
103
|
-
← Back
|
|
104
|
-
</button>
|
|
57
|
+
{snapshot.navigation.canGoBack && (
|
|
58
|
+
<button onClick={() => void app.perform({ kind: "pop" })}>← Back</button>
|
|
105
59
|
)}
|
|
60
|
+
<Outlet app={app} views={views} />
|
|
106
61
|
</div>
|
|
107
62
|
);
|
|
108
63
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { loadMessages } from "virtual:finesoft-front/i18n-loader";
|
|
2
|
+
import { appId } from "./config";
|
|
3
|
+
import { getErrorPage } from "./lib/controllers/error";
|
|
4
|
+
import {
|
|
5
|
+
definePage,
|
|
6
|
+
defineWebApp,
|
|
7
|
+
stack,
|
|
8
|
+
tabs,
|
|
9
|
+
type LeafNode,
|
|
10
|
+
type RouteMatch,
|
|
11
|
+
} from "@finesoft/front";
|
|
12
|
+
import { DetailController } from "./lib/controllers/detail";
|
|
13
|
+
import { HomeController } from "./lib/controllers/home";
|
|
14
|
+
import { NotesController } from "./lib/controllers/notes";
|
|
15
|
+
export const homePage = definePage({
|
|
16
|
+
id: "home",
|
|
17
|
+
create: () => new HomeController(),
|
|
18
|
+
routes: ["/"],
|
|
19
|
+
});
|
|
20
|
+
export const detailPage = definePage({
|
|
21
|
+
id: "detail",
|
|
22
|
+
create: () => new DetailController(),
|
|
23
|
+
routes: ["/item/:id"],
|
|
24
|
+
});
|
|
25
|
+
export const notesPage = definePage({
|
|
26
|
+
id: "notes",
|
|
27
|
+
create: () => new NotesController(),
|
|
28
|
+
routes: ["/notes"],
|
|
29
|
+
});
|
|
30
|
+
export function createNavigation({ match, target }: { match?: RouteMatch; target?: LeafNode }) {
|
|
31
|
+
if (!match || !target) return undefined;
|
|
32
|
+
const home =
|
|
33
|
+
match.intent.id === "detail" ? stack([homePage.leaf(), target]) : stack(homePage.leaf());
|
|
34
|
+
return tabs({
|
|
35
|
+
active: match.intent.id === "notes" ? "notes" : "home",
|
|
36
|
+
branches: { home, notes: stack(match.intent.id === "notes" ? target : notesPage.leaf()) },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const app = defineWebApp({
|
|
41
|
+
id: appId,
|
|
42
|
+
loadMessages,
|
|
43
|
+
configuration: { locale: "zh-Hans" },
|
|
44
|
+
pages: [homePage, detailPage, notesPage],
|
|
45
|
+
getErrorPage,
|
|
46
|
+
|
|
47
|
+
navigation: createNavigation,
|
|
48
|
+
});
|
|
@@ -1,22 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { BaseServerController } from "@finesoft/front";
|
|
2
|
+
import { markPublic } from "@finesoft/front";
|
|
3
|
+
import type { DetailPage } from "../models/page";
|
|
4
|
+
import type { DetailControllerInput as Input } from "../../../.finesoft/controller-types";
|
|
2
5
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
readonly itemId: string;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export class DetailController extends BaseController<{ id?: string }, DetailPage> {
|
|
9
|
-
readonly intentId = "detail";
|
|
10
|
-
|
|
11
|
-
execute(params: { id?: string }): DetailPage {
|
|
6
|
+
export class DetailController extends BaseServerController<Input, DetailPage> {
|
|
7
|
+
execute({ params, context }: Input): DetailPage {
|
|
12
8
|
const id = params.id ?? "?";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
// GET during SSR, POST when browser navigation loads this controller remotely.
|
|
10
|
+
context.responseHeaders.set("x-demo-request-method", context.request.method);
|
|
11
|
+
return markPublic(
|
|
12
|
+
{
|
|
13
|
+
id: `detail-${id}`,
|
|
14
|
+
pageType: "detail",
|
|
15
|
+
url: `/item/${id}`,
|
|
16
|
+
title: `Item ${id}`,
|
|
17
|
+
description: "A pushed detail screen. Its scoped note is lost once you pop it.",
|
|
18
|
+
itemId: id,
|
|
19
|
+
},
|
|
20
|
+
["itemId"],
|
|
21
|
+
);
|
|
21
22
|
}
|
|
22
23
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { markPublic } from "@finesoft/front";
|
|
2
|
+
import type { ErrorPage } from "../models/page";
|
|
3
|
+
|
|
4
|
+
export function getErrorPage(status: number, message: string): ErrorPage {
|
|
5
|
+
return markPublic(
|
|
6
|
+
{
|
|
7
|
+
id: "error",
|
|
8
|
+
pageType: "error",
|
|
9
|
+
title: `Error ${status}`,
|
|
10
|
+
description: message,
|
|
11
|
+
status,
|
|
12
|
+
},
|
|
13
|
+
["status"],
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
import { BaseController, type
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
export interface FeedItem {
|
|
5
|
-
readonly id: string;
|
|
6
|
-
readonly title: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/** Home(feed)页面:携带可 push 进 detail 的列表项。 */
|
|
10
|
-
export interface FeedPage extends BasePage {
|
|
11
|
-
readonly items: readonly FeedItem[];
|
|
12
|
-
}
|
|
1
|
+
import { BaseController, type ControllerInput } from "@finesoft/front";
|
|
2
|
+
import { markPublic } from "@finesoft/front";
|
|
3
|
+
import type { FeedItem, HomePage } from "../models/page";
|
|
13
4
|
|
|
14
5
|
const ITEMS: readonly FeedItem[] = [
|
|
15
6
|
{ id: "1", title: "Structured navigation" },
|
|
@@ -17,17 +8,21 @@ const ITEMS: readonly FeedItem[] = [
|
|
|
17
8
|
{ id: "3", title: "Navigation-scoped state" },
|
|
18
9
|
];
|
|
19
10
|
|
|
20
|
-
export class HomeController extends BaseController<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
11
|
+
export class HomeController extends BaseController<
|
|
12
|
+
ControllerInput<Record<string, string>>,
|
|
13
|
+
HomePage
|
|
14
|
+
> {
|
|
15
|
+
execute(): HomePage {
|
|
16
|
+
return markPublic(
|
|
17
|
+
{
|
|
18
|
+
id: "home",
|
|
19
|
+
pageType: "home",
|
|
20
|
+
url: "/",
|
|
21
|
+
title: "Feed",
|
|
22
|
+
description: "Tap an item to push a detail screen.",
|
|
23
|
+
items: ITEMS,
|
|
24
|
+
},
|
|
25
|
+
{ items: { id: true, title: true } },
|
|
26
|
+
);
|
|
32
27
|
}
|
|
33
28
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { BaseController, type
|
|
1
|
+
import { BaseController, type ControllerInput } from "@finesoft/front";
|
|
2
|
+
import type { NotesPage } from "../models/page";
|
|
2
3
|
|
|
3
4
|
/** Notes 页面:第二个 tab,演示切 tab 保活该分支的作用域状态。 */
|
|
4
|
-
export class NotesController extends BaseController<
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
export class NotesController extends BaseController<
|
|
6
|
+
ControllerInput<Record<string, string>>,
|
|
7
|
+
NotesPage
|
|
8
|
+
> {
|
|
9
|
+
execute(): NotesPage {
|
|
8
10
|
return {
|
|
9
11
|
id: "notes",
|
|
10
12
|
pageType: "notes",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { WebAppView } from "@finesoft/front";
|
|
2
|
+
|
|
3
|
+
/** Both SSR and hydrated views read the translator from their own application. */
|
|
4
|
+
export function getHomeLocale(app?: WebAppView) {
|
|
5
|
+
const translator = app?.translator;
|
|
6
|
+
return {
|
|
7
|
+
lang: app?.locale?.lang ?? "unknown",
|
|
8
|
+
label: translator?.t("home.localeLabel") ?? "Current locale",
|
|
9
|
+
badge:
|
|
10
|
+
translator?.t("home.runtimeBadge") ??
|
|
11
|
+
"Hydration kept the translator alive on the client.",
|
|
12
|
+
hint:
|
|
13
|
+
translator?.t("home.switchHint") ??
|
|
14
|
+
"Change frameworkConfig.locale in src/app-definition.ts to load another JSON file.",
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { BasePage } from "@finesoft/front";
|
|
2
|
+
|
|
3
|
+
export interface FeedItem {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly title: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface HomePage extends BasePage {
|
|
9
|
+
readonly pageType: "home";
|
|
10
|
+
readonly items: readonly FeedItem[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface DetailPage extends BasePage {
|
|
14
|
+
readonly pageType: "detail";
|
|
15
|
+
readonly itemId: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface NotesPage extends BasePage {
|
|
19
|
+
readonly pageType: "notes";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ErrorPage extends BasePage {
|
|
23
|
+
readonly pageType: "error";
|
|
24
|
+
readonly status: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type AppPage = HomePage | DetailPage | NotesPage | ErrorPage;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { isStackNode, isTabsNode, type NavigationSnapshot } from "@finesoft/front";
|
|
2
|
+
|
|
3
|
+
export const TAB_LABELS: Readonly<Record<string, string>> = { home: "Feed", notes: "Notes" };
|
|
4
|
+
|
|
5
|
+
/** The renderer supplies each committed snapshot; chrome needs no second subscription. */
|
|
6
|
+
export function getNavigationChrome(snapshot?: NavigationSnapshot) {
|
|
7
|
+
const tree = snapshot?.tree;
|
|
8
|
+
const tabs = tree && isTabsNode(tree) ? { order: tree.order, active: tree.active } : null;
|
|
9
|
+
const branch = tree && isTabsNode(tree) ? tree.branches[tree.active] : null;
|
|
10
|
+
return { tabs, canGoBack: !!branch && isStackNode(branch) && branch.entries.length > 1 };
|
|
11
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createBrowserApp } from "@finesoft/front";
|
|
2
|
+
import { createRoot, hydrateRoot } from "react-dom/client";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { app } from "./app-definition";
|
|
5
|
+
import { appId } from "./config";
|
|
6
|
+
import App from "./App";
|
|
7
|
+
import "./styles.css";
|
|
8
|
+
|
|
9
|
+
export async function mountApplication(
|
|
10
|
+
target: HTMLElement,
|
|
11
|
+
persistenceKey = appId,
|
|
12
|
+
history: "browser" | "memory" = "browser",
|
|
13
|
+
url?: string,
|
|
14
|
+
) {
|
|
15
|
+
const handle = await createBrowserApp({
|
|
16
|
+
definition: app,
|
|
17
|
+
target,
|
|
18
|
+
history,
|
|
19
|
+
url,
|
|
20
|
+
persistenceKey,
|
|
21
|
+
domRestore: true,
|
|
22
|
+
session: {},
|
|
23
|
+
});
|
|
24
|
+
const root = handle.shouldHydrate
|
|
25
|
+
? hydrateRoot(target, createElement(App, { app: handle }))
|
|
26
|
+
: createRoot(target);
|
|
27
|
+
if (!handle.shouldHydrate) root.render(createElement(App, { app: handle }));
|
|
28
|
+
const originalDispose = handle.dispose.bind(handle);
|
|
29
|
+
let disposal: Promise<void> | undefined;
|
|
30
|
+
Object.assign(handle, {
|
|
31
|
+
dispose: () => (disposal ??= originalDispose().finally(() => root.unmount())),
|
|
32
|
+
});
|
|
33
|
+
await handle.ready;
|
|
34
|
+
return handle;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const started = mountApplication(document.getElementById("app")!);
|
|
38
|
+
|
|
39
|
+
if (import.meta.hot) {
|
|
40
|
+
import.meta.hot.dispose(async () => {
|
|
41
|
+
await (await started).dispose();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DetailPage } from "../lib/models/page";
|
|
2
|
+
|
|
3
|
+
export default function Detail({ page }: { page: DetailPage }) {
|
|
4
|
+
return (
|
|
5
|
+
<section className="page">
|
|
6
|
+
<h1>{page.title}</h1>
|
|
7
|
+
<p>{page.description}</p>
|
|
8
|
+
{/* The entry owns this draft; data-restore-root also restores it after reload. */}
|
|
9
|
+
<div className="draft" data-restore-root>
|
|
10
|
+
<label>
|
|
11
|
+
Detail note
|
|
12
|
+
<input
|
|
13
|
+
name="note"
|
|
14
|
+
placeholder="Kept while this screen is alive; lost when popped"
|
|
15
|
+
/>
|
|
16
|
+
</label>
|
|
17
|
+
</div>
|
|
18
|
+
</section>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ViewProps } from "@finesoft/front";
|
|
2
|
+
import type { HomePage } from "../lib/models/page";
|
|
3
|
+
import { getHomeLocale } from "../lib/locale";
|
|
4
|
+
|
|
5
|
+
export default function Home({
|
|
6
|
+
page,
|
|
7
|
+
app,
|
|
8
|
+
}: {
|
|
9
|
+
page: HomePage;
|
|
10
|
+
} & Pick<ViewProps, "app">) {
|
|
11
|
+
const locale = getHomeLocale(app);
|
|
12
|
+
return (
|
|
13
|
+
<section className="page">
|
|
14
|
+
<h1>{page.title}</h1>
|
|
15
|
+
<p>{page.description}</p>
|
|
16
|
+
<ul className="feed">
|
|
17
|
+
{page.items.map((item) => (
|
|
18
|
+
<li key={item.id}>
|
|
19
|
+
<button
|
|
20
|
+
onClick={() =>
|
|
21
|
+
void app.perform({ kind: "flow", url: `/item/${item.id}` })
|
|
22
|
+
}
|
|
23
|
+
>
|
|
24
|
+
{item.title}
|
|
25
|
+
</button>
|
|
26
|
+
</li>
|
|
27
|
+
))}
|
|
28
|
+
</ul>
|
|
29
|
+
<section className="locale-info" aria-label="Locale">
|
|
30
|
+
<p>
|
|
31
|
+
<strong>{locale.label}:</strong> {locale.lang}
|
|
32
|
+
</p>
|
|
33
|
+
<p>{locale.badge}</p>
|
|
34
|
+
<p>{locale.hint}</p>
|
|
35
|
+
</section>
|
|
36
|
+
</section>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ErrorPage } from "../lib/models/page";
|
|
2
|
+
|
|
3
|
+
export default function NotFound({ page }: { page: ErrorPage }) {
|
|
4
|
+
return (
|
|
5
|
+
<section className="page">
|
|
6
|
+
<h1>{page.title}</h1>
|
|
7
|
+
<p>{page.description}</p>
|
|
8
|
+
<a href="/">← Go Home</a>
|
|
9
|
+
</section>
|
|
10
|
+
);
|
|
11
|
+
}
|