From 2f260e0f3b1cdad544f5b69f4a94eb62efc28095 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 17 Sep 2026 14:06:19 +0700 Subject: [PATCH 01/52] =?UTF-8?q?CD:=20=E0=B9=80=E0=B8=9E=E0=B8=B4?= =?UTF-8?q?=E0=B9=88=E0=B8=A1=20workflow=20uat.yml=20deploy=20=E0=B9=80?= =?UTF-8?q?=E0=B8=84=E0=B8=A3=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=87=20UAT=20?= =?UTF-8?q?=E2=80=94=20mirror=20build.yml=20=E0=B8=97=E0=B8=B8=E0=B8=81?= =?UTF-8?q?=E0=B8=AD=E0=B8=A2=E0=B9=88=E0=B8=B2=E0=B8=87=20=E0=B9=80?= =?UTF-8?q?=E0=B8=9B=E0=B8=A5=E0=B8=B5=E0=B9=88=E0=B8=A2=E0=B8=99=E0=B8=95?= =?UTF-8?q?=E0=B8=B1=E0=B8=A7=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7?= =?UTF-8?q?:=20DEPLOY=5FHOST=20=E0=B8=AD=E0=B9=88=E0=B8=B2=E0=B8=99?= =?UTF-8?q?=E0=B8=88=E0=B8=B2=E0=B8=81=20vars.SSH=5FUAT=5FDEPLOY=5FHOST=20?= =?UTF-8?q?=E0=B9=83=E0=B8=AB=E0=B8=A1=E0=B9=88=20(=E0=B8=95=E0=B8=B1?= =?UTF-8?q?=E0=B9=89=E0=B8=87=E0=B9=80=E0=B8=9E=E0=B8=B4=E0=B9=88=E0=B8=A1?= =?UTF-8?q?=E0=B9=83=E0=B8=99=20Forgejo=20=E0=B8=81=E0=B9=88=E0=B8=AD?= =?UTF-8?q?=E0=B8=99=E0=B9=83=E0=B8=8A=E0=B9=89=20=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B8=87=E0=B8=B1=E0=B9=89=E0=B8=99=20deploy=20job=20?= =?UTF-8?q?SSH=20=E0=B9=84=E0=B8=9B=20string=20=E0=B8=A7=E0=B9=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=87)=20=E2=80=94=20trigger=20tag=20v*.*.*=20=E0=B9=80?= =?UTF-8?q?=E0=B8=94=E0=B8=B4=E0=B8=A1=20(push=20tag=20=E0=B9=80=E0=B8=94?= =?UTF-8?q?=E0=B8=B5=E0=B8=A2=E0=B8=A7=20deploy=20Test=20+=20UAT=20?= =?UTF-8?q?=E0=B8=9E=E0=B8=A3=E0=B9=89=E0=B8=AD=E0=B8=A1=E0=B8=81=E0=B8=B1?= =?UTF-8?q?=E0=B8=99),=20registry/image=20=E0=B9=80=E0=B8=94=E0=B8=B4?= =?UTF-8?q?=E0=B8=A1=20push=20:v*=20=E0=B9=81=E0=B8=A5=E0=B8=B0=20:latest?= =?UTF-8?q?=20=E0=B8=8B=E0=B9=89=E0=B8=B3=E0=B8=81=E0=B8=B1=E0=B8=99=20(co?= =?UTF-8?q?mmit=20=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B8=81?= =?UTF-8?q?=E0=B8=B1=E0=B8=99=20=E0=B9=80=E0=B8=99=E0=B8=B7=E0=B9=89?= =?UTF-8?q?=E0=B8=AD=E0=B8=AB=E0=B8=B2=E0=B9=80=E0=B8=AB=E0=B8=A1=E0=B8=B7?= =?UTF-8?q?=E0=B8=AD=E0=B8=99=20=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=8A?= =?UTF-8?q?=E0=B8=99=E0=B8=81=E0=B8=B1=E0=B8=99),=20SSH=20user/port/passwo?= =?UTF-8?q?rd=20=E0=B8=8A=E0=B8=B8=E0=B8=94=E0=B9=80=E0=B8=94=E0=B8=B4?= =?UTF-8?q?=E0=B8=A1,=20script=20~/repo/replace-env.sh=20+=20deploy.sh=20?= =?UTF-8?q?=E0=B9=80=E0=B8=94=E0=B8=B4=E0=B8=A1=20(=E0=B9=80=E0=B8=84?= =?UTF-8?q?=E0=B8=A3=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=87=20UAT=20=E0=B8=95?= =?UTF-8?q?=E0=B9=89=E0=B8=AD=E0=B8=87=E0=B8=A1=E0=B8=B5=E0=B9=84=E0=B8=9F?= =?UTF-8?q?=E0=B8=A5=E0=B9=8C=E0=B8=AA=E0=B8=AD=E0=B8=87=E0=B8=95=E0=B8=B1?= =?UTF-8?q?=E0=B8=A7=E0=B8=99=E0=B8=B5=E0=B9=89=20+=20=E0=B8=A3=E0=B8=B1?= =?UTF-8?q?=E0=B8=9A=20credentials=20=E0=B9=80=E0=B8=94=E0=B8=B4=E0=B8=A1)?= =?UTF-8?q?,=20Discord=20webhook/Web=20URL=20=E0=B8=95=E0=B8=B1=E0=B8=A7?= =?UTF-8?q?=E0=B9=80=E0=B8=94=E0=B8=B4=E0=B8=A1=20=E2=80=94=20=E0=B9=80?= =?UTF-8?q?=E0=B8=9B=E0=B8=A5=E0=B8=B5=E0=B9=88=E0=B8=A2=E0=B8=99=E0=B9=80?= =?UTF-8?q?=E0=B8=89=E0=B8=9E=E0=B8=B2=E0=B8=B0=E0=B8=82=E0=B9=89=E0=B8=AD?= =?UTF-8?q?=E0=B8=84=E0=B8=A7=E0=B8=B2=E0=B8=A1=20workflow/job/notificatio?= =?UTF-8?q?n=20=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20"UAT"=20=E0=B9=83?= =?UTF-8?q?=E0=B8=AB=E0=B9=89=E0=B9=81=E0=B8=A2=E0=B8=81=20notification=20?= =?UTF-8?q?=E0=B8=AA=E0=B8=AD=E0=B8=87=E0=B9=80=E0=B8=84=E0=B8=A3=E0=B8=B7?= =?UTF-8?q?=E0=B9=88=E0=B8=AD=E0=B8=87=E0=B9=84=E0=B8=94=E0=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- .forgejo/workflows/uat.yml | 249 +++++++++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 .forgejo/workflows/uat.yml diff --git a/.forgejo/workflows/uat.yml b/.forgejo/workflows/uat.yml new file mode 100644 index 0000000..772bcfe --- /dev/null +++ b/.forgejo/workflows/uat.yml @@ -0,0 +1,249 @@ +name: Deploy eCourt Note Web to UAT Server + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + +env: + # Container Registry + REGISTRY_URL: ${{ vars.CONTAINER_REGISTRY }} + REGISTRY_USERNAME: ${{ vars.CONTAINER_REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }} + + # Docker Image + WEB_IMAGE_NAME: chamomind/ecourt-note + IMAGE_TAG: ${{ github.ref_name }} + + # SSH Deployment — ต่างจาก build.yml ตัวเดียว: ชี้เครื่อง UAT (ตั้ง vars.SSH_UAT_DEPLOY_HOST เพิ่มใน Forgejo) + DEPLOY_HOST: ${{ vars.SSH_UAT_DEPLOY_HOST }} + DEPLOY_USER: ${{ vars.SSH_DEPLOY_USER }} + + # Notifications + DISCORD_WEBHOOK_URL: ${{ vars.DISCORD_WEBHOOK_URL }} + NEXT_PUBLIC_APP_URL: ${{ vars.NEXT_PUBLIC_APP_URL }} + +jobs: + validate-schema: + name: Validate Database Schema (Drizzle) + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Verify tag is on dev branch + run: | + echo "======================================================" + echo "Verifying tag is pushed to dev branch" + echo "======================================================" + echo "Tag: ${{ github.ref_name }}" + echo "Commit: ${{ github.sha }}" + + # Fetch dev branch + git fetch origin dev + + # Check if the tag's commit is on dev branch + if git branch -r --contains ${{ github.sha }} | grep -q "origin/dev"; then + echo "✅ Tag is on dev branch" + echo "test_branch=true" >> $GITHUB_ENV + else + echo "❌ Tag is NOT on dev branch" + echo "This workflow only triggers for tags pushed to dev branch" + echo "" + echo "Current branches containing this commit:" + git branch -r --contains ${{ github.sha }} + exit 1 + fi + + - name: Check for schema changes + id: schema-check + run: | + # โปรเจกต์นี้ใช้ Drizzle (drizzle-kit push) ไม่มีโฟลเดอร์ migrations — ดูการเปลี่ยนแปลงที่ db/schema.ts + if git diff --name-only origin/dev...HEAD | grep -qE "^db/schema\.ts$"; then + echo "has_schema_changes=true" >> $GITHUB_OUTPUT + echo "✅ Schema changes detected" + else + echo "has_schema_changes=false" >> $GITHUB_OUTPUT + echo "ℹ️ No schema changes" + fi + + - name: Schema change summary + if: steps.schema-check.outputs.has_schema_changes == 'true' + run: | + echo "=== Schema Change Summary ===" + echo "" + echo "Database schema (db/schema.ts) has been modified in this deployment." + echo "This project uses Drizzle push (no migration files)." + echo "" + echo "⚠️ REMINDERS:" + echo " 1. Run 'npx drizzle-kit push' on the target database (staging first)" + echo " 2. Monitor logs after deployment" + echo "" + echo "📚 Reference: README.md / docs/PLAN.md §3" + + build: + name: Build Web Docker Image + runs-on: ubuntu-latest + needs: validate-schema + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY_URL }} + username: ${{ env.REGISTRY_USERNAME }} + password: ${{ env.REGISTRY_PASSWORD }} + + - name: Build Docker image (no buildx) + run: | + # Build using standard docker build (not buildx) + git reset --hard HEAD + cd ${{ github.workspace }} + docker build \ + --no-cache \ + --pull \ + --file ./docker/web.Dockerfile \ + --tag ${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:${{ env.IMAGE_TAG }} \ + --tag ${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:latest \ + . + + echo "✅ Build successful!" + docker images | grep ${{ env.WEB_IMAGE_NAME }} + + - name: Push Docker image + run: | + docker push ${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:${{ env.IMAGE_TAG }} + docker push ${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:latest + echo "✅ Image pushed to registry" + + - name: Test image can run + run: | + # Quick test that image can start + docker run --rm ${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:${{ env.IMAGE_TAG }} node --version + + deploy: + name: Deploy eCourt Note Web to UAT Server + runs-on: ubuntu-latest + needs: build + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: Deploy web on UAT server + uses: appleboy/ssh-action@v1.2.1 + with: + host: ${{ env.DEPLOY_HOST }} + port: ${{ vars.SSH_DEPLOY_PORT }} + username: ${{ env.DEPLOY_USER }} + password: ${{ secrets.SSH_DEPLOY_PASSWORD }} + script: | + cd ~/repo + + chmod +x ./replace-env.sh ./deploy.sh + ./replace-env.sh ECOURT_NOTE_TAG "${{ env.IMAGE_TAG }}" + ./deploy.sh + + notify: + name: Notify Deployment Status + runs-on: ubuntu-latest + needs: [build, deploy] + if: always() + steps: + - name: Send Discord notification - Success + if: ${{ needs.deploy.result == 'success' }} + run: | + curl -H "Content-Type: application/json" \ + -d '{ + "embeds": [{ + "title": "✅ eCourt Note UAT Deployment Successful", + "description": "eCourt Note Web deployed to UAT server", + "color": 3066993, + "fields": [ + { + "name": "Environment", + "value": "UAT", + "inline": true + }, + { + "name": "Tag", + "value": "${{ github.ref_name }}", + "inline": true + }, + { + "name": "Commit", + "value": "`${{ github.sha }}`", + "inline": false + }, + { + "name": "Image Tag", + "value": "`${{ env.IMAGE_TAG }}`", + "inline": true + }, + { + "name": "Web URL", + "value": "${{ env.NEXT_PUBLIC_APP_URL }}", + "inline": false + }, + { + "name": "Deployed By", + "value": "${{ github.actor }}", + "inline": true + } + ], + "footer": { + "text": "eCourt Note CD Pipeline" + }, + "timestamp": "'"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)"'" + }] + }' \ + ${{ env.DISCORD_WEBHOOK_URL }} + + - name: Send Discord notification - Failure + if: ${{ needs.deploy.result == 'failure' || needs.build.result == 'failure' }} + run: | + curl -H "Content-Type: application/json" \ + -d '{ + "embeds": [{ + "title": "❌ eCourt Note UAT Deployment Failed", + "description": "Failed to deploy eCourt Note Web to UAT server", + "color": 15158332, + "fields": [ + { + "name": "Environment", + "value": "UAT", + "inline": true + }, + { + "name": "Tag", + "value": "${{ github.ref_name }}", + "inline": true + }, + { + "name": "Commit", + "value": "`${{ github.sha }}`", + "inline": false + }, + { + "name": "Failed Job", + "value": "${{ needs.build.result == 'failure' && 'Build' || 'Deploy' }}", + "inline": true + }, + { + "name": "Deployed By", + "value": "${{ github.actor }}", + "inline": true + } + ], + "footer": { + "text": "eCourt Note CD Pipeline" + }, + "timestamp": "'"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)"'" + }] + }' \ + ${{ env.DISCORD_WEBHOOK_URL }} + exit 1 From 34c3cf2a8ff17359aca06f432c216322e64e5b11 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 17 Sep 2026 14:22:56 +0700 Subject: [PATCH 02/52] fix --- .forgejo/workflows/uat.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/uat.yml b/.forgejo/workflows/uat.yml index 772bcfe..a09aa77 100644 --- a/.forgejo/workflows/uat.yml +++ b/.forgejo/workflows/uat.yml @@ -3,7 +3,7 @@ name: Deploy eCourt Note Web to UAT Server on: push: tags: - - "v*.*.*" + - "deploy*.*.*" workflow_dispatch: env: From fa880b314e8df9813f75a76c230d22f37c1645cd Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 17 Sep 2026 16:06:20 +0700 Subject: [PATCH 03/52] =?UTF-8?q?BASE=5FPATH:=20serve=20=E0=B9=83=E0=B8=95?= =?UTF-8?q?=E0=B9=89=20/docedit/note=20=E2=80=94=20next.config.mjs=20?= =?UTF-8?q?=E0=B8=95=E0=B8=B1=E0=B9=89=E0=B8=87=20basePath=20+=20env=20NEX?= =?UTF-8?q?T=5FPUBLIC=5FBASE=5FPATH=20(=E0=B8=9B=E0=B8=A3=E0=B8=B0?= =?UTF-8?q?=E0=B8=81=E0=B8=B2=E0=B8=A8=E0=B8=84=E0=B9=88=E0=B8=B2=E0=B9=80?= =?UTF-8?q?=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B8=97=E0=B8=B5=E0=B9=88?= =?UTF-8?q?=E0=B9=84=E0=B8=9F=E0=B8=A5=E0=B9=8C=E0=B8=99=E0=B8=B5=E0=B9=89?= =?UTF-8?q?)=20=E0=B9=83=E0=B8=AB=E0=B9=89=20utils/base-path.ts=20(?= =?UTF-8?q?=E0=B9=83=E0=B8=AB=E0=B8=A1=E0=B9=88)=20withBasePath()=20?= =?UTF-8?q?=E0=B9=80=E0=B8=95=E0=B8=B4=E0=B8=A1=20prefix=20=E0=B9=83?= =?UTF-8?q?=E0=B8=AB=E0=B9=89=20URL=20=E0=B8=9D=E0=B8=B1=E0=B9=88=E0=B8=87?= =?UTF-8?q?=20browser=20=E0=B8=97=E0=B8=B5=E0=B9=88=20Next=20=E0=B9=84?= =?UTF-8?q?=E0=B8=A1=E0=B9=88=E0=B9=80=E0=B8=95=E0=B8=B4=E0=B8=A1=E0=B9=83?= =?UTF-8?q?=E0=B8=AB=E0=B9=89=E0=B9=80=E0=B8=AD=E0=B8=87=20(fetch/EventSou?= =?UTF-8?q?rce/plain=20href/workerSrc/img=20src):=20PDFViewer=20(workerSrc?= =?UTF-8?q?,=20/api/pdf=20=E0=B8=8B=E0=B9=88=E0=B8=AD=E0=B8=A1=20ToUnicode?= =?UTF-8?q?,=20fallback=20file.pdfUrl,=20image=20branch=20N3=20=E0=B8=A3?= =?UTF-8?q?=E0=B8=A7=E0=B8=9A=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=E0=B8=95?= =?UTF-8?q?=E0=B8=B1=E0=B8=A7=E0=B9=81=E0=B8=9B=E0=B8=A3=20fileUrl,=20expo?= =?UTF-8?q?rt=20PDF=20F8),=20SSE=20stream=20=E0=B9=83=E0=B8=99=20useAnnota?= =?UTF-8?q?tionRealtime,=20=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1=E0=B8=AA?= =?UTF-8?q?=E0=B9=88=E0=B8=87=E0=B8=AD=E0=B8=AD=E0=B8=81=20bookmark=20JSON?= =?UTF-8?q?=20(N7)=20+=20=E0=B8=84=E0=B8=B1=E0=B8=94=E0=B8=A5=E0=B8=AD?= =?UTF-8?q?=E0=B8=81=E0=B8=A5=E0=B8=B4=E0=B8=87=E0=B8=81=E0=B9=8C=20F12=20?= =?UTF-8?q?=E0=B9=83=E0=B8=99=20AnnotationSidebar=20(window.location.origi?= =?UTF-8?q?n=20+=20basePath=20=E0=B8=81=E0=B8=B1=E0=B8=99=20paste=20?= =?UTF-8?q?=E0=B9=81=E0=B8=A5=E0=B9=89=E0=B8=A7=20404),=20Export=20CSV=20(?= =?UTF-8?q?F16)=20=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2=20summary=20?= =?UTF-8?q?=E2=80=94=20/next/navigation/middleware.ts=20=E0=B9=84?= =?UTF-8?q?=E0=B8=A1=E0=B9=88=E0=B8=95=E0=B9=89=E0=B8=AD=E0=B8=87=E0=B9=81?= =?UTF-8?q?=E0=B8=81=E0=B9=89=20(Next=2014.2=20auto-prefix=20=E0=B9=83?= =?UTF-8?q?=E0=B8=AB=E0=B9=89=20+=20matcher=20=E0=B8=88=E0=B8=B1=E0=B8=9A?= =?UTF-8?q?=20path=20=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B8=95=E0=B8=B1?= =?UTF-8?q?=E0=B8=94=20basePath=20=E0=B9=81=E0=B8=A5=E0=B9=89=E0=B8=A7=20+?= =?UTF-8?q?=20NextURL=20redirect=20=E0=B9=83=E0=B8=AA=E0=B9=88=E0=B8=81?= =?UTF-8?q?=E0=B8=A5=E0=B8=B1=E0=B8=9A=E0=B9=80=E0=B8=AD=E0=B8=87=20?= =?UTF-8?q?=E0=B8=A2=E0=B8=B7=E0=B8=99=E0=B8=A2=E0=B8=B1=E0=B8=99=E0=B8=88?= =?UTF-8?q?=E0=B8=B2=E0=B8=81=20source=20=E0=B9=83=E0=B8=99=20node=5Fmodul?= =?UTF-8?q?es)=20+=20pdf=5Furl=20=E0=B9=83=E0=B8=99=20DB=20=E0=B9=80?= =?UTF-8?q?=E0=B8=81=E0=B9=87=E0=B8=9A=20path=20=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B8=A3=E0=B8=A7=E0=B8=A1=20basePath=20=E0=B9=80?= =?UTF-8?q?=E0=B8=95=E0=B8=B4=E0=B8=A1=E0=B8=95=E0=B8=AD=E0=B8=99=E0=B9=83?= =?UTF-8?q?=E0=B8=8A=E0=B9=89=E0=B9=80=E0=B8=97=E0=B9=88=E0=B8=B2=E0=B8=99?= =?UTF-8?q?=E0=B8=B1=E0=B9=89=E0=B8=99=20+=20sync=20=E0=B9=80=E0=B8=AD?= =?UTF-8?q?=E0=B8=81=E0=B8=AA=E0=B8=B2=E0=B8=A3=E0=B8=95=E0=B8=B2=E0=B8=A1?= =?UTF-8?q?=20governance:=20CLAUDE.md=20invariant=20#6=20(workerSrc=20?= =?UTF-8?q?=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=20withBasePath)=20+=20#12?= =?UTF-8?q?=20=E0=B9=83=E0=B8=AB=E0=B8=A1=E0=B9=88=20(URL=20=E0=B8=9D?= =?UTF-8?q?=E0=B8=B1=E0=B9=88=E0=B8=87=20browser=20=E0=B8=97=E0=B8=B5?= =?UTF-8?q?=E0=B9=88=E0=B8=AA=E0=B8=A3=E0=B9=89=E0=B8=B2=E0=B8=87=E0=B9=80?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=E0=B8=95=E0=B9=89=E0=B8=AD=E0=B8=87=E0=B8=9C?= =?UTF-8?q?=E0=B9=88=E0=B8=B2=E0=B8=99=20withBasePath),=20PLAN.md=20A3/?= =?UTF-8?q?=C2=A74,=20rules=20pdf-viewer/realtime,=20plan-reviewer=20#8,?= =?UTF-8?q?=20README=20URL=20localhost:3000/docedit/note=20=E2=80=94=20ver?= =?UTF-8?q?ify:=20tsc=20strict=20=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=20+?= =?UTF-8?q?=20curl=20(root=20/=20404,=20=E0=B9=84=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B8=A1=E0=B8=B5=20cookie=20307=E2=86=92/docedit/note/login,?= =?UTF-8?q?=20worker=20200,=20SSE=20reachable,=20=E0=B8=A1=E0=B8=B5=20cook?= =?UTF-8?q?ie=20200)=20+=20Playwright=20login=20=E0=B8=88=E0=B8=A3?= =?UTF-8?q?=E0=B8=B4=E0=B8=87=E0=B9=80=E0=B8=9B=E0=B8=B4=E0=B8=94=E0=B9=84?= =?UTF-8?q?=E0=B8=9F=E0=B8=A5=E0=B9=8C=2044=20=E0=B8=AB=E0=B8=99=E0=B9=89?= =?UTF-8?q?=E0=B8=B2=20render=20=E0=B8=84=E0=B8=A3=E0=B8=9A=20(SSE=20/api/?= =?UTF-8?q?pdf=20/worker=20200=20=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=20ba?= =?UTF-8?q?sePath)=20+=20code-reviewer=20=E0=B8=9C=E0=B9=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=99=20(vitest=20126=20tests,=20=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B8=9E=E0=B8=9A=20bug,=20blob=20URL=20=E0=B9=84?= =?UTF-8?q?=E0=B8=A1=E0=B9=88=E0=B9=82=E0=B8=94=E0=B8=99=20prefix=20?= =?UTF-8?q?=E0=B8=8B=E0=B9=89=E0=B8=B3,=20env=20inline=20=E0=B8=84?= =?UTF-8?q?=E0=B8=A3=E0=B8=9A=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87=20client?= =?UTF-8?q?/server=20=E0=B8=95=E0=B8=B2=E0=B8=A1=20define-env-plugin)=20?= =?UTF-8?q?=E2=80=94=20deploy:=20basePath=20=E0=B9=80=E0=B8=9B=E0=B9=87?= =?UTF-8?q?=E0=B8=99=20build-time=20=E0=B8=95=E0=B9=89=E0=B8=AD=E0=B8=87?= =?UTF-8?q?=20build=20image=20=E0=B9=83=E0=B8=AB=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B8=96=E0=B8=B6=E0=B8=87=E0=B8=88=E0=B8=B0=E0=B9=80=E0=B8=AA?= =?UTF-8?q?=E0=B8=B4=E0=B8=A3=E0=B9=8C=E0=B8=9F=E0=B8=97=E0=B8=B5=E0=B9=88?= =?UTF-8?q?=20/docedit/note=20+=20reverse=20proxy=20=E0=B8=95=E0=B9=89?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=20forward=20path=20=E0=B9=80=E0=B8=95?= =?UTF-8?q?=E0=B9=87=E0=B8=A1=E0=B9=81=E0=B8=9A=E0=B8=9A=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=20strip=20prefix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- .claude/agents/plan-reviewer.md | 2 +- .claude/rules/pdf-viewer.md | 4 ++-- .claude/rules/realtime.md | 2 +- CLAUDE.md | 6 ++++-- README.md | 2 +- app/annotate/[id]/summary/page.tsx | 3 ++- components/pdf/PDFViewer.tsx | 17 ++++++++++------- components/sidebar/AnnotationSidebar.tsx | 5 +++-- docs/PLAN.md | 6 +++--- hooks/useAnnotationRealtime.ts | 3 ++- next.config.mjs | 7 +++++++ utils/base-path.ts | 7 +++++++ 12 files changed, 43 insertions(+), 21 deletions(-) create mode 100644 utils/base-path.ts diff --git a/.claude/agents/plan-reviewer.md b/.claude/agents/plan-reviewer.md index 24f0d8c..61648c3 100644 --- a/.claude/agents/plan-reviewer.md +++ b/.claude/agents/plan-reviewer.md @@ -22,7 +22,7 @@ You review the Judge's Note codebase — PDF annotation system for e-Court (Next 5. **เขียนผ่าน Server Actions เท่านั้น** — ไม่มี client insert/update/delete DB ตรง 6. **Realtime event** — SSE payload เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ ไม่มีการแปลง; Server Actions ทุกตัว (รวมตัวใหม่: update/resolve/reply/import/restore) ต้อง publish ผ่าน `lib/annotate-events.ts` หลังเขียน DB สำเร็จ 7. **ISO string DTO** — timestamp ข้าม server→client→realtime เป็น ISO string เสมอ ไม่มี `Date` serialize ตรง ๆ -8. **pdfjs** — dynamic import ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"`; ห้าม `new URL(..., import.meta.url)` +8. **pdfjs** — dynamic import ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")`; ห้าม `new URL(..., import.meta.url)` 9. **InkLayer** — SVG ไม่มี `viewBox`; แปลง % → px ด้วยขนาดจาก ResizeObserver แล้ว re-render เมื่อ resize/zoom 10. **JSONB strict** — `data` เป็น discriminated union `$type()` ตาม enum `type` — ไม่มี `any` 11. **Optimistic** — ใช้ `useOptimistic` ภายใน `startTransition`; realtime hook dedupe ด้วย id; ไม่มี id `temp-*` ค้างใน state diff --git a/.claude/rules/pdf-viewer.md b/.claude/rules/pdf-viewer.md index c99a685..7c33ca7 100644 --- a/.claude/rules/pdf-viewer.md +++ b/.claude/rules/pdf-viewer.md @@ -11,12 +11,12 @@ paths: - Canvas Retina: `canvas.width = viewport.width × devicePixelRatio` + render `transform: [dpr,0,0,dpr,0,0]` แต่ `style.width` คง viewport px - Canvas + TextLayer re-render เมื่อ dep `[doc, pageNumber, scale]` เปลี่ยน + cancel render task ตอน unmount (กัน memory leak) - Selection คลุมดำ: รับเฉพาะ rects ใน page เดียว (`commonAncestorContainer.closest('[data-page]')`) และกรอง rect กว้าง 0 ทิ้ง -- **PDF ไทย cmap ของฟอนต์เพี้น (คำสั่งศาล) → โหลดผ่าน `/api/pdf?fileId=` เสมอ** (ไม่ใช่ `file.pdfUrl` ตรง ๆ): `lib/pdf-actual-text.ts` เรียน glyph-code → ข้อความจาก `/ActualText` ใน marked content ของ content stream แล้วเขียน ToUnicode CMap ใหม่**บนสำเนาในหน่วยความจำ** (ไฟล์ใน public/ ไม่ถูกแตะ — invariant #1) ทำให้ textLayer มีข้อความถูกตั้งแต่ต้น (select/copy/ค้นหา/ชื่อ bookmark ถูกทั้งหมด) — PDF ปกติไม่มี ActualText = ได้ bytes เดิมคืน ไม่มี cost; fail ก็ตัวเอง fetch ไฟล์ดิบตามเดิม (fallback ใน PDFViewer) +- **PDF ไทย cmap ของฟอนต์เพี้น (คำสั่งศาล) → โหลดผ่าน `/api/pdf?fileId=` เสมอ** (ไม่ใช่ `file.pdfUrl` ตรง ๆ): `lib/pdf-actual-text.ts` เรียน glyph-code → ข้อความจาก `/ActualText` ใน marked content ของ content stream แล้วเขียน ToUnicode CMap ใหม่**บนสำเนาในหน่วยความจำ** (ไฟล์ใน public/ ไม่ถูกแตะ — invariant #1) ทำให้ textLayer มีข้อความถูกตั้งแต่ต้น (select/copy/ค้นหา/ชื่อ bookmark ถูกทั้งหมด) — PDF ปกติไม่มี ActualText = ได้ bytes เดิมคืน ไม่มี cost; fail ก็ตัวเอง fetch ไฟล์ดิบตามเดิม (fallback ใน PDFViewer) — **fetch/`img.src` ทุกตัว (รวม fallback และรูป N3 ที่โหลด static `pdfUrl` ตรง ๆ) ต้องผ่าน `withBasePath()`** (invariant #12) - **ข้อความจาก selection ห้ามใช้ `selection.toString()` ตรง ๆ** — แม้ซ่อม ToUnicode แล้ว textLayer ไทยบางไฟล์ยังเรียงใน DOM ไม่ตรงลำดับบนจอ มี ctrl/space ขยะหลงเหลือในส่วนที่ ActualText ไม่ครอบ และ DOM range ครอบ span เกินบริเวณที่ลาก: ใช้ `selectionTextWithin(keptRects, range)` (เดินทีละตัวอักษร เช็ค rect ตกใน rects ที่กรองแล้ว เรียงตามตำแหน่งบนจอ — แพง ใช้เฉพาะ mouseup ที่ `readTextSelection(pageIndex, { withText: true })` กับ event `copy` (กรองด้วยเส้นทางลากล่าสุดจาก `lastSweepRef`) เท่านั้น ไม่ใช้ใน live preview) ปิดท้ายด้วย `sanitizePdfText` (`utils/annotation-display.ts` — ตัด ctrl ขยะ + กลุ่ม space คู่ขึ้นไป มี unit tests) ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด - คีย์ลัด H/D/C/B ต้อง `e.preventDefault()` เสมอ — B เปิด popup bookmark ที่ input `autoFocus` ทันทีใน keydown เดียวกัน ถ้าไม่กัน browser พิมพ์ "b" ตามลงในช่องชื่อ - `DEL`/`Backspace` ลบ annotation ที่เลือกอยู่ (`selectedId`) — เฉพาะรายการของตัวเอง (เงื่อนไขเดียวกับปุ่มลบในแผง selection), ข้ามเมื่อ focus อยู่ใน form (`inForm` เดียวกับคีย์ลัดอื่น — พิมพ์ใน composer/textbox ต้องไม่โดนลบ) - Page Virtualization: `IntersectionObserver` render เฉพาะหน้าใน viewport ± 1 หน้า; หน้าพ้นจอ = placeholder `div` ขนาดจริง (จาก `pdfPage.getViewport()` ทุกหน้า ครั้งเดียวตอนโหลด) + unmount canvas/TextLayer/InkLayer คืน memory — annotation ของหน้าที่ไม่ render ยังอยู่ใน state -- pdfjs: `dynamic import("pdfjs-dist")` ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"` (ไฟล์คัดลอกไว้ที่ `public/` ด้วย postinstall) — ห้าม `new URL(..., import.meta.url)` +- pdfjs: `dynamic import("pdfjs-dist")` ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` (ไฟล์คัดลอกไว้ที่ `public/` ด้วย postinstall) — ห้าม `new URL(..., import.meta.url)` - Bookmark scroll: คำนวณ absolute position ครั้งเดียวแล้ว smooth-scroll ครั้งเดียว (ห้าม scroll ซ้อนสองจังหวะ) - Deep link (F12/C4): PDFViewer อ่าน `window.location.search` **ครั้งเดียวหลัง `pageSizes` พร้อม** (guard ด้วย ref — ไม่ใช้ `useSearchParams` เลี่ยงข้อจำกัด prerender ของ Next 14) — `?page=N` (1-based) scroll ไปหน้านั้น, `?ann=` scroll ไปหน้าของ annotation นั้น + `setSelectedId` — placeholder ขนาดจริงทุกหน้าทำให้คำนวณ absolute scroll ได้แม้หน้ายังไม่ render (กลไกเดียวกับ bookmark); ปุ่ม "คัดลอกลิงก์" ใน sidebar สร้าง URL รูป `/annotate/?page=&ann=` - Dialog `fixed z-50` ใน sidebar (ประวัติ) ต้อง render ผ่าน **`createPortal` ไป `document.body`** + guard `typeof document === "undefined"` ตอน SSR — `aside.sticky` สร้าง stacking context ครอบ z-index ของ dialog ไว้ ทำให้ `fixed z-50` แพ้ `textLayer` (z-[2] ระดับ root) จนคลิกปุ่มไม่ได้ (PLAN-FEAT.md §5) diff --git a/.claude/rules/realtime.md b/.claude/rules/realtime.md index 991b6ef..d156101 100644 --- a/.claude/rules/realtime.md +++ b/.claude/rules/realtime.md @@ -7,7 +7,7 @@ paths: # Realtime Rules (รายละเอียด: docs/PLAN.md §4) -- Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `/api/annotations/stream?fileId=` และ `es.close()` เมื่อ unmount +- Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `withBasePath("/api/annotations/stream?fileId=")` (basePath ตาม invariant #12) และ `es.close()` เมื่อ unmount - `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด — **ห้ามทำ retry logic เอง** - Event payload เป็น **DTO (camelCase + ISO string) จาก server ตรง ๆ** — ไม่มีการแปลง snake_case - Server Actions ต้อง **publish ผ่าน `lib/annotate-events.ts` หลังเขียน DB สำเร็จทุกครั้ง** (create/update/delete ของ annotations, create/delete ของ replies, create/**update**/delete ของ bookmarks — update ของ bookmarks ใช้กับแก้ชื่อ/โน้ต/ย้ายหัวข้อ และลูกที่ถูกยกขึ้น root ตอนแม่ถูกลบ, client replace by id) — ลืม publish = client อื่นไม่เห็น; คู่กันในจุดเดียวกันต้อง **`revalidatePath` หน้า viewer+summary** (`revalidateFilePages`) มิฉะนั้นกลับเข้าหน้าใหม่ (back → คลิกไฟล์) ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh diff --git a/CLAUDE.md b/CLAUDE.md index 56919df..41b5d9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ ## Tech Stack -- Next.js 14 (App Router) + TypeScript **strict** +- Next.js 14 (App Router) + TypeScript **strict** — serve ใต้ basePath `/docedit/note` (`next.config.mjs`) - pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/pdf.worker.min.mjs` (postinstall คัดลอกจาก node_modules) - Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`) - Tailwind CSS @@ -22,12 +22,13 @@ 3. **ลำดับ layer ตาม PLAN.md §1:** canvas (`pointer-events: none` เสมอ) → highlight-layer (`none` เสมอ, `mix-blend-mode: multiply`) → textLayer → InkLayer (SVG) → comment pins — สลับ pointer-events ตาม tool ปัจจุบัน 4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ 5. **Timestamp ข้าม server→client→SSE เป็น ISO string เสมอ** (DTO types) — ห้ามส่ง Date object ตรง ๆ -6. **pdfjs-dist ต้อง `dynamic import()` ภายใน useEffect เท่านั้น** — ห้าม import top-level ใน module scope ที่ server evaluate และต้องตั้ง `GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"` (postinstall คัดลอกไฟล์ไป `public/`) — ห้ามใช้ `new URL(..., import.meta.url)` +6. **pdfjs-dist ต้อง `dynamic import()` ภายใน useEffect เท่านั้น** — ห้าม import top-level ใน module scope ที่ server evaluate และต้องตั้ง `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` (postinstall คัดลอกไฟล์ไป `public/`) — ห้ามใช้ `new URL(..., import.meta.url)` 7. **InkLayer ใช้ SVG แบบไม่มี viewBox** — render ด้วย px จริง แปลง % → px จากขนาดที่ ResizeObserver วัด (viewBox จะทำให้ความหนาเส้นบิดเบี้ยว) 8. **JSONB `data` เป็น strict discriminated union** (`AnnotationData` แยกตาม enum `type`) — ห้าม loosen type เป็น `any`/generic object 9. **Optimistic update ใช้ `useOptimistic` (React 18) ภายใน `startTransition`** — React rollback อัตโนมัติเมื่อ action ล้มเหลว + dedupe ด้วย id ใน `hooks/useAnnotationRealtime.ts` (duplicate ชั่วขณะกับ realtime event หายเอง) 10. **Page Virtualization:** render เฉพาะหน้าใน viewport ± 1 หน้าด้วย IntersectionObserver — หน้าพ้นจอเก็บ placeholder ขนาดจริง (scroll ไม่ jump, bookmark ยังคำนวณตำแหน่งได้) และ unmount canvas/TextLayer/InkLayer คืน memory — ห้าม render ทุกหน้าพร้อมกัน 11. **DB connection:** `DATABASE_URL` ชี้ที่ plain PostgreSQL container `judes-note-db` (`postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note`) + `prepare: false` ใน postgres.js เสมอ — pub/sub ของ SSE เป็น in-memory (ใช้ได้เมื่อรัน process เดียว; ถ้ารันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) +12. **URL ฝั่ง browser ที่สร้างเองต้องผ่าน `withBasePath()`** (`utils/base-path.ts`) — app serve ใต้ basePath `/docedit/note` ครอบคลุม: `fetch()`/`EventSource`/plain `` (export CSV/JSON)/`workerSrc`/`img.src`/`file.pdfUrl` ตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน ``/`next/navigation` (router.push/replace/redirect) เติม basePath ให้เอง และ `middleware.ts` จับ path ที่ตัด basePath แล้ว (เขียน matcher/`url.pathname` เหมือนเดิม — NextURL ใส่ basePath กลับตอน redirect เอง); `pdf_url` ใน DB เก็บ path ไม่รวม basePath เติมตอนใช้เท่านั้น ## โครงสร้างไฟล์ (ตามแผน) @@ -43,6 +44,7 @@ utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + ค่า default TEXTBOX/STAMP (sidebar/สรุป/CSV ใช้ร่วม) utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wrapText + hexToRgb (export route) utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {userId: u_userid, userName: u_userlogin} (pure module — middleware ใช้ร่วมได้) +utils/base-path.ts ← withBasePath() เติม basePath /docedit/note ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (ค่าจาก next.config.mjs ผ่าน env — invariant #12) utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed) utils/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3) utils/image-to-pdf.ts ← สร้าง PDF ใหม่จากไฟล์รูป embedJpg/embedPng + EXIF rotate + TIFF→PNG (N3, export route) diff --git a/README.md b/README.md index ae13d78..8d8b89d 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ npm run seed-samples npm run dev ``` -เปิด http://localhost:3000 — คลิกไฟล์เพื่อเข้าหน้า annotate หรืออัปโหลดไฟล์ใหม่ (PDF/รูปภาพ ≤ 100MB) ผ่านฟอร์มบนหน้ารายการ (N4 — ไฟล์เก็บที่ `public/uploads/`) +เปิด http://localhost:3000/docedit/note — คลิกไฟล์เพื่อเข้าหน้า annotate หรืออัปโหลดไฟล์ใหม่ (PDF/รูปภาพ ≤ 100MB) ผ่านฟอร์มบนหน้ารายการ (N4 — ไฟล์เก็บที่ `public/uploads/`) ## การใช้งานบนหน้า annotate diff --git a/app/annotate/[id]/summary/page.tsx b/app/annotate/[id]/summary/page.tsx index a0a881b..91fef30 100644 --- a/app/annotate/[id]/summary/page.tsx +++ b/app/annotate/[id]/summary/page.tsx @@ -6,6 +6,7 @@ import { ArrowLeft, MapPin } from "lucide-react"; import { db } from "@/db"; import { annotations, files, toAnnotationDTO, type AnnotationDTO } from "@/db/schema"; import { annotationNote, annotationPreviewText, annotationTypeLabel } from "@/utils/annotation-display"; +import { withBasePath } from "@/utils/base-path"; // หน้าสรุปหมายเหตุ (F15/D2) — server component รวมทุก annotation (ทุกผู้ใช้) // จัดกลุ่มตามหน้า พร้อมปุ่มกระโดดเป็น deep link `/annotate/?ann=` (F12) @@ -57,7 +58,7 @@ export default async function SummaryPage({ {/* F16/D3: export สรุปเป็น CSV (UTF-8 BOM) */} Export CSV diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index e154660..7a6a30b 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -115,6 +115,7 @@ import { } from "@/utils/coordinates"; import type { OacUser } from "@/utils/user"; import { fileKindFromUrl, imageFormatFromUrl } from "@/utils/file-kind"; +import { withBasePath } from "@/utils/base-path"; import { decodeTiffPages } from "@/utils/tiff-client"; import { TEXTBOX_DEFAULT_WIDTH_PCT, @@ -1401,7 +1402,7 @@ export default function PDFViewer({ setExporting(true); setExportError(null); try { - const res = await fetch(`/api/annotations/export?fileId=${file.id}`); + const res = await fetch(withBasePath(`/api/annotations/export?fileId=${file.id}`)); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(text || `HTTP ${res.status}`); @@ -1449,21 +1450,23 @@ export default function PDFViewer({ setDoc(null); setPdfjs(null); const objectUrls: string[] = []; + // URL จริงที่ browser ใช้โหลดไฟล์ (fetch/img.src ตรง — ต้องเติม basePath เอง) + const fileUrl = withBasePath(file.pdfUrl); (async () => { let pages: { src: string | null; width: number; height: number }[]; if (imageFormatFromUrl(file.pdfUrl) === "tiff") { // N3: TIFF หลายเฟรม = หลายหน้า — decode ครั้งเดียวต่อการเปิดไฟล์ // (utif2 dynamic import ภายใน decodeTiffPages) แล้วเก็บ blob URL ไว้ใช้ต่อ - const buffer = await (await fetch(file.pdfUrl)).arrayBuffer(); + const buffer = await (await fetch(fileUrl)).arrayBuffer(); pages = await decodeTiffPages(buffer, IMAGE_PAGE_WIDTH); for (const p of pages) if (p.src) objectUrls.push(p.src); } else { const img = new Image(); - img.src = file.pdfUrl; + img.src = fileUrl; await img.decode(); const factor = IMAGE_PAGE_WIDTH / img.naturalWidth; pages = [ - { src: file.pdfUrl, width: IMAGE_PAGE_WIDTH, height: img.naturalHeight * factor }, + { src: fileUrl, width: IMAGE_PAGE_WIDTH, height: img.naturalHeight * factor }, ]; } if (cancelled) { @@ -1488,16 +1491,16 @@ export default function PDFViewer({ (async () => { const pdfjs = await import("pdfjs-dist"); - pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"; + pdfjs.GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs"); // โหลดผ่าน /api/pdf ที่ซ่อม ToUnicode จาก ActualText ของ PDF ไทยที่ cmap เพี้ยน // (ข้อความถูกทั้ง select/copy/ค้นหา) — endpoint fail ก็ถอยไปไฟล์ดิบตามเดิม let data: ArrayBuffer; try { - const res = await fetch(`/api/pdf?fileId=${file.id}`); + const res = await fetch(withBasePath(`/api/pdf?fileId=${file.id}`)); if (!res.ok) throw new Error(`HTTP ${res.status}`); data = await res.arrayBuffer(); } catch { - data = await (await fetch(file.pdfUrl)).arrayBuffer(); + data = await (await fetch(withBasePath(file.pdfUrl))).arrayBuffer(); } const doc = await pdfjs.getDocument({ data }).promise; loadedDoc = doc; diff --git a/components/sidebar/AnnotationSidebar.tsx b/components/sidebar/AnnotationSidebar.tsx index 6e9e791..90aa933 100644 --- a/components/sidebar/AnnotationSidebar.tsx +++ b/components/sidebar/AnnotationSidebar.tsx @@ -52,6 +52,7 @@ import type { ShapeData, } from "@/db/schema"; import { annotationNote, annotationPreviewText } from "@/utils/annotation-display"; +import { withBasePath } from "@/utils/base-path"; /** UX audit 2026-09-16: focus ring ตอน keyboard focus — ค่าเดียวกับ FOCUS_RING_CLS * ของ PDFViewer.tsx (ห้าม import ข้ามไฟล์: PDFViewer → sidebar → PDFViewer เป็น @@ -471,7 +472,7 @@ export default function AnnotationSidebar({ const [copyFailedId, setCopyFailedId] = useState(null); const copyTimerRef = useRef(null); function copyAnnotationLink(a: AnnotationDTO) { - const url = `${window.location.origin}/annotate/${a.fileId}?page=${a.pageIndex + 1}&ann=${a.id}`; + const url = `${window.location.origin}${withBasePath(`/annotate/${a.fileId}?page=${a.pageIndex + 1}&ann=${a.id}`)}`; if (copyTimerRef.current !== null) { window.clearTimeout(copyTimerRef.current); copyTimerRef.current = null; @@ -1117,7 +1118,7 @@ export default function AnnotationSidebar({ {/* N7: ส่งออกรายการ bookmark เป็น JSON — ลิงก์ธรรมดา pattern Export CSV (F16) ส่งออกทุกรายการของไฟล์เสมอ ไม่ผูกกับเงื่อนไขค้นหาที่ตั้งไว้ */} .` เท่านั้น (ไม่แตะไฟล์เดิม) — sample เก็บใน `public/samples/` แล้ว sync ด้วย `npm run seed-samples` (รายละเอียดด้านล่าง) | | A6 | **ทุก annotation ลบได้** | แสดงปุ่มลบเฉพาะรายการของตัวเอง (`userId` ตรงกัน) — ไม่มี role/permission ในเฟสนี้ | @@ -149,7 +149,7 @@ Client A วาด ──▶ Server Action createAnnotation() ──▶ INSERT P ``` - **เขียน:** ผ่าน Server Actions เท่านั้น (ต่อ `DATABASE_URL` ตรง ๆ) — และ**หลังเขียนสำเร็จทุกครั้ง ต้อง publish event** ให้ `lib/annotate-events.ts` -- **อ่าน/ sync:** `useAnnotationRealtime` hook เปิด `EventSource` ไปที่ SSE route `/api/annotations/stream?fileId=` — payload เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ ไม่ต้องแปลง; `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด (ห้ามทำ retry เอง) +- **อ่าน/ sync:** `useAnnotationRealtime` hook เปิด `EventSource` ไปที่ SSE route `withBasePath("/api/annotations/stream?fileId=")` — payload เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ ไม่ต้องแปลง; `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด (ห้ามทำ retry เอง) - pub/sub เป็น **in-memory ของ Next.js process เดียว** (dev / `next start` ธรรมดา) — หากอนาคตรันหลาย instance ต้องเพิ่มตัวกลาง (Redis pub/sub หรือ Postgres `LISTEN/NOTIFY`) ก่อน - **Optimistic update ด้วย `useOptimistic` (React 18):** ตอนผู้ใช้ปล่อยเมาส์ → `startTransition(() => { addOptimistic(row ชั่วคราว); return createAnnotation(...) })` → React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ และ **rollback ให้เองหาก action ล้มเหลว** (ไม่ต้องจัดการ id `temp-*` เอง) — client ส่ง id ของ optimistic row ให้ `createAnnotation` ใช้ซ้ำ (action validate เป็น UUID) เพื่อให้ SSE event ที่มาถึงก่อน action เสร็จ dedupe ด้วย id เดียวกันได้ทันที **ไม่เห็น duplicate ชั่วขณะ** (เดิม row จริงใช้ id ใหม่ทำให้แสดง 2 แถวแวบหนึ่งก่อน row ชั่วคราวถูกถอด) @@ -242,7 +242,7 @@ judes-note/ - **Server component ส่ง Date ไม่ได้ตรง ๆ กับ realtime string** → normalize เป็น ISO string DTO ทั้งระบบ - **pdfjs บน SSR** → `dynamic import("pdfjs-dist")` ภายใน useEffect เท่านั้น (ไม่ import top-level ใน module scope ที่ server evaluate ด้วย) - **PDF ยาว (20–100+ หน้า): Page Virtualization** → `IntersectionObserver` ใน PDFViewer render เฉพาะหน้าใน viewport ± 1 หน้า (canvas + TextLayer + InkLayer); หน้าที่พ้นจอเก็บเป็น placeholder `div` **ขนาดจริง** (วัดจาก `pdfPage.getViewport()` ทุกหน้าตอนโหลดครั้งเดียว — cost ถูก) เพื่อไม่ให้ scroll jump และให้ bookmark ยังคำนวณ absolute scroll ได้ แล้ว unmount layer ทั้งหมดของหน้านั้นคืน memory — annotation ของหน้าที่ยังไม่ render อยู่ใน state ตามปกติ จะแสดงเมื่อหน้าเข้าจอ -- **pdf.worker v4 เป็นไฟล์ `.mjs`** → คัดลอก `pdf.worker.min.mjs` จาก `node_modules/pdfjs-dist/build/` ไป `public/` ด้วย **postinstall script** (version ตรงกับ lockfile เสมอ) และตั้ง `pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"` — อย่าใช้ `new URL(..., import.meta.url)` ซึ่งมักชน Turbopack/webpack ใน Next 14 +- **pdf.worker v4 เป็นไฟล์ `.mjs`** → คัดลอก `pdf.worker.min.mjs` จาก `node_modules/pdfjs-dist/build/` ไป `public/` ด้วย **postinstall script** (version ตรงกับ lockfile เสมอ) และตั้ง `pdfjs.GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` — อย่าใช้ `new URL(..., import.meta.url)` ซึ่งมักชน Turbopack/webpack ใน Next 14 --- diff --git a/hooks/useAnnotationRealtime.ts b/hooks/useAnnotationRealtime.ts index d9db212..3c2a98d 100644 --- a/hooks/useAnnotationRealtime.ts +++ b/hooks/useAnnotationRealtime.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import type { AnnotationDTO, BookmarkDTO, ReplyDTO } from "@/db/schema"; import type { AnnotateEvent } from "@/lib/annotate-events"; +import { withBasePath } from "@/utils/base-path"; // SSE sync (PLAN.md §4, rules/realtime.md) // - เปิด EventSource ไป /api/annotations/stream?fileId= — reconnect อัตโนมัติเมื่อขาด (ห้ามทำ retry เอง) @@ -19,7 +20,7 @@ export function useAnnotationRealtime( const [replies, setReplies] = useState(initialReplies); useEffect(() => { - const es = new EventSource(`/api/annotations/stream?fileId=${fileId}`); + const es = new EventSource(withBasePath(`/api/annotations/stream?fileId=${fileId}`)); es.onmessage = (e: MessageEvent) => { let ev: AnnotateEvent; diff --git a/next.config.mjs b/next.config.mjs index cf3bd9b..9b075c8 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,7 +1,14 @@ +const basePath = "/docedit/note"; + /** @type {import('next').NextConfig} */ const nextConfig = { // สำหรับ Docker (docker/web.Dockerfile) — build แล้วได้ .next/standalone รันด้วย node server.js output: "standalone", + // serve ใต้ /docedit/note — /next/navigation เติมให้เอง แต่ fetch/EventSource/ + // plain /workerSrc ต้องผ่าน withBasePath() (utils/base-path.ts) + basePath, + // expose ให้โค้ดใช้ (inline ตอน build) — ค่าจริงประกาศที่เดียวที่ไฟล์นี้ + env: { NEXT_PUBLIC_BASE_PATH: basePath }, // N4 อัปโหลดไฟล์ผ่าน Server Action — default 1MB ไม่พอสำหรับ PDF/สแกนศาล (ต้องตรงกับ MAX_UPLOAD_BYTES ใน app/actions/files.ts) experimental: { serverActions: { diff --git a/utils/base-path.ts b/utils/base-path.ts new file mode 100644 index 0000000..09adacc --- /dev/null +++ b/utils/base-path.ts @@ -0,0 +1,7 @@ +// basePath (/docedit/note) — ค่าจาก next.config.mjs (inline ตอน build ผ่าน env) +// ใช้กับ URL ฝั่ง browser ที่ Next ไม่เติม basePath ให้เอง: fetch/EventSource/ +// plain /workerSrc/img src — ต่างจาก /router/redirect ของ next +// ที่เติมให้อัตโนมัติ (pdfUrl ใน DB เก็บ path ไม่รวม basePath เติมตอนใช้เท่านั้น) +export function withBasePath(path: string): string { + return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${path}`; +} From 8ba911a119908a830fb4ed97a6dec933b355997a Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 17 Sep 2026 16:56:38 +0700 Subject: [PATCH 04/52] =?UTF-8?q?CD:=20uat.yml=20=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=20build=20image=20=E0=B9=80=E0=B8=AD=E0=B8=87=20reuse?= =?UTF-8?q?=20=E0=B8=82=E0=B8=AD=E0=B8=87=20build.yml=20+=20=E0=B8=82?= =?UTF-8?q?=E0=B8=B6=E0=B9=89=E0=B8=99=20UAT=20=E0=B8=9C=E0=B9=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=99=20Forgejo=20UI=20=E0=B9=84=E0=B8=94=E0=B9=89=20?= =?UTF-8?q?=E2=80=94=20=E0=B8=A5=E0=B8=9A=20job=20build=20=E0=B8=97?= =?UTF-8?q?=E0=B8=B1=E0=B9=89=E0=B8=87=20job=20(=E0=B9=80=E0=B8=94?= =?UTF-8?q?=E0=B8=B4=E0=B8=A1=20build=20=E0=B8=8B=E0=B9=89=E0=B8=B3?= =?UTF-8?q?=E0=B8=97=E0=B8=B8=E0=B8=81=E0=B8=84=E0=B8=A3=E0=B8=B1=E0=B9=89?= =?UTF-8?q?=E0=B8=87)=20=E0=B9=81=E0=B8=97=E0=B8=99=E0=B8=94=E0=B9=89?= =?UTF-8?q?=E0=B8=A7=E0=B8=A2=20steps=20=E0=B9=83=E0=B8=AB=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B9=83=E0=B8=99=20deploy=20job:=20Resolve=20image?= =?UTF-8?q?=20tag=20map=20deploy1.2.3/1.2.3/v1.2.3=20=E2=86=92=20v1.2.3=20?= =?UTF-8?q?=E0=B9=80=E0=B8=AA=E0=B8=A1=E0=B8=AD=20(=E0=B8=95=E0=B8=B1?= =?UTF-8?q?=E0=B8=94=20prefix=20deploy=20=E0=B8=95=E0=B8=B1=E0=B8=94=20v?= =?UTF-8?q?=20=E0=B9=80=E0=B8=95=E0=B8=B4=E0=B8=A1=20v=20=E0=B8=81?= =?UTF-8?q?=E0=B8=A5=E0=B8=B1=E0=B8=9A=20=E2=80=94=20=E0=B9=83=E0=B8=8A?= =?UTF-8?q?=E0=B9=89=20image=20=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2?= =?UTF-8?q?=E0=B8=A7=E0=B8=81=E0=B8=B1=E0=B8=9A=E0=B8=97=E0=B8=B5=E0=B9=88?= =?UTF-8?q?=20build.yml=20push=20=E0=B8=95=E0=B8=AD=E0=B8=99=20tag=20v*)?= =?UTF-8?q?=20+=20login=20registry=20+=20docker=20manifest=20inspect=20?= =?UTF-8?q?=E0=B9=80=E0=B8=8A=E0=B9=87=E0=B8=84=20image=20=E0=B8=A1?= =?UTF-8?q?=E0=B8=B5=E0=B8=88=E0=B8=A3=E0=B8=B4=E0=B8=87=E0=B8=81=E0=B9=88?= =?UTF-8?q?=E0=B8=AD=E0=B8=99=20SSH=20(fail-fast=20=E0=B8=9E=E0=B8=A3?= =?UTF-8?q?=E0=B9=89=E0=B8=AD=E0=B8=A1=E0=B8=82=E0=B9=89=E0=B8=AD=E0=B8=84?= =?UTF-8?q?=E0=B8=A7=E0=B8=B2=E0=B8=A1=E0=B8=9A=E0=B8=AD=E0=B8=81=E0=B9=83?= =?UTF-8?q?=E0=B8=AB=E0=B9=89=20push=20tag=20v*=20=E0=B8=81=E0=B9=88?= =?UTF-8?q?=E0=B8=AD=E0=B8=99=20=E0=B8=96=E0=B9=89=E0=B8=B2=E0=B8=A2?= =?UTF-8?q?=E0=B8=B1=E0=B8=87=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=A1=E0=B8=B5?= =?UTF-8?q?)=20+=20workflow=5Fdispatch=20=E0=B9=80=E0=B8=9E=E0=B8=B4?= =?UTF-8?q?=E0=B9=88=E0=B8=A1=20input=20version=20(required)=20=E2=80=94?= =?UTF-8?q?=20=E0=B8=81=E0=B8=94=20Run=20workflow=20=E0=B9=83=E0=B8=99=20U?= =?UTF-8?q?I=20=E0=B9=83=E0=B8=AA=E0=B9=88=E0=B9=80=E0=B8=A7=E0=B8=AD?= =?UTF-8?q?=E0=B8=A3=E0=B9=8C=E0=B8=8A=E0=B8=B1=E0=B8=99=E0=B9=84=E0=B8=94?= =?UTF-8?q?=E0=B9=89=E0=B9=80=E0=B8=A5=E0=B8=A2=E0=B9=84=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B8=95=E0=B9=89=E0=B8=AD=E0=B8=87=E0=B8=AA=E0=B8=A3=E0=B9=89?= =?UTF-8?q?=E0=B8=B2=E0=B8=87=20tag=20deploy*=20(=E0=B8=97=E0=B8=B2?= =?UTF-8?q?=E0=B8=87=E0=B9=80=E0=B8=94=E0=B8=B4=E0=B8=A1=E0=B8=A2=E0=B8=B1?= =?UTF-8?q?=E0=B8=87=E0=B9=83=E0=B8=8A=E0=B9=89=E0=B9=84=E0=B8=94=E0=B9=89?= =?UTF-8?q?)=20=E2=80=94=20deploy=20job=20=E0=B9=80=E0=B8=9B=E0=B8=B4?= =?UTF-8?q?=E0=B8=94=20if=20=E0=B9=83=E0=B8=AB=E0=B9=89=E0=B8=A3=E0=B8=B1?= =?UTF-8?q?=E0=B8=99=E0=B8=81=E0=B8=A3=E0=B8=93=E0=B9=89=20dispatch=20(alw?= =?UTF-8?q?ays()=20&&=20!failure()=20&&=20!cancelled()=20=E2=80=94=20?= =?UTF-8?q?=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=E0=B9=80=E0=B8=A1=E0=B8=B7?= =?UTF-8?q?=E0=B9=88=E0=B8=AD=20validate-schema=20success=20=E0=B8=AB?= =?UTF-8?q?=E0=B8=A3=E0=B8=B7=E0=B8=AD=20skip=20=E0=B9=81=E0=B8=95?= =?UTF-8?q?=E0=B9=88=E0=B8=A2=E0=B8=B1=E0=B8=87=20block=20=E0=B8=96?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=20fail=20=E0=B9=80=E0=B8=AB=E0=B8=A1?= =?UTF-8?q?=E0=B8=B7=E0=B8=AD=E0=B8=99=E0=B9=80=E0=B8=94=E0=B8=B4=E0=B8=A1?= =?UTF-8?q?)=20+=20notify=20=E0=B8=96=E0=B8=AD=E0=B8=94=20needs.build=20?= =?UTF-8?q?=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B8=A5=E0=B8=9A=E0=B9=84=E0=B8=9B?= =?UTF-8?q?=20+=20field=20Tag=20=E0=B9=82=E0=B8=8A=E0=B8=A7=E0=B9=8C?= =?UTF-8?q?=E0=B9=80=E0=B8=A7=E0=B8=AD=E0=B8=A3=E0=B9=8C=E0=B8=8A=E0=B8=B1?= =?UTF-8?q?=E0=B8=99=E0=B8=88=E0=B8=B2=E0=B8=81=20input=20=E0=B8=95?= =?UTF-8?q?=E0=B8=AD=E0=B8=99=20dispatch=20=E0=B9=81=E0=B8=97=E0=B8=99?= =?UTF-8?q?=E0=B8=8A=E0=B8=B7=E0=B9=88=E0=B8=AD=20branch=20+=20field=20Ima?= =?UTF-8?q?ge=20Tag=20=E0=B8=94=E0=B8=B6=E0=B8=87=E0=B8=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=81=20output=20=E0=B8=82=E0=B8=AD=E0=B8=87=20deploy=20job?= =?UTF-8?q?=20=E2=80=94=20=E0=B8=81=E0=B8=95=E0=B8=B4=E0=B8=81=E0=B8=B2?= =?UTF-8?q?=E0=B8=84=E0=B8=87=E0=B9=80=E0=B8=94=E0=B8=B4=E0=B8=A1:=20image?= =?UTF-8?q?=20=E0=B9=80=E0=B8=81=E0=B8=B4=E0=B8=94=E0=B8=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=81=20build.yml=20=E0=B8=95=E0=B8=AD=E0=B8=99=20push=20ta?= =?UTF-8?q?g=20v*=20(deploy=20Test=20=E0=B9=84=E0=B8=9B=E0=B8=94=E0=B9=89?= =?UTF-8?q?=E0=B8=A7=E0=B8=A2)=20=E0=B9=80=E0=B8=97=E0=B9=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=99=E0=B8=B1=E0=B9=89=E0=B8=99=20UAT=20=E0=B9=81=E0=B8=84?= =?UTF-8?q?=E0=B9=88=20reuse=20=E2=80=94=20verify:=20js-yaml=20parse=20?= =?UTF-8?q?=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=20+=20=E0=B8=97=E0=B8=94?= =?UTF-8?q?=E0=B8=AA=E0=B8=AD=E0=B8=9A=20normalize=20=E0=B8=84=E0=B8=A3?= =?UTF-8?q?=E0=B8=9A=203=20=E0=B8=A3=E0=B8=B9=E0=B8=9B=E0=B9=81=E0=B8=9A?= =?UTF-8?q?=E0=B8=9A=20(deploy1.2.3/v1.2.3/1.2.3=20=E2=86=92=20v1.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- .forgejo/workflows/uat.yml | 84 ++++++++++++++++++-------------------- 1 file changed, 39 insertions(+), 45 deletions(-) diff --git a/.forgejo/workflows/uat.yml b/.forgejo/workflows/uat.yml index a09aa77..e38514c 100644 --- a/.forgejo/workflows/uat.yml +++ b/.forgejo/workflows/uat.yml @@ -5,6 +5,10 @@ on: tags: - "deploy*.*.*" workflow_dispatch: + inputs: + version: + description: "เวอร์ชันที่จะ deploy เช่น 1.2.3 / v1.2.3 / deploy1.2.3 — จะ map เป็น image v1.2.3 (ต้อง push tag v1.2.3 ให้ build.yml build ไว้ก่อน)" + required: true env: # Container Registry @@ -12,11 +16,11 @@ env: REGISTRY_USERNAME: ${{ vars.CONTAINER_REGISTRY_USERNAME }} REGISTRY_PASSWORD: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }} - # Docker Image + # Docker Image — ไม่ build เอง ใช้ image เดียวกับที่ build.yml push ไว้ + # (push tag deploy1.2.3 หรือ Run workflow จาก UI ใส่เวอร์ชัน → map เป็น image v1.2.3 เสมอ) WEB_IMAGE_NAME: chamomind/ecourt-note - IMAGE_TAG: ${{ github.ref_name }} - # SSH Deployment — ต่างจาก build.yml ตัวเดียว: ชี้เครื่อง UAT (ตั้ง vars.SSH_UAT_DEPLOY_HOST เพิ่มใน Forgejo) + # SSH Deployment — ต่างจาก build.yml: ชี้เครื่อง UAT (ตั้ง vars.SSH_UAT_DEPLOY_HOST เพิ่มใน Forgejo) + ไม่ build image เอง DEPLOY_HOST: ${{ vars.SSH_UAT_DEPLOY_HOST }} DEPLOY_USER: ${{ vars.SSH_DEPLOY_USER }} @@ -85,14 +89,28 @@ jobs: echo "" echo "📚 Reference: README.md / docs/PLAN.md §3" - build: - name: Build Web Docker Image + deploy: + name: Deploy eCourt Note Web to UAT Server runs-on: ubuntu-latest needs: validate-schema - if: startsWith(github.ref, 'refs/tags/') + # รันได้ 2 ทาง: push tag deploy*.*.* หรือกด Run workflow จาก UI (ใส่เวอร์ชันใน input) + # !failure() && !cancelled() = ผ่านได้เมื่อ validate-schema success หรือถูก skip (กรณี dispatch) + # แต่ยังไม่รันถ้า validate-schema fail (tag ไม่อยู่บน dev) + if: >- + always() && !failure() && !cancelled() && + (startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch') + outputs: + image_tag: ${{ steps.image-tag.outputs.tag }} steps: - - name: Checkout code - uses: actions/checkout@v4 + - name: Resolve image tag (deploy1.2.3 / v1.2.3 → v1.2.3) + id: image-tag + run: | + RAW="${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.ref_name }}" + TAG="${RAW#deploy}" + TAG="${TAG#v}" + TAG="v$TAG" + echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "Reuse image จาก build.yml: ${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:$TAG" - name: Log in to Container Registry uses: docker/login-action@v3 @@ -101,39 +119,15 @@ jobs: username: ${{ env.REGISTRY_USERNAME }} password: ${{ env.REGISTRY_PASSWORD }} - - name: Build Docker image (no buildx) + - name: Verify image exists in registry run: | - # Build using standard docker build (not buildx) - git reset --hard HEAD - cd ${{ github.workspace }} - docker build \ - --no-cache \ - --pull \ - --file ./docker/web.Dockerfile \ - --tag ${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:${{ env.IMAGE_TAG }} \ - --tag ${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:latest \ - . + if ! docker manifest inspect "${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:${{ steps.image-tag.outputs.tag }}" > /dev/null; then + echo "❌ Image ${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:${{ steps.image-tag.outputs.tag }} ไม่มีใน registry" + echo "workflow นี้ไม่ build เอง — push tag ${{ steps.image-tag.outputs.tag }} ก่อน เพื่อให้ build.yml build + push image ให้" + exit 1 + fi + echo "✅ Image found — deploy ด้วย image เดียวกับ build.yml" - echo "✅ Build successful!" - docker images | grep ${{ env.WEB_IMAGE_NAME }} - - - name: Push Docker image - run: | - docker push ${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:${{ env.IMAGE_TAG }} - docker push ${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:latest - echo "✅ Image pushed to registry" - - - name: Test image can run - run: | - # Quick test that image can start - docker run --rm ${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:${{ env.IMAGE_TAG }} node --version - - deploy: - name: Deploy eCourt Note Web to UAT Server - runs-on: ubuntu-latest - needs: build - if: startsWith(github.ref, 'refs/tags/') - steps: - name: Deploy web on UAT server uses: appleboy/ssh-action@v1.2.1 with: @@ -145,13 +139,13 @@ jobs: cd ~/repo chmod +x ./replace-env.sh ./deploy.sh - ./replace-env.sh ECOURT_NOTE_TAG "${{ env.IMAGE_TAG }}" + ./replace-env.sh ECOURT_NOTE_TAG "${{ steps.image-tag.outputs.tag }}" ./deploy.sh notify: name: Notify Deployment Status runs-on: ubuntu-latest - needs: [build, deploy] + needs: [deploy] if: always() steps: - name: Send Discord notification - Success @@ -171,7 +165,7 @@ jobs: }, { "name": "Tag", - "value": "${{ github.ref_name }}", + "value": "${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.ref_name }}", "inline": true }, { @@ -181,7 +175,7 @@ jobs: }, { "name": "Image Tag", - "value": "`${{ env.IMAGE_TAG }}`", + "value": "`${{ needs.deploy.outputs.image_tag }}`", "inline": true }, { @@ -204,7 +198,7 @@ jobs: ${{ env.DISCORD_WEBHOOK_URL }} - name: Send Discord notification - Failure - if: ${{ needs.deploy.result == 'failure' || needs.build.result == 'failure' }} + if: ${{ needs.deploy.result == 'failure' }} run: | curl -H "Content-Type: application/json" \ -d '{ @@ -230,7 +224,7 @@ jobs: }, { "name": "Failed Job", - "value": "${{ needs.build.result == 'failure' && 'Build' || 'Deploy' }}", + "value": "Deploy", "inline": true }, { From e0c78d68fe5e497e482c6d7670cd45e6a22390c4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 17 Sep 2026 17:02:31 +0700 Subject: [PATCH 05/52] =?UTF-8?q?bookmark:=20=E0=B8=94=E0=B8=B1=E0=B8=81?= =?UTF-8?q?=E0=B8=84=E0=B8=A7=E0=B8=B2=E0=B8=A1=E0=B8=A2=E0=B8=B2=E0=B8=A7?= =?UTF-8?q?=E0=B8=8A=E0=B8=B7=E0=B9=88=E0=B8=AD/=E0=B9=82=E0=B8=99?= =?UTF-8?q?=E0=B9=89=E0=B8=95=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B8=8A=E0=B9=88?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=E0=B8=81=E0=B8=A3=E0=B8=AD=E0=B8=81=20+=20F1?= =?UTF-8?q?9=20=E0=B8=AA=E0=B8=A3=E0=B8=B8=E0=B8=9B=E0=B8=AA=E0=B8=B4?= =?UTF-8?q?=E0=B9=88=E0=B8=87=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B9=80=E0=B8=9B?= =?UTF-8?q?=E0=B8=A5=E0=B8=B5=E0=B9=88=E0=B8=A2=E0=B8=99=E0=B9=83=E0=B8=99?= =?UTF-8?q?=20dialog=20=E0=B8=9B=E0=B8=A3=E0=B8=B0=E0=B8=A7=E0=B8=B1?= =?UTF-8?q?=E0=B8=95=E0=B8=B4=20+=20formatThaiDate=20=E2=80=94=20(1)=20boo?= =?UTF-8?q?kmark=20limits:=20utils/bookmark-limits.ts=20=E0=B9=83=E0=B8=AB?= =?UTF-8?q?=E0=B8=A1=E0=B9=88=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20source?= =?UTF-8?q?=20of=20truth=20=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7?= =?UTF-8?q?=20(=E0=B8=8A=E0=B8=B7=E0=B9=88=E0=B8=AD=20120/=E0=B9=82?= =?UTF-8?q?=E0=B8=99=E0=B9=89=E0=B8=95=20200=20=E0=B8=95=E0=B8=B1=E0=B8=A7?= =?UTF-8?q?=E0=B8=AD=E0=B8=B1=E0=B8=81=E0=B8=A9=E0=B8=A3)=20server=20actio?= =?UTF-8?q?n=20import=20=E0=B8=A3=E0=B9=88=E0=B8=A7=E0=B8=A1=E0=B9=81?= =?UTF-8?q?=E0=B8=97=E0=B8=99=20const=20=E0=B8=97=E0=B9=89=E0=B8=AD?= =?UTF-8?q?=E0=B8=87=E0=B8=96=E0=B8=B4=E0=B9=88=E0=B8=99=20+=20=E0=B9=83?= =?UTF-8?q?=E0=B8=AA=E0=B9=88=20maxLength=20=E0=B8=97=E0=B8=B5=E0=B9=88=20?= =?UTF-8?q?popup=20=E0=B8=9A=E0=B8=B1=E0=B8=99=E0=B8=97=E0=B8=B6=E0=B8=81?= =?UTF-8?q?=20bookmark=20(PDFViewer)=20=E0=B9=81=E0=B8=A5=E0=B8=B0?= =?UTF-8?q?=E0=B8=9F=E0=B8=AD=E0=B8=A3=E0=B9=8C=E0=B8=A1=E0=B9=81=E0=B8=81?= =?UTF-8?q?=E0=B9=89=E0=B9=84=E0=B8=82=20=E2=9C=8F=20=E0=B9=83=E0=B8=99=20?= =?UTF-8?q?sidebar=20=E0=B8=94=E0=B8=B1=E0=B8=81=E0=B8=97=E0=B8=B1?= =?UTF-8?q?=E0=B9=89=E0=B8=87=E0=B8=9E=E0=B8=B4=E0=B8=A1=E0=B8=9E=E0=B9=8C?= =?UTF-8?q?/paste=20=E0=B8=95=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B9=81=E0=B8=95?= =?UTF-8?q?=E0=B9=88=E0=B9=83=E0=B8=99=E0=B8=8A=E0=B9=88=E0=B8=AD=E0=B8=87?= =?UTF-8?q?=20=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=A3=E0=B8=AD=20error=20?= =?UTF-8?q?=E0=B8=95=E0=B8=AD=E0=B8=99=E0=B8=81=E0=B8=94=E0=B8=9A=E0=B8=B1?= =?UTF-8?q?=E0=B8=99=E0=B8=97=E0=B8=B6=E0=B8=81=20+=20=E0=B9=81=E0=B8=81?= =?UTF-8?q?=E0=B9=89=20off-by-one=20=E0=B8=82=E0=B8=AD=E0=B8=87=20normaliz?= =?UTF-8?q?eBookmarkTitle=20(=E0=B9=80=E0=B8=94=E0=B8=B4=E0=B8=A1=20slice?= =?UTF-8?q?=20120=20=E0=B9=81=E0=B8=A5=E0=B9=89=E0=B8=A7=E0=B8=95=E0=B9=88?= =?UTF-8?q?=E0=B8=AD=20=E2=80=A6=20=3D=20121=20=E0=B8=95=E0=B8=B1=E0=B8=A7?= =?UTF-8?q?=20=E0=B8=97=E0=B8=B3=E0=B9=83=E0=B8=AB=E0=B9=89=E0=B8=84?= =?UTF-8?q?=E0=B8=A5=E0=B8=B8=E0=B8=A1=E0=B8=82=E0=B9=89=E0=B8=AD=E0=B8=84?= =?UTF-8?q?=E0=B8=A7=E0=B8=B2=E0=B8=A1=E0=B8=A2=E0=B8=B2=E0=B8=A7=E0=B9=81?= =?UTF-8?q?=E0=B8=A5=E0=B9=89=E0=B8=A7=E0=B8=81=E0=B8=94=20B=20=E0=B8=9A?= =?UTF-8?q?=E0=B8=B1=E0=B8=99=E0=B8=97=E0=B8=B6=E0=B8=81=E0=B9=80=E0=B8=A5?= =?UTF-8?q?=E0=B8=A2=E0=B9=82=E0=B8=94=E0=B8=99=20limit=20=E0=B8=82?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=20server=20=E0=B8=95=E0=B8=B5=E0=B8=81?= =?UTF-8?q?=E0=B8=A5=E0=B8=B1=E0=B8=9A=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87?= =?UTF-8?q?=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B9=84?= =?UTF-8?q?=E0=B8=94=E0=B9=89=E0=B8=9E=E0=B8=B4=E0=B8=A1=E0=B8=9E=E0=B9=8C?= =?UTF-8?q?=E0=B9=80=E0=B8=AD=E0=B8=87=20=E2=80=94=20=E0=B8=95=E0=B8=AD?= =?UTF-8?q?=E0=B8=99=E0=B8=99=E0=B8=B5=E0=B9=89=20slice=20119=20+=20?= =?UTF-8?q?=E2=80=A6=20=3D=20120=20=E0=B8=9E=E0=B8=AD=E0=B8=94=E0=B8=B5?= =?UTF-8?q?=E0=B8=95=E0=B8=B2=E0=B8=A1=E0=B8=AA=E0=B9=80=E0=B8=9B=E0=B8=81?= =?UTF-8?q?=20=C2=A77=20N2=20=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B9=80=E0=B8=82?= =?UTF-8?q?=E0=B8=B5=E0=B8=A2=E0=B8=99=E0=B9=84=E0=B8=A7=E0=B9=89=20?= =?UTF-8?q?=E2=89=A4=20120=20=E0=B8=AD=E0=B8=A2=E0=B8=B9=E0=B9=88=E0=B9=81?= =?UTF-8?q?=E0=B8=A5=E0=B9=89=E0=B8=A7)=20+=20PLAN-FEAT.md=20=C2=A77=20N2?= =?UTF-8?q?=20=E0=B8=9A=E0=B8=B1=E0=B8=99=E0=B8=97=E0=B8=B6=E0=B8=81?= =?UTF-8?q?=E0=B9=80=E0=B8=9E=E0=B8=B4=E0=B9=88=E0=B8=A1=20=E2=80=94=20(2)?= =?UTF-8?q?=20=E0=B8=87=E0=B8=B2=E0=B8=99=E0=B8=84=E0=B8=B9=E0=B9=88?= =?UTF-8?q?=E0=B8=82=E0=B8=99=E0=B8=B2=E0=B8=94=E0=B8=88=E0=B8=B2=E0=B8=81?= =?UTF-8?q?=E0=B8=AD=E0=B8=B5=E0=B8=81=20session=20(=E0=B9=81=E0=B8=8A?= =?UTF-8?q?=E0=B8=A3=E0=B9=8C=E0=B9=84=E0=B8=9F=E0=B8=A5=E0=B9=8C=20PDFVie?= =?UTF-8?q?wer/sidebar=20=E0=B9=80=E0=B8=A5=E0=B8=A2=E0=B8=A3=E0=B8=A7?= =?UTF-8?q?=E0=B8=A1=20commit):=20dialog=20=E0=B8=9B=E0=B8=A3=E0=B8=B0?= =?UTF-8?q?=E0=B8=A7=E0=B8=B1=E0=B8=95=E0=B8=B4=20F19=20=E0=B9=81=E0=B8=AA?= =?UTF-8?q?=E0=B8=94=E0=B8=87=E0=B8=AA=E0=B8=A3=E0=B8=B8=E0=B8=9B=E0=B8=AA?= =?UTF-8?q?=E0=B8=B4=E0=B9=88=E0=B8=87=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B9=80?= =?UTF-8?q?=E0=B8=9B=E0=B8=A5=E0=B8=B5=E0=B9=88=E0=B8=A2=E0=B8=99=E0=B8=95?= =?UTF-8?q?=E0=B9=88=E0=B8=AD=E0=B8=A3=E0=B8=AD=E0=B8=9A=E0=B8=9C=E0=B9=88?= =?UTF-8?q?=E0=B8=B2=E0=B8=99=20describeAnnotationChange=20=E0=B9=83?= =?UTF-8?q?=E0=B8=AB=E0=B8=A1=E0=B9=88=E0=B9=83=E0=B8=99=20utils/annotatio?= =?UTF-8?q?n-display.ts=20(=E0=B9=80=E0=B8=97=E0=B8=B5=E0=B8=A2=E0=B8=9A?= =?UTF-8?q?=20snapshot=20=E0=B8=81=E0=B9=88=E0=B8=AD=E0=B8=99=E0=B9=81?= =?UTF-8?q?=E0=B8=81=E0=B9=89=20v.data=20=E0=B8=81=E0=B8=B1=E0=B8=9A=20sta?= =?UTF-8?q?te=20=E0=B8=96=E0=B8=B1=E0=B8=94=E0=B9=84=E0=B8=9B=20=E2=80=94?= =?UTF-8?q?=20snapshot=20=E0=B8=A3=E0=B8=AD=E0=B8=9A=E0=B9=83=E0=B8=AB?= =?UTF-8?q?=E0=B8=A1=E0=B9=88=E0=B8=81=E0=B8=A7=E0=B9=88=E0=B8=B2=20?= =?UTF-8?q?=E0=B8=AB=E0=B8=A3=E0=B8=B7=E0=B8=AD=E0=B8=84=E0=B9=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=9B=E0=B8=B1=E0=B8=88=E0=B8=88=E0=B8=B8=E0=B8=9A=E0=B8=B1?= =?UTF-8?q?=E0=B8=99=E0=B8=88=E0=B8=B2=E0=B8=81=20realtime=20lookup=20?= =?UTF-8?q?=E0=B8=AA=E0=B8=94=20historyCurrent=20=E2=80=94=20=E0=B8=AA?= =?UTF-8?q?=E0=B8=A3=E0=B8=B8=E0=B8=9B=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99?= =?UTF-8?q?=E0=B8=AB=E0=B8=99=E0=B9=88=E0=B8=A7=E0=B8=A2=E0=B9=80=E0=B8=94?= =?UTF-8?q?=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B8=81=E0=B8=B1=E0=B8=9A=E0=B8=95?= =?UTF-8?q?=E0=B8=B1=E0=B8=A7=E0=B8=9B=E0=B8=A3=E0=B8=B1=E0=B8=9A=20UI:=20?= =?UTF-8?q?=E0=B8=84=E0=B8=A7=E0=B8=B2=E0=B8=A1=E0=B8=AB=E0=B8=99=E0=B8=B2?= =?UTF-8?q?=20=C3=9710=20=E0=B8=84=E0=B8=A7=E0=B8=B2=E0=B8=A1=E0=B8=97?= =?UTF-8?q?=E0=B8=B6=E0=B8=9A=20%=20fontSize=20px=20=E0=B8=8A=E0=B8=B7?= =?UTF-8?q?=E0=B9=88=E0=B8=AD=E0=B8=AA=E0=B8=B5=20preset=20=E0=B8=A2?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=A2/=E0=B8=9B=E0=B8=A3=E0=B8=B1?= =?UTF-8?q?=E0=B8=9A=E0=B8=82=E0=B8=99=E0=B8=B2=E0=B8=94=E0=B8=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=81=20bbox=20=E0=B8=9C=E0=B8=A5=E0=B8=95=E0=B9=88?= =?UTF-8?q?=E0=B8=B2=E0=B8=87=E0=B9=80=E0=B8=81=E0=B8=B4=E0=B8=99=20GEOM?= =?UTF-8?q?=5FEPS=5FPCT=200.5%=20=E0=B8=82=E0=B9=89=E0=B8=AD=E0=B8=84?= =?UTF-8?q?=E0=B8=A7=E0=B8=B2=E0=B8=A1/=E0=B9=82=E0=B8=99=E0=B9=89?= =?UTF-8?q?=E0=B8=95/label)=20+=20dialog=20=E0=B8=82=E0=B8=A2=E0=B8=B2?= =?UTF-8?q?=E0=B8=A2=20w-96=20max-h-[60vh]=20=E0=B9=81=E0=B8=96=E0=B8=A7?= =?UTF-8?q?=E0=B9=80=E0=B8=9B=E0=B8=A5=E0=B8=B5=E0=B9=88=E0=B8=A2=E0=B8=99?= =?UTF-8?q?=20items-start=20+=20utils/date-format.ts=20=E0=B9=83=E0=B8=AB?= =?UTF-8?q?=E0=B8=A1=E0=B9=88=20formatThaiDate=20=E0=B8=A7=E0=B8=B1?= =?UTF-8?q?=E0=B8=99=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B9=84=E0=B8=97=E0=B8=A2?= =?UTF-8?q?=E0=B9=80=E0=B8=95=E0=B9=87=E0=B8=A1=20"18=20=E0=B8=81=E0=B8=B1?= =?UTF-8?q?=E0=B8=99=E0=B8=A2=E0=B8=B2=E0=B8=A2=E0=B8=99=20=E0=B8=9E.?= =?UTF-8?q?=E0=B8=A8.=202569"=20(+=E0=B9=80=E0=B8=A7=E0=B8=A5=E0=B8=B2)=20?= =?UTF-8?q?=E0=B9=81=E0=B8=97=E0=B8=99=20toLocaleString("th-TH")=20?= =?UTF-8?q?=E0=B8=97=E0=B8=B8=E0=B8=81=E0=B8=88=E0=B8=B8=E0=B8=94=E0=B9=81?= =?UTF-8?q?=E0=B8=AA=E0=B8=94=E0=B8=87=E0=B8=A7=E0=B8=B1=E0=B8=99=E0=B8=97?= =?UTF-8?q?=E0=B8=B5=E0=B9=88=E0=B9=83=E0=B8=99=20UI=20(=E0=B8=AB=E0=B8=99?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=A3=E0=B8=B2=E0=B8=A2=E0=B8=81=E0=B8=B2?= =?UTF-8?q?=E0=B8=A3=E0=B9=84=E0=B8=9F=E0=B8=A5=E0=B9=8C=20sidebar=20?= =?UTF-8?q?=E0=B8=9B=E0=B8=A3=E0=B8=B0=E0=B8=A7=E0=B8=B1=E0=B8=95=E0=B8=B4?= =?UTF-8?q?/=E0=B8=A3=E0=B8=B2=E0=B8=A2=E0=B8=81=E0=B8=B2=E0=B8=A3=20times?= =?UTF-8?q?tamps=20=E0=B9=83=E0=B8=99=20PDFViewer)=20=E0=B8=81=E0=B8=B1?= =?UTF-8?q?=E0=B8=99=20server/client=20locale=20=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B8=95=E0=B8=A3=E0=B8=87=E0=B8=81=E0=B8=B1=E0=B8=99?= =?UTF-8?q?=20+=20tests=20=E0=B9=83=E0=B8=AB=E0=B8=A1=E0=B9=88/=E0=B8=82?= =?UTF-8?q?=E0=B8=A2=E0=B8=B2=E0=B8=A2=20(date-format=20+=20describeAnnota?= =?UTF-8?q?tionChange)=20=E2=80=94=20CLAUDE.md=20sync=20=E0=B9=82=E0=B8=84?= =?UTF-8?q?=E0=B8=A3=E0=B8=87=E0=B8=AA=E0=B8=A3=E0=B9=89=E0=B8=B2=E0=B8=87?= =?UTF-8?q?=20utils=20=E2=80=94=20verify:=20tsc=20strict=20=E0=B8=9C?= =?UTF-8?q?=E0=B9=88=E0=B8=B2=E0=B8=99=20+=20vitest=20139/139=20=E0=B8=9C?= =?UTF-8?q?=E0=B9=88=E0=B8=B2=E0=B8=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- CLAUDE.md | 3 +- app/actions/annotations.ts | 6 +- app/page.tsx | 3 +- components/pdf/PDFViewer.tsx | 23 ++- components/sidebar/AnnotationSidebar.tsx | 80 +++++--- docs/PLAN-FEAT.md | 4 +- tests/utils/annotation-display.test.ts | 100 +++++++++- tests/utils/date-format.test.ts | 26 +++ utils/annotation-display.ts | 221 +++++++++++++++++++++++ utils/bookmark-limits.ts | 7 + utils/date-format.ts | 34 ++++ 11 files changed, 469 insertions(+), 38 deletions(-) create mode 100644 tests/utils/date-format.test.ts create mode 100644 utils/bookmark-limits.ts create mode 100644 utils/date-format.ts diff --git a/CLAUDE.md b/CLAUDE.md index 41b5d9f..1681511 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,8 @@ lib/server-pdf.ts ← path guard อ่าน PDF จาก public/ (export lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court) utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) -utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + ค่า default TEXTBOX/STAMP (sidebar/สรุป/CSV ใช้ร่วม) +utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/CSV ใช้ร่วม) +utils/date-format.ts ← formatThaiDate วันที่ไทยแบบเต็ม "18 กันยายน พ.ศ. 2569" (+เวลา "… 16:33:00 น.") — ทุกจุดที่แสดงวันที่ใน UI ใช้ร่วม (เว้น export CSV ที่ lock Asia/Bangkok) utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wrapText + hexToRgb (export route) utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {userId: u_userid, userName: u_userlogin} (pure module — middleware ใช้ร่วมได้) utils/base-path.ts ← withBasePath() เติม basePath /docedit/note ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (ค่าจาก next.config.mjs ผ่าน env — invariant #12) diff --git a/app/actions/annotations.ts b/app/actions/annotations.ts index 2217211..6aea7b9 100644 --- a/app/actions/annotations.ts +++ b/app/actions/annotations.ts @@ -30,6 +30,10 @@ import { type StampData, type TextboxData, } from "@/db/schema"; +import { + MAX_BOOKMARK_NOTE_CHARS, + MAX_BOOKMARK_TITLE_CHARS, +} from "@/utils/bookmark-limits"; // ─── Input types (client ใช้ตอนเรียก action) ──────────────────────────────── @@ -88,8 +92,6 @@ const SHAPE_KINDS: readonly string[] = ["rect", "ellipse", "line", "arrow"]; // ตรง ๆ ด้วยข้อความขนาดหลาย MB จน JSONB บวมและ SSE event ใหญ่ผิดปกติ const MAX_USER_NAME_CHARS = 100; const MAX_TEXT_CHARS = 5_000; // message/text/note/label ของ annotation + reply -const MAX_BOOKMARK_TITLE_CHARS = 120; // ให้ตรงค่าที่ UI ตัดไว้ -const MAX_BOOKMARK_NOTE_CHARS = 200; const MAX_QUERY_CHARS = 200; // คำค้นหา (F14) function fieldError(field: string, expect: string): never { diff --git a/app/page.tsx b/app/page.tsx index 7ed5772..de03430 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -8,6 +8,7 @@ import { files } from "@/db/schema"; import SessionBar from "@/components/SessionBar"; import UploadFileForm from "@/components/UploadFileForm"; import { imageFormatFromUrl } from "@/utils/file-kind"; +import { formatThaiDate } from "@/utils/date-format"; import { getSessionUser } from "@/lib/session"; export const dynamic = "force-dynamic"; @@ -95,7 +96,7 @@ export default async function HomePage() { {kindBadge(f.pdfUrl)} - {new Date(f.createdAt).toLocaleString("th-TH")} + {formatThaiDate(f.createdAt, true)} {/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป้าจิ้ม 36px (H4) */} diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index 7a6a30b..6b5c939 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -114,8 +114,13 @@ import { type Size, } from "@/utils/coordinates"; import type { OacUser } from "@/utils/user"; +import { + MAX_BOOKMARK_NOTE_CHARS, + MAX_BOOKMARK_TITLE_CHARS, +} from "@/utils/bookmark-limits"; import { fileKindFromUrl, imageFormatFromUrl } from "@/utils/file-kind"; import { withBasePath } from "@/utils/base-path"; +import { formatThaiDate } from "@/utils/date-format"; import { decodeTiffPages } from "@/utils/tiff-client"; import { TEXTBOX_DEFAULT_WIDTH_PCT, @@ -196,10 +201,13 @@ const HIGHLIGHT_COLORS = ["#fde047", "#86efac", "#93c5fd", "#f9a8d4", "#fdba74"] /** ชื่อ bookmark ที่มาจากข้อความ (คลุม/ข้อความรายการ) — ล้างขยะของ textLayer ไทย * (sanitizePdfText ครอบด้วยข้อความจาก DB เก่าที่ยังไม่ผ่านการล้าง) ยุบ whitespace - * และตัดยาวไม่เกิน 120 ตัวอักษร */ + * และตัดยาวไม่เกิน MAX_BOOKMARK_TITLE_CHARS ตัวอักษร (รวม … ที่ต่อท้าย — + * เดิม slice 120 แล้วต่อ … กลายเป็น 121 ตัว โดน limit ของ server ตีกลับตอนบันทึก) */ function normalizeBookmarkTitle(s: string): string { const t = sanitizePdfText(s).replace(/\s+/g, " ").trim(); - return t.length > 120 ? `${t.slice(0, 120)}…` : t; + return t.length > MAX_BOOKMARK_TITLE_CHARS + ? `${t.slice(0, MAX_BOOKMARK_TITLE_CHARS - 1)}…` + : t; } /** ความสูง sticky header ของ viewer — ใช้เว้นระยะเวลาเลื่อนหน้าไม่ให้เป้าหมายถูก header บัง */ @@ -492,12 +500,7 @@ function ReplyBox({

{r.userName} - {new Date(r.createdAt).toLocaleString("th-TH", { - hour: "2-digit", - minute: "2-digit", - day: "2-digit", - month: "short", - })} + {formatThaiDate(r.createdAt, true)}

{r.message}

@@ -1075,7 +1078,7 @@ function PdfPage({

)}

- {new Date(a.createdAt).toLocaleString("th-TH")} + {formatThaiDate(a.createdAt, true)}

{/* F10/A5: toggle จัดการแล้ว — ใครก็ได้ (D6) */}