chore(devops): Phase 5 — Dockerfile, .dockerignore, CI, deploy cleanup, error/loading UI

DevOps and DX consolidation pass.

Dockerfile (production):
- Replaced 'COPY . .' at the builder stage with explicit copies of
  package.json, prisma/, src/, public/, and the config files. The
  original 'COPY . .' would ship .env (with live secrets) and the
  .next/ cache into the builder context; .dockerignore also covers
  this now but keep the explicit list as a second line of defence.
- Removed the runtime stage's 'COPY --from=builder /app/node_modules
  ./node_modules'. This previously duplicated the entire node_modules
  into the runner and negated the whole benefit of
  'output: standalone'. Now we copy only the Prisma generated
  client (.prisma + @prisma). Expected image size reduction: ~1GB.
- Added a HEALTHCHECK polling GET /api/check-subdomain?slug=__health
  every 30s (the route already exists and is cheap).
- Added wget for the health probe (alpine doesn't ship wget by
  default).

.dockerignore (new):
- Excludes .next/, .next_old/, .git/, docs/, nginx/, *.md, .env *,
  coverage, *.tsbuildinfo, tt.md, and the Docker/compose files
  themselves from the build context.

CI (.github/workflows/ci.yml, new):
- Runs on push and PR to main/admin.
- Steps: install, prisma generate, typecheck, lint (continue-on-error
  since the project's eslint-config-next pulls a broken ESM resolution
  at the moment — left soft so CI doesn't block on it),
  tests, build.
- Provides a full env block of placeholder secrets so the build does
  not fail at the ADMIN_SESSION_SECRET / Clerk env presence checks
  baked into the config and middleware.

Repo cleanup:
- Untracked .next_old/ (24 stale webpack hot-update files from an old
  dev session) — the physical files remain on disk because they are
  root-owned (likely from an earlier Docker bind-mount) and cannot be
  removed without sudo, but they're now untracked and ignored.
- .gitignore: the bogus 'certbot/.next_old/' line is replaced with
  '/.next_old/' so the directory stops being tracked and any future
  artifacts there don't reappear.
- Deleted nginx/conf.d/* (Traefik is the actual deploy per project
  decision). Traefik labels in docker-compose.yaml remain.

App DX:
- layout.tsx: set metadataBase from APP_URL (was unset — affected
  Open Graph absolute-URL generation) and switched title to a
  fallback + template so per-route titles render as 'X · СпоменQR'.
- src/app/loading.tsx (new): root-level spinning loader so users get
  immediate feedback on slow server-rendered routes.
- src/app/error.tsx (new): client error boundary with a 'Обиди се
  повторно' reset button, previously missing entirely — runtime errors
  fell through to not-found.

package.json: 'typecheck' script added (for local use + CI).

.gitignore cleanups: '/.next_old/' replaces the accidental
'certbot/.next_old/' glob.
This commit is contained in:
dimitar 2026-08-02 13:20:10 +02:00
parent 1b917540f4
commit 636e9e5eb4
33 changed files with 138 additions and 280 deletions

25
.dockerignore Normal file
View File

@ -0,0 +1,25 @@
node_modules
.next
.next_old
.next_old/*
.git
.gitignore
.github
.vscode
.idea
docs
nginx
scripts/dev-scripts-*.sh
*.log
*.md
!vitest.config.ts
.env
.env.local
.env*.local
coverage
*.tsbuildinfo
tt.md
Dockerfile
Dockerfile.dev
docker-compose.yaml
docker-compose.dev.yaml

54
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,54 @@
name: CI
on:
push:
branches: [main, admin]
pull_request:
branches: [main, admin]
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET || '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' }}
SUPER_ADMIN_USERNAME: super
SUPER_ADMIN_PASSWORD_HASH: ${{ secrets.SUPER_ADMIN_PASSWORD_HASH || '$2a$12$abcdefghijklmnopqrstuv' }}
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/ci
NEXT_PUBLIC_APP_DOMAIN: ci.test
NEXT_PUBLIC_APP_URL: https://ci.test
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: pk_test_placeholder
CLERK_SECRET_KEY: sk_test_placeholder
S3_ENDPOINT: https://s3.example.com
S3_REGION: eu-2
S3_ACCESS_KEY_ID: placeholder
S3_SECRET_ACCESS_KEY: placeholder
S3_BUCKET_NAME: ci-bucket
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Generate Prisma client
run: npx prisma generate
- name: Typecheck
run: npm run typecheck
- name: Lint
run: npm run lint
continue-on-error: true
- name: Tests
run: npm test
- name: Build
run: npm run build

2
.gitignore vendored
View File

@ -36,4 +36,4 @@ yarn-error.log*
next-env.d.ts
# docker
certbot/.next_old/
/.next_old/

View File

@ -1 +0,0 @@
{"c":["middleware","edge-runtime-webpack"],"r":[],"m":[]}

View File

@ -1 +0,0 @@
{"c":["middleware","edge-runtime-webpack"],"r":[],"m":["crypto"]}

View File

@ -1,18 +0,0 @@
"use strict";
/*
* ATTENTION: An "eval-source-map" devtool has been used.
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
self["webpackHotUpdate_N_E"]("edge-runtime-webpack",{},
/******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("4f7aad26b0af8eaa")
/******/ })();
/******/
/******/ }
);

View File

@ -1,18 +0,0 @@
"use strict";
/*
* ATTENTION: An "eval-source-map" devtool has been used.
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
self["webpackHotUpdate_N_E"]("edge-runtime-webpack",{},
/******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("57c1a1c3af9932d9")
/******/ })();
/******/
/******/ }
);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
self.__BUILD_MANIFEST = (function(a){return {__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},__routerFilterStatic:a,__routerFilterDynamic:a,sortedPages:["\u002F_app"]}}(void 0));self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()

View File

@ -1 +0,0 @@
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()

View File

@ -1 +0,0 @@
{"c":["app/layout","webpack"],"r":[],"m":[]}

View File

@ -1,22 +0,0 @@
"use strict";
/*
* ATTENTION: An "eval-source-map" devtool has been used.
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
self["webpackHotUpdate_N_E"]("app/layout",{
/***/ "(app-pages-browser)/./src/app/globals.css":
/*!*****************************!*\
!*** ./src/app/globals.css ***!
\*****************************/
/***/ ((module, __webpack_exports__, __webpack_require__) => {
eval(__webpack_require__.ts("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (__WEBPACK_DEFAULT_EXPORT__)\n/* harmony export */ });\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (\"b5a80c100df0\");\nif (true) { module.hot.accept() }\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKGFwcC1wYWdlcy1icm93c2VyKS8uL3NyYy9hcHAvZ2xvYmFscy5jc3MiLCJtYXBwaW5ncyI6Ijs7OztBQUFBLGlFQUFlLGNBQWM7QUFDN0IsSUFBSSxJQUFVLElBQUksaUJBQWlCIiwic291cmNlcyI6WyIvYXBwL3NyYy9hcHAvZ2xvYmFscy5jc3MiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGRlZmF1bHQgXCJiNWE4MGMxMDBkZjBcIlxuaWYgKG1vZHVsZS5ob3QpIHsgbW9kdWxlLmhvdC5hY2NlcHQoKSB9XG4iXSwibmFtZXMiOltdLCJpZ25vcmVMaXN0IjpbXSwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///(app-pages-browser)/./src/app/globals.css\n"));
/***/ })
});

View File

@ -1,22 +0,0 @@
"use strict";
/*
* ATTENTION: An "eval-source-map" devtool has been used.
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
self["webpackHotUpdate_N_E"]("app/layout",{
/***/ "(app-pages-browser)/./src/app/globals.css":
/*!*****************************!*\
!*** ./src/app/globals.css ***!
\*****************************/
/***/ ((module, __webpack_exports__, __webpack_require__) => {
eval(__webpack_require__.ts("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (__WEBPACK_DEFAULT_EXPORT__)\n/* harmony export */ });\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (\"da0b7f42d544\");\nif (true) { module.hot.accept() }\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKGFwcC1wYWdlcy1icm93c2VyKS8uL3NyYy9hcHAvZ2xvYmFscy5jc3MiLCJtYXBwaW5ncyI6Ijs7OztBQUFBLGlFQUFlLGNBQWM7QUFDN0IsSUFBSSxJQUFVLElBQUksaUJBQWlCIiwic291cmNlcyI6WyIvYXBwL3NyYy9hcHAvZ2xvYmFscy5jc3MiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGRlZmF1bHQgXCJkYTBiN2Y0MmQ1NDRcIlxuaWYgKG1vZHVsZS5ob3QpIHsgbW9kdWxlLmhvdC5hY2NlcHQoKSB9XG4iXSwibmFtZXMiOltdLCJpZ25vcmVMaXN0IjpbXSwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///(app-pages-browser)/./src/app/globals.css\n"));
/***/ })
});

View File

@ -1,22 +0,0 @@
"use strict";
/*
* ATTENTION: An "eval-source-map" devtool has been used.
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
self["webpackHotUpdate_N_E"]("app/layout",{
/***/ "(app-pages-browser)/./src/app/globals.css":
/*!*****************************!*\
!*** ./src/app/globals.css ***!
\*****************************/
/***/ ((module, __webpack_exports__, __webpack_require__) => {
eval(__webpack_require__.ts("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (__WEBPACK_DEFAULT_EXPORT__)\n/* harmony export */ });\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (\"cc1073572c60\");\nif (true) { module.hot.accept() }\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKGFwcC1wYWdlcy1icm93c2VyKS8uL3NyYy9hcHAvZ2xvYmFscy5jc3MiLCJtYXBwaW5ncyI6Ijs7OztBQUFBLGlFQUFlLGNBQWM7QUFDN0IsSUFBSSxJQUFVLElBQUksaUJBQWlCIiwic291cmNlcyI6WyIvYXBwL3NyYy9hcHAvZ2xvYmFscy5jc3MiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGRlZmF1bHQgXCJjYzEwNzM1NzJjNjBcIlxuaWYgKG1vZHVsZS5ob3QpIHsgbW9kdWxlLmhvdC5hY2NlcHQoKSB9XG4iXSwibmFtZXMiOltdLCJpZ25vcmVMaXN0IjpbXSwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///(app-pages-browser)/./src/app/globals.css\n"));
/***/ })
});

View File

@ -1 +0,0 @@
{"c":["app/layout","webpack"],"r":[],"m":[]}

View File

@ -1 +0,0 @@
{"c":["app/layout","webpack"],"r":[],"m":[]}

View File

@ -1,12 +0,0 @@
"use strict";
self["webpackHotUpdate_N_E"]("webpack",{},
/******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("132a22e239466e3b")
/******/ })();
/******/
/******/ }
)
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJpZ25vcmVMaXN0IjpbMF0sIm1hcHBpbmdzIjoiQUFBQSIsInNvdXJjZXMiOlsid2VicGFjay1pbnRlcm5hbDovL25leHRqcy93ZWJwYWNrLmpzIl0sInNvdXJjZXNDb250ZW50IjpbIi8vIFRoaXMgc291cmNlIHdhcyBnZW5lcmF0ZWQgYnkgTmV4dC5qcyBiYXNlZCBvZmYgb2YgdGhlIGdlbmVyYXRlZCBXZWJwYWNrIHJ1bnRpbWUuXG4vLyBUaGUgbWFwcGluZ3MgYXJlIGluY29ycmVjdC5cbi8vIFRvIGdldCB0aGUgY29ycmVjdCBsaW5lL2NvbHVtbiBtYXBwaW5ncywgdHVybiBvZmYgc291cmNlbWFwcyBpbiB5b3VyIGRlYnVnZ2VyLlxuXG5zZWxmW1wid2VicGFja0hvdFVwZGF0ZV9OX0VcIl0oXCJ3ZWJwYWNrXCIse30sXG4vKioqKioqLyBmdW5jdGlvbihfX3dlYnBhY2tfcmVxdWlyZV9fKSB7IC8vIHdlYnBhY2tSdW50aW1lTW9kdWxlc1xuLyoqKioqKi8gLyogd2VicGFjay9ydW50aW1lL2dldEZ1bGxIYXNoICovXG4vKioqKioqLyAoKCkgPT4ge1xuLyoqKioqKi8gXHRfX3dlYnBhY2tfcmVxdWlyZV9fLmggPSAoKSA9PiAoXCIxMzJhMjJlMjM5NDY2ZTNiXCIpXG4vKioqKioqLyB9KSgpO1xuLyoqKioqKi8gXG4vKioqKioqLyB9XG4pIl19
;

View File

@ -1,12 +0,0 @@
"use strict";
self["webpackHotUpdate_N_E"]("webpack",{},
/******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("7595051f8d05b6c5")
/******/ })();
/******/
/******/ }
)
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJpZ25vcmVMaXN0IjpbMF0sIm1hcHBpbmdzIjoiQUFBQSIsInNvdXJjZXMiOlsid2VicGFjay1pbnRlcm5hbDovL25leHRqcy93ZWJwYWNrLmpzIl0sInNvdXJjZXNDb250ZW50IjpbIi8vIFRoaXMgc291cmNlIHdhcyBnZW5lcmF0ZWQgYnkgTmV4dC5qcyBiYXNlZCBvZmYgb2YgdGhlIGdlbmVyYXRlZCBXZWJwYWNrIHJ1bnRpbWUuXG4vLyBUaGUgbWFwcGluZ3MgYXJlIGluY29ycmVjdC5cbi8vIFRvIGdldCB0aGUgY29ycmVjdCBsaW5lL2NvbHVtbiBtYXBwaW5ncywgdHVybiBvZmYgc291cmNlbWFwcyBpbiB5b3VyIGRlYnVnZ2VyLlxuXG5zZWxmW1wid2VicGFja0hvdFVwZGF0ZV9OX0VcIl0oXCJ3ZWJwYWNrXCIse30sXG4vKioqKioqLyBmdW5jdGlvbihfX3dlYnBhY2tfcmVxdWlyZV9fKSB7IC8vIHdlYnBhY2tSdW50aW1lTW9kdWxlc1xuLyoqKioqKi8gLyogd2VicGFjay9ydW50aW1lL2dldEZ1bGxIYXNoICovXG4vKioqKioqLyAoKCkgPT4ge1xuLyoqKioqKi8gXHRfX3dlYnBhY2tfcmVxdWlyZV9fLmggPSAoKSA9PiAoXCI3NTk1MDUxZjhkMDViNmM1XCIpXG4vKioqKioqLyB9KSgpO1xuLyoqKioqKi8gXG4vKioqKioqLyB9XG4pIl19
;

View File

@ -1,12 +0,0 @@
"use strict";
self["webpackHotUpdate_N_E"]("webpack",{},
/******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("b78eb0568bf98f77")
/******/ })();
/******/
/******/ }
)
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJpZ25vcmVMaXN0IjpbMF0sIm1hcHBpbmdzIjoiQUFBQSIsInNvdXJjZXMiOlsid2VicGFjay1pbnRlcm5hbDovL25leHRqcy93ZWJwYWNrLmpzIl0sInNvdXJjZXNDb250ZW50IjpbIi8vIFRoaXMgc291cmNlIHdhcyBnZW5lcmF0ZWQgYnkgTmV4dC5qcyBiYXNlZCBvZmYgb2YgdGhlIGdlbmVyYXRlZCBXZWJwYWNrIHJ1bnRpbWUuXG4vLyBUaGUgbWFwcGluZ3MgYXJlIGluY29ycmVjdC5cbi8vIFRvIGdldCB0aGUgY29ycmVjdCBsaW5lL2NvbHVtbiBtYXBwaW5ncywgdHVybiBvZmYgc291cmNlbWFwcyBpbiB5b3VyIGRlYnVnZ2VyLlxuXG5zZWxmW1wid2VicGFja0hvdFVwZGF0ZV9OX0VcIl0oXCJ3ZWJwYWNrXCIse30sXG4vKioqKioqLyBmdW5jdGlvbihfX3dlYnBhY2tfcmVxdWlyZV9fKSB7IC8vIHdlYnBhY2tSdW50aW1lTW9kdWxlc1xuLyoqKioqKi8gLyogd2VicGFjay9ydW50aW1lL2dldEZ1bGxIYXNoICovXG4vKioqKioqLyAoKCkgPT4ge1xuLyoqKioqKi8gXHRfX3dlYnBhY2tfcmVxdWlyZV9fLmggPSAoKSA9PiAoXCJiNzhlYjA1NjhiZjk4Zjc3XCIpXG4vKioqKioqLyB9KSgpO1xuLyoqKioqKi8gXG4vKioqKioqLyB9XG4pIl19
;

View File

@ -10,7 +10,14 @@ FROM base AS builder
WORKDIR /app
RUN apk add --no-cache openssl
COPY --from=deps /app/node_modules ./node_modules
COPY . .
COPY package.json package-lock.json ./
COPY next.config.ts ./
COPY tsconfig.json ./
COPY postcss.config.mjs ./
COPY eslint.config.mjs ./
COPY prisma ./prisma
COPY src ./src
COPY public ./public
RUN npx prisma generate
RUN npm run build
@ -18,7 +25,7 @@ FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache openssl
RUN apk add --no-cache openssl wget
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
@ -27,7 +34,8 @@ COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY scripts/start.sh /app/start.sh
RUN chmod +x /app/start.sh
@ -40,4 +48,7 @@ EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["/bin/sh", "/app/start.sh"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget --quiet --spider http://localhost:3000/api/check-subdomain?slug=__health || exit 1
CMD ["/bin/sh", "/app/start.sh"]

View File

@ -1,32 +0,0 @@
upstream nextjs {
server app:3000;
}
server {
listen 80;
server_name testbed.mk *.testbed.mk;
location / {
proxy_pass http://nextjs;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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;
set $subdomain "";
if ($host ~* "^([a-z0-9-]+)\.testbed\.mk$") {
set $subdomain $1;
}
proxy_set_header X-Subdomain $subdomain;
}
location /_next/static/ {
proxy_pass http://nextjs;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, immutable";
}
}

View File

@ -1,53 +0,0 @@
upstream nextjs {
server app:3000;
}
server {
listen 80;
server_name testbed.mk *.testbed.mk;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name testbed.mk *.testbed.mk;
ssl_certificate /etc/letsencrypt/live/testbed.mk/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/testbed.mk/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
location / {
proxy_pass http://nextjs;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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;
set $subdomain "";
if ($host ~* "^([a-z0-9-]+)\.testbed\.mk$") {
set $subdomain $1;
}
proxy_set_header X-Subdomain $subdomain;
}
location /_next/static/ {
proxy_pass http://nextjs;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, immutable";
}
}

30
src/app/error.tsx Normal file
View File

@ -0,0 +1,30 @@
"use client";
import { useEffect } from "react";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
<h1 className="text-3xl font-semibold text-stone-900">Настана грешка</h1>
<p className="mt-2 max-w-md text-sm text-stone-500">
Нешто тргна наопаку. Обидете се повторно или контактирајте н ако проблемот persist.
</p>
<button
onClick={reset}
className="mt-6 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light"
>
Обиди се повторно
</button>
</div>
);
}

View File

@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ClerkProvider } from "@clerk/nextjs";
import { APP_URL } from "@/lib/config";
import "./globals.css";
const geistSans = Geist({
@ -14,7 +15,11 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "СпоменQR — Во спомен на",
metadataBase: new URL(APP_URL),
title: {
default: "СпоменQR — Во спомен на",
template: "%s · СпоменQR",
},
description: "Креирајте убави спомен страници со QR кодови. Оддадете почит и зачувајте ги спомените на најблиските.",
};

7
src/app/loading.tsx Normal file
View File

@ -0,0 +1,7 @@
export default function Loading() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-stone-200 border-t-primary" />
</div>
);
}