easy-starter 1.0.0 → 1.2.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 +66 -0
- package/bin/index.js +4 -0
- package/package.json +5 -2
- package/template/.cursorrules +92 -0
- package/template/README.md +67 -1
- package/template/env +6 -3
- package/template/gitignore +9 -0
- package/template/package.json +1 -5
- package/template/public/images/icon.png +0 -0
- package/template/scripts/deploy.ts +26 -13
- 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 +4 -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/template/tsconfig.json +1 -1
- package/template/{types.d.ts → types.ts} +1 -1
package/README.md
CHANGED
|
@@ -33,8 +33,74 @@ The generated boilerplate comes pre-configured with a stack designed for develop
|
|
|
33
33
|
* **Database:** [`easy-db-node`](https://www.npmjs.com/package/easy-db-node) - A simple, local JSON-based database perfect for small to medium projects where setting up Postgres/MongoDB is overkill.
|
|
34
34
|
* **Routing:** [`easy-page-router`](https://www.npmjs.com/package/easy-page-router) - A lightweight, programmatic routing solution.
|
|
35
35
|
* **Analytics:** [`easy-analytics`](https://www.npmjs.com/package/easy-analytics) - Privacy-friendly, local analytics tracking.
|
|
36
|
+
* **AI-Friendly (.cursorrules):** Pre-configured, LLM-optimized guidelines in the project root so AI coding assistants (Cursor, Windsurf, Copilot, etc.) immediately understand the codebase conventions, API restrictions, routing patterns, and database operations.
|
|
36
37
|
* **Deployment:** Integrated Github Actions workflow and PM2 deployment script for seamless, automated deployment straight to your own VPS.
|
|
37
38
|
|
|
39
|
+
## Core Concepts & Examples
|
|
40
|
+
|
|
41
|
+
> **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!
|
|
42
|
+
|
|
43
|
+
### 1. Defining your API
|
|
44
|
+
|
|
45
|
+
Your API contract is defined in a single file (`types.d.ts`). This ensures your frontend and backend are always perfectly in sync.
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
// types.d.ts
|
|
49
|
+
import { APIDefinition, Endpoint } from "typed-client-server-api";
|
|
50
|
+
|
|
51
|
+
export type API = APIDefinition<{
|
|
52
|
+
// Define an endpoint: parameters -> response
|
|
53
|
+
getItems: Endpoint<{}, Item[]>;
|
|
54
|
+
addItem: Endpoint<{ name: string; value: number }, string>;
|
|
55
|
+
}>;
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
On the server, you implement these endpoints:
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
// server/index.tsx
|
|
62
|
+
import { initApi } from "typed-client-server-api";
|
|
63
|
+
import { API } from "../types";
|
|
64
|
+
|
|
65
|
+
initApi<API>(app, {
|
|
66
|
+
getItems: async () => {
|
|
67
|
+
return db.select("item");
|
|
68
|
+
},
|
|
69
|
+
addItem: async ({ name, value }) => {
|
|
70
|
+
return db.insert("item", { name, value });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 2. Fetching Data with SSR (`useSSRApi`)
|
|
76
|
+
|
|
77
|
+
To fetch data on the server during SSR and seamlessly hydrate it on the client without loading spinners, use `useSSRApi` in your React components.
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
// src/pages/Index.tsx
|
|
81
|
+
import React from "react";
|
|
82
|
+
import { useSSRApi } from "../services/api";
|
|
83
|
+
|
|
84
|
+
export default function Index() {
|
|
85
|
+
// This will run on the server, fetch the data, embed it in the HTML,
|
|
86
|
+
// and hydrate perfectly on the client. Fully type-safe!
|
|
87
|
+
const [items, error, isLoading, reload] = useSSRApi.getItems({});
|
|
88
|
+
|
|
89
|
+
if (isLoading) return <p>Loading...</p>;
|
|
90
|
+
if (error) return <p>Error: {error.message}</p>;
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<ul>
|
|
94
|
+
{items?.map(item => <li key={item._id}>{item.name}</li>)}
|
|
95
|
+
</ul>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 3. AI Integration (`.cursorrules`)
|
|
101
|
+
|
|
102
|
+
This template includes a pre-configured `.cursorrules` file in the root. If you develop using AI assistants such as Cursor, Windsurf, or GitHub Copilot, they will automatically read this file to understand the specific conventions of `easy-starter`.
|
|
103
|
+
|
|
38
104
|
## Getting Started
|
|
39
105
|
|
|
40
106
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "easy-starter",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "A lightweight CLI tool to bootstrap modern fullstack React web projects.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"easy-starter": "./bin/index.js"
|
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
"typescript",
|
|
16
16
|
"fullstack",
|
|
17
17
|
"easy-db",
|
|
18
|
-
"parcel"
|
|
18
|
+
"parcel",
|
|
19
|
+
"ai",
|
|
20
|
+
"cursorrules",
|
|
21
|
+
"ai-friendly"
|
|
19
22
|
],
|
|
20
23
|
"author": "Filip Paulů <ing.fenix@seznam.cz>",
|
|
21
24
|
"license": "MIT",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# AI Coding Assistant Guidelines & Project Rules
|
|
2
|
+
|
|
3
|
+
You are an expert AI software engineer pair-programming in a fullstack React application bootstrapped with `easy-starter`.
|
|
4
|
+
Follow these strict rules, project structures, and conventions at all times.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 1. API Architecture & Type Safety (`typed-client-server-api`)
|
|
9
|
+
|
|
10
|
+
All React-to-Express communication must use the RPC contract defined in `types.ts` via the `API` type (`APIDefinition`).
|
|
11
|
+
|
|
12
|
+
### Naming Conventions for Endpoints
|
|
13
|
+
EVERY endpoint name in `API` **MUST** start with exactly one of the following prefixes:
|
|
14
|
+
* `add...` (Create/Insert operations. Example: `addItem`, `addRecord`)
|
|
15
|
+
* `get...` (Read/Query operations. Example: `getItems`, `getItem`, `getAnalytics`)
|
|
16
|
+
* `update...` (Update/Edit operations. Example: `updateItem`, `updateUser`)
|
|
17
|
+
* `remove...` (Delete/Remove operations. Example: `removeItem`, `removePost`)
|
|
18
|
+
|
|
19
|
+
**CRITICAL**: Do NOT use other prefixes (e.g., `create`, `delete`, `post`, `fetch`, `save`).
|
|
20
|
+
|
|
21
|
+
### Frontend API Invocation Methods
|
|
22
|
+
You have exactly three (3) methods to trigger API endpoints on the frontend. Use them strictly according to these boundaries:
|
|
23
|
+
|
|
24
|
+
| Method | Import | Use Case | Pattern / Example |
|
|
25
|
+
| :--- | :--- | :--- | :--- |
|
|
26
|
+
| **`useSSRApi`** | `import { useSSRApi } from "../services/api";` | **ONLY** inside React components during initial SSR render. Enables server-side rendering and hydration without flash-of-loading or spinners. | `const [items, error, isLoading, reload] = useSSRApi.getItems({});` |
|
|
27
|
+
| **`useApi`** | `import { useApi } from "../services/api";` | **ONLY** inside React components for client-side-only reactive data fetching (e.g., late loaded components). | `const [items, error, isLoading, reload] = useApi.getItems({});` |
|
|
28
|
+
| **`api`** | `import { api } from "../services/api";` | **ONLY** inside event handlers (e.g., `onClick`, `onSubmit`), utility/non-React functions, or async triggers. Returns a Promise resolving to `[data, error]`. | `const [newId, err] = await api.addItem({ name: "Foo" });` |
|
|
29
|
+
|
|
30
|
+
**NEVER** use standard `fetch`, `axios`, or any other fetcher libraries for internal API calls.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## 2. Routing & Navigation (`easy-page-router`)
|
|
35
|
+
|
|
36
|
+
This project uses `easy-page-router`, a lightweight, universal routing library.
|
|
37
|
+
|
|
38
|
+
### Route Configurations
|
|
39
|
+
* Router paths are defined in `src/Router.tsx` within the `renderPage({ path })` callback of the `Router` component.
|
|
40
|
+
* The `path` parameter is a string array (`string[]`) of URL segments:
|
|
41
|
+
* `/` -> `path.length === 0`
|
|
42
|
+
* `/admin` -> `path[0] === "admin"`
|
|
43
|
+
* `/users/123` -> `path[0] === "users" && path[1]` (where `path[1]` is the user ID segment).
|
|
44
|
+
|
|
45
|
+
### Navigation
|
|
46
|
+
1. **Declarative Navigation**: Use `<Link href="/path">Text</Link>` imported from `"easy-page-router/react"`.
|
|
47
|
+
2. **Programmatic Navigation**: Destructure `push` from the `useRouter()` hook imported from `"easy-page-router/react"`.
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
import { Link, useRouter } from "easy-page-router/react";
|
|
51
|
+
|
|
52
|
+
// Declarative:
|
|
53
|
+
<Link href="/admin">Go to Admin</Link>
|
|
54
|
+
|
|
55
|
+
// Programmatic:
|
|
56
|
+
const { push } = useRouter();
|
|
57
|
+
const onSubmit = async () => {
|
|
58
|
+
await saveSomething();
|
|
59
|
+
push("/dashboard");
|
|
60
|
+
};
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 3. Database Operations (`easy-db-node`)
|
|
66
|
+
|
|
67
|
+
The local database stores records as JSON files.
|
|
68
|
+
|
|
69
|
+
### Schema Definition
|
|
70
|
+
* The database collections/tables must be structured and typed in `types.ts` inside the exported `Database` type.
|
|
71
|
+
```typescript
|
|
72
|
+
export type Database = {
|
|
73
|
+
item: Item;
|
|
74
|
+
user: User;
|
|
75
|
+
};
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Writing to Database
|
|
79
|
+
* **Auto-generated ID (Standard Insertion)**:
|
|
80
|
+
* Use `insert(table, data)`.
|
|
81
|
+
* **Rule**: Do **NOT** pass `_id` in the input data. `easy-db-node` ignores any `_id` property passed to `insert()` and automatically generates and returns the unique string ID.
|
|
82
|
+
```typescript
|
|
83
|
+
// CORRECT:
|
|
84
|
+
const newId = await insert("item", { name: "Hello", value: 10 });
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
* **Custom / Specific ID (Explicit Key)**:
|
|
88
|
+
* To insert or overwrite a record with a **specific/predefined ID**, you **MUST** use `update(table, id, data)` instead of `insert`.
|
|
89
|
+
```typescript
|
|
90
|
+
// CORRECT (custom ID):
|
|
91
|
+
await update("item", "explicit-custom-id", { name: "Custom ID Item", value: 99 });
|
|
92
|
+
```
|
package/template/README.md
CHANGED
|
@@ -22,6 +22,71 @@ 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
|
+
|
|
86
|
+
### 3. AI Integration (`.cursorrules`)
|
|
87
|
+
|
|
88
|
+
This template includes a pre-configured `.cursorrules` file in the root. If you develop using AI assistants such as Cursor, Windsurf, or GitHub Copilot, they will automatically read this file to understand the specific conventions of `easy-starter`:
|
|
89
|
+
|
|
25
90
|
## Scripts
|
|
26
91
|
|
|
27
92
|
* `npm run dev` - Starts the development server.
|
|
@@ -33,7 +98,8 @@ This will concurrently start your Express server and the Parcel bundler. Open [h
|
|
|
33
98
|
|
|
34
99
|
* `/src` - Your React frontend code.
|
|
35
100
|
* `/server` - Your Express backend code.
|
|
36
|
-
* `/types.
|
|
101
|
+
* `/types.ts` - Shared TypeScript definitions for your API and Database.
|
|
102
|
+
* `/.cursorrules` - Rules instructing AI coding assistants how to interact with the project stack.
|
|
37
103
|
* `/public` - Static assets (images, fonts, etc.).
|
|
38
104
|
* `/easy-db` - Where your local database JSON files are stored.
|
|
39
105
|
|
package/template/env
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
NODE_ENV=development
|
|
1
|
+
NODE_ENV=development # development/production
|
|
2
2
|
SERVER_PORT=1111
|
|
3
3
|
ADMIN_PASSWORD=admin
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
# deploy
|
|
6
|
+
SSH_HOST=[IP_ADDRESS]
|
|
6
7
|
SSH_USERNAME=root
|
|
7
|
-
|
|
8
|
+
SSH_PASSWORD=[PASSWORD]
|
|
9
|
+
SERVER_URL=https://your-production-url.com/
|
|
10
|
+
PM2_APP_NAME=easy-starter-app
|
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
|
|
@@ -9,16 +9,21 @@ const username = process.env.SSH_USERNAME;
|
|
|
9
9
|
const password = process.env.SSH_PASSWORD;
|
|
10
10
|
|
|
11
11
|
// Configuration for your target server
|
|
12
|
-
const server =
|
|
13
|
-
const pm2AppName =
|
|
14
|
-
const sshPath = resolve("/", "root", "
|
|
12
|
+
const server = process.env.SERVER_URL;
|
|
13
|
+
const pm2AppName = process.env.PM2_APP_NAME;
|
|
14
|
+
const sshPath = resolve("/", "root", pm2AppName || ""); // Path on the remote VPS
|
|
15
15
|
|
|
16
16
|
const path = resolve(process.cwd()); // Local project path
|
|
17
17
|
const ignoreFiles = [
|
|
18
18
|
".git",
|
|
19
|
+
".github",
|
|
20
|
+
".parcel-cache",
|
|
19
21
|
"node_modules",
|
|
20
22
|
"scripts",
|
|
21
|
-
"
|
|
23
|
+
"easy-db",
|
|
24
|
+
"easy-db-files",
|
|
25
|
+
"easy-db-backup",
|
|
26
|
+
".DS_Store",
|
|
22
27
|
];
|
|
23
28
|
|
|
24
29
|
// Ensure credentials are provided
|
|
@@ -73,20 +78,28 @@ async function deploy() {
|
|
|
73
78
|
console.log(`✔ Build project.\n`);
|
|
74
79
|
|
|
75
80
|
// PM2
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
if (pm2AppName) {
|
|
82
|
+
try {
|
|
83
|
+
await sshExec(ssh, `pm2 reload ${pm2AppName}`);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
console.log(`✘ ${pm2AppName} is not running in PM2!`);
|
|
86
|
+
await sshExec(ssh, `pm2 start npm --name "${pm2AppName}" -- start`, `${pm2AppName} could not be registered in PM2!`);
|
|
87
|
+
}
|
|
88
|
+
await sshExec(ssh, `pm2 list`);
|
|
89
|
+
} else {
|
|
90
|
+
console.log(`i PM2 step is skipped because PM2_APP_NAME is not defined.\n`);
|
|
81
91
|
}
|
|
82
|
-
await sshExec(ssh, `pm2 list`);
|
|
83
92
|
|
|
84
93
|
ssh.dispose();
|
|
85
94
|
console.log(`✔ SSH connection is closed.\n`);
|
|
86
95
|
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
if (server) {
|
|
97
|
+
if (!await serverCheck(server))
|
|
98
|
+
throw new Error(`Server ${server} is not available!`);
|
|
99
|
+
console.log(`✔ Server ${server} is running.\n`);
|
|
100
|
+
} else {
|
|
101
|
+
console.log(`i Server check is skipped because SERVER_URL is not defined.\n`);
|
|
102
|
+
}
|
|
90
103
|
|
|
91
104
|
console.log(`i Server was ${(new Date().getTime() - timeServerOff) / 1000}s off!\n`);
|
|
92
105
|
|
|
@@ -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" }}>
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import React, { useEffect, useState } from "react";
|
|
2
2
|
import { useGroup, GroupTable, getGroupedCount } from "easy-analytics/react";
|
|
3
3
|
import { Link } from "easy-page-router/react";
|
|
4
|
+
|
|
4
5
|
import { useHead } from "../../services/common";
|
|
5
6
|
import { api, handleError } from "../../services/api";
|
|
6
7
|
|
|
7
8
|
export default function Admin() {
|
|
8
|
-
useHead("Analytics Dashboard", "View statistics and analytics for your application.");
|
|
9
|
+
useHead({ title: "Analytics Dashboard", description: "View statistics and analytics for your application." });
|
|
9
10
|
|
|
10
11
|
const [password, setPassword] = useState("");
|
|
11
12
|
const [loggedIn, setLoggedIn] = useState(false);
|
|
@@ -34,7 +35,7 @@ export default function Admin() {
|
|
|
34
35
|
setLoggedIn(true);
|
|
35
36
|
localStorage.setItem("admin_pwd", pwd);
|
|
36
37
|
} else if (err) {
|
|
37
|
-
setError(err
|
|
38
|
+
setError(err);
|
|
38
39
|
setLoggedIn(false);
|
|
39
40
|
localStorage.removeItem("admin_pwd");
|
|
40
41
|
}
|
|
@@ -77,7 +78,7 @@ export default function Admin() {
|
|
|
77
78
|
return "Unknown";
|
|
78
79
|
});
|
|
79
80
|
|
|
80
|
-
// Simplified UA parser
|
|
81
|
+
// Simplified UA parser - for production consider using a library like 'ua-parser-js'
|
|
81
82
|
const byBrowserLike = useGroup(records, (r: any) => {
|
|
82
83
|
const ua = (r.userAgent || "").toLowerCase();
|
|
83
84
|
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 ---
|
package/template/tsconfig.json
CHANGED