easy-starter 1.2.1 → 2.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 +33 -80
- package/bin/index.js +251 -49
- package/package.json +20 -11
- package/template/.cursorrules +42 -10
- package/template/README.md +129 -25
- package/template/env +0 -1
- package/template/gitignore +2 -1
- package/template/index.html +15 -0
- package/template/package.json +14 -8
- package/template/{src → public}/site.webmanifest +6 -12
- package/template/scripts/deploy.ts +3 -2
- package/template/server/index.tsx +264 -56
- package/template/server/mail.tsx +120 -0
- package/template/src/App.tsx +33 -7
- package/template/src/Router.tsx +13 -4
- package/template/src/components/Img.tsx +127 -0
- package/template/src/index.tsx +28 -1
- package/template/src/pages/Index.tsx +36 -6
- package/template/src/pages/NotFound.tsx +1 -1
- package/template/src/pages/ResetPassword.tsx +124 -0
- package/template/src/pages/admin/Admin.tsx +332 -85
- package/template/src/pages/admin/AnalyticsTab.tsx +71 -0
- package/template/src/pages/admin/ErrorsTab.tsx +46 -0
- package/template/src/pages/admin/UsersTab.tsx +54 -0
- package/template/src/services/api.ts +5 -7
- package/template/src/services/common.ts +34 -1
- package/template/src/styles.css +667 -0
- package/template/tsconfig.json +7 -2
- package/template/types.ts +44 -3
- package/template/vite.config.ts +29 -0
- package/template/.parcelrc +0 -6
- package/template/src/images/icon.png +0 -0
- package/template/src/index.html +0 -16
- package/template/src/styles.scss +0 -292
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
export type ImgProps = React.ImgHTMLAttributes<HTMLImageElement> & {
|
|
4
|
+
/** Target width in pixels */
|
|
5
|
+
w?: number | string;
|
|
6
|
+
/** Target height in pixels */
|
|
7
|
+
h?: number | string;
|
|
8
|
+
/** Max width in pixels */
|
|
9
|
+
mw?: number | string;
|
|
10
|
+
/** Max height in pixels */
|
|
11
|
+
mh?: number | string;
|
|
12
|
+
/** Max width in pixels (alias) */
|
|
13
|
+
maxWidth?: number | string;
|
|
14
|
+
/** Max height in pixels (alias) */
|
|
15
|
+
maxHeight?: number | string;
|
|
16
|
+
/** Output image format (default: "webp") */
|
|
17
|
+
format?: "webp" | "jpeg" | "png";
|
|
18
|
+
/** Output image format (alias) */
|
|
19
|
+
type?: "webp" | "jpeg" | "png";
|
|
20
|
+
/** Quality from 1 to 100 */
|
|
21
|
+
quality?: number;
|
|
22
|
+
/** Quality from 1 to 100 (alias) */
|
|
23
|
+
q?: number;
|
|
24
|
+
/** Smooth resize processing */
|
|
25
|
+
smooth?: boolean;
|
|
26
|
+
/** Smooth resize processing (alias) */
|
|
27
|
+
s?: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function imgSrc(
|
|
31
|
+
src: string,
|
|
32
|
+
options: {
|
|
33
|
+
w?: number | string;
|
|
34
|
+
h?: number | string;
|
|
35
|
+
mw?: number | string;
|
|
36
|
+
mh?: number | string;
|
|
37
|
+
format?: "webp" | "jpeg" | "png";
|
|
38
|
+
quality?: number;
|
|
39
|
+
smooth?: boolean;
|
|
40
|
+
}
|
|
41
|
+
): string | undefined {
|
|
42
|
+
if (!src) return src;
|
|
43
|
+
|
|
44
|
+
const targetFormat = options.format || "webp";
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const [base, queryString] = src.split("?");
|
|
48
|
+
const params = new URLSearchParams(queryString || "");
|
|
49
|
+
|
|
50
|
+
if (options.w !== undefined && !params.has("w") && !params.has("width")) {
|
|
51
|
+
params.set("w", String(options.w));
|
|
52
|
+
}
|
|
53
|
+
if (options.h !== undefined && !params.has("h") && !params.has("height")) {
|
|
54
|
+
params.set("h", String(options.h));
|
|
55
|
+
}
|
|
56
|
+
if (options.mw !== undefined && !params.has("mw") && !params.has("maxWidth")) {
|
|
57
|
+
params.set("mw", String(options.mw));
|
|
58
|
+
}
|
|
59
|
+
if (options.mh !== undefined && !params.has("mh") && !params.has("maxHeight")) {
|
|
60
|
+
params.set("mh", String(options.mh));
|
|
61
|
+
}
|
|
62
|
+
if (options.quality !== undefined && !params.has("q") && !params.has("quality")) {
|
|
63
|
+
params.set("q", String(options.quality));
|
|
64
|
+
}
|
|
65
|
+
if (options.smooth !== undefined && !params.has("s") && !params.has("smooth")) {
|
|
66
|
+
params.set("s", String(options.smooth));
|
|
67
|
+
}
|
|
68
|
+
if (targetFormat && !params.has("format") && !params.has("type") && !params.has("ext")) {
|
|
69
|
+
params.set("format", targetFormat);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const finalQuery = params.toString();
|
|
73
|
+
return finalQuery ? `${base}?${finalQuery}` : base;
|
|
74
|
+
} catch {
|
|
75
|
+
return src;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Smart image component wrapping HTML <img> with automatic easy-image-resizer URL formatting.
|
|
81
|
+
* Defaults to WebP format with optional width/height/quality scaling options.
|
|
82
|
+
*/
|
|
83
|
+
export function Img({
|
|
84
|
+
src,
|
|
85
|
+
width,
|
|
86
|
+
height,
|
|
87
|
+
w,
|
|
88
|
+
h,
|
|
89
|
+
mw,
|
|
90
|
+
mh,
|
|
91
|
+
maxWidth,
|
|
92
|
+
maxHeight,
|
|
93
|
+
format,
|
|
94
|
+
type,
|
|
95
|
+
quality,
|
|
96
|
+
q,
|
|
97
|
+
smooth,
|
|
98
|
+
s,
|
|
99
|
+
...restProps
|
|
100
|
+
}: ImgProps) {
|
|
101
|
+
const finalW = w ?? width;
|
|
102
|
+
const finalH = h ?? height;
|
|
103
|
+
const finalMw = mw ?? maxWidth;
|
|
104
|
+
const finalMh = mh ?? maxHeight;
|
|
105
|
+
const finalFormat = format ?? type ?? "webp";
|
|
106
|
+
const finalQuality = q ?? quality;
|
|
107
|
+
const finalSmooth = s ?? smooth;
|
|
108
|
+
|
|
109
|
+
const finalSrc = imgSrc(src || "", {
|
|
110
|
+
w: finalW,
|
|
111
|
+
h: finalH,
|
|
112
|
+
mw: finalMw,
|
|
113
|
+
mh: finalMh,
|
|
114
|
+
format: finalFormat,
|
|
115
|
+
quality: finalQuality,
|
|
116
|
+
smooth: finalSmooth,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return <img
|
|
120
|
+
src={finalSrc}
|
|
121
|
+
width={finalW}
|
|
122
|
+
height={finalH}
|
|
123
|
+
{...restProps}
|
|
124
|
+
/>;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export default Img;
|
package/template/src/index.tsx
CHANGED
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { createRoot
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
// --- SSR START ---
|
|
4
|
+
import { hydrateRoot } from "react-dom/client";
|
|
5
|
+
// --- SSR END ---
|
|
3
6
|
|
|
4
7
|
import { handleError } from "./services/api";
|
|
5
8
|
|
|
6
9
|
import App from "./App";
|
|
10
|
+
import "./styles.css";
|
|
11
|
+
|
|
12
|
+
// --- VIEWPORT START ---
|
|
13
|
+
// Fix iOS safari issues with viewport
|
|
14
|
+
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
15
|
+
const updateViewport = () => {
|
|
16
|
+
const metaViewport = document.querySelector('meta[name="viewport"]');
|
|
17
|
+
if (metaViewport) {
|
|
18
|
+
if (window.screen.width <= 600) {
|
|
19
|
+
metaViewport.setAttribute('content', 'width=600, user-scalable=no');
|
|
20
|
+
} else {
|
|
21
|
+
metaViewport.setAttribute('content', 'width=device-width');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
updateViewport();
|
|
26
|
+
window.addEventListener('resize', updateViewport);
|
|
27
|
+
}
|
|
28
|
+
// --- VIEWPORT END ---
|
|
7
29
|
|
|
8
30
|
const rootElement = document.getElementById("root") as HTMLElement;
|
|
9
31
|
|
|
32
|
+
// --- SSR START ---
|
|
10
33
|
if (process.env.NODE_ENV === "development") {
|
|
11
34
|
// We use createRoot because we often don't want to deal with full SSR hydration quirks
|
|
12
35
|
// during local frontend development (like Hot Module Replacement causing mismatches).
|
|
@@ -21,3 +44,7 @@ if (process.env.NODE_ENV === "development") {
|
|
|
21
44
|
onRecoverableError: handleError,
|
|
22
45
|
});
|
|
23
46
|
}
|
|
47
|
+
// --- SSR END ---
|
|
48
|
+
// --- NO_SSR START ---
|
|
49
|
+
createRoot(rootElement).render(<App />);
|
|
50
|
+
// --- NO_SSR END ---
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import React, { useState } from "react";
|
|
2
2
|
import { Link } from "easy-page-router/react";
|
|
3
3
|
|
|
4
4
|
import { api, useSSRApi } from "../services/api";
|
|
@@ -6,8 +6,20 @@ import { useHead } from "../services/common";
|
|
|
6
6
|
|
|
7
7
|
import { Item } from "../../types";
|
|
8
8
|
|
|
9
|
+
// --- AUTH START ---
|
|
10
|
+
import { useUser, UserDialog } from "easy-user-auth/react";
|
|
11
|
+
import { UserProfile } from "../../types";
|
|
12
|
+
// --- AUTH END ---
|
|
13
|
+
|
|
9
14
|
export default function Index() {
|
|
10
|
-
useHead({
|
|
15
|
+
useHead({
|
|
16
|
+
title: "Welcome to easy-starter",
|
|
17
|
+
description: "The ultimate foundation for your next project."
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// --- AUTH START ---
|
|
21
|
+
const { isLoggedIn, user, loginUser, showUserDialog } = useUser<UserProfile>();
|
|
22
|
+
// --- AUTH END ---
|
|
11
23
|
|
|
12
24
|
// This will run on the server, fetch the data, embed it in the HTML,
|
|
13
25
|
// and hydrate perfectly on the client. Fully type-safe!
|
|
@@ -47,17 +59,35 @@ export default function Index() {
|
|
|
47
59
|
|
|
48
60
|
return (
|
|
49
61
|
<main>
|
|
62
|
+
{/* --- AUTH START --- */}
|
|
63
|
+
<div className="auth-bar">
|
|
64
|
+
{isLoggedIn ? (
|
|
65
|
+
<div className="auth-user-info">
|
|
66
|
+
<span>Logged in as: <strong>{user.name} ({user.role})</strong></span>
|
|
67
|
+
<button onClick={() => loginUser(null)}>Log Out</button>
|
|
68
|
+
</div>
|
|
69
|
+
) : (
|
|
70
|
+
<button onClick={showUserDialog}>Log In / Sign Up</button>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
{/* --- AUTH END --- */}
|
|
74
|
+
|
|
50
75
|
<header>
|
|
51
76
|
<figure></figure>
|
|
52
77
|
<h1>Welcome to easy-starter!</h1>
|
|
53
78
|
<p>The ultimate foundation for your next project.</p>
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
79
|
+
|
|
80
|
+
{/* --- ADMIN START --- */}
|
|
81
|
+
<div className="admin-link-wrapper">
|
|
82
|
+
<Link href="/admin">
|
|
83
|
+
→ Go to Admin Dashboard
|
|
84
|
+
</Link>
|
|
85
|
+
</div>
|
|
86
|
+
{/* --- ADMIN END --- */}
|
|
57
87
|
</header>
|
|
58
88
|
|
|
59
89
|
<section>
|
|
60
|
-
<h2>Items
|
|
90
|
+
<h2>Items</h2>
|
|
61
91
|
{loading && <p>Loading from server...</p>}
|
|
62
92
|
{error && <p>Error loading items: {error.message}</p>}
|
|
63
93
|
{items && (
|
|
@@ -6,7 +6,7 @@ export default function NotFound() {
|
|
|
6
6
|
useHead({ title: "404 Not Found", description: "Page you are looking for does not exist." });
|
|
7
7
|
|
|
8
8
|
return (
|
|
9
|
-
<div
|
|
9
|
+
<div className="not-found">
|
|
10
10
|
<h1>404</h1>
|
|
11
11
|
<p>Oops, page not found.</p>
|
|
12
12
|
<Link href="/">Go back home</Link>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { useRouter } from "easy-page-router/react";
|
|
3
|
+
import { useUser } from "easy-user-auth/react";
|
|
4
|
+
import { useHead } from "../services/common";
|
|
5
|
+
import { UserProfile } from "../../types";
|
|
6
|
+
|
|
7
|
+
export default function ResetPasswordPage() {
|
|
8
|
+
useHead({ title: "Reset Password", description: "Enter a new password for your account." });
|
|
9
|
+
|
|
10
|
+
const { api, loginUser, dict } = useUser<UserProfile>();
|
|
11
|
+
const { push } = useRouter();
|
|
12
|
+
|
|
13
|
+
const [token, setToken] = useState<string | null>(null);
|
|
14
|
+
const [password, setPassword] = useState("");
|
|
15
|
+
const [passwordAgain, setPasswordAgain] = useState("");
|
|
16
|
+
const [error, setError] = useState<string | null>(null);
|
|
17
|
+
const [success, setSuccess] = useState<boolean>(false);
|
|
18
|
+
const [loading, setLoading] = useState<boolean>(false);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (typeof window !== "undefined") {
|
|
22
|
+
const params = new URLSearchParams(window.location.search);
|
|
23
|
+
const tok = params.get("token");
|
|
24
|
+
if (tok) {
|
|
25
|
+
setToken(tok);
|
|
26
|
+
} else {
|
|
27
|
+
setError("Reset token is missing in the URL.");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
let validationError = "";
|
|
33
|
+
if (password.length > 0 && password.length < 6) {
|
|
34
|
+
validationError = dict.userWrongPasswordLength;
|
|
35
|
+
} else if (password && passwordAgain && password !== passwordAgain) {
|
|
36
|
+
validationError = dict.userWrongPasswordMatch;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
if (validationError || !password || !passwordAgain || !token) return;
|
|
42
|
+
|
|
43
|
+
setLoading(true);
|
|
44
|
+
setError(null);
|
|
45
|
+
|
|
46
|
+
const [userResult, err] = await api.updateForgottenPassword({ token, password });
|
|
47
|
+
|
|
48
|
+
if (err) {
|
|
49
|
+
setError(dict.userForgottenPasswordErrorChange);
|
|
50
|
+
} else if (userResult) {
|
|
51
|
+
setSuccess(true);
|
|
52
|
+
setTimeout(() => {
|
|
53
|
+
loginUser(userResult);
|
|
54
|
+
push("/");
|
|
55
|
+
}, 2000);
|
|
56
|
+
}
|
|
57
|
+
setLoading(false);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<main className="reset-password-main">
|
|
62
|
+
<h2 className="easy-user-auth-title">🔑 {dict.userForgottenPasswordChange}</h2>
|
|
63
|
+
|
|
64
|
+
{error && !token && (
|
|
65
|
+
<div className="easy-user-auth-alert">{error}</div>
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
{success ? (
|
|
69
|
+
<div className="easy-user-auth-alert alert-success">
|
|
70
|
+
{dict.userForgottenPasswordSuccessChange}
|
|
71
|
+
</div>
|
|
72
|
+
) : (
|
|
73
|
+
token && (
|
|
74
|
+
<form onSubmit={handleSubmit} className="easy-user-auth-form">
|
|
75
|
+
<div className="easy-user-auth-form-input">
|
|
76
|
+
<label className="easy-user-auth-label">{dict.password}</label>
|
|
77
|
+
<input
|
|
78
|
+
type="password"
|
|
79
|
+
className="easy-user-auth-input"
|
|
80
|
+
value={password}
|
|
81
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
82
|
+
required
|
|
83
|
+
maxLength={50}
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div className="easy-user-auth-form-input">
|
|
88
|
+
<label className="easy-user-auth-label">{dict.passwordAgain}</label>
|
|
89
|
+
<input
|
|
90
|
+
type="password"
|
|
91
|
+
className="easy-user-auth-input"
|
|
92
|
+
value={passwordAgain}
|
|
93
|
+
onChange={(e) => setPasswordAgain(e.target.value)}
|
|
94
|
+
required
|
|
95
|
+
maxLength={50}
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{error && <div className="easy-user-auth-alert">{error}</div>}
|
|
100
|
+
{validationError && <p className="easy-user-auth-info text-error">{validationError}</p>}
|
|
101
|
+
|
|
102
|
+
<div className="btn-group">
|
|
103
|
+
<button
|
|
104
|
+
type="submit"
|
|
105
|
+
className="easy-user-auth-submit-btn"
|
|
106
|
+
disabled={!!validationError || !password || !passwordAgain || loading}
|
|
107
|
+
>
|
|
108
|
+
{loading ? "Updating..." : dict.change}
|
|
109
|
+
</button>
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
className="easy-user-auth-back-btn"
|
|
113
|
+
onClick={() => push("/")}
|
|
114
|
+
disabled={loading}
|
|
115
|
+
>
|
|
116
|
+
Cancel
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
</form>
|
|
120
|
+
)
|
|
121
|
+
)}
|
|
122
|
+
</main>
|
|
123
|
+
);
|
|
124
|
+
}
|