Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9029375247 | |||
| 95d0a1622f | |||
| 646d615e76 | |||
| 51409099fc | |||
| 22cd20e639 | |||
| e7cc1c8ca5 | |||
| 0ccb15a929 | |||
| a94ddcfbb2 | |||
| d3a9af140c | |||
| 0dc3ba0da4 | |||
| 1a94465dba | |||
| 7e256025ea | |||
| e843de42da | |||
| 4d1b2231e3 | |||
| 71f47f9037 | |||
| 79d41b6a73 |
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.npmrc
|
||||
dist
|
||||
build
|
||||
out
|
||||
coverage
|
||||
.vercel
|
||||
.turbo
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -2,13 +2,8 @@ name: Monorepo Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -18,6 +13,8 @@ jobs:
|
||||
qa:
|
||||
name: 🧪 Quality Assurance
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -31,7 +28,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node_version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -52,6 +48,8 @@ jobs:
|
||||
needs: qa
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -70,7 +68,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node_version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -86,6 +83,8 @@ jobs:
|
||||
needs: qa
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -94,49 +93,64 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 🔐 Registry Login
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.infra.mintel.me
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASS }}
|
||||
|
||||
- name: 🏗️ Build & Push Nextjs Build-Base
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t registry.infra.mintel.me/mintel/nextjs:$TAG \
|
||||
-t registry.infra.mintel.me/mintel/nextjs:latest \
|
||||
-f packages/infra/docker/Dockerfile.nextjs \
|
||||
--push .
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.nextjs
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/nextjs:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/nextjs:latest
|
||||
|
||||
- name: 🏗️ Build & Push Production Runtime
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t registry.infra.mintel.me/mintel/runtime:$TAG \
|
||||
-t registry.infra.mintel.me/mintel/runtime:latest \
|
||||
-f packages/infra/docker/Dockerfile.runtime \
|
||||
--push .
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.runtime
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/runtime:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/runtime:latest
|
||||
|
||||
- name: 🏗️ Build & Push Gatekeeper (Product)
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t registry.infra.mintel.me/mintel/gatekeeper:$TAG \
|
||||
-t registry.infra.mintel.me/mintel/gatekeeper:latest \
|
||||
-f packages/infra/docker/Dockerfile.gatekeeper \
|
||||
--push .
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.gatekeeper
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/gatekeeper:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/gatekeeper:latest
|
||||
|
||||
- name: 🏗️ Build & Push Directus (Base)
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t registry.infra.mintel.me/mintel/directus:$TAG \
|
||||
-t registry.infra.mintel.me/mintel/directus:latest \
|
||||
-f packages/infra/docker/Dockerfile.directus \
|
||||
--push .
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.directus
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/directus:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/directus:latest
|
||||
|
||||
@@ -13,10 +13,14 @@ export const nextConfig = [
|
||||
{
|
||||
ignores: [
|
||||
"**/dist/**",
|
||||
"**/build/**",
|
||||
"**/out/**",
|
||||
"**/coverage/**",
|
||||
"**/.next/**",
|
||||
"**/node_modules/**",
|
||||
"**/.gitea/**",
|
||||
"**/.changeset/**",
|
||||
"**/.vercel/**",
|
||||
],
|
||||
},
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "15.1.6",
|
||||
"next-intl": "^3.26.5",
|
||||
"next-intl": "^4.8.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
|
||||
12
packages/gatekeeper/public/logo-black.svg
Normal file
12
packages/gatekeeper/public/logo-black.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 17 KiB |
12
packages/gatekeeper/public/logo-white.svg
Normal file
12
packages/gatekeeper/public/logo-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
@@ -55,12 +55,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
{/* Logo / Icon */}
|
||||
<div className="flex justify-center mb-12">
|
||||
<div
|
||||
className="w-20 h-20 rounded-3xl flex items-center justify-center border border-white/10 bg-white/5 backdrop-blur-xl shadow-2xl"
|
||||
className="w-48 h-24 rounded-3xl flex items-center justify-center border border-white/10 bg-white/5 backdrop-blur-xl shadow-2xl p-6"
|
||||
style={{ borderBottom: `2px solid ${projectColor}44` }}
|
||||
>
|
||||
<ShieldCheck
|
||||
className="w-10 h-10"
|
||||
style={{ color: projectColor }}
|
||||
<img
|
||||
src="/logo-white.svg"
|
||||
alt={projectName}
|
||||
className="w-full h-auto opacity-90"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const config = {
|
||||
extends: ["@commitlint/config-conventional"],
|
||||
rules: {
|
||||
"header-max-length": [2, "always", 150],
|
||||
"header-max-length": [2, "always", 250],
|
||||
"subject-case": [0],
|
||||
"subject-full-stop": [0],
|
||||
},
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Step 1: Builder stage
|
||||
FROM node:20-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat curl
|
||||
WORKDIR /app
|
||||
|
||||
# Enable pnpm
|
||||
RUN corepack enable pnpm
|
||||
|
||||
# Install dependencies (using monorepo root context)
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc* ./
|
||||
COPY packages/gatekeeper/package.json ./packages/gatekeeper/
|
||||
COPY packages/next-utils/package.json ./packages/next-utils/
|
||||
COPY packages/tsconfig/package.json ./packages/tsconfig/
|
||||
COPY packages/eslint-config/package.json ./packages/eslint-config/
|
||||
COPY packages/next-config/package.json ./packages/next-config/
|
||||
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
|
||||
pnpm i --frozen-lockfile
|
||||
|
||||
# Copy source
|
||||
# Copy source (honoring .dockerignore)
|
||||
COPY . .
|
||||
|
||||
# Build Gatekeeper
|
||||
RUN pnpm --filter @mintel/gatekeeper build
|
||||
# Use a secret for NPM_TOKEN to authenticate with private registry
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
|
||||
--mount=type=secret,id=NPM_TOKEN \
|
||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||
pnpm i --frozen-lockfile
|
||||
|
||||
# Runner
|
||||
FROM base AS runner
|
||||
# Build Gatekeeper and its dependencies
|
||||
RUN pnpm --filter @mintel/gatekeeper... build
|
||||
RUN mkdir -p packages/gatekeeper/public
|
||||
|
||||
# Step 2: Runner stage
|
||||
FROM node:20-alpine AS runner
|
||||
RUN apk add --no-cache libc6-compat curl
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Step 1: Builder image
|
||||
FROM node:20-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat curl
|
||||
WORKDIR /app
|
||||
|
||||
# Enable pnpm
|
||||
RUN corepack enable pnpm
|
||||
|
||||
# Copy root configurations
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc* ./
|
||||
|
||||
# Copy all package.json files to allow pnpm install to be cached
|
||||
COPY packages/*/package.json ./packages/
|
||||
COPY apps/*/package.json ./apps/
|
||||
|
||||
# Install dependencies for the entire monorepo
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
|
||||
pnpm i --frozen-lockfile
|
||||
|
||||
# Copy the rest of the source code
|
||||
# Step 2: Install dependencies
|
||||
# We copy everything first because we have a .dockerignore
|
||||
# and we need the workspace structure for pnpm to work correctly
|
||||
COPY . .
|
||||
|
||||
# Post-install/Build shared packages if needed
|
||||
RUN pnpm -r build --filter="./packages/*"
|
||||
# Use a secret for NPM_TOKEN to authenticate with private registry
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
|
||||
--mount=type=secret,id=NPM_TOKEN \
|
||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||
pnpm i --frozen-lockfile
|
||||
|
||||
# Step 3: Build shared packages
|
||||
RUN pnpm --filter "./packages/*" -r build
|
||||
|
||||
@@ -24,6 +24,8 @@ jobs:
|
||||
prepare:
|
||||
name: 🔍 Prepare Environment
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
outputs:
|
||||
target: ${{ steps.determine.outputs.target }}
|
||||
image_tag: ${{ steps.determine.outputs.image_tag }}
|
||||
@@ -136,6 +138,8 @@ jobs:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -144,7 +148,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -171,6 +174,8 @@ jobs:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -179,23 +184,28 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 🔐 Registry Login
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.infra.mintel.me
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASS }}
|
||||
|
||||
- name: 🏗️ Docker Build & Push
|
||||
env:
|
||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
||||
run: |
|
||||
docker buildx build \
|
||||
--pull \
|
||||
--platform linux/arm64 \
|
||||
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
||||
--build-arg NEXT_PUBLIC_TARGET="${{ needs.prepare.outputs.target }}" \
|
||||
-t registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:$IMAGE_TAG \
|
||||
--cache-from type=registry,ref=registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:buildcache \
|
||||
--cache-to type=registry,ref=registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:buildcache,mode=max \
|
||||
--push .
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.nextjs
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }}
|
||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:${{ needs.prepare.outputs.image_tag }}
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:buildcache
|
||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:buildcache,mode=max
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 4: Deploy
|
||||
@@ -205,6 +215,8 @@ jobs:
|
||||
needs: [prepare, build, qa]
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
TARGET: ${{ needs.prepare.outputs.target }}
|
||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||
@@ -271,6 +283,8 @@ jobs:
|
||||
needs: [prepare, deploy]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: 🔔 Gotify - Success
|
||||
if: needs.deploy.result == 'success'
|
||||
|
||||
60
packages/infra/scripts/prune-registry.sh
Normal file
60
packages/infra/scripts/prune-registry.sh
Normal file
@@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
REGISTRY_DATA="/opt/infra/registry/data/docker/registry/v2"
|
||||
KEEP_TAGS=3
|
||||
|
||||
echo "🏥 Starting Aggressive Registry & Docker Maintenance..."
|
||||
|
||||
# 1. Prune Registry Tags (Filesystem level)
|
||||
for repo_dir in "$REGISTRY_DATA/repositories/mintel/"*; do
|
||||
repo_name=$(basename "$repo_dir")
|
||||
tags_dir="$repo_dir/_manifests/tags"
|
||||
|
||||
if [ -d "$tags_dir" ]; then
|
||||
echo "🔍 Processing repository: mintel/$repo_name"
|
||||
|
||||
# Prune main-* tags
|
||||
echo " 📦 Pruning main tags..."
|
||||
main_tags=$(ls -dt "$tags_dir"/main-* 2>/dev/null || true)
|
||||
count=0
|
||||
for tag_path in $main_tags; do
|
||||
((++count))
|
||||
if [ $count -gt $KEEP_TAGS ]; then
|
||||
echo " 🗑️ Deleting old main tag: $(basename "$tag_path")"
|
||||
rm -rf "$tag_path"
|
||||
fi
|
||||
done
|
||||
|
||||
# Prune version tags (v* and rc*)
|
||||
echo " 🏷️ Pruning version tags..."
|
||||
version_tags=$(ls -dt "$tags_dir"/v1* 2>/dev/null || true)
|
||||
count=0
|
||||
for tag_path in $version_tags; do
|
||||
((++count))
|
||||
if [ $count -gt $KEEP_TAGS ]; then
|
||||
echo " 🗑️ Deleting old version tag: $(basename "$tag_path")"
|
||||
rm -rf "$tag_path"
|
||||
fi
|
||||
done
|
||||
|
||||
# Always prune buildcache (as it rebuilds quickly)
|
||||
if [ -d "$tags_dir/buildcache" ]; then
|
||||
echo " 🧹 Deleting buildcache tag"
|
||||
rm -rf "$tags_dir/buildcache"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# 2. Run Garbage Collection
|
||||
echo "♻️ Running Registry Garbage Collection..."
|
||||
docker exec registry-registry-1 bin/registry garbage-collect /etc/docker/registry/config.yml
|
||||
|
||||
# 3. Prune Host Docker resources (Shorter window: 24h)
|
||||
echo "🧹 Pruning Host Docker resources..."
|
||||
docker system prune -af --filter "until=24h"
|
||||
docker volume prune -f
|
||||
|
||||
echo "✅ Maintenance complete!"
|
||||
df -h /
|
||||
30
packages/mail/package.json
Normal file
30
packages/mail/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@mintel/mail",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./templates/*": "./src/templates/*"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --dts",
|
||||
"dev": "tsup src/index.ts --format esm --watch",
|
||||
"lint": "eslint src",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "^0.0.33",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.0.4"
|
||||
}
|
||||
}
|
||||
12
packages/mail/src/assets/logo-black.svg
Normal file
12
packages/mail/src/assets/logo-black.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 17 KiB |
12
packages/mail/src/assets/logo-white.svg
Normal file
12
packages/mail/src/assets/logo-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
29
packages/mail/src/components/MintelLogo.tsx
Normal file
29
packages/mail/src/components/MintelLogo.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react";
|
||||
import { Link, Img } from "@react-email/components";
|
||||
|
||||
export interface MintelLogoProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const MintelLogo = ({ size = 200 }: MintelLogoProps) => {
|
||||
// Original Logo is 545x260, we scale it
|
||||
const width = size;
|
||||
const height = (size * 260) / 545;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="https://mintel.me"
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
display: "inline-block",
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+Cjxzdmcgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDU0NSAyNjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIgeG1sbnM6c2VyaWY9Imh0dHA6Ly93d3cuc2VyaWYuY29tLyIgc3R5bGU9ImZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2UtbWl0ZXJsaW1pdDoyOyI+CiAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgxLDAsMCwxLC0xMjg2LC0xMTUwKSI+CiAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoMSwtMCwtMCwxLDEyODYsMTE1MCkiPgogICAgICAgICAgICA8dXNlIHhsaW5rOmhyZWY9IiNfSW1hZ2UxIiB4PSI0MS41NjkiIHk9IjMxLjM4NSIgd2lkdGg9IjQ2MnB4IiBoZWlnaHQ9IjE5N3B4Ii8+CiAgICAgICAgPC9nPgogICAgPC9nPgogICAgPGRlZnM+CiAgICAgICAgPGltYWdlIGlkPSJfSW1hZ2UxIiB3aWR0aD0iNDYycHgiIGhlaWdodD0iMTk3cHgiIHhsaW5rOmhyZWY9ImRhdGE6aW1hZ2UvcG5nO2Jhc2U2NCxpVkJPUncwS0dnb0FBQU5TVWhFVWdBQUFjNFNBQUFERkNBWUFBQUNYQlJXMEFBQWdBRWxFUVZSNFhMU0JTQlNScGRmZWU0eE9lTXVlemV6NnoxY3VaeXpNeXpNeDU3cnI5ZXY5ZTFlNTVxNU56WXpNeFpPYmRkNWRNdG93MHcwMHcwMHcwMHcwMHV0eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4cDVxNHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4O0VYSVRDT0RFOiAwIgogICAgPC9kZWZzPgo8L3N2Zz4K"
|
||||
alt="Mintel Logo"
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
25
packages/mail/src/index.test.tsx
Normal file
25
packages/mail/src/index.test.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import * as React from "react";
|
||||
import { render } from "./index";
|
||||
import { MintelLogo } from "./components/MintelLogo";
|
||||
import { ContactFormNotification } from "./templates/ContactFormNotification";
|
||||
|
||||
describe("@mintel/mail rendering", () => {
|
||||
it("should render the MintelLogo to HTML", async () => {
|
||||
const html = await render(React.createElement(MintelLogo));
|
||||
expect(html).toContain("Mintel Logo");
|
||||
});
|
||||
|
||||
it("should render a ContactFormNotification to HTML", async () => {
|
||||
const html = await render(
|
||||
React.createElement(ContactFormNotification, {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
message: "Hello World",
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("New Submission");
|
||||
expect(html).toContain("Test User");
|
||||
expect(html).toContain("test@example.com");
|
||||
});
|
||||
});
|
||||
24
packages/mail/src/index.ts
Normal file
24
packages/mail/src/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { render as reactEmailRender } from "@react-email/components";
|
||||
import { ReactElement } from "react";
|
||||
|
||||
/**
|
||||
* Renders a React email template to HTML.
|
||||
*/
|
||||
export async function render(
|
||||
template: ReactElement,
|
||||
options?: any,
|
||||
): Promise<string> {
|
||||
return reactEmailRender(template, options);
|
||||
}
|
||||
|
||||
// Export Components
|
||||
export * from "./components/MintelLogo";
|
||||
|
||||
// Export Layouts
|
||||
export * from "./layouts/BaseLayout";
|
||||
export * from "./layouts/MintelLayout";
|
||||
export * from "./layouts/ClientLayout";
|
||||
|
||||
// Export Templates
|
||||
export * from "./templates/ContactFormNotification";
|
||||
export * from "./templates/ConfirmationMessage";
|
||||
53
packages/mail/src/layouts/BaseLayout.tsx
Normal file
53
packages/mail/src/layouts/BaseLayout.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
export interface BaseLayoutProps {
|
||||
preview: string;
|
||||
children: React.ReactNode;
|
||||
brandColor?: string;
|
||||
}
|
||||
|
||||
export const BaseLayout = ({
|
||||
preview,
|
||||
children,
|
||||
brandColor = "#82ed20",
|
||||
}: BaseLayoutProps) => {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{preview}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={content}>{children}</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
const main = {
|
||||
backgroundColor: "#0a0a0a",
|
||||
color: "#ffffff",
|
||||
fontFamily:
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||
};
|
||||
|
||||
const container = {
|
||||
backgroundColor: "#0f0f0f",
|
||||
margin: "0 auto",
|
||||
padding: "40px 0",
|
||||
maxWidth: "600px",
|
||||
border: "1px solid #1a1a1a",
|
||||
borderRadius: "12px",
|
||||
};
|
||||
|
||||
const content = {
|
||||
padding: "0 40px",
|
||||
};
|
||||
80
packages/mail/src/layouts/ClientLayout.tsx
Normal file
80
packages/mail/src/layouts/ClientLayout.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import * as React from "react";
|
||||
import { Hr, Section, Text, Img } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
export interface ClientLayoutProps {
|
||||
preview: string;
|
||||
children: React.ReactNode;
|
||||
clientLogo?: string;
|
||||
clientName: string;
|
||||
brandColor?: string;
|
||||
}
|
||||
|
||||
export const ClientLayout = ({
|
||||
preview,
|
||||
children,
|
||||
clientLogo,
|
||||
clientName,
|
||||
brandColor = "#82ed20",
|
||||
}: ClientLayoutProps) => {
|
||||
return (
|
||||
<BaseLayout preview={preview} brandColor={brandColor}>
|
||||
<Section style={header}>
|
||||
{clientLogo ? (
|
||||
<Img src={clientLogo} alt={clientName} height="40" style={logo} />
|
||||
) : (
|
||||
<Text style={logoText(brandColor)}>{clientName}</Text>
|
||||
)}
|
||||
</Section>
|
||||
<Hr style={hr} />
|
||||
<Section style={mainContent}>{children}</Section>
|
||||
<Hr style={hr} />
|
||||
<Section style={footer}>
|
||||
<Text style={footerText}>
|
||||
© 2026 {clientName}. All rights reserved.
|
||||
</Text>
|
||||
</Section>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const header = {
|
||||
marginBottom: "32px",
|
||||
};
|
||||
|
||||
const logo = {
|
||||
margin: "0 auto",
|
||||
display: "block",
|
||||
};
|
||||
|
||||
const logoText = (color: string) => ({
|
||||
margin: "0 auto",
|
||||
textAlign: "center" as const,
|
||||
fontSize: "24px",
|
||||
fontWeight: 900,
|
||||
color: "#ffffff",
|
||||
letterSpacing: "-0.02em",
|
||||
borderLeft: `4px solid ${color}`,
|
||||
paddingLeft: "12px",
|
||||
});
|
||||
|
||||
const mainContent = {
|
||||
marginBottom: "32px",
|
||||
};
|
||||
|
||||
const hr = {
|
||||
borderColor: "#222222",
|
||||
margin: "20px 0",
|
||||
};
|
||||
|
||||
const footer = {
|
||||
marginTop: "32px",
|
||||
textAlign: "center" as const,
|
||||
};
|
||||
|
||||
const footerText = {
|
||||
fontSize: "10px",
|
||||
color: "#333333",
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: "0.1em",
|
||||
};
|
||||
53
packages/mail/src/layouts/MintelLayout.tsx
Normal file
53
packages/mail/src/layouts/MintelLayout.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react";
|
||||
import { Hr, Section, Text } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
import { MintelLogo } from "../components/MintelLogo";
|
||||
|
||||
export interface MintelLayoutProps {
|
||||
preview: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const MintelLayout = ({ preview, children }: MintelLayoutProps) => {
|
||||
return (
|
||||
<BaseLayout preview={preview} brandColor="#82ed20">
|
||||
<Section style={header}>
|
||||
<MintelLogo />
|
||||
</Section>
|
||||
<Hr style={hr} />
|
||||
<Section style={mainContent}>{children}</Section>
|
||||
<Hr style={hr} />
|
||||
<Section style={footer}>
|
||||
<Text style={footerText}>
|
||||
© 2026 Mintel Infrastructure. Secure Communication Channel.
|
||||
</Text>
|
||||
</Section>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const header = {
|
||||
marginBottom: "32px",
|
||||
};
|
||||
|
||||
const mainContent = {
|
||||
marginBottom: "32px",
|
||||
};
|
||||
|
||||
const hr = {
|
||||
borderColor: "#222222",
|
||||
margin: "20px 0",
|
||||
};
|
||||
|
||||
const footer = {
|
||||
marginTop: "32px",
|
||||
textAlign: "center" as const,
|
||||
};
|
||||
|
||||
const footerText = {
|
||||
fontSize: "12px",
|
||||
color: "#444444",
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: "0.1em",
|
||||
};
|
||||
57
packages/mail/src/templates/ConfirmationMessage.tsx
Normal file
57
packages/mail/src/templates/ConfirmationMessage.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
import { Heading, Text } from "@react-email/components";
|
||||
import { ClientLayout } from "../layouts/ClientLayout";
|
||||
|
||||
export interface ConfirmationMessageProps {
|
||||
name: string;
|
||||
clientName: string;
|
||||
clientLogo?: string;
|
||||
brandColor?: string;
|
||||
}
|
||||
|
||||
export const ConfirmationMessage = ({
|
||||
name,
|
||||
clientName,
|
||||
clientLogo,
|
||||
brandColor,
|
||||
}: ConfirmationMessageProps) => {
|
||||
const preview = `Thank you for your message, ${name}`;
|
||||
|
||||
return (
|
||||
<ClientLayout
|
||||
preview={preview}
|
||||
clientName={clientName}
|
||||
clientLogo={clientLogo}
|
||||
brandColor={brandColor}
|
||||
>
|
||||
<Heading style={h1}>Thank You</Heading>
|
||||
<Text style={text}>Hello {name},</Text>
|
||||
<Text style={text}>
|
||||
Thank you for contacting us. We have received your message and will get
|
||||
back to you as soon as possible.
|
||||
</Text>
|
||||
<Text style={text}>
|
||||
Best regards,
|
||||
<br />
|
||||
The {clientName} Team
|
||||
</Text>
|
||||
</ClientLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationMessage;
|
||||
|
||||
const h1 = {
|
||||
fontSize: "28px",
|
||||
fontWeight: "900",
|
||||
margin: "0 0 16px",
|
||||
color: "#ffffff",
|
||||
letterSpacing: "-0.04em",
|
||||
};
|
||||
|
||||
const text = {
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
color: "#cccccc",
|
||||
margin: "16px 0",
|
||||
};
|
||||
118
packages/mail/src/templates/ContactFormNotification.tsx
Normal file
118
packages/mail/src/templates/ContactFormNotification.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import * as React from "react";
|
||||
import { Heading, Section, Text, Row, Column } from "@react-email/components";
|
||||
import { MintelLayout } from "../layouts/MintelLayout";
|
||||
|
||||
export interface ContactFormNotificationProps {
|
||||
name: string;
|
||||
email: string;
|
||||
message: string;
|
||||
productName?: string;
|
||||
}
|
||||
|
||||
export const ContactFormNotification = ({
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
productName,
|
||||
}: ContactFormNotificationProps) => {
|
||||
const preview = `New message from ${name}`;
|
||||
|
||||
return (
|
||||
<MintelLayout preview={preview}>
|
||||
<Heading style={h1}>New Submission</Heading>
|
||||
<Text style={intro}>
|
||||
A new message has been received via the contact form.
|
||||
</Text>
|
||||
|
||||
<Section style={detailsContainer}>
|
||||
<Row>
|
||||
<Column style={labelCol}>
|
||||
<Text style={label}>Name</Text>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text style={value}>{name}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row>
|
||||
<Column style={labelCol}>
|
||||
<Text style={label}>Email</Text>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text style={value}>{email}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
{productName && (
|
||||
<Row>
|
||||
<Column style={labelCol}>
|
||||
<Text style={label}>Product</Text>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text style={value}>{productName}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section style={messageSection}>
|
||||
<Text style={label}>Message</Text>
|
||||
<Text style={messageText}>{message}</Text>
|
||||
</Section>
|
||||
</MintelLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactFormNotification;
|
||||
|
||||
const h1 = {
|
||||
fontSize: "28px",
|
||||
fontWeight: "900",
|
||||
margin: "0 0 16px",
|
||||
color: "#ffffff",
|
||||
letterSpacing: "-0.04em",
|
||||
};
|
||||
|
||||
const intro = {
|
||||
fontSize: "16px",
|
||||
color: "#888888",
|
||||
margin: "0 0 32px",
|
||||
};
|
||||
|
||||
const detailsContainer = {
|
||||
backgroundColor: "#151515",
|
||||
padding: "24px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "24px",
|
||||
};
|
||||
|
||||
const labelCol = {
|
||||
width: "100px",
|
||||
};
|
||||
|
||||
const label = {
|
||||
fontSize: "10px",
|
||||
fontWeight: "900",
|
||||
textTransform: "uppercase" as const,
|
||||
color: "#444444",
|
||||
margin: "0 0 4px",
|
||||
letterSpacing: "0.1em",
|
||||
};
|
||||
|
||||
const value = {
|
||||
fontSize: "16px",
|
||||
color: "#ffffff",
|
||||
margin: "0 0 12px",
|
||||
};
|
||||
|
||||
const messageSection = {
|
||||
padding: "0 24px",
|
||||
};
|
||||
|
||||
const messageText = {
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
color: "#cccccc",
|
||||
fontStyle: "italic",
|
||||
borderLeft: "2px solid #222222",
|
||||
paddingLeft: "16px",
|
||||
margin: "12px 0 0",
|
||||
};
|
||||
14
packages/mail/tsconfig.json
Normal file
14
packages/mail/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
export const baseNextConfig = {
|
||||
@@ -34,10 +34,30 @@ export const baseNextConfig = {
|
||||
};
|
||||
|
||||
const withMintelConfig = (config) => {
|
||||
const nextIntlConfig = withNextIntl({ ...baseNextConfig, ...config });
|
||||
const i18nPaths = [
|
||||
"src/i18n/request.ts",
|
||||
"src/i18n/request.tsx",
|
||||
"i18n/request.ts",
|
||||
"i18n/request.tsx",
|
||||
"src/i18n.ts",
|
||||
"src/i18n.tsx",
|
||||
"i18n.ts",
|
||||
"i18n.tsx",
|
||||
];
|
||||
|
||||
const hasI18nConfig = i18nPaths.some((p) =>
|
||||
fs.existsSync(path.resolve(process.cwd(), p)),
|
||||
);
|
||||
|
||||
let nextConfig = { ...baseNextConfig, ...config };
|
||||
|
||||
if (hasI18nConfig) {
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
nextConfig = withNextIntl(nextConfig);
|
||||
}
|
||||
|
||||
return withSentryConfig(
|
||||
nextIntlConfig,
|
||||
nextConfig,
|
||||
{
|
||||
silent: !process.env.CI,
|
||||
treeshake: { removeDebugLogging: true },
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"next-intl": "^3.0.0",
|
||||
"next-intl": "^4.8.2",
|
||||
"@sentry/nextjs": "^8.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"dependencies": {
|
||||
"@directus/sdk": "^21.0.0",
|
||||
"next": "15.1.6",
|
||||
"next-intl": "^3.0.0",
|
||||
"next-intl": "^4.8.2",
|
||||
"zod": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isValidLang } from "../src/index";
|
||||
import { isValidLang } from "./lang";
|
||||
|
||||
describe("next-utils", () => {
|
||||
it("should validate languages correctly", () => {
|
||||
|
||||
@@ -30,12 +30,7 @@ export async function rateLimit(
|
||||
submissions[identifier] = now;
|
||||
}
|
||||
|
||||
export const languages = ["en", "de"] as const;
|
||||
export type Lang = (typeof languages)[number];
|
||||
|
||||
export function isValidLang(lang: string): lang is Lang {
|
||||
return (languages as readonly string[]).includes(lang);
|
||||
}
|
||||
export * from "./lang";
|
||||
|
||||
export * from "./i18n";
|
||||
export * from "./env";
|
||||
|
||||
6
packages/next-utils/src/lang.ts
Normal file
6
packages/next-utils/src/lang.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const languages = ["en", "de"] as const;
|
||||
export type Lang = (typeof languages)[number];
|
||||
|
||||
export function isValidLang(lang: string): lang is Lang {
|
||||
return (languages as readonly string[]).includes(lang);
|
||||
}
|
||||
726
pnpm-lock.yaml
generated
726
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user