easy-starter 1.0.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/LICENSE +21 -0
- package/README.md +72 -0
- package/bin/index.js +141 -0
- package/package.json +29 -0
- package/template/.github/workflows/deploy.yml +29 -0
- package/template/.parcelrc +6 -0
- package/template/README.md +40 -0
- package/template/env +7 -0
- package/template/package.json +39 -0
- package/template/scripts/deploy.ts +148 -0
- package/template/server/index.tsx +171 -0
- package/template/src/App.tsx +22 -0
- package/template/src/Router.tsx +35 -0
- package/template/src/images/icon.png +0 -0
- package/template/src/index.html +16 -0
- package/template/src/index.tsx +29 -0
- package/template/src/pages/Index.tsx +115 -0
- package/template/src/pages/NotFound.tsx +14 -0
- package/template/src/pages/admin/Admin.tsx +166 -0
- package/template/src/services/api.ts +39 -0
- package/template/src/services/common.ts +12 -0
- package/template/src/site.webmanifest +36 -0
- package/template/src/styles.scss +283 -0
- package/template/tsconfig.json +23 -0
- package/template/types.d.ts +26 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { resolve } from "path";
|
|
2
|
+
import { readFile } from "fs/promises";
|
|
3
|
+
import express from "express";
|
|
4
|
+
import cors from "cors";
|
|
5
|
+
|
|
6
|
+
// easy-db-node provides a simple local database storing data in JSON files.
|
|
7
|
+
// It's ideal for small to medium projects where setting up a full SQL/NoSQL DB is overkill.
|
|
8
|
+
import easyDBNode from "easy-db-node";
|
|
9
|
+
|
|
10
|
+
// ssr-hook allows for simple Server-Side Rendering of React components,
|
|
11
|
+
// ensuring SEO friendliness and faster initial page loads.
|
|
12
|
+
import { renderToHTML } from "ssr-hook/server";
|
|
13
|
+
|
|
14
|
+
// typed-client-server-api establishes a type-safe RPC-like API between your React frontend
|
|
15
|
+
// and this Express backend, preventing mismatch errors and providing autocomplete.
|
|
16
|
+
import { setAPIBackend } from "typed-client-server-api";
|
|
17
|
+
|
|
18
|
+
// easy-analytics handles privacy-friendly local analytics collection.
|
|
19
|
+
import { postEasyAnalytics, getEasyAnalytics } from "easy-analytics/server";
|
|
20
|
+
|
|
21
|
+
import App from "../src/App";
|
|
22
|
+
import { API, Database } from "../types";
|
|
23
|
+
|
|
24
|
+
const PORT = process.env.SERVER_PORT || 1111;
|
|
25
|
+
const LOCALHOST = `http://localhost:${PORT}`;
|
|
26
|
+
|
|
27
|
+
// The ORIGIN is used by ssr-hook and typed-client-server-api to know where the requests are coming from.
|
|
28
|
+
const ORIGIN = process.env.NODE_ENV === "development"
|
|
29
|
+
? LOCALHOST
|
|
30
|
+
: "https://your-production-url.com"; // TODO: Update to your domain
|
|
31
|
+
|
|
32
|
+
const INDEX_HTML_PATH = resolve(__dirname, "..", "dist", "index.html");
|
|
33
|
+
|
|
34
|
+
// The directory where easy-db-node will store its data files.
|
|
35
|
+
const FILE_URL = process.env.NODE_ENV === "development"
|
|
36
|
+
? LOCALHOST + "/files"
|
|
37
|
+
: "/files";
|
|
38
|
+
|
|
39
|
+
// Initialize the database with our schema. We can destructure common operations.
|
|
40
|
+
const { insert, select, selectArray, update, remove } = easyDBNode<Database>({ fileUrl: FILE_URL });
|
|
41
|
+
|
|
42
|
+
const app = express();
|
|
43
|
+
|
|
44
|
+
if (process.env.NODE_ENV === "development") {
|
|
45
|
+
console.log("Starting development server...");
|
|
46
|
+
app.use(cors());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------- SSR for index ----------------------------------
|
|
50
|
+
// This route handles the initial load of our application and renders the React tree to HTML.
|
|
51
|
+
app.get(["/", "/index.html"], async (req, res) => {
|
|
52
|
+
try {
|
|
53
|
+
// We read the HTML template generated by our bundler (Parcel)
|
|
54
|
+
const indexHtml = await readFile(INDEX_HTML_PATH, "utf-8");
|
|
55
|
+
// We render our App component into that HTML template.
|
|
56
|
+
const html = await renderToHTML(ORIGIN, req.url, indexHtml, <App />);
|
|
57
|
+
res.set("Content-Type", "text/html");
|
|
58
|
+
res.send(html);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.error("SSR error:", e);
|
|
61
|
+
res.status(500);
|
|
62
|
+
res.json({ message: "Internal server error" });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Serve static files generated by the bundler (JS, CSS)
|
|
67
|
+
app.use(express.static("dist"));
|
|
68
|
+
// Serve any public assets (like images, fonts)
|
|
69
|
+
app.use(express.static("public"));
|
|
70
|
+
// Serve the database files (e.g. uploaded images or JSON files from easy-db-node)
|
|
71
|
+
app.use("/files", express.static("easy-db-files"));
|
|
72
|
+
|
|
73
|
+
// --------------------------------- SEO ---------------------------------------
|
|
74
|
+
// Basic configuration for search engine crawlers.
|
|
75
|
+
app.get("/robots.txt", (_, res) => {
|
|
76
|
+
res.type("text/plain");
|
|
77
|
+
res.send(`User-agent: *\nAllow: /\n\nSitemap: ${ORIGIN}/sitemap.xml`);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Sitemap helps search engines index your site. You should dynamically generate this
|
|
81
|
+
// based on your application's routes and database content.
|
|
82
|
+
app.get("/sitemap.xml", async (_, res) => {
|
|
83
|
+
// TODO: add all pages
|
|
84
|
+
const sitemap = [{
|
|
85
|
+
url: ORIGIN + "/",
|
|
86
|
+
lastModified: "2024-01-01",
|
|
87
|
+
changeFrequency: "weekly",
|
|
88
|
+
priority: 1,
|
|
89
|
+
}];
|
|
90
|
+
|
|
91
|
+
res.set("Content-Type", "text/xml");
|
|
92
|
+
res.send(`<?xml version="1.0" encoding="UTF-8" ?>
|
|
93
|
+
<urlset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd" xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${sitemap.map(s => `<url>
|
|
94
|
+
<loc>${s.url}</loc>
|
|
95
|
+
<lastmod>${s.lastModified}</lastmod>
|
|
96
|
+
${s.changeFrequency ? `<changefreq>${s.changeFrequency}</changefreq>` : ""}
|
|
97
|
+
<priority>${s.priority}</priority>
|
|
98
|
+
</url>`).join("\n")}</urlset>`);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// --------------------------- Easy analytics ----------------------------------
|
|
102
|
+
// This parses incoming JSON payloads for the API and analytics.
|
|
103
|
+
app.use(express.json({ limit: "1MB" }));
|
|
104
|
+
|
|
105
|
+
// Endpoint to receive analytics data from the client.
|
|
106
|
+
app.post("/api/easy-analytics", async (req, res) => {
|
|
107
|
+
const userAgent = req.headers['user-agent'] || "";
|
|
108
|
+
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || "";
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const response = await postEasyAnalytics(req.body, userAgent, ip as string);
|
|
112
|
+
res.status(200).json(response);
|
|
113
|
+
} catch (e: any) {
|
|
114
|
+
console.error("Analytics error:", e);
|
|
115
|
+
res.status(400).json({ message: e.message || "Bad request" });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// --- ADMIN START ---
|
|
120
|
+
// --- ADMIN END ---
|
|
121
|
+
|
|
122
|
+
// --------------------------------- API ---------------------------------------
|
|
123
|
+
// 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 the api() client.
|
|
125
|
+
setAPIBackend<API>(app, {
|
|
126
|
+
async getItems() {
|
|
127
|
+
return await selectArray("item");
|
|
128
|
+
},
|
|
129
|
+
async getItem({ id }) {
|
|
130
|
+
const item = await select("item", id);
|
|
131
|
+
if (!item) throw new Error("Item not found");
|
|
132
|
+
return item; // easy-db-node select returns the object
|
|
133
|
+
},
|
|
134
|
+
async addItem({ name, value }) {
|
|
135
|
+
const id = await insert("item", { name, value });
|
|
136
|
+
return id;
|
|
137
|
+
},
|
|
138
|
+
async removeItem({ id }) {
|
|
139
|
+
await remove("item", id);
|
|
140
|
+
return true;
|
|
141
|
+
},
|
|
142
|
+
// --- ADMIN START ---
|
|
143
|
+
async getEasyAnalyticsData({ password, month }) {
|
|
144
|
+
const validPassword = process.env.ADMIN_PASSWORD;
|
|
145
|
+
if (!validPassword || password !== validPassword) {
|
|
146
|
+
throw new Error("Invalid password or server error");
|
|
147
|
+
}
|
|
148
|
+
return await getEasyAnalytics(month);
|
|
149
|
+
}
|
|
150
|
+
// --- ADMIN END ---
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// --------------------------------- SSR ---------------------------------------
|
|
154
|
+
|
|
155
|
+
app.get(/.*/, async (req, res) => {
|
|
156
|
+
try {
|
|
157
|
+
const indexHtml = await readFile(INDEX_HTML_PATH, "utf-8");
|
|
158
|
+
const html = await renderToHTML(ORIGIN, req.url, indexHtml, <App />);
|
|
159
|
+
res.set("Content-Type", "text/html");
|
|
160
|
+
res.send(html);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
console.error("SSR error:", e);
|
|
163
|
+
res.status(500);
|
|
164
|
+
res.json({ message: "Internal server error" });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Start listening for incoming requests.
|
|
169
|
+
app.listen(PORT, () => {
|
|
170
|
+
console.log(`Server is running on ${LOCALHOST}`);
|
|
171
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
import { init } from "easy-analytics/client";
|
|
6
|
+
|
|
7
|
+
import Router from "./Router";
|
|
8
|
+
|
|
9
|
+
// Initialize analytics. It sends data to our Express backend endpoint.
|
|
10
|
+
init(process.env.NODE_ENV === "development"
|
|
11
|
+
? "http://localhost:1111/api/easy-analytics"
|
|
12
|
+
: "/api/easy-analytics");
|
|
13
|
+
|
|
14
|
+
export default function App() {
|
|
15
|
+
return (
|
|
16
|
+
// The RouterProvider must wrap your application router so that
|
|
17
|
+
// nested components can react to URL changes.
|
|
18
|
+
<RouterProvider>
|
|
19
|
+
<Router />
|
|
20
|
+
</RouterProvider>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Router } from "easy-page-router/react";
|
|
2
|
+
|
|
3
|
+
import Index from "./pages/Index";
|
|
4
|
+
import NotFound from "./pages/NotFound";
|
|
5
|
+
// --- ADMIN START ---
|
|
6
|
+
import { lazy, Suspense } from "react";
|
|
7
|
+
const Admin = lazy(() => import("./pages/admin/Admin"));
|
|
8
|
+
// --- ADMIN END ---
|
|
9
|
+
|
|
10
|
+
export default function AppRouter() {
|
|
11
|
+
return <Router
|
|
12
|
+
renderPage={({ path }) => {
|
|
13
|
+
// The 'path' variable is an array of strings representing the URL segments.
|
|
14
|
+
// For example, "/users/123" would be ["users", "123"].
|
|
15
|
+
|
|
16
|
+
// If the path is empty, we are at the root URL (/).
|
|
17
|
+
if (path.length === 0) return <Index />;
|
|
18
|
+
|
|
19
|
+
// --- ADMIN START ---
|
|
20
|
+
if (path[0] === 'admin') return (
|
|
21
|
+
<Suspense fallback={<div style={{ padding: 40, textAlign: 'center' }}>Loading admin...</div>}>
|
|
22
|
+
<Admin />
|
|
23
|
+
</Suspense>
|
|
24
|
+
);
|
|
25
|
+
// --- ADMIN END ---
|
|
26
|
+
|
|
27
|
+
// You can add more routes here, for example:
|
|
28
|
+
// if (path[0] === 'about') return <AboutPage />;
|
|
29
|
+
// if (path[0] === 'users' && path[1]) return <UserPage id={path[1]} />;
|
|
30
|
+
|
|
31
|
+
// If no routes match, render a 404 Not Found component.
|
|
32
|
+
return <NotFound />;
|
|
33
|
+
}}
|
|
34
|
+
/>;
|
|
35
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<link rel="apple-touch-icon" sizes="180x180" href="./images/icon.png?width=180&height=180" />
|
|
7
|
+
<link rel="icon" type="image/png" sizes="32x32" href="./images/icon.png?width=32&height=32" />
|
|
8
|
+
<link rel="icon" type="image/png" sizes="16x16" href="./images/icon.png?width=16&height=16" />
|
|
9
|
+
<link rel="manifest" href="./site.webmanifest" />
|
|
10
|
+
<link rel="stylesheet" href="./styles.scss" />
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<div id="root"></div>
|
|
14
|
+
<script type="module" src="index.tsx"></script>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { createRoot, hydrateRoot } from "react-dom/client";
|
|
3
|
+
|
|
4
|
+
// ssr-hook requires knowing the backend origin to fetch initial state during SSR.
|
|
5
|
+
import { setSSROrigin } from "ssr-hook";
|
|
6
|
+
import { handleError } from "./services/api";
|
|
7
|
+
|
|
8
|
+
import App from "./App";
|
|
9
|
+
|
|
10
|
+
const rootElement = document.getElementById("root") as HTMLElement;
|
|
11
|
+
|
|
12
|
+
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
|
+
// We use createRoot because we often don't want to deal with full SSR hydration quirks
|
|
18
|
+
// during local frontend development (like Hot Module Replacement causing mismatches).
|
|
19
|
+
createRoot(rootElement).render(<App />);
|
|
20
|
+
} else {
|
|
21
|
+
// In production, the HTML is pre-rendered by the Express server.
|
|
22
|
+
// hydrateRoot attaches React event listeners to the existing HTML without recreating it,
|
|
23
|
+
// making the initial load much faster and seamless.
|
|
24
|
+
hydrateRoot(rootElement, <App />, {
|
|
25
|
+
onCaughtError: handleError,
|
|
26
|
+
onUncaughtError: handleError,
|
|
27
|
+
onRecoverableError: handleError,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
import { Link } from "easy-page-router/react";
|
|
9
|
+
|
|
10
|
+
import { useHead } from "../services/common";
|
|
11
|
+
|
|
12
|
+
export default function Index() {
|
|
13
|
+
useHead("Welcome to easy-starter", "The ultimate foundation for your next project.");
|
|
14
|
+
|
|
15
|
+
const [items, error, loading, reload] = useSSRHook<API["getItems"]["result"]>("/api/items");
|
|
16
|
+
|
|
17
|
+
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
|
|
18
|
+
const [selectedLoading, setSelectedLoading] = useState(false);
|
|
19
|
+
|
|
20
|
+
const [newItemName, setNewItemName] = useState("");
|
|
21
|
+
const [newItemValue, setNewItemValue] = useState("");
|
|
22
|
+
const [adding, setAdding] = useState(false);
|
|
23
|
+
|
|
24
|
+
async function handleView(id: string) {
|
|
25
|
+
setSelectedLoading(true);
|
|
26
|
+
const [item, err] = await api.getItem({ id });
|
|
27
|
+
setSelectedLoading(false);
|
|
28
|
+
if (item) setSelectedItem(item);
|
|
29
|
+
if (err) handleError(err);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function handleAdd(e: React.FormEvent) {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
setAdding(true);
|
|
35
|
+
const [id, err] = await api.addItem({ name: newItemName, value: Number(newItemValue) });
|
|
36
|
+
setAdding(false);
|
|
37
|
+
if (id) {
|
|
38
|
+
setNewItemName("");
|
|
39
|
+
setNewItemValue("");
|
|
40
|
+
reload();
|
|
41
|
+
}
|
|
42
|
+
if (err) handleError(err);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function handleRemove(id: string) {
|
|
46
|
+
if (!confirm("Are you sure you want to delete this item?")) return;
|
|
47
|
+
const [success, err] = await api.removeItem({ id });
|
|
48
|
+
if (success) reload();
|
|
49
|
+
if (err) handleError(err);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="container">
|
|
54
|
+
<div className="hero">
|
|
55
|
+
<div className="app-icon"></div>
|
|
56
|
+
<h1>Welcome to easy-starter! 🚀</h1>
|
|
57
|
+
<p>The ultimate foundation for your next project.</p>
|
|
58
|
+
<Link href="/admin">
|
|
59
|
+
→ Go to Admin Dashboard
|
|
60
|
+
</Link>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div className="api-test">
|
|
64
|
+
<h2>Items (SSR Hook)</h2>
|
|
65
|
+
{loading && <p>Loading from server...</p>}
|
|
66
|
+
{error && <p>Error loading items: {error.message}</p>}
|
|
67
|
+
{items && (
|
|
68
|
+
<ul>
|
|
69
|
+
{items.map(item => (
|
|
70
|
+
<li key={item._id}>
|
|
71
|
+
<span><strong>{item.name}</strong> - {item.value}</span>
|
|
72
|
+
<div>
|
|
73
|
+
<button onClick={() => handleView(item._id!)}>View Details</button>
|
|
74
|
+
<button className="btn-danger" onClick={() => handleRemove(item._id!)} style={{ marginLeft: 8 }}>Delete</button>
|
|
75
|
+
</div>
|
|
76
|
+
</li>
|
|
77
|
+
))}
|
|
78
|
+
</ul>
|
|
79
|
+
)}
|
|
80
|
+
{items?.length === 0 && <p>No items found. Add one below!</p>}
|
|
81
|
+
|
|
82
|
+
{selectedLoading && <p>Loading item details...</p>}
|
|
83
|
+
{selectedItem && (
|
|
84
|
+
<div className="item-details">
|
|
85
|
+
<h3>Selected Item:</h3>
|
|
86
|
+
<p>ID: {selectedItem._id}</p>
|
|
87
|
+
<p>Name: {selectedItem.name}</p>
|
|
88
|
+
<p>Value: {selectedItem.value}</p>
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
<h3 style={{ marginTop: 20 }}>Add New Item</h3>
|
|
93
|
+
<form onSubmit={handleAdd}>
|
|
94
|
+
<input
|
|
95
|
+
type="text"
|
|
96
|
+
placeholder="Item name"
|
|
97
|
+
value={newItemName}
|
|
98
|
+
onChange={e => setNewItemName(e.target.value)}
|
|
99
|
+
required
|
|
100
|
+
/>
|
|
101
|
+
<input
|
|
102
|
+
type="number"
|
|
103
|
+
placeholder="Value"
|
|
104
|
+
value={newItemValue}
|
|
105
|
+
onChange={e => setNewItemValue(e.target.value)}
|
|
106
|
+
required
|
|
107
|
+
/>
|
|
108
|
+
<button type="submit" disabled={adding}>
|
|
109
|
+
{adding ? "Adding..." : "Add"}
|
|
110
|
+
</button>
|
|
111
|
+
</form>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useHead } from "../services/common";
|
|
2
|
+
import { Link } from "easy-page-router/react";
|
|
3
|
+
|
|
4
|
+
export default function NotFound() {
|
|
5
|
+
useHead("404 Not Found", "Page you are looking for does not exist.");
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<div style={{ padding: "40px", textAlign: "center", fontFamily: "sans-serif" }}>
|
|
9
|
+
<h1>404</h1>
|
|
10
|
+
<p>Oops, page not found.</p>
|
|
11
|
+
<Link href="/">Go back home</Link>
|
|
12
|
+
</div>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { useGroup, GroupTable, getGroupedCount } from "easy-analytics/react";
|
|
3
|
+
import { Link } from "easy-page-router/react";
|
|
4
|
+
import { useHead } from "../../services/common";
|
|
5
|
+
import { api, handleError } from "../../services/api";
|
|
6
|
+
|
|
7
|
+
export default function Admin() {
|
|
8
|
+
useHead("Analytics Dashboard", "View statistics and analytics for your application.");
|
|
9
|
+
|
|
10
|
+
const [password, setPassword] = useState("");
|
|
11
|
+
const [loggedIn, setLoggedIn] = useState(false);
|
|
12
|
+
const [error, setError] = useState("");
|
|
13
|
+
|
|
14
|
+
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7));
|
|
15
|
+
const [records, setRecords] = useState([]);
|
|
16
|
+
const [loading, setLoading] = useState(false);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const savedPwd = localStorage.getItem("admin_pwd");
|
|
20
|
+
if (savedPwd) {
|
|
21
|
+
setPassword(savedPwd);
|
|
22
|
+
fetchData(savedPwd, month);
|
|
23
|
+
}
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
const fetchData = async (pwd: string, m: string) => {
|
|
27
|
+
setLoading(true);
|
|
28
|
+
setError("");
|
|
29
|
+
try {
|
|
30
|
+
const [data, err] = await api.getEasyAnalyticsData({ password: pwd, month: m });
|
|
31
|
+
|
|
32
|
+
if (data) {
|
|
33
|
+
setRecords(data as any);
|
|
34
|
+
setLoggedIn(true);
|
|
35
|
+
localStorage.setItem("admin_pwd", pwd);
|
|
36
|
+
} else if (err) {
|
|
37
|
+
setError(err.message || "Invalid password or server error");
|
|
38
|
+
setLoggedIn(false);
|
|
39
|
+
localStorage.removeItem("admin_pwd");
|
|
40
|
+
}
|
|
41
|
+
} catch (e) {
|
|
42
|
+
setError(String(e));
|
|
43
|
+
handleError(e as Error);
|
|
44
|
+
}
|
|
45
|
+
setLoading(false);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const handleLogin = (e: React.FormEvent) => {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
fetchData(password, month);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const handleMonthChange = (move: number) => {
|
|
54
|
+
const newMonth = new Date(month);
|
|
55
|
+
newMonth.setMonth(newMonth.getMonth() + move);
|
|
56
|
+
const nextM = newMonth.toISOString().slice(0, 7);
|
|
57
|
+
setMonth(nextM);
|
|
58
|
+
if (loggedIn) fetchData(password, nextM);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const byURL = useGroup(records, (r: any) => {
|
|
62
|
+
try {
|
|
63
|
+
return new URL(r.url).pathname;
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return r.url || "Unknown";
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const byReferrer = useGroup(records, (r: any) => r.referrer || "Direct", (rs: any) => getGroupedCount(rs, (row: any) => row.localId));
|
|
70
|
+
|
|
71
|
+
const byWidth = useGroup(records, (r: any) => {
|
|
72
|
+
const w = r.window?.innerWidth || 0;
|
|
73
|
+
if (w >= 1920) return ">= 1920px Full HD";
|
|
74
|
+
if (w >= 1280) return "1280px - 1920px HD";
|
|
75
|
+
if (w >= 768) return "768px - 1280px Tablet";
|
|
76
|
+
if (w > 0) return "< 768px Mobile";
|
|
77
|
+
return "Unknown";
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Simplified UA parser
|
|
81
|
+
const byBrowserLike = useGroup(records, (r: any) => {
|
|
82
|
+
const ua = (r.userAgent || "").toLowerCase();
|
|
83
|
+
if (ua.includes("firefox")) return "Firefox";
|
|
84
|
+
if (ua.includes("edg")) return "Edge";
|
|
85
|
+
if (ua.includes("chrome")) return "Chrome";
|
|
86
|
+
if (ua.includes("safari")) return "Safari";
|
|
87
|
+
return "Other/Unknown";
|
|
88
|
+
}, (rs: any) => getGroupedCount(rs, (row: any) => row.localId));
|
|
89
|
+
|
|
90
|
+
if (!loggedIn) {
|
|
91
|
+
return (
|
|
92
|
+
<div className="admin-login">
|
|
93
|
+
<h2>Analytics Login</h2>
|
|
94
|
+
<div className="back-link">
|
|
95
|
+
<Link href="/">Back to website</Link>
|
|
96
|
+
</div>
|
|
97
|
+
<form onSubmit={handleLogin}>
|
|
98
|
+
<input
|
|
99
|
+
type="password"
|
|
100
|
+
placeholder="Enter admin password"
|
|
101
|
+
value={password}
|
|
102
|
+
onChange={e => setPassword(e.target.value)}
|
|
103
|
+
/>
|
|
104
|
+
<button type="submit">Login</button>
|
|
105
|
+
{error && <p style={{ color: "#ef4444", marginTop: "10px" }}>{error}</p>}
|
|
106
|
+
</form>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Daily visits counter
|
|
112
|
+
const daysInMonth = new Date(parseInt(month.slice(0, 4)), parseInt(month.slice(5, 7)), 0).getDate();
|
|
113
|
+
const dailyStats = Array.from({ length: daysInMonth }, (_, i) => {
|
|
114
|
+
const d = i + 1;
|
|
115
|
+
const dateStr = `${month}-${d.toString().padStart(2, "0")}`;
|
|
116
|
+
const count = records.filter((r: any) => r.serverDate && r.serverDate.startsWith(dateStr)).length;
|
|
117
|
+
return { date: d, count };
|
|
118
|
+
});
|
|
119
|
+
const maxVisits = Math.max(...dailyStats.map(s => s.count), 1);
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className="admin-container">
|
|
123
|
+
<div className="header">
|
|
124
|
+
<h2>Analytics ({records.length} events)</h2>
|
|
125
|
+
<Link href="/">Back to website</Link>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div className="controls">
|
|
129
|
+
<button onClick={() => handleMonthChange(-1)}>{"< Month"}</button>
|
|
130
|
+
<strong>{month}</strong>
|
|
131
|
+
<button onClick={() => handleMonthChange(1)}>{"Month >"}</button>
|
|
132
|
+
<button onClick={() => fetchData(password, month)} disabled={loading}>
|
|
133
|
+
{loading ? "Loading..." : "Refresh"}
|
|
134
|
+
</button>
|
|
135
|
+
<button className="btn-logout" onClick={() => {
|
|
136
|
+
setLoggedIn(false);
|
|
137
|
+
setPassword("");
|
|
138
|
+
localStorage.removeItem("admin_pwd");
|
|
139
|
+
}}>Logout</button>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<div className="chart-container">
|
|
143
|
+
<h3>Pageviews per day</h3>
|
|
144
|
+
<div className="chart">
|
|
145
|
+
{dailyStats.map(({ date, count }) => (
|
|
146
|
+
<div key={date} className="bar-wrapper">
|
|
147
|
+
<span className="count">{count > 0 ? count : ""}</span>
|
|
148
|
+
<div className={`bar ${count > 0 ? 'active' : ''}`} style={{
|
|
149
|
+
height: `${(count / maxVisits) * 100}%`,
|
|
150
|
+
minHeight: count > 0 ? "4px" : "0",
|
|
151
|
+
}}></div>
|
|
152
|
+
<span className="date">{date}.</span>
|
|
153
|
+
</div>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<div className="grid">
|
|
159
|
+
<GroupTable title="Pageviews" data={byURL} mainColor="#6366f1" />
|
|
160
|
+
<GroupTable title="Traffic Sources (Unique)" data={byReferrer} mainColor="#34d399" />
|
|
161
|
+
<GroupTable title="Screen Resolution" data={byWidth} mainColor="#fbbf24" />
|
|
162
|
+
<GroupTable title="Browser (Unique)" data={byBrowserLike} mainColor="#c084fc" />
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { sendError } from "easy-analytics/client";
|
|
3
|
+
import { setServerUrl, getUseAPIFrontend, getAPIFrontend, setHeaders } from "typed-client-server-api/hooks";
|
|
4
|
+
|
|
5
|
+
import { API } from "../../types";
|
|
6
|
+
|
|
7
|
+
setServerUrl(process.env.NODE_ENV === "development" ? "http://localhost:1111" : "");
|
|
8
|
+
|
|
9
|
+
let authorization = "";
|
|
10
|
+
|
|
11
|
+
setHeaders(() => ({
|
|
12
|
+
"Content-Type": "application/json",
|
|
13
|
+
"Authorization": authorization,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
export function setAuthorizationHeader(newAuthorization: string) {
|
|
17
|
+
authorization = newAuthorization;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const api = getAPIFrontend<API>();
|
|
21
|
+
export const useApi = getUseAPIFrontend<API>();
|
|
22
|
+
|
|
23
|
+
export function handleError(error?: null | string | Error, errorInfo?: any) {
|
|
24
|
+
if (!error) return;
|
|
25
|
+
if (typeof error === "string") error = new Error(error);
|
|
26
|
+
|
|
27
|
+
if (errorInfo && typeof errorInfo === "object" && typeof errorInfo.componentStack === "string") {
|
|
28
|
+
error.stack = errorInfo.componentStack;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.error(error);
|
|
32
|
+
sendError(error);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useHandleError(error?: null | string | Error) {
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
handleError(error);
|
|
38
|
+
}, [error]);
|
|
39
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useHeaders } from "ssr-hook";
|
|
2
|
+
import { useTitle } from "easy-page-router/react";
|
|
3
|
+
|
|
4
|
+
export function useHead(title: string, description: string) {
|
|
5
|
+
useHeaders({
|
|
6
|
+
title,
|
|
7
|
+
description,
|
|
8
|
+
image: `/images/icon.png`, // Zde je možné zapojit dynamické generování jako v titimeme
|
|
9
|
+
canonical: typeof window !== "undefined" ? window.location.origin + window.location.pathname : "",
|
|
10
|
+
});
|
|
11
|
+
useTitle(title);
|
|
12
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__NAME__",
|
|
3
|
+
"short_name": "__NAME__",
|
|
4
|
+
"description": "Welcome to __NAME__ application.",
|
|
5
|
+
"start_url": "/",
|
|
6
|
+
"icons": [
|
|
7
|
+
{
|
|
8
|
+
"src": "./images/icon.png?width=16&height=16",
|
|
9
|
+
"sizes": "16x16",
|
|
10
|
+
"type": "image/png"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"src": "./images/icon.png?width=32&height=32",
|
|
14
|
+
"sizes": "32x32",
|
|
15
|
+
"type": "image/png"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"src": "./images/icon.png?as=webp&width=192&height=192",
|
|
19
|
+
"sizes": "192x192",
|
|
20
|
+
"type": "image/webp"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"src": "./images/icon.png?width=192&height=192",
|
|
24
|
+
"sizes": "192x192",
|
|
25
|
+
"type": "image/png"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"src": "./images/icon.png?width=180&height=180",
|
|
29
|
+
"sizes": "180x180",
|
|
30
|
+
"type": "image/png"
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"theme_color": "#2563eb",
|
|
34
|
+
"background_color": "#ffffff",
|
|
35
|
+
"display": "standalone"
|
|
36
|
+
}
|