diff --git a/.env.example b/.env.example deleted file mode 100644 index 63eee96..0000000 --- a/.env.example +++ /dev/null @@ -1 +0,0 @@ -VITE_API_URL=http://localhost:5050/api/v1 diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml index db9e331..a99d512 100644 --- a/.github/actions/setup-node/action.yml +++ b/.github/actions/setup-node/action.yml @@ -2,12 +2,12 @@ name: Setup Node.js description: Setup Node.js runs: - using: "composite" - steps: - - uses: actions/setup-node@v4 - name: Setup Node.js - with: - node-version-file: .nvmrc - - name: Install dependencies - run: npm i - shell: bash + using: "composite" + steps: + - uses: actions/setup-node@v4 + name: Setup Node.js + with: + node-version-file: .nvmrc + - name: Install dependencies + run: npm i + shell: bash diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..8695e39 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,63 @@ +name: Publish + +on: + push: + branches: [main] + tags: [v*] + pull_request: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + DOCKER_USER: ${{ github.actor }} + DOCKER_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + +jobs: + publish: + name: Publish + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + attestations: write + id-token: write + steps: + - name: Install Cosign + uses: sigstore/cosign-installer@v3.8.0 + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Login to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ env.DOCKER_USER }} + password: ${{ env.DOCKER_PASSWORD }} + - name: Get metadata + uses: docker/metadata-action@v4 + id: meta + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + - name: Build and push + uses: docker/build-push-action@v4 + id: build-and-push + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + cache-to: type=inline + - name: Sign the published Docker image + if: ${{ github.event_name != 'pull_request' }} + env: + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build-and-push.outputs.digest }} + run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} diff --git a/CHANGELOG.md b/CHANGELOG.md index f333619..5ff067a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,13 @@ ## [1.1.0](https://github.com/thangved/react-boilerplate/compare/v1.0.7...v1.1.0) (2024-12-17) - ### Features -* add i18next ([05f925b](https://github.com/thangved/react-boilerplate/commit/05f925be508b94f7f1ab300b09c690c70d1dea7d)) - +- add i18next ([05f925b](https://github.com/thangved/react-boilerplate/commit/05f925be508b94f7f1ab300b09c690c70d1dea7d)) ### Bug Fixes -* **deps:** update react monorepo to v19 ([95fe746](https://github.com/thangved/react-boilerplate/commit/95fe7469d5116af762419e35fd97cce8e08b7d5d)) +- **deps:** update react monorepo to v19 ([95fe746](https://github.com/thangved/react-boilerplate/commit/95fe7469d5116af762419e35fd97cce8e08b7d5d)) ## [1.0.7](https://github.com/thangved/react-boilerplate/compare/v1.0.6...v1.0.7) (2024-12-06) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a2ec9e9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM node:22 AS base + +FROM base AS builder + +WORKDIR /app + +ADD ./package.json ./ + +RUN npm i --ignore-scripts + +ADD ./locales ./locales +ADD ./public ./public +ADD ./src ./src +ADD ./index.html\ + ./tsconfig.json\ + ./tsconfig.node.json\ + ./vite.config.ts\ + ./ + +RUN npm run build + +FROM nginx:alpine AS runner + +COPY --from=builder /app/dist /usr/share/nginx/html +ADD ./nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..246b0ca --- /dev/null +++ b/compose.yml @@ -0,0 +1,10 @@ +services: + web: + build: + context: . + dockerfile: Dockerfile + image: ghrc.io/thangved/react-boilerplate + ports: + - 8888:80 + api: + image: ghrc.io/thangved/express0 diff --git a/eslint.config.mjs b/eslint.config.mjs index 2b5389c..bc7f7e0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,42 +1,47 @@ import { fixupConfigRules } from "@eslint/compat"; +import { FlatCompat } from "@eslint/eslintrc"; +import js from "@eslint/js"; +import tsParser from "@typescript-eslint/parser"; import reactRefresh from "eslint-plugin-react-refresh"; import globals from "globals"; -import tsParser from "@typescript-eslint/parser"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import js from "@eslint/js"; -import { FlatCompat } from "@eslint/eslintrc"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const compat = new FlatCompat({ - baseDirectory: __dirname, - recommendedConfig: js.configs.recommended, - allConfig: js.configs.all + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, }); -export default [{ - ignores: ["**/dist", "**/.eslintrc.cjs"], -}, ...fixupConfigRules(compat.extends( - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:react-hooks/recommended", -)), { - plugins: { - "react-refresh": reactRefresh, - }, +export default [ + { + ignores: ["**/dist", "**/.eslintrc.cjs"], + }, + ...fixupConfigRules( + compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react-hooks/recommended"), + ), + { + plugins: { + "react-refresh": reactRefresh, + }, - languageOptions: { - globals: { - ...globals.browser, - }, + languageOptions: { + globals: { + ...globals.browser, + }, - parser: tsParser, - }, + parser: tsParser, + }, - rules: { - "react-refresh/only-export-components": ["warn", { - allowConstantExport: true, - }], - }, -}]; \ No newline at end of file + rules: { + "react-refresh/only-export-components": [ + "warn", + { + allowConstantExport: true, + }, + ], + }, + }, +]; diff --git a/index.html b/index.html index 3f6de4c..2586014 100644 --- a/index.html +++ b/index.html @@ -59,7 +59,7 @@ .app-logo { width: 150px; height: 150px; - background: url(@/assets/logo.svg) no-repeat center center / contain; + background: url(/logo.svg) no-repeat center center / contain; animation: blingbling 0.5s infinite; position: relative; z-index: 10; diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..256a63d --- /dev/null +++ b/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 80; + + location /api { + proxy_pass http://api:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + } +} \ No newline at end of file diff --git a/package.json b/package.json index b4e1ab0..c052fb9 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "react-boilerplate", "version": "1.1.0", "private": true, + "license": "MIT", "type": "module", "scripts": { "build": "tsc && vite build", @@ -22,8 +23,6 @@ "clsx": "^2.1.1", "i18next": "^24.1.1", "i18next-browser-languagedetector": "^8.0.2", - "i18next-chained-backend": "^4.6.2", - "i18next-http-backend": "^3.0.1", "i18next-localstorage-backend": "^4.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -31,10 +30,7 @@ "react-redux": "^9.2.0", "react-router-dom": "^7.0.2", "sharp": "^0.33.5", - "svgo": "^3.3.2", - "translation-check": "^1.1.0", - "vite-plugin-bundle-prefetch": "^0.0.4", - "vite-plugin-image-optimizer": "^1.1.8" + "svgo": "^3.3.2" }, "devDependencies": { "@commitlint/cli": "^19.6.1", diff --git a/public/apple-touch-icon-180x180.png b/public/apple-touch-icon-180x180.png index 74e32c8..cbf148b 100644 Binary files a/public/apple-touch-icon-180x180.png and b/public/apple-touch-icon-180x180.png differ diff --git a/public/favicon.ico b/public/favicon.ico index 0867fc1..5d420bc 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/logo.svg b/public/logo.svg index d9cf046..f9d50b7 100644 --- a/public/logo.svg +++ b/public/logo.svg @@ -1,11 +1,5 @@ - - - - - - - - - \ No newline at end of file + + + + + diff --git a/public/maskable-icon-512x512.png b/public/maskable-icon-512x512.png index 6c30546..8d6cd63 100644 Binary files a/public/maskable-icon-512x512.png and b/public/maskable-icon-512x512.png differ diff --git a/public/pwa-192x192.png b/public/pwa-192x192.png index 8d4c09d..1ed7535 100644 Binary files a/public/pwa-192x192.png and b/public/pwa-192x192.png differ diff --git a/public/pwa-512x512.png b/public/pwa-512x512.png index 30cb3ea..a0a9d39 100644 Binary files a/public/pwa-512x512.png and b/public/pwa-512x512.png differ diff --git a/public/pwa-64x64.png b/public/pwa-64x64.png index 68aca83..6f54817 100644 Binary files a/public/pwa-64x64.png and b/public/pwa-64x64.png differ diff --git a/src/assets/logo.svg b/src/assets/logo.svg deleted file mode 100644 index d9cf046..0000000 --- a/src/assets/logo.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/src/components/AppLogo/AppLogo.module.scss b/src/components/AppLogo/AppLogo.module.scss new file mode 100644 index 0000000..fa765a6 --- /dev/null +++ b/src/components/AppLogo/AppLogo.module.scss @@ -0,0 +1,5 @@ +.wrapper { + width: 150px; + height: 150px; + background: url(/logo.svg) no-repeat center center / contain; +} diff --git a/src/components/AppLogo/AppLogo.tsx b/src/components/AppLogo/AppLogo.tsx new file mode 100644 index 0000000..b2860d8 --- /dev/null +++ b/src/components/AppLogo/AppLogo.tsx @@ -0,0 +1,9 @@ +import clsx from "clsx"; +import React from "react"; +import styles from "./AppLogo.module.scss"; + +export type AppLogoProps = React.HTMLAttributes; + +export default function AppLogo({ className, ...props }: AppLogoProps) { + return
; +} diff --git a/src/components/AppLogo/index.ts b/src/components/AppLogo/index.ts new file mode 100644 index 0000000..c038fcc --- /dev/null +++ b/src/components/AppLogo/index.ts @@ -0,0 +1 @@ +export { default } from "./AppLogo"; diff --git a/src/components/first-loader/first-loader.module.scss b/src/components/FirstLoader/FirstLoader.module.scss similarity index 100% rename from src/components/first-loader/first-loader.module.scss rename to src/components/FirstLoader/FirstLoader.module.scss diff --git a/src/components/FirstLoader/FirstLoader.tsx b/src/components/FirstLoader/FirstLoader.tsx new file mode 100644 index 0000000..ab02212 --- /dev/null +++ b/src/components/FirstLoader/FirstLoader.tsx @@ -0,0 +1,10 @@ +import AppLogo from "../AppLogo"; +import styles from "./FirstLoader.module.scss"; + +export default function FirstLoader() { + return ( +
+ +
+ ); +} diff --git a/src/components/FirstLoader/index.ts b/src/components/FirstLoader/index.ts new file mode 100644 index 0000000..4db2c72 --- /dev/null +++ b/src/components/FirstLoader/index.ts @@ -0,0 +1 @@ +export { default } from "./FirstLoader"; diff --git a/src/components/first-loader/index.tsx b/src/components/first-loader/index.tsx deleted file mode 100644 index 237656b..0000000 --- a/src/components/first-loader/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import Logo from "../logo"; -import styles from "./first-loader.module.scss"; - -export default function FirstLoader() { - return ( -
- -
- ); -} diff --git a/src/components/logo/index.tsx b/src/components/logo/index.tsx deleted file mode 100644 index 6e1997d..0000000 --- a/src/components/logo/index.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import clsx from "clsx"; -import React from "react"; -import styles from "./logo.module.scss"; - -export type LogoProps = React.HTMLAttributes; - -export default function Logo({ className, ...props }: LogoProps) { - return
; -} diff --git a/src/components/logo/logo.module.scss b/src/components/logo/logo.module.scss deleted file mode 100644 index 5a42a12..0000000 --- a/src/components/logo/logo.module.scss +++ /dev/null @@ -1,5 +0,0 @@ -.wrapper { - width: 150px; - height: 150px; - background: url(@/assets/logo.svg) no-repeat center center / contain; -} diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..1a9829c --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,2 @@ +export * from "./useAppDispatch"; +export * from "./useAppSelector"; diff --git a/src/hooks/useAppDispatch.ts b/src/hooks/useAppDispatch.ts index 4c76064..5c02194 100644 --- a/src/hooks/useAppDispatch.ts +++ b/src/hooks/useAppDispatch.ts @@ -2,5 +2,4 @@ import store from "@/store"; import { useDispatch } from "react-redux"; export type AppDispatch = typeof store.dispatch; -const useAppDispatch = useDispatch.withTypes(); -export default useAppDispatch; +export const useAppDispatch = useDispatch.withTypes(); diff --git a/src/hooks/useAppSelector.ts b/src/hooks/useAppSelector.ts index cf55380..c0672c3 100644 --- a/src/hooks/useAppSelector.ts +++ b/src/hooks/useAppSelector.ts @@ -2,5 +2,4 @@ import store from "@/store"; import { useSelector } from "react-redux"; export type RootState = ReturnType; -const useAppSelector = useSelector.withTypes(); -export default useAppSelector; +export const useAppSelector = useSelector.withTypes(); diff --git a/src/http/http.ts b/src/http/http.ts deleted file mode 100644 index a4d5def..0000000 --- a/src/http/http.ts +++ /dev/null @@ -1,35 +0,0 @@ -import tokenService from "@/services/token.service"; -import axios from "axios"; - -/** - * Create an Axios instance for making HTTP requests - * @param resource Resource path for the service - * @example http("users") => http://localhost:3000/users - * @returns Axios instance - */ -export default function http(resource = "") { - const http = axios.create({ - baseURL: `${import.meta.env.VITE_API_URL}/${resource}`, - }); - - // Add a request interceptor - http.interceptors.request.use((config) => { - // Get the access token from the token service - const accessToken = tokenService.accessToken; - // If the access token exists, add it to the Authorization header - if (accessToken) config.headers.Authorization = `Bearer ${accessToken}`; - return config; - }); - - // Add a response interceptor - http.interceptors.response.use( - // Return the data if the request is successful - (res) => res.data, - // Handle the error if the request is unsuccessful - (error) => { - throw error.response?.data || error; - }, - ); - - return http; -} diff --git a/src/layouts/admin/index.tsx b/src/layouts/admin/index.tsx index f526845..1839b36 100644 --- a/src/layouts/admin/index.tsx +++ b/src/layouts/admin/index.tsx @@ -1,4 +1,4 @@ -import FirstLoader from "@/components/first-loader"; +import FirstLoader from "@/components/FirstLoader"; import { Suspense } from "react"; import { Outlet } from "react-router-dom"; diff --git a/src/layouts/default/index.tsx b/src/layouts/default/index.tsx index 0d07f4b..36e4b40 100644 --- a/src/layouts/default/index.tsx +++ b/src/layouts/default/index.tsx @@ -1,5 +1,5 @@ -import FirstLoader from "@/components/first-loader"; -import useAppSelector from "@/hooks/useAppSelector"; +import FirstLoader from "@/components/FirstLoader"; +import { useAppSelector } from "@/hooks"; import { Suspense } from "react"; import { Outlet } from "react-router-dom"; diff --git a/src/layouts/user/index.tsx b/src/layouts/user/index.tsx index 37f3148..519b36b 100644 --- a/src/layouts/user/index.tsx +++ b/src/layouts/user/index.tsx @@ -1,4 +1,4 @@ -import FirstLoader from "@/components/first-loader"; +import FirstLoader from "@/components/FirstLoader"; import { Suspense } from "react"; import { Outlet } from "react-router-dom"; diff --git a/src/libs/i18n.ts b/src/libs/i18n.ts index f30f29d..fe96128 100644 --- a/src/libs/i18n.ts +++ b/src/libs/i18n.ts @@ -1,25 +1,17 @@ import i18n from "i18next"; import BrowserLanguageDetector from "i18next-browser-languagedetector"; -import ChainedBackend from "i18next-chained-backend"; -import HttpBackend from "i18next-http-backend"; import LocalStorageBackend from "i18next-localstorage-backend"; -import { initReactI18next } from "react-i18next"; -import { i18nextPlugin } from "translation-check"; import resources from "virtual:i18next-loader"; -i18n.use(BrowserLanguageDetector) - .use(ChainedBackend) - .use(i18nextPlugin) - .use(initReactI18next) - .init({ - backend: { - backends: [HttpBackend, LocalStorageBackend], - }, - fallbackLng: "en", - interpolation: { - escapeValue: false, - }, - resources, - }); +i18n.use(BrowserLanguageDetector).init({ + backend: { + backends: [LocalStorageBackend], + }, + fallbackLng: "en", + interpolation: { + escapeValue: false, + }, + resources, +}); export default i18n; diff --git a/src/models/User.ts b/src/models/User.ts deleted file mode 100644 index 46f55ef..0000000 --- a/src/models/User.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Base } from "./Base"; - -/** - * User model, extended from the base model - */ -export type User = Base & { - /** - * Email of the user - */ - email: string; -}; diff --git a/src/services/base.service.ts b/src/services/base.service.ts index 21a1f01..cd056de 100644 --- a/src/services/base.service.ts +++ b/src/services/base.service.ts @@ -1,13 +1,39 @@ -import { AxiosInstance } from "axios"; +import axios, { AxiosInstance } from "axios"; +import { TokenService } from "./token.service"; /** * Base service class to be extended by other services * @author Kim Minh Thang */ export class BaseService { + protected client: AxiosInstance; + private readonly tokenService: TokenService; /** * Constructor for the base service - * @param http AxiosInstance to be used for making requests */ - constructor(protected readonly http: AxiosInstance) {} + constructor(resource: string) { + this.tokenService = new TokenService(); + this.client = axios.create({ + baseURL: `/api/${resource}`, + }); + + // Add a request interceptor + this.client.interceptors.request.use((config) => { + // Get the access token from the token service + const accessToken = this.tokenService.accessToken; + // If the access token exists, add it to the Authorization header + if (accessToken) config.headers.Authorization = `Bearer ${accessToken}`; + return config; + }); + + // Add a response interceptor + this.client.interceptors.response.use( + // Return the data if the request is successful + (res) => res.data, + // Handle the error if the request is unsuccessful + (error) => { + throw error.response?.data || error; + }, + ); + } } diff --git a/src/services/crud.service.ts b/src/services/crud.service.ts index eb9c287..41ef019 100644 --- a/src/services/crud.service.ts +++ b/src/services/crud.service.ts @@ -1,4 +1,4 @@ -import http from "@/http/http"; +import { Base, BaseInput, BaseUpdate } from "@/types/base"; import { BaseService } from "./base.service"; /** @@ -9,31 +9,22 @@ import { BaseService } from "./base.service"; * - DELETE: Delete * @author Kim Minh Thang */ -export class CrudService extends BaseService { - /** - * Constructor for the CRUD service - * @param resource Resource path for the service - * @example new CrudService("users") => http://localhost:3000/users - */ - constructor(resource: string) { - super(http(resource)); - } - +export class CrudService extends BaseService { /** * Create a new data in the resource * @param data - Data to be created * @returns Created data */ - async create(data: Omit): Promise { - return await this.http.post("", data); + create(data: BaseInput): Promise { + return this.client.post("", data); } /** * Get all data from the resource * @returns All data */ - async getAll(): Promise { - return await this.http.get(""); + getAll(): Promise { + return this.client.get(""); } /** @@ -41,8 +32,8 @@ export class CrudService extends BaseService { * @param id ID of the data to be fetched * @returns Data with the given ID */ - async get(id: string): Promise { - return await this.http.get(id); + get(id: string): Promise { + return this.client.get(id); } /** @@ -51,15 +42,15 @@ export class CrudService extends BaseService { * @param data Data to be updated * @returns Updated data */ - async update(id: string, data: Partial): Promise { - return await this.http.put(id, data); + update(id: string, data: BaseUpdate): Promise { + return this.client.put(id, data); } /** * Delete a data from the resource * @param id ID of the data to be deleted */ - async delete(id: string): Promise { - await this.http.delete(id); + delete(id: string): Promise { + return this.client.delete(id); } } diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..7bbaebb --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,3 @@ +export * from "./base.service"; +export * from "./crud.service"; +export * from "./token.service"; diff --git a/src/services/token.service.ts b/src/services/token.service.ts index 451eef6..32b7ab0 100644 --- a/src/services/token.service.ts +++ b/src/services/token.service.ts @@ -43,5 +43,3 @@ export class TokenService { if (typeof window !== "undefined") localStorage.removeItem(this._accessTokenName); // Remove the access token from the local storage } } - -export default new TokenService() as TokenService; diff --git a/src/store/user.ts b/src/store/user.ts index 61a3aad..0f07cf5 100644 --- a/src/store/user.ts +++ b/src/store/user.ts @@ -1,4 +1,4 @@ -import { User } from "@/models/User"; +import { User } from "@/types/user"; import { createSlice } from "@reduxjs/toolkit"; /** diff --git a/src/models/Base.ts b/src/types/base.ts similarity index 50% rename from src/models/Base.ts rename to src/types/base.ts index 255e2d9..ce8076c 100644 --- a/src/models/Base.ts +++ b/src/types/base.ts @@ -15,3 +15,12 @@ export type Base = { */ updatedAt: string; // ISO 8601 date string }; + +/** + * Base input model, used to create a new base + * @template T The type of the base + * @extends Base + */ +export type BaseInput = Omit; + +export type BaseUpdate = Partial>; diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 0000000..1fd4094 --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,16 @@ +import { Base, BaseInput } from "./base"; + +/** + * User model, extended from the base model + */ +export type User = Base & { + /** + * Email of the user + */ + email: string; +}; + +/** + * User input model, used to create a new user + */ +export type UserInput = BaseInput;