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: '新闻来源' },