diff --git a/Dockerfile b/Dockerfile
index 2574c3c..3c39f9d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,3 @@
-# syntax=docker/dockerfile:1
-
FROM uhub.service.ucloud.cn/mysql-images/node:22-bookworm-slim AS deps
WORKDIR /app
@@ -13,6 +11,7 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
+RUN mkdir -p public
ARG NEXT_PUBLIC_SITE_URL
ARG PAYLOAD_SECRET
@@ -35,7 +34,7 @@ ENV HOSTNAME=0.0.0.0
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs \
- && adduser --system --uid 1001 --gid nodejs nextjs
+ && adduser --system --uid 1001 --ingroup nodejs nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
@@ -48,4 +47,4 @@ USER nextjs
EXPOSE 3000
-CMD ["node", "server.js"]
+CMD ["node", "server.js"]
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index befc4c2..18a7ec3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -66,4 +66,4 @@ networks:
driver: bridge
volumes:
- bootstrap_node_modules:
+ bootstrap_node_modules:
\ No newline at end of file
diff --git a/scripts/rsync-deploy.sh b/scripts/rsync-deploy.sh
new file mode 100755
index 0000000..a3f9248
--- /dev/null
+++ b/scripts/rsync-deploy.sh
@@ -0,0 +1,305 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Deploy source code to the production server via rsync.
+#
+# Usage:
+# # Full deploy (sync entire project)
+# ./scripts/rsync-deploy.sh
+# ./scripts/rsync-deploy.sh --dry-run
+#
+# # Deploy a specific directory
+# ./scripts/rsync-deploy.sh src/
+# ./scripts/rsync-deploy.sh src/payload/
+#
+# # Deploy one or more files
+# ./scripts/rsync-deploy.sh src/payload/globals/Homepage.ts
+# ./scripts/rsync-deploy.sh src/payload/globals/Homepage.ts src/app/sitemap.ts
+#
+# # Mix directories and files
+# ./scripts/rsync-deploy.sh src/migrations/ src/payload/globals/Homepage.ts
+#
+# Options:
+# --dry-run, -n Show what would be transferred without making changes
+# --target HOST SSH target (default: luxsin-website or $DEPLOY_TARGET)
+# --port PORT SSH port (default: 22 or $SSH_PORT)
+# --delete Enable --delete for specific-path syncs (full deploy always uses --delete)
+# --help, -h Show this help message
+#
+# Environment variables:
+# DEPLOY_TARGET SSH target (default: luxsin-website)
+# REMOTE_DIR Remote project root (default: /data/project/eversolo-app)
+# SSH_PORT Custom SSH port (default: 22)
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT_DIR"
+
+DEPLOY_TARGET="${DEPLOY_TARGET:-luxsin-website}"
+REMOTE_DIR="${REMOTE_DIR:-/data/project/eversolo-app}"
+SSH_PORT="${SSH_PORT:-22}"
+DRY_RUN=0
+FORCE_DELETE=0
+PATHS=()
+
+# ── Parse arguments ──────────────────────────────────────────────
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --dry-run|-n)
+ DRY_RUN=1
+ shift
+ ;;
+ --target|-t)
+ DEPLOY_TARGET="$2"
+ shift 2
+ ;;
+ --port|-p)
+ SSH_PORT="$2"
+ shift 2
+ ;;
+ --delete)
+ FORCE_DELETE=1
+ shift
+ ;;
+ --help|-h)
+ sed -n '3,/^$/p' "$0" | sed 's/^# \{0,1\}//'
+ exit 0
+ ;;
+ --*)
+ echo "ERROR: Unknown option: $1" >&2
+ exit 1
+ ;;
+ *)
+ PATHS+=("$1")
+ shift
+ ;;
+ esac
+done
+
+# ── Verify rsync is available ────────────────────────────────────
+if ! command -v rsync >/dev/null 2>&1; then
+ echo "ERROR: rsync is not installed." >&2
+ exit 1
+fi
+
+SSH_CMD="ssh -p ${SSH_PORT}"
+
+# ── Full-deploy excludes ─────────────────────────────────────────
+# Only upload files needed to build and run the Docker deployment.
+# Server-managed files (.env, data/, media/, etc.) are excluded and
+# will NOT be deleted by --delete on the remote.
+FULL_EXCLUDES=(
+ # Build artifacts
+ --exclude='.next/'
+ --exclude='.next-dev/'
+ --exclude='.turbo/'
+ --exclude='tsconfig.tsbuildinfo'
+ --exclude='out/'
+
+ # Dependencies (installed on server via npm ci / Docker)
+ --exclude='node_modules/'
+
+ # Environment files (managed on server)
+ --exclude='.env'
+ --exclude='.env.*'
+
+ # Server-managed data and uploads
+ --exclude='data/'
+ --exclude='media/'
+ --exclude='files/'
+ --exclude='videos/'
+ --exclude='public/'
+
+ # Local build / packaging artifacts
+ --exclude='deploy-packages/'
+ --exclude='output/'
+ --exclude='tmp-*'
+ --exclude='*.log'
+ --exclude='*.tar.gz'
+ --exclude='*.tgz'
+ --exclude='*.dump'
+
+ # Documentation (not needed for runtime)
+ --exclude='docs/'
+ --exclude='*.md'
+
+ # Cloudflare deployment (not used in Docker deployment)
+ --exclude='cloudflare/'
+ --exclude='Dockerfile.cloudflare'
+ --exclude='wrangler.jsonc'
+
+ # Version control
+ --exclude='.git/'
+ --exclude='.github/'
+
+ # Dev tools and editor configs
+ --exclude='.claude/'
+ --exclude='.playwright-cli/'
+ --exclude='.tmp/'
+ --exclude='.wrangler/'
+ --exclude='.vercel/'
+ --exclude='.vscode/'
+ --exclude='.idea/'
+ --exclude='.githooks/'
+
+ # OS artifacts
+ --exclude='.DS_Store'
+ --exclude='._*'
+ --exclude='__MACOSX/'
+
+ # Raw seed data (not needed in production)
+ --exclude='src/seed/raw/'
+
+ # TLS certificates (managed on server)
+ --exclude='nginx/certs/*.pem'
+ --exclude='nginx/certs/*.key'
+)
+
+# ── Minimal excludes for specific-path syncs ─────────────────────
+# When uploading a specific directory or file, only filter out
+# OS junk and dangerous directories. Project-level excludes like
+# *.md or docs/ do not apply.
+MINIMAL_EXCLUDES=(
+ --exclude='.DS_Store'
+ --exclude='._*'
+ --exclude='__MACOSX/'
+ --exclude='*.log'
+ --exclude='node_modules/'
+ --exclude='.next/'
+ --exclude='.next-dev/'
+ --exclude='nginx/certs/*.pem'
+ --exclude='nginx/certs/*.key'
+)
+
+# ── Build rsync flags ────────────────────────────────────────────
+BASE_FLAGS=(-a -v -z)
+if [[ $DRY_RUN -eq 1 ]]; then
+ BASE_FLAGS+=(--dry-run)
+fi
+
+# ── Summary header ───────────────────────────────────────────────
+print_header() {
+ local scope="$1"
+ echo "=============================================="
+ echo " Eversolo Web — rsync Deploy"
+ echo "=============================================="
+ echo " Target: ${DEPLOY_TARGET}:${REMOTE_DIR}"
+ echo " SSH Port: ${SSH_PORT}"
+ echo " Scope: ${scope}"
+ echo " Mode: $([[ $DRY_RUN -eq 1 ]] && echo 'DRY RUN' || echo 'LIVE')"
+ echo "=============================================="
+ echo ""
+}
+
+# ── Full deploy ──────────────────────────────────────────────────
+full_deploy() {
+ print_header "Full project"
+ rsync "${BASE_FLAGS[@]}" --delete \
+ -e "$SSH_CMD" \
+ "${FULL_EXCLUDES[@]}" \
+ ./ "${DEPLOY_TARGET}:${REMOTE_DIR}/"
+}
+
+# ── Sync a single directory ──────────────────────────────────────
+sync_directory() {
+ local dir="$1"
+ # Ensure no leading ./
+ dir="${dir#./}"
+ # Ensure trailing slash for rsync "contents" semantics
+ dir="${dir%/}/"
+
+ if [[ ! -d "$dir" ]]; then
+ echo "ERROR: Directory not found: $dir" >&2
+ return 1
+ fi
+
+ echo " → ${dir} → ${REMOTE_DIR}/${dir}"
+ local flags=("${BASE_FLAGS[@]}")
+ if [[ $FORCE_DELETE -eq 1 ]]; then
+ flags+=(--delete)
+ fi
+
+ rsync "${flags[@]}" \
+ -e "$SSH_CMD" \
+ "${MINIMAL_EXCLUDES[@]}" \
+ "./${dir}" "${DEPLOY_TARGET}:${REMOTE_DIR}/${dir}"
+}
+
+# ── Sync one or more files ───────────────────────────────────────
+sync_files() {
+ # Use --relative so rsync preserves the full directory structure
+ # e.g. ./src/payload/globals/Homepage.ts → /remote/src/payload/globals/Homepage.ts
+ local file_args=()
+ for f in "$@"; do
+ f="${f#./}"
+ if [[ ! -f "$f" ]]; then
+ echo "ERROR: File not found: $f" >&2
+ return 1
+ fi
+ echo " → ${f}"
+ file_args+=("./${f}")
+ done
+
+ rsync "${BASE_FLAGS[@]}" --relative \
+ -e "$SSH_CMD" \
+ "${MINIMAL_EXCLUDES[@]}" \
+ "${file_args[@]}" \
+ "${DEPLOY_TARGET}:${REMOTE_DIR}/"
+}
+
+# ── Execute ─────────────────────────────────────────────────────
+if [[ ${#PATHS[@]} -eq 0 ]]; then
+ # ── Full deploy ────────────────────────────────────────────────
+ full_deploy
+else
+ # ── Specific paths: separate directories from files ───────────
+ DIRS=()
+ FILES=()
+
+ for p in "${PATHS[@]}"; do
+ # Strip leading ./
+ p="${p#./}"
+ if [[ -d "$p" ]]; then
+ DIRS+=("$p")
+ elif [[ -f "$p" ]]; then
+ FILES+=("$p")
+ else
+ echo "ERROR: Path does not exist: $p" >&2
+ exit 1
+ fi
+ done
+
+ print_header "Specific paths (${#PATHS[@]} item(s))"
+
+ # Sync directories
+ if [[ ${#DIRS[@]} -gt 0 ]]; then
+ for d in "${DIRS[@]}"; do
+ sync_directory "$d" || exit 1
+ done
+ fi
+
+ # Sync files (batch with --relative)
+ if [[ ${#FILES[@]} -gt 0 ]]; then
+ sync_files "${FILES[@]}" || exit 1
+ fi
+fi
+
+# ── Post-deploy instructions ────────────────────────────────────
+echo ""
+if [[ $DRY_RUN -eq 1 ]]; then
+ echo "✓ Dry run complete — no files were transferred."
+ echo " Run without --dry-run to apply changes."
+else
+ echo "✓ Upload complete."
+ if [[ ${#PATHS[@]} -eq 0 ]]; then
+ echo ""
+ echo "Next steps (run on the production server):"
+ echo ""
+ echo " 1. Run database migrations:"
+ echo " cd ${REMOTE_DIR}"
+ echo " docker compose run --rm --no-deps bootstrap bash -lc 'npm ci --include=dev && npm run payload:migrate && npm run payload:migrate:status'"
+ echo ""
+ echo " 2. Rebuild and restart the app:"
+ echo " docker compose build app"
+ echo " docker compose up -d app"
+ fi
+fi
diff --git a/src/app/(frontend)/[locale]/page.tsx b/src/app/(frontend)/[locale]/page.tsx
index 1815c87..c821d97 100644
--- a/src/app/(frontend)/[locale]/page.tsx
+++ b/src/app/(frontend)/[locale]/page.tsx
@@ -428,9 +428,11 @@ export default async function HomePage({
(review) => !!review && typeof review === 'object',
)}
/>
-
-
-
+ {homepage.showNewsSection !== false ? (
+
+
+
+ ) : null}
>
)
}
diff --git a/src/migrations/20260724_120000_add_homepage_show_news_section.ts b/src/migrations/20260724_120000_add_homepage_show_news_section.ts
new file mode 100644
index 0000000..094f123
--- /dev/null
+++ b/src/migrations/20260724_120000_add_homepage_show_news_section.ts
@@ -0,0 +1,19 @@
+import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
+
+export async function up({ db }: MigrateUpArgs): Promise {
+ await db.execute(sql`
+ ALTER TABLE "homepage"
+ ADD COLUMN IF NOT EXISTS "show_news_section" boolean DEFAULT true;
+
+ UPDATE "homepage"
+ SET "show_news_section" = true
+ WHERE "show_news_section" IS NULL;
+ `)
+}
+
+export async function down({ db }: MigrateDownArgs): Promise {
+ await db.execute(sql`
+ ALTER TABLE "homepage"
+ DROP COLUMN IF EXISTS "show_news_section";
+ `)
+}
diff --git a/src/migrations/index.ts b/src/migrations/index.ts
index 861a992..9152e98 100644
--- a/src/migrations/index.ts
+++ b/src/migrations/index.ts
@@ -79,6 +79,7 @@ import * as migration_20260603_010000_update_submission_workflow_fields from './
import * as migration_20260603_020000_add_support_photos_collection from './20260603_020000_add_support_photos_collection';
import * as migration_20260603_030000_add_after_service_upload_token from './20260603_030000_add_after_service_upload_token';
import * as migration_20260604_010000_localize_homepage_hero_media_controls from './20260604_010000_localize_homepage_hero_media_controls';
+import * as migration_20260724_120000_add_homepage_show_news_section from './20260724_120000_add_homepage_show_news_section';
export const migrations = [
{
@@ -486,4 +487,9 @@ export const migrations = [
down: migration_20260604_010000_localize_homepage_hero_media_controls.down,
name: '20260604_010000_localize_homepage_hero_media_controls'
},
+ {
+ up: migration_20260724_120000_add_homepage_show_news_section.up,
+ down: migration_20260724_120000_add_homepage_show_news_section.down,
+ name: '20260724_120000_add_homepage_show_news_section'
+ },
];
diff --git a/src/payload-types.ts b/src/payload-types.ts
index 3211b77..d837042 100644
--- a/src/payload-types.ts
+++ b/src/payload-types.ts
@@ -2149,6 +2149,10 @@ export interface Homepage {
}[]
| null;
};
+ /**
+ * Uncheck to hide the news grid from the homepage. Does not affect the /news page or navigation entries.
+ */
+ showNewsSection?: boolean | null;
/**
* Choose whether the homepage News section reads from the external WordPress blog or from local CMS News Highlights.
*/
diff --git a/src/payload/globals/Homepage.ts b/src/payload/globals/Homepage.ts
index a35a984..e3827d8 100644
--- a/src/payload/globals/Homepage.ts
+++ b/src/payload/globals/Homepage.ts
@@ -427,6 +427,16 @@ export const Homepage: GlobalConfig = {
},
],
},
+ {
+ name: 'showNewsSection',
+ label: { en: 'Show News Section', zh: '显示首页新闻区块' },
+ type: 'checkbox',
+ defaultValue: true,
+ admin: {
+ description:
+ 'Uncheck to hide the news grid from the homepage. Does not affect the /news page or navigation entries.',
+ },
+ },
{
name: 'newsSource',
label: { en: 'News Source', zh: '新闻来源' },