easy-starter 1.0.0 → 1.1.0
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/README.md +61 -0
- package/bin/index.js +4 -0
- package/package.json +1 -1
- package/template/README.md +61 -0
- package/template/env +2 -1
- package/template/gitignore +9 -0
- package/template/package.json +1 -5
- package/template/public/images/icon.png +0 -0
- package/template/server/index.tsx +12 -13
- package/template/src/App.tsx +5 -12
- package/template/src/Router.tsx +5 -4
- package/template/src/index.tsx +0 -6
- package/template/src/pages/Index.tsx +21 -25
- package/template/src/pages/NotFound.tsx +3 -2
- package/template/src/pages/admin/Admin.tsx +3 -3
- package/template/src/services/api.ts +26 -3
- package/template/src/services/common.ts +12 -3
- package/template/src/site.webmanifest +23 -12
- package/template/src/styles.scss +69 -60
package/README.md
CHANGED
|
@@ -35,6 +35,67 @@ The generated boilerplate comes pre-configured with a stack designed for develop
|
|
|
35
35
|
* **Analytics:** [`easy-analytics`](https://www.npmjs.com/package/easy-analytics) - Privacy-friendly, local analytics tracking.
|
|
36
36
|
* **Deployment:** Integrated Github Actions workflow and PM2 deployment script for seamless, automated deployment straight to your own VPS.
|
|
37
37
|
|
|
38
|
+
## Core Concepts & Examples
|
|
39
|
+
|
|
40
|
+
> **Note:** `easy-starter` gives you a solid foundation, but you are not forced into any of these patterns. If you prefer a different routing library (like `react-router`), another data fetching solution (like `react-query` or `swr`), or standard REST endpoints, you are completely free to swap them out and use your own libraries!
|
|
41
|
+
|
|
42
|
+
### 1. Defining your API
|
|
43
|
+
|
|
44
|
+
Your API contract is defined in a single file (`types.d.ts`). This ensures your frontend and backend are always perfectly in sync.
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
// types.d.ts
|
|
48
|
+
import { APIDefinition, Endpoint } from "typed-client-server-api";
|
|
49
|
+
|
|
50
|
+
export type API = APIDefinition<{
|
|
51
|
+
// Define an endpoint: parameters -> response
|
|
52
|
+
getItems: Endpoint<{}, Item[]>;
|
|
53
|
+
addItem: Endpoint<{ name: string; value: number }, string>;
|
|
54
|
+
}>;
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
On the server, you implement these endpoints:
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
// server/index.tsx
|
|
61
|
+
import { initApi } from "typed-client-server-api";
|
|
62
|
+
import { API } from "../types";
|
|
63
|
+
|
|
64
|
+
initApi<API>(app, {
|
|
65
|
+
getItems: async () => {
|
|
66
|
+
return db.select("item");
|
|
67
|
+
},
|
|
68
|
+
addItem: async ({ name, value }) => {
|
|
69
|
+
return db.insert("item", { name, value });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 2. Fetching Data with SSR (`useSSRApi`)
|
|
75
|
+
|
|
76
|
+
To fetch data on the server during SSR and seamlessly hydrate it on the client without loading spinners, use `useSSRApi` in your React components.
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
// src/pages/Index.tsx
|
|
80
|
+
import React from "react";
|
|
81
|
+
import { useSSRApi } from "../services/api";
|
|
82
|
+
|
|
83
|
+
export default function Index() {
|
|
84
|
+
// This will run on the server, fetch the data, embed it in the HTML,
|
|
85
|
+
// and hydrate perfectly on the client. Fully type-safe!
|
|
86
|
+
const [items, error, isLoading, reload] = useSSRApi.getItems({});
|
|
87
|
+
|
|
88
|
+
if (isLoading) return <p>Loading...</p>;
|
|
89
|
+
if (error) return <p>Error: {error.message}</p>;
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<ul>
|
|
93
|
+
{items?.map(item => <li key={item._id}>{item.name}</li>)}
|
|
94
|
+
</ul>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
38
99
|
## Getting Started
|
|
39
100
|
|
|
40
101
|
Bootstrapping a new project is as simple as running one command:
|
package/bin/index.js
CHANGED
|
@@ -71,6 +71,9 @@ async function main() {
|
|
|
71
71
|
if (fs.existsSync(path.join(targetPath, 'env'))) {
|
|
72
72
|
fs.renameSync(path.join(targetPath, 'env'), path.join(targetPath, '.env'));
|
|
73
73
|
}
|
|
74
|
+
if (fs.existsSync(path.join(targetPath, 'gitignore'))) {
|
|
75
|
+
fs.renameSync(path.join(targetPath, 'gitignore'), path.join(targetPath, '.gitignore'));
|
|
76
|
+
}
|
|
74
77
|
|
|
75
78
|
// Update site.webmanifest
|
|
76
79
|
const manifestPath = path.join(targetPath, 'src', 'site.webmanifest');
|
|
@@ -98,6 +101,7 @@ async function main() {
|
|
|
98
101
|
|
|
99
102
|
stripAdminBlocks(path.join(targetPath, 'src', 'Router.tsx'));
|
|
100
103
|
stripAdminBlocks(path.join(targetPath, 'server', 'index.tsx'));
|
|
104
|
+
stripAdminBlocks(path.join(targetPath, 'src', 'styles.scss'));
|
|
101
105
|
}
|
|
102
106
|
|
|
103
107
|
// Update package.json from template
|
package/package.json
CHANGED
package/template/README.md
CHANGED
|
@@ -22,6 +22,67 @@ npm run dev
|
|
|
22
22
|
|
|
23
23
|
This will concurrently start your Express server and the Parcel bundler. Open [http://localhost:1234](http://localhost:1234) in your browser. The page will automatically reload when you make changes.
|
|
24
24
|
|
|
25
|
+
## Core Concepts & Examples
|
|
26
|
+
|
|
27
|
+
> **Note:** `easy-starter` gives you a solid foundation, but you are not forced into any of these patterns. If you prefer a different routing library (like `react-router`), another data fetching solution (like `react-query` or `swr`), or standard REST endpoints, you are completely free to swap them out and use your own libraries!
|
|
28
|
+
|
|
29
|
+
### 1. Defining your API
|
|
30
|
+
|
|
31
|
+
Your API contract is defined in a single file (`types.d.ts`). This ensures your frontend and backend are always perfectly in sync.
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
// types.d.ts
|
|
35
|
+
import { APIDefinition, Endpoint } from "typed-client-server-api";
|
|
36
|
+
|
|
37
|
+
export type API = APIDefinition<{
|
|
38
|
+
// Define an endpoint: parameters -> response
|
|
39
|
+
getItems: Endpoint<{}, Item[]>;
|
|
40
|
+
addItem: Endpoint<{ name: string; value: number }, string>;
|
|
41
|
+
}>;
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
On the server, you implement these endpoints:
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
// server/index.tsx
|
|
48
|
+
import { initApi } from "typed-client-server-api";
|
|
49
|
+
import { API } from "../types";
|
|
50
|
+
|
|
51
|
+
initApi<API>(app, {
|
|
52
|
+
getItems: async () => {
|
|
53
|
+
return db.select("item");
|
|
54
|
+
},
|
|
55
|
+
addItem: async ({ name, value }) => {
|
|
56
|
+
return db.insert("item", { name, value });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 2. Fetching Data with SSR (`useSSRApi`)
|
|
62
|
+
|
|
63
|
+
To fetch data on the server during SSR and seamlessly hydrate it on the client without loading spinners, use `useSSRApi` in your React components.
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
// src/pages/Index.tsx
|
|
67
|
+
import React from "react";
|
|
68
|
+
import { useSSRApi } from "../services/api";
|
|
69
|
+
|
|
70
|
+
export default function Index() {
|
|
71
|
+
// This will run on the server, fetch the data, embed it in the HTML,
|
|
72
|
+
// and hydrate perfectly on the client. Fully type-safe!
|
|
73
|
+
const [items, error, isLoading, reload] = useSSRApi.getItems({});
|
|
74
|
+
|
|
75
|
+
if (isLoading) return <p>Loading...</p>;
|
|
76
|
+
if (error) return <p>Error: {error.message}</p>;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<ul>
|
|
80
|
+
{items?.map(item => <li key={item._id}>{item.name}</li>)}
|
|
81
|
+
</ul>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
25
86
|
## Scripts
|
|
26
87
|
|
|
27
88
|
* `npm run dev` - Starts the development server.
|
package/template/env
CHANGED
package/template/package.json
CHANGED
|
@@ -19,12 +19,9 @@
|
|
|
19
19
|
"react": "^19.2.6",
|
|
20
20
|
"react-dom": "^19.2.6",
|
|
21
21
|
"ssr-hook": "^0.2.0",
|
|
22
|
-
"typed-client-server-api": "^1.0
|
|
22
|
+
"typed-client-server-api": "^1.1.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@parcel/packager-raw-url": "^2.16.4",
|
|
26
|
-
"@parcel/transformer-sass": "^2.16.4",
|
|
27
|
-
"@parcel/transformer-webmanifest": "^2.16.4",
|
|
28
25
|
"@types/cors": "^2.8.19",
|
|
29
26
|
"@types/express": "^5.0.6",
|
|
30
27
|
"@types/node": "^24.0.0",
|
|
@@ -32,7 +29,6 @@
|
|
|
32
29
|
"@types/react-dom": "^19.2.3",
|
|
33
30
|
"concurrently": "^9.2.1",
|
|
34
31
|
"parcel": "^2.16.4",
|
|
35
|
-
"sharp": "^0.33.5",
|
|
36
32
|
"tsx": "^4.21.0",
|
|
37
33
|
"typescript": "^6.0.3"
|
|
38
34
|
}
|
|
Binary file
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { resolve } from "path";
|
|
2
2
|
import { readFile } from "fs/promises";
|
|
3
|
-
|
|
3
|
+
|
|
4
4
|
import cors from "cors";
|
|
5
|
+
import express from "express";
|
|
5
6
|
|
|
6
7
|
// easy-db-node provides a simple local database storing data in JSON files.
|
|
7
8
|
// It's ideal for small to medium projects where setting up a full SQL/NoSQL DB is overkill.
|
|
@@ -19,12 +20,13 @@ import { setAPIBackend } from "typed-client-server-api";
|
|
|
19
20
|
import { postEasyAnalytics, getEasyAnalytics } from "easy-analytics/server";
|
|
20
21
|
|
|
21
22
|
import App from "../src/App";
|
|
23
|
+
|
|
22
24
|
import { API, Database } from "../types";
|
|
23
25
|
|
|
24
26
|
const PORT = process.env.SERVER_PORT || 1111;
|
|
25
27
|
const LOCALHOST = `http://localhost:${PORT}`;
|
|
26
28
|
|
|
27
|
-
// The ORIGIN is used by ssr-hook
|
|
29
|
+
// The ORIGIN is used for SEO and by ssr-hook to know where the requests are coming from.
|
|
28
30
|
const ORIGIN = process.env.NODE_ENV === "development"
|
|
29
31
|
? LOCALHOST
|
|
30
32
|
: "https://your-production-url.com"; // TODO: Update to your domain
|
|
@@ -47,12 +49,9 @@ if (process.env.NODE_ENV === "development") {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
// ---------------------------- SSR for index ----------------------------------
|
|
50
|
-
// This route handles the initial load of our application and renders the React tree to HTML.
|
|
51
52
|
app.get(["/", "/index.html"], async (req, res) => {
|
|
52
53
|
try {
|
|
53
|
-
// We read the HTML template generated by our bundler (Parcel)
|
|
54
54
|
const indexHtml = await readFile(INDEX_HTML_PATH, "utf-8");
|
|
55
|
-
// We render our App component into that HTML template.
|
|
56
55
|
const html = await renderToHTML(ORIGIN, req.url, indexHtml, <App />);
|
|
57
56
|
res.set("Content-Type", "text/html");
|
|
58
57
|
res.send(html);
|
|
@@ -67,9 +66,10 @@ app.get(["/", "/index.html"], async (req, res) => {
|
|
|
67
66
|
app.use(express.static("dist"));
|
|
68
67
|
// Serve any public assets (like images, fonts)
|
|
69
68
|
app.use(express.static("public"));
|
|
70
|
-
// Serve the database files (e.g. uploaded images or
|
|
69
|
+
// Serve the database files (e.g. uploaded images or PDF files from easy-db-node)
|
|
71
70
|
app.use("/files", express.static("easy-db-files"));
|
|
72
71
|
|
|
72
|
+
|
|
73
73
|
// --------------------------------- SEO ---------------------------------------
|
|
74
74
|
// Basic configuration for search engine crawlers.
|
|
75
75
|
app.get("/robots.txt", (_, res) => {
|
|
@@ -83,7 +83,7 @@ app.get("/sitemap.xml", async (_, res) => {
|
|
|
83
83
|
// TODO: add all pages
|
|
84
84
|
const sitemap = [{
|
|
85
85
|
url: ORIGIN + "/",
|
|
86
|
-
lastModified: "
|
|
86
|
+
lastModified: "2026-01-01",
|
|
87
87
|
changeFrequency: "weekly",
|
|
88
88
|
priority: 1,
|
|
89
89
|
}];
|
|
@@ -98,10 +98,11 @@ app.get("/sitemap.xml", async (_, res) => {
|
|
|
98
98
|
</url>`).join("\n")}</urlset>`);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
|
|
102
102
|
// This parses incoming JSON payloads for the API and analytics.
|
|
103
103
|
app.use(express.json({ limit: "1MB" }));
|
|
104
104
|
|
|
105
|
+
// --------------------------- Easy analytics ----------------------------------
|
|
105
106
|
// Endpoint to receive analytics data from the client.
|
|
106
107
|
app.post("/api/easy-analytics", async (req, res) => {
|
|
107
108
|
const userAgent = req.headers['user-agent'] || "";
|
|
@@ -116,12 +117,9 @@ app.post("/api/easy-analytics", async (req, res) => {
|
|
|
116
117
|
}
|
|
117
118
|
});
|
|
118
119
|
|
|
119
|
-
// --- ADMIN START ---
|
|
120
|
-
// --- ADMIN END ---
|
|
121
|
-
|
|
122
120
|
// --------------------------------- API ---------------------------------------
|
|
123
121
|
// Set up our type-safe API backend. All functions defined here map directly to the
|
|
124
|
-
// API type defined in types.d.ts, and can be called from the frontend using
|
|
122
|
+
// API type defined in types.d.ts, and can be called from the frontend using by api., useApi., or useSSRApi.
|
|
125
123
|
setAPIBackend<API>(app, {
|
|
126
124
|
async getItems() {
|
|
127
125
|
return await selectArray("item");
|
|
@@ -150,8 +148,8 @@ setAPIBackend<API>(app, {
|
|
|
150
148
|
// --- ADMIN END ---
|
|
151
149
|
});
|
|
152
150
|
|
|
153
|
-
// --------------------------------- SSR ---------------------------------------
|
|
154
151
|
|
|
152
|
+
// --------------------------------- SSR ---------------------------------------
|
|
155
153
|
app.get(/.*/, async (req, res) => {
|
|
156
154
|
try {
|
|
157
155
|
const indexHtml = await readFile(INDEX_HTML_PATH, "utf-8");
|
|
@@ -165,6 +163,7 @@ app.get(/.*/, async (req, res) => {
|
|
|
165
163
|
}
|
|
166
164
|
});
|
|
167
165
|
|
|
166
|
+
|
|
168
167
|
// Start listening for incoming requests.
|
|
169
168
|
app.listen(PORT, () => {
|
|
170
169
|
console.log(`Server is running on ${LOCALHOST}`);
|
package/template/src/App.tsx
CHANGED
|
@@ -1,22 +1,15 @@
|
|
|
1
|
-
// RouterProvider manages the browser history and current route state.
|
|
2
|
-
import { RouterProvider } from "easy-page-router";
|
|
3
|
-
|
|
4
|
-
// init sets up the easy-analytics client to start tracking page views and custom events.
|
|
5
1
|
import { init } from "easy-analytics/client";
|
|
2
|
+
import { RouterProvider } from "easy-page-router";
|
|
6
3
|
|
|
7
4
|
import Router from "./Router";
|
|
8
5
|
|
|
9
|
-
// Initialize analytics. It sends data to our
|
|
6
|
+
// Initialize analytics. It sends data to our backend endpoint.
|
|
10
7
|
init(process.env.NODE_ENV === "development"
|
|
11
8
|
? "http://localhost:1111/api/easy-analytics"
|
|
12
9
|
: "/api/easy-analytics");
|
|
13
10
|
|
|
14
11
|
export default function App() {
|
|
15
|
-
return
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
<RouterProvider>
|
|
19
|
-
<Router />
|
|
20
|
-
</RouterProvider>
|
|
21
|
-
);
|
|
12
|
+
return <RouterProvider>
|
|
13
|
+
<Router />
|
|
14
|
+
</RouterProvider>;
|
|
22
15
|
}
|
package/template/src/Router.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { Router } from "easy-page-router/react";
|
|
|
3
3
|
import Index from "./pages/Index";
|
|
4
4
|
import NotFound from "./pages/NotFound";
|
|
5
5
|
// --- ADMIN START ---
|
|
6
|
+
|
|
6
7
|
import { lazy, Suspense } from "react";
|
|
7
8
|
const Admin = lazy(() => import("./pages/admin/Admin"));
|
|
8
9
|
// --- ADMIN END ---
|
|
@@ -12,10 +13,10 @@ export default function AppRouter() {
|
|
|
12
13
|
renderPage={({ path }) => {
|
|
13
14
|
// The 'path' variable is an array of strings representing the URL segments.
|
|
14
15
|
// For example, "/users/123" would be ["users", "123"].
|
|
15
|
-
|
|
16
|
+
|
|
16
17
|
// If the path is empty, we are at the root URL (/).
|
|
17
18
|
if (path.length === 0) return <Index />;
|
|
18
|
-
|
|
19
|
+
|
|
19
20
|
// --- ADMIN START ---
|
|
20
21
|
if (path[0] === 'admin') return (
|
|
21
22
|
<Suspense fallback={<div style={{ padding: 40, textAlign: 'center' }}>Loading admin...</div>}>
|
|
@@ -23,11 +24,11 @@ export default function AppRouter() {
|
|
|
23
24
|
</Suspense>
|
|
24
25
|
);
|
|
25
26
|
// --- ADMIN END ---
|
|
26
|
-
|
|
27
|
+
|
|
27
28
|
// You can add more routes here, for example:
|
|
28
29
|
// if (path[0] === 'about') return <AboutPage />;
|
|
29
30
|
// if (path[0] === 'users' && path[1]) return <UserPage id={path[1]} />;
|
|
30
|
-
|
|
31
|
+
|
|
31
32
|
// If no routes match, render a 404 Not Found component.
|
|
32
33
|
return <NotFound />;
|
|
33
34
|
}}
|
package/template/src/index.tsx
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { createRoot, hydrateRoot } from "react-dom/client";
|
|
3
3
|
|
|
4
|
-
// ssr-hook requires knowing the backend origin to fetch initial state during SSR.
|
|
5
|
-
import { setSSROrigin } from "ssr-hook";
|
|
6
4
|
import { handleError } from "./services/api";
|
|
7
5
|
|
|
8
6
|
import App from "./App";
|
|
@@ -10,10 +8,6 @@ import App from "./App";
|
|
|
10
8
|
const rootElement = document.getElementById("root") as HTMLElement;
|
|
11
9
|
|
|
12
10
|
if (process.env.NODE_ENV === "development") {
|
|
13
|
-
// In development mode, the frontend (Parcel) and backend (Express) run on different ports.
|
|
14
|
-
// We explicitly tell ssr-hook where the API backend is located.
|
|
15
|
-
setSSROrigin("http://localhost:1111");
|
|
16
|
-
|
|
17
11
|
// We use createRoot because we often don't want to deal with full SSR hydration quirks
|
|
18
12
|
// during local frontend development (like Hot Module Replacement causing mismatches).
|
|
19
13
|
createRoot(rootElement).render(<App />);
|
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
|
-
// useSSRHook is a specialized hook that fetches data both on the server (during SSR)
|
|
3
|
-
// and on the client. It ensures the HTML generated on the server already contains the data.
|
|
4
|
-
import { useSSRHook } from "ssr-hook";
|
|
5
|
-
|
|
6
|
-
import { api, handleError } from "../services/api";
|
|
7
|
-
import { API, Item } from "../../types";
|
|
8
2
|
import { Link } from "easy-page-router/react";
|
|
9
3
|
|
|
4
|
+
import { api, useSSRApi } from "../services/api";
|
|
10
5
|
import { useHead } from "../services/common";
|
|
11
6
|
|
|
7
|
+
import { Item } from "../../types";
|
|
8
|
+
|
|
12
9
|
export default function Index() {
|
|
13
|
-
useHead("Welcome to easy-starter", "The ultimate foundation for your next project.");
|
|
10
|
+
useHead({ title: "Welcome to easy-starter", description: "The ultimate foundation for your next project." });
|
|
14
11
|
|
|
15
|
-
|
|
12
|
+
// This will run on the server, fetch the data, embed it in the HTML,
|
|
13
|
+
// and hydrate perfectly on the client. Fully type-safe!
|
|
14
|
+
const [items, error, loading, reload] = useSSRApi.getItems({});
|
|
16
15
|
|
|
17
16
|
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
|
|
18
17
|
const [selectedLoading, setSelectedLoading] = useState(false);
|
|
@@ -26,7 +25,6 @@ export default function Index() {
|
|
|
26
25
|
const [item, err] = await api.getItem({ id });
|
|
27
26
|
setSelectedLoading(false);
|
|
28
27
|
if (item) setSelectedItem(item);
|
|
29
|
-
if (err) handleError(err);
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
async function handleAdd(e: React.FormEvent) {
|
|
@@ -39,28 +37,26 @@ export default function Index() {
|
|
|
39
37
|
setNewItemValue("");
|
|
40
38
|
reload();
|
|
41
39
|
}
|
|
42
|
-
if (err) handleError(err);
|
|
43
40
|
}
|
|
44
41
|
|
|
45
42
|
async function handleRemove(id: string) {
|
|
46
43
|
if (!confirm("Are you sure you want to delete this item?")) return;
|
|
47
44
|
const [success, err] = await api.removeItem({ id });
|
|
48
45
|
if (success) reload();
|
|
49
|
-
if (err) handleError(err);
|
|
50
46
|
}
|
|
51
47
|
|
|
52
48
|
return (
|
|
53
|
-
<
|
|
54
|
-
<
|
|
55
|
-
<
|
|
56
|
-
<h1>Welcome to easy-starter
|
|
49
|
+
<main>
|
|
50
|
+
<header>
|
|
51
|
+
<figure></figure>
|
|
52
|
+
<h1>Welcome to easy-starter!</h1>
|
|
57
53
|
<p>The ultimate foundation for your next project.</p>
|
|
58
54
|
<Link href="/admin">
|
|
59
55
|
→ Go to Admin Dashboard
|
|
60
56
|
</Link>
|
|
61
|
-
</
|
|
57
|
+
</header>
|
|
62
58
|
|
|
63
|
-
<
|
|
59
|
+
<section>
|
|
64
60
|
<h2>Items (SSR Hook)</h2>
|
|
65
61
|
{loading && <p>Loading from server...</p>}
|
|
66
62
|
{error && <p>Error loading items: {error.message}</p>}
|
|
@@ -69,10 +65,10 @@ export default function Index() {
|
|
|
69
65
|
{items.map(item => (
|
|
70
66
|
<li key={item._id}>
|
|
71
67
|
<span><strong>{item.name}</strong> - {item.value}</span>
|
|
72
|
-
<
|
|
68
|
+
<nav>
|
|
73
69
|
<button onClick={() => handleView(item._id!)}>View Details</button>
|
|
74
|
-
<button className="btn-danger" onClick={() => handleRemove(item._id!)}
|
|
75
|
-
</
|
|
70
|
+
<button className="btn-danger" onClick={() => handleRemove(item._id!)}>Delete</button>
|
|
71
|
+
</nav>
|
|
76
72
|
</li>
|
|
77
73
|
))}
|
|
78
74
|
</ul>
|
|
@@ -81,15 +77,15 @@ export default function Index() {
|
|
|
81
77
|
|
|
82
78
|
{selectedLoading && <p>Loading item details...</p>}
|
|
83
79
|
{selectedItem && (
|
|
84
|
-
<
|
|
80
|
+
<article>
|
|
85
81
|
<h3>Selected Item:</h3>
|
|
86
82
|
<p>ID: {selectedItem._id}</p>
|
|
87
83
|
<p>Name: {selectedItem.name}</p>
|
|
88
84
|
<p>Value: {selectedItem.value}</p>
|
|
89
|
-
</
|
|
85
|
+
</article>
|
|
90
86
|
)}
|
|
91
87
|
|
|
92
|
-
<h3
|
|
88
|
+
<h3>Add New Item</h3>
|
|
93
89
|
<form onSubmit={handleAdd}>
|
|
94
90
|
<input
|
|
95
91
|
type="text"
|
|
@@ -109,7 +105,7 @@ export default function Index() {
|
|
|
109
105
|
{adding ? "Adding..." : "Add"}
|
|
110
106
|
</button>
|
|
111
107
|
</form>
|
|
112
|
-
</
|
|
113
|
-
</
|
|
108
|
+
</section>
|
|
109
|
+
</main>
|
|
114
110
|
);
|
|
115
111
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { useHead } from "../services/common";
|
|
2
1
|
import { Link } from "easy-page-router/react";
|
|
3
2
|
|
|
3
|
+
import { useHead } from "../services/common";
|
|
4
|
+
|
|
4
5
|
export default function NotFound() {
|
|
5
|
-
useHead("404 Not Found", "Page you are looking for does not exist.");
|
|
6
|
+
useHead({ title: "404 Not Found", description: "Page you are looking for does not exist." });
|
|
6
7
|
|
|
7
8
|
return (
|
|
8
9
|
<div style={{ padding: "40px", textAlign: "center", fontFamily: "sans-serif" }}>
|
|
@@ -5,7 +5,7 @@ import { useHead } from "../../services/common";
|
|
|
5
5
|
import { api, handleError } from "../../services/api";
|
|
6
6
|
|
|
7
7
|
export default function Admin() {
|
|
8
|
-
useHead("Analytics Dashboard", "View statistics and analytics for your application.");
|
|
8
|
+
useHead({ title: "Analytics Dashboard", description: "View statistics and analytics for your application." });
|
|
9
9
|
|
|
10
10
|
const [password, setPassword] = useState("");
|
|
11
11
|
const [loggedIn, setLoggedIn] = useState(false);
|
|
@@ -34,7 +34,7 @@ export default function Admin() {
|
|
|
34
34
|
setLoggedIn(true);
|
|
35
35
|
localStorage.setItem("admin_pwd", pwd);
|
|
36
36
|
} else if (err) {
|
|
37
|
-
setError(err
|
|
37
|
+
setError(err);
|
|
38
38
|
setLoggedIn(false);
|
|
39
39
|
localStorage.removeItem("admin_pwd");
|
|
40
40
|
}
|
|
@@ -77,7 +77,7 @@ export default function Admin() {
|
|
|
77
77
|
return "Unknown";
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
-
// Simplified UA parser
|
|
80
|
+
// Simplified UA parser - for production consider using a library like 'ua-parser-js'
|
|
81
81
|
const byBrowserLike = useGroup(records, (r: any) => {
|
|
82
82
|
const ua = (r.userAgent || "").toLowerCase();
|
|
83
83
|
if (ua.includes("firefox")) return "Firefox";
|
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import { useEffect } from "react";
|
|
2
2
|
import { sendError } from "easy-analytics/client";
|
|
3
3
|
import { setServerUrl, getUseAPIFrontend, getAPIFrontend, setHeaders } from "typed-client-server-api/hooks";
|
|
4
|
+
import { useSSRHook } from "ssr-hook/client";
|
|
5
|
+
import { setSSROrigin } from "ssr-hook";
|
|
4
6
|
|
|
5
7
|
import { API } from "../../types";
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
export type UseSSRAPIType = {
|
|
10
|
+
[I in keyof API]: (params: API[I]["params"]) =>
|
|
11
|
+
| [response: null, error: null, isLoading: boolean, reload: () => void]
|
|
12
|
+
| [response: API[I]["result"], error: null, isLoading: boolean, reload: () => void]
|
|
13
|
+
| [response: null, error: Error, isLoading: boolean, reload: () => void];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if (process.env.NODE_ENV === "development") {
|
|
18
|
+
// In development mode, the frontend (Parcel) and backend (Express) run on different ports.
|
|
19
|
+
// We explicitly tell ssr-hook where the API backend is located.
|
|
20
|
+
setSSROrigin("http://localhost:1111");
|
|
21
|
+
setServerUrl("http://localhost:1111");
|
|
22
|
+
}
|
|
8
23
|
|
|
9
24
|
let authorization = "";
|
|
10
25
|
|
|
@@ -19,12 +34,20 @@ export function setAuthorizationHeader(newAuthorization: string) {
|
|
|
19
34
|
|
|
20
35
|
export const api = getAPIFrontend<API>();
|
|
21
36
|
export const useApi = getUseAPIFrontend<API>();
|
|
37
|
+
export const useSSRApi = new Proxy({}, {
|
|
38
|
+
get(target, prop: string) {
|
|
39
|
+
return (params: any) => {
|
|
40
|
+
const url = api[prop as keyof typeof api].getUrl(params);
|
|
41
|
+
return useSSRHook(url);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}) as UseSSRAPIType;
|
|
22
45
|
|
|
23
|
-
export function handleError(error?:
|
|
46
|
+
export function handleError(error?: unknown, errorInfo?: any) {
|
|
24
47
|
if (!error) return;
|
|
25
48
|
if (typeof error === "string") error = new Error(error);
|
|
26
49
|
|
|
27
|
-
if (errorInfo && typeof errorInfo === "object" && typeof errorInfo.componentStack === "string") {
|
|
50
|
+
if (error instanceof Error && errorInfo && typeof errorInfo === "object" && typeof errorInfo.componentStack === "string") {
|
|
28
51
|
error.stack = errorInfo.componentStack;
|
|
29
52
|
}
|
|
30
53
|
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import { useHeaders } from "ssr-hook";
|
|
2
2
|
import { useTitle } from "easy-page-router/react";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
type UseHeadProps = {
|
|
5
|
+
title: string;
|
|
6
|
+
description: string;
|
|
7
|
+
image?: string;
|
|
8
|
+
canonical?: string;
|
|
9
|
+
lang?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function useHead({ title, description, image, canonical, lang }: UseHeadProps) {
|
|
5
13
|
useHeaders({
|
|
6
14
|
title,
|
|
7
15
|
description,
|
|
8
|
-
image: `/images/icon.png`, //
|
|
9
|
-
canonical: typeof window !== "undefined" ? window.location.origin + window.location.pathname : "",
|
|
16
|
+
image: image || `/images/icon.png`, // Images must be placed in the public directory to be accessible
|
|
17
|
+
canonical: canonical || (typeof window !== "undefined" ? window.location.origin + window.location.pathname : ""),
|
|
18
|
+
lang,
|
|
10
19
|
});
|
|
11
20
|
useTitle(title);
|
|
12
21
|
}
|
|
@@ -3,34 +3,45 @@
|
|
|
3
3
|
"short_name": "__NAME__",
|
|
4
4
|
"description": "Welcome to __NAME__ application.",
|
|
5
5
|
"start_url": "/",
|
|
6
|
+
"display": "standalone",
|
|
7
|
+
"theme_color": "#6366f1",
|
|
8
|
+
"background_color": "#09090b",
|
|
6
9
|
"icons": [
|
|
7
10
|
{
|
|
8
11
|
"src": "./images/icon.png?width=16&height=16",
|
|
9
12
|
"sizes": "16x16",
|
|
10
|
-
"type": "image/png"
|
|
13
|
+
"type": "image/png",
|
|
14
|
+
"purpose": "maskable"
|
|
11
15
|
},
|
|
12
16
|
{
|
|
13
17
|
"src": "./images/icon.png?width=32&height=32",
|
|
14
18
|
"sizes": "32x32",
|
|
15
|
-
"type": "image/png"
|
|
19
|
+
"type": "image/png",
|
|
20
|
+
"purpose": "maskable"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"src": "./images/icon.png?width=180&height=180",
|
|
24
|
+
"sizes": "180x180",
|
|
25
|
+
"type": "image/png",
|
|
26
|
+
"purpose": "maskable"
|
|
16
27
|
},
|
|
17
28
|
{
|
|
18
29
|
"src": "./images/icon.png?as=webp&width=192&height=192",
|
|
19
30
|
"sizes": "192x192",
|
|
20
|
-
"type": "image/webp"
|
|
31
|
+
"type": "image/webp",
|
|
32
|
+
"purpose": "maskable"
|
|
21
33
|
},
|
|
22
34
|
{
|
|
23
35
|
"src": "./images/icon.png?width=192&height=192",
|
|
24
36
|
"sizes": "192x192",
|
|
25
|
-
"type": "image/png"
|
|
37
|
+
"type": "image/png",
|
|
38
|
+
"purpose": "maskable"
|
|
26
39
|
},
|
|
27
40
|
{
|
|
28
|
-
"src": "./images/icon.png?width=
|
|
29
|
-
"sizes": "
|
|
30
|
-
"type": "image/
|
|
41
|
+
"src": "./images/icon.png?as=webp&width=512&height=512",
|
|
42
|
+
"sizes": "512x512",
|
|
43
|
+
"type": "image/webp",
|
|
44
|
+
"purpose": "maskable"
|
|
31
45
|
}
|
|
32
|
-
]
|
|
33
|
-
|
|
34
|
-
"background_color": "#ffffff",
|
|
35
|
-
"display": "standalone"
|
|
36
|
-
}
|
|
46
|
+
]
|
|
47
|
+
}
|
package/template/src/styles.scss
CHANGED
|
@@ -27,7 +27,7 @@ a {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
main {
|
|
31
31
|
max-width: 800px;
|
|
32
32
|
margin: 50px auto;
|
|
33
33
|
padding: 40px;
|
|
@@ -35,50 +35,38 @@ a {
|
|
|
35
35
|
border-radius: 16px;
|
|
36
36
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
|
37
37
|
border: 1px solid $border;
|
|
38
|
+
}
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
header {
|
|
41
|
+
text-align: center;
|
|
42
|
+
margin-bottom: 40px;
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
font-size: 2.5rem;
|
|
47
|
-
background: linear-gradient(90deg, #818cf8, #c084fc);
|
|
48
|
-
-webkit-background-clip: text;
|
|
49
|
-
-webkit-text-fill-color: transparent;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
p {
|
|
53
|
-
color: $text-muted;
|
|
54
|
-
font-size: 1.1rem;
|
|
55
|
-
}
|
|
44
|
+
h1 {
|
|
45
|
+
color: $primary;
|
|
46
|
+
}
|
|
56
47
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
48
|
+
p {
|
|
49
|
+
color: $text-muted;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
figure {
|
|
53
|
+
width: 140px;
|
|
54
|
+
height: 140px;
|
|
55
|
+
margin: 0 auto 20px;
|
|
56
|
+
background-image: url("./images/icon.png?as=webp");
|
|
57
|
+
background-size: contain;
|
|
58
|
+
background-repeat: no-repeat;
|
|
59
|
+
background-position: center;
|
|
66
60
|
}
|
|
67
61
|
}
|
|
68
62
|
|
|
69
|
-
|
|
63
|
+
section {
|
|
70
64
|
margin-top: 40px;
|
|
71
65
|
padding: 30px;
|
|
72
66
|
background: rgba(255, 255, 255, 0.02);
|
|
73
67
|
border-radius: 12px;
|
|
74
68
|
border: 1px solid $border;
|
|
75
69
|
|
|
76
|
-
h2,
|
|
77
|
-
h3 {
|
|
78
|
-
margin-top: 0;
|
|
79
|
-
color: #fff;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
70
|
ul {
|
|
83
71
|
list-style: none;
|
|
84
72
|
padding: 0;
|
|
@@ -98,20 +86,23 @@ a {
|
|
|
98
86
|
transform: translateY(-2px);
|
|
99
87
|
border-color: $primary;
|
|
100
88
|
}
|
|
89
|
+
|
|
90
|
+
nav {
|
|
91
|
+
display: flex;
|
|
92
|
+
gap: 8px;
|
|
93
|
+
}
|
|
101
94
|
}
|
|
102
95
|
}
|
|
103
96
|
|
|
104
|
-
|
|
105
|
-
padding: 20px;
|
|
97
|
+
article {
|
|
98
|
+
padding: 0px 20px 10px;
|
|
106
99
|
background: $bg;
|
|
107
|
-
margin-top: 20px;
|
|
108
100
|
border-radius: 8px;
|
|
109
101
|
border: 1px solid $primary;
|
|
110
102
|
box-shadow: 0 0 15px rgba(99, 102, 241, 0.15);
|
|
111
103
|
|
|
112
104
|
h3 {
|
|
113
105
|
color: $primary;
|
|
114
|
-
margin-bottom: 10px;
|
|
115
106
|
}
|
|
116
107
|
|
|
117
108
|
p {
|
|
@@ -123,7 +114,6 @@ a {
|
|
|
123
114
|
form {
|
|
124
115
|
display: flex;
|
|
125
116
|
gap: 12px;
|
|
126
|
-
margin-top: 20px;
|
|
127
117
|
flex-wrap: wrap;
|
|
128
118
|
}
|
|
129
119
|
}
|
|
@@ -160,23 +150,16 @@ button {
|
|
|
160
150
|
transform: scale(1.02);
|
|
161
151
|
}
|
|
162
152
|
|
|
163
|
-
&:active:not(:disabled) {
|
|
164
|
-
transform: scale(0.98);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
&:disabled {
|
|
168
|
-
opacity: 0.5;
|
|
169
|
-
cursor: not-allowed;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
153
|
&.btn-danger {
|
|
173
154
|
background: #ef4444;
|
|
155
|
+
|
|
174
156
|
&:hover:not(:disabled) {
|
|
175
157
|
background: #dc2626;
|
|
176
158
|
}
|
|
177
159
|
}
|
|
178
160
|
}
|
|
179
161
|
|
|
162
|
+
// --- ADMIN START ---
|
|
180
163
|
// --- ADMIN DASHBOARD ---
|
|
181
164
|
.admin-login {
|
|
182
165
|
padding: 40px;
|
|
@@ -184,12 +167,14 @@ button {
|
|
|
184
167
|
margin: 0 auto;
|
|
185
168
|
text-align: center;
|
|
186
169
|
|
|
187
|
-
h2 {
|
|
188
|
-
|
|
170
|
+
h2 {
|
|
171
|
+
color: $text;
|
|
172
|
+
}
|
|
173
|
+
|
|
189
174
|
.back-link {
|
|
190
175
|
margin-bottom: 20px;
|
|
191
176
|
}
|
|
192
|
-
|
|
177
|
+
|
|
193
178
|
form {
|
|
194
179
|
display: flex;
|
|
195
180
|
flex-direction: column;
|
|
@@ -197,7 +182,6 @@ button {
|
|
|
197
182
|
margin-top: 20px;
|
|
198
183
|
}
|
|
199
184
|
|
|
200
|
-
input { width: 100%; box-sizing: border-box; }
|
|
201
185
|
}
|
|
202
186
|
|
|
203
187
|
.admin-container {
|
|
@@ -211,8 +195,16 @@ button {
|
|
|
211
195
|
align-items: center;
|
|
212
196
|
margin-bottom: 30px;
|
|
213
197
|
|
|
214
|
-
h2 {
|
|
215
|
-
|
|
198
|
+
h2 {
|
|
199
|
+
margin: 0;
|
|
200
|
+
color: $text;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
a {
|
|
204
|
+
color: $primary;
|
|
205
|
+
text-decoration: none;
|
|
206
|
+
font-weight: bold;
|
|
207
|
+
}
|
|
216
208
|
}
|
|
217
209
|
|
|
218
210
|
.controls {
|
|
@@ -222,12 +214,18 @@ button {
|
|
|
222
214
|
margin-bottom: 30px;
|
|
223
215
|
flex-wrap: wrap;
|
|
224
216
|
|
|
225
|
-
strong {
|
|
217
|
+
strong {
|
|
218
|
+
font-size: 1.1rem;
|
|
219
|
+
color: $text;
|
|
220
|
+
}
|
|
226
221
|
|
|
227
222
|
.btn-logout {
|
|
228
223
|
margin-left: auto;
|
|
229
224
|
background: #ef4444;
|
|
230
|
-
|
|
225
|
+
|
|
226
|
+
&:hover {
|
|
227
|
+
background: #dc2626;
|
|
228
|
+
}
|
|
231
229
|
}
|
|
232
230
|
}
|
|
233
231
|
|
|
@@ -258,16 +256,26 @@ button {
|
|
|
258
256
|
justify-content: flex-end;
|
|
259
257
|
height: 100%;
|
|
260
258
|
|
|
261
|
-
.count {
|
|
262
|
-
|
|
263
|
-
|
|
259
|
+
.count {
|
|
260
|
+
font-size: 11px;
|
|
261
|
+
color: $text;
|
|
262
|
+
margin-bottom: 4px;
|
|
263
|
+
font-weight: bold;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.date {
|
|
267
|
+
font-size: 10px;
|
|
268
|
+
margin-top: 8px;
|
|
269
|
+
color: $text-muted;
|
|
270
|
+
}
|
|
271
|
+
|
|
264
272
|
.bar {
|
|
265
273
|
width: 100%;
|
|
266
274
|
max-width: 24px;
|
|
267
275
|
border-radius: 4px 4px 0 0;
|
|
268
276
|
transition: height 0.3s ease;
|
|
269
277
|
}
|
|
270
|
-
|
|
278
|
+
|
|
271
279
|
.bar.active {
|
|
272
280
|
background: $primary;
|
|
273
281
|
}
|
|
@@ -280,4 +288,5 @@ button {
|
|
|
280
288
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
281
289
|
gap: 20px;
|
|
282
290
|
}
|
|
283
|
-
}
|
|
291
|
+
}
|
|
292
|
+
// --- ADMIN END ---
|