From 65b72cf81786e5e77caaa425273a31da55497546 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 17 Sep 2026 13:41:02 +0700 Subject: [PATCH 01/55] =?UTF-8?q?mock=20login=20e-Court=20=E0=B8=AB?= =?UTF-8?q?=E0=B8=99=E0=B9=89=E0=B8=B2=20/login=20+=20identity=20=E0=B8=88?= =?UTF-8?q?=E0=B8=B2=E0=B8=81=20cookie=20oacCase=20=E0=B9=81=E0=B8=97?= =?UTF-8?q?=E0=B8=99=20localStorage=20=E2=80=94=20dropdown=20=E0=B8=9C?= =?UTF-8?q?=E0=B8=B9=E0=B9=89=E0=B9=83=E0=B8=8A=E0=B9=89=20MOCK=5FUSERS=20?= =?UTF-8?q?(user1=E2=80=93user5,=20utils/user.ts=20pure=20module=20?= =?UTF-8?q?=E0=B9=83=E0=B8=AB=E0=B9=89=20client+server=20=E0=B9=83?= =?UTF-8?q?=E0=B8=8A=E0=B9=89=E0=B8=A3=E0=B9=88=E0=B8=A7=E0=B8=A1)=20?= =?UTF-8?q?=E0=B8=A3=E0=B8=AB=E0=B8=B1=E0=B8=AA=E0=B8=9C=E0=B9=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=99=E0=B8=81=E0=B8=A5=E0=B8=B2=E0=B8=87=20Demo1234=20?= =?UTF-8?q?=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=20server=20action=20login?= =?UTF-8?q?=20(=E0=B9=80=E0=B8=8A=E0=B9=87=E0=B8=84=E0=B8=A3=E0=B8=B2?= =?UTF-8?q?=E0=B8=A2=E0=B8=8A=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=9D=E0=B8=B1?= =?UTF-8?q?=E0=B9=88=E0=B8=87=20server=20=E0=B9=80=E0=B8=9B=E0=B9=87?= =?UTF-8?q?=E0=B8=99=20defense=20=E0=B8=81=E0=B8=B1=E0=B8=99=20hand-craft?= =?UTF-8?q?=20+=20delay=20500ms=20=E0=B9=83=E0=B8=AB=E0=B9=89=E0=B9=80?= =?UTF-8?q?=E0=B8=AB=E0=B9=87=E0=B8=99=20"=E0=B8=81=E0=B8=B3=E0=B8=A5?= =?UTF-8?q?=E0=B8=B1=E0=B8=87=E0=B8=95=E0=B8=A3=E0=B8=A7=E0=B8=88=E0=B8=AA?= =?UTF-8?q?=E0=B8=AD=E0=B8=9A=E2=80=A6")=20=E0=B8=95=E0=B8=B1=E0=B9=89?= =?UTF-8?q?=E0=B8=87=20cookie=20oacCase=20=E0=B8=95=E0=B8=B2=E0=B8=A1=20fo?= =?UTF-8?q?rmat=20=E0=B8=88=E0=B8=A3=E0=B8=B4=E0=B8=87=E0=B8=82=E0=B8=AD?= =?UTF-8?q?=E0=B8=87=E0=B8=A3=E0=B8=B0=E0=B8=9A=E0=B8=9A=E0=B8=A8=E0=B8=B2?= =?UTF-8?q?=E0=B8=A5=2016=20field=20=E2=80=94=20u=5Fuserid/u=5Fuserlogin?= =?UTF-8?q?=20=E0=B8=95=E0=B9=88=E0=B8=B2=E0=B8=87=E0=B8=81=E0=B8=B1?= =?UTF-8?q?=E0=B8=99=E0=B8=95=E0=B9=88=E0=B8=AD=E0=B8=84=E0=B8=99=20(=3D?= =?UTF-8?q?=20=E0=B8=8A=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=9C=E0=B8=B9?= =?UTF-8?q?=E0=B9=89=E0=B9=83=E0=B8=8A=E0=B9=89)=20=E0=B9=80=E0=B8=9E?= =?UTF-8?q?=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B9=83=E0=B8=AB=E0=B9=89=E0=B8=AA?= =?UTF-8?q?=E0=B8=B4=E0=B8=97=E0=B8=98=E0=B8=B4=E0=B9=8C=20"=E0=B9=81?= =?UTF-8?q?=E0=B8=81=E0=B9=89=E0=B9=84=E0=B8=82=E0=B9=84=E0=B8=94=E0=B9=89?= =?UTF-8?q?=E0=B9=80=E0=B8=89=E0=B8=9E=E0=B8=B2=E0=B8=B0=E0=B8=82=E0=B8=AD?= =?UTF-8?q?=E0=B8=87=E0=B8=95=E0=B8=B1=E0=B8=A7=E0=B9=80=E0=B8=AD=E0=B8=87?= =?UTF-8?q?"=20(guard=20=E0=B8=94=E0=B9=89=E0=B8=A7=E0=B8=A2=20userId)=20?= =?UTF-8?q?=E0=B9=81=E0=B8=A2=E0=B8=81=E0=B8=81=E0=B8=B1=E0=B8=99=E0=B8=88?= =?UTF-8?q?=E0=B8=A3=E0=B8=B4=E0=B8=87=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87?= =?UTF-8?q?=205=20=E0=B8=84=E0=B8=99=20httpOnly/sameSite=20lax/8=20?= =?UTF-8?q?=E0=B8=8A=E0=B8=A1.=20+=20middleware.ts=20=E0=B8=9A=E0=B8=B1?= =?UTF-8?q?=E0=B8=87=E0=B8=84=E0=B8=B1=E0=B8=9A=20login=20=E0=B8=97?= =?UTF-8?q?=E0=B8=B8=E0=B8=81=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2=20(parse?= =?UTF-8?q?=20=E0=B8=88=E0=B8=A3=E0=B8=B4=E0=B8=87=E0=B8=94=E0=B9=89?= =?UTF-8?q?=E0=B8=A7=E0=B8=A2=20parseOacCase=20=E0=B9=84=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B9=83=E0=B8=8A=E0=B9=88=E0=B9=81=E0=B8=84=E0=B9=88=E0=B8=A1?= =?UTF-8?q?=E0=B8=B5=20cookie=20=E2=80=94=20=E0=B8=82=E0=B8=A2=E0=B8=B0/?= =?UTF-8?q?=E0=B8=A7=E0=B9=88=E0=B8=B2=E0=B8=87/=E0=B8=82=E0=B8=B2?= =?UTF-8?q?=E0=B8=94=20field=20=3D=20redirect=20/login;=20matcher=20?= =?UTF-8?q?=E0=B8=A2=E0=B8=81=E0=B9=80=E0=B8=A7=E0=B9=89=E0=B8=99=20/login?= =?UTF-8?q?,=20/api/*,=20/uploads/*,=20=5Fnext=20=E0=B9=81=E0=B8=A5?= =?UTF-8?q?=E0=B8=B0=20static=20=E0=B9=83=E0=B8=99=20public/=20=E2=80=94?= =?UTF-8?q?=20SSE/export=20=E0=B8=88=E0=B8=B6=E0=B8=87=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B9=82=E0=B8=94=E0=B8=99=20302)=20+=20lib/session.t?= =?UTF-8?q?s=20getSessionUser=20=E0=B8=AD=E0=B9=88=E0=B8=B2=E0=B8=99=20ide?= =?UTF-8?q?ntity=20=E0=B8=9D=E0=B8=B1=E0=B9=88=E0=B8=87=20server=20?= =?UTF-8?q?=E0=B9=83=E0=B8=8A=E0=B9=89=E0=B9=83=E0=B8=99=E0=B8=97=E0=B8=B8?= =?UTF-8?q?=E0=B8=81=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2=20(defense=20?= =?UTF-8?q?=E0=B8=8B=E0=B9=89=E0=B8=AD=E0=B8=99=20middleware)=20+=20PDFVie?= =?UTF-8?q?wer=20=E0=B8=A3=E0=B8=B1=E0=B8=9A=20user=20=E0=B9=80=E0=B8=9B?= =?UTF-8?q?=E0=B9=87=E0=B8=99=20prop=20=E0=B8=88=E0=B8=B2=E0=B8=81=20annot?= =?UTF-8?q?ate=20page=20=E0=B8=95=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B9=81?= =?UTF-8?q?=E0=B8=95=E0=B9=88=20first=20paint=20(=E0=B8=AB=E0=B8=A1?= =?UTF-8?q?=E0=B8=94=E0=B8=8A=E0=B9=88=E0=B8=A7=E0=B8=87=20null=20user=20?= =?UTF-8?q?=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B9=80=E0=B8=84=E0=B8=A2=E0=B8=AA?= =?UTF-8?q?=E0=B8=A3=E0=B9=89=E0=B8=B2=E0=B8=87=20annotation=20=E0=B8=94?= =?UTF-8?q?=E0=B9=89=E0=B8=A7=E0=B8=A2=20userId=3D""=20=E0=B9=84=E0=B8=94?= =?UTF-8?q?=E0=B9=89=20+=20=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=A1=E0=B8=B5?= =?UTF-8?q?=20hydration=20mismatch;=20Server=20Actions=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=E2=80=94=20vString=20=E0=B8=A3=E0=B8=B1?= =?UTF-8?q?=E0=B8=9A=20userId=20=E0=B9=83=E0=B8=AB=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B8=AD=E0=B8=A2=E0=B8=B9=E0=B9=88=E0=B9=81=E0=B8=A5=E0=B9=89?= =?UTF-8?q?=E0=B8=A7)=20+=20SessionBar=20=E0=B9=81=E0=B8=AA=E0=B8=94?= =?UTF-8?q?=E0=B8=87=20"=E0=B9=80=E0=B8=82=E0=B9=89=E0=B8=B2=E0=B8=AA?= =?UTF-8?q?=E0=B8=B9=E0=B9=88=E0=B8=A3=E0=B8=B0=E0=B8=9A=E0=B8=9A=E0=B9=80?= =?UTF-8?q?=E0=B8=9B=E0=B9=87=E0=B8=99=20"=20+=20=E0=B8=9B=E0=B8=B8?= =?UTF-8?q?=E0=B9=88=E0=B8=A1=E0=B8=AD=E0=B8=AD=E0=B8=81=E0=B8=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=81=E0=B8=A3=E0=B8=B0=E0=B8=9A=E0=B8=9A=20(=E0=B8=A5?= =?UTF-8?q?=E0=B8=9A=20cookie=20=E0=B8=94=E0=B9=89=E0=B8=A7=E0=B8=A2=20pat?= =?UTF-8?q?h=20"/"=20=E0=B8=95=E0=B8=A3=E0=B8=87=E0=B8=95=E0=B8=AD?= =?UTF-8?q?=E0=B8=99=20set=20+=20redirect=20=E0=B9=83=E0=B8=99=20action=20?= =?UTF-8?q?=E0=B9=80=E0=B8=AD=E0=B8=87=20=E2=80=94=20client=20router.repla?= =?UTF-8?q?ce=20=E0=B9=80=E0=B8=AD=E0=B8=87=E0=B8=8A=E0=B8=99=E0=B8=81?= =?UTF-8?q?=E0=B8=B1=E0=B8=9A=20re-render=20=E0=B8=82=E0=B8=AD=E0=B8=87?= =?UTF-8?q?=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2=E0=B8=97=E0=B8=B5=E0=B9=88?= =?UTF-8?q?=E0=B9=80=E0=B8=9E=E0=B8=B4=E0=B9=88=E0=B8=87=E0=B9=80=E0=B8=AA?= =?UTF-8?q?=E0=B8=B5=E0=B8=A2=20session=20=E0=B8=88=E0=B8=99=20navigation?= =?UTF-8?q?=20=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B9=80=E0=B8=81=E0=B8=B4?= =?UTF-8?q?=E0=B8=94)=20=E0=B9=81=E0=B8=97=E0=B8=99=20UserPicker=20mock=20?= =?UTF-8?q?10=20=E0=B8=84=E0=B8=99=20(=E0=B8=A5=E0=B8=9A=E0=B9=84=E0=B8=9F?= =?UTF-8?q?=E0=B8=A5=E0=B9=8C=E0=B8=9E=E0=B8=A3=E0=B9=89=E0=B8=AD=E0=B8=A1?= =?UTF-8?q?=20MOCK=5FUSERS/localStorage/getLocalUser=20=E0=B8=97=E0=B8=B1?= =?UTF-8?q?=E0=B9=89=E0=B8=87=E0=B8=8A=E0=B8=B8=E0=B8=94)=20+=20LoginForm?= =?UTF-8?q?=20=E0=B8=81=E0=B8=B2=E0=B8=A3=E0=B9=8C=E0=B8=94=20max-w-sm=20?= =?UTF-8?q?=E0=B8=81=E0=B8=A5=E0=B8=B2=E0=B8=87=E0=B8=88=E0=B8=AD=20reuse?= =?UTF-8?q?=20brand=20lockup=20=E0=B9=81=E0=B8=A5=E0=B8=B0=E0=B8=9B?= =?UTF-8?q?=E0=B8=B8=E0=B9=88=E0=B8=A1=20primary=20=E0=B9=80=E0=B8=94?= =?UTF-8?q?=E0=B8=B4=E0=B8=A1,=20input=20text-base=2016px=20=E0=B8=81?= =?UTF-8?q?=E0=B8=B1=E0=B8=99=20iOS=20zoom=20=E0=B8=9A=E0=B8=99=20iPad,=20?= =?UTF-8?q?dropdown=20placeholder=20"=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B8=AD?= =?UTF-8?q?=E0=B8=81=E0=B8=9C=E0=B8=B9=E0=B9=89=E0=B9=83=E0=B8=8A=E0=B9=89?= =?UTF-8?q?"=20=E0=B8=9A=E0=B8=B1=E0=B8=87=E0=B8=84=E0=B8=B1=E0=B8=9A?= =?UTF-8?q?=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B8=AD=E0=B8=81=20(=E0=B9=84?= =?UTF-8?q?=E0=B8=A1=E0=B9=88=20preselect=20=E0=B8=81=E0=B8=B1=E0=B8=99=20?= =?UTF-8?q?login=20=E0=B8=9C=E0=B8=B4=E0=B8=94=E0=B8=84=E0=B8=99=E0=B9=82?= =?UTF-8?q?=E0=B8=94=E0=B8=A2=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=95=E0=B8=B1?= =?UTF-8?q?=E0=B9=89=E0=B8=87=E0=B9=83=E0=B8=88),=20error=20=E0=B9=81?= =?UTF-8?q?=E0=B8=96=E0=B8=9A=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7?= =?UTF-8?q?=20role=3Dalert=20"=E0=B8=A3=E0=B8=AB=E0=B8=B1=E0=B8=AA?= =?UTF-8?q?=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=E0=B9=84=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B8=96=E0=B8=B9=E0=B8=81=E0=B8=95=E0=B9=89=E0=B8=AD=E0=B8=87?= =?UTF-8?q?"=20(dropdown=20=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B8=AD=E0=B8=81?= =?UTF-8?q?=E0=B8=9C=E0=B8=B4=E0=B8=94=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B9=84?= =?UTF-8?q?=E0=B8=94=E0=B9=89=E0=B9=81=E0=B8=A5=E0=B9=89=E0=B8=A7),=20?= =?UTF-8?q?=E0=B8=95=E0=B8=AD=E0=B8=99=E0=B8=9C=E0=B8=B4=E0=B8=94=E0=B8=A5?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=87=20password=20=E0=B8=84=E0=B8=87?= =?UTF-8?q?=E0=B8=9C=E0=B8=B9=E0=B9=89=E0=B9=83=E0=B8=8A=E0=B9=89=E0=B8=97?= =?UTF-8?q?=E0=B8=B5=E0=B9=88=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B8=AD=E0=B8=81?= =?UTF-8?q?=E0=B9=84=E0=B8=A7=E0=B9=89=20+=20focus+select=20+=20tests/util?= =?UTF-8?q?s/user.test.ts=20=E0=B8=A5=E0=B9=87=E0=B8=AD=E0=B8=81=20format?= =?UTF-8?q?=20=E0=B8=88=E0=B8=A3=E0=B8=B4=E0=B8=87=E0=B8=82=E0=B8=AD?= =?UTF-8?q?=E0=B8=87=20e-Court=20(parse=20=E0=B8=95=E0=B8=B1=E0=B8=A7?= =?UTF-8?q?=E0=B8=AD=E0=B8=A2=E0=B9=88=E0=B8=B2=E0=B8=87=20cookie=20?= =?UTF-8?q?=E0=B8=88=E0=B8=A3=E0=B8=B4=E0=B8=87=20235821/tu=5F2,=20garbage?= =?UTF-8?q?/empty/=E0=B8=82=E0=B8=B2=E0=B8=94=20field=20=E2=86=92=20null,?= =?UTF-8?q?=20round-trip,=20buildOacCaseCookie=20=E0=B8=84=E0=B8=A3?= =?UTF-8?q?=E0=B8=9A=2016=20field)=20+=20sync=20CLAUDE.md/PLAN.md=20=C2=A7?= =?UTF-8?q?0=20A4/README=20=E2=80=94=20annotation=20=E0=B8=AA=E0=B8=A1?= =?UTF-8?q?=E0=B8=B1=E0=B8=A2=20mock-user-01/UUID=20=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B9=83=E0=B8=8A=E0=B9=88=E0=B8=82=E0=B8=AD=E0=B8=87?= =?UTF-8?q?=E0=B8=95=E0=B8=B1=E0=B8=A7=E0=B9=80=E0=B8=AD=E0=B8=87=E0=B8=AD?= =?UTF-8?q?=E0=B8=B5=E0=B8=81=20(auth=20=E0=B8=88=E0=B8=A3=E0=B8=B4?= =?UTF-8?q?=E0=B8=87=E0=B9=80=E0=B8=97=E0=B8=B5=E0=B8=A2=E0=B8=9A=E0=B8=A3?= =?UTF-8?q?=E0=B8=B0=E0=B8=9A=E0=B8=9A=E0=B8=A8=E0=B8=B2=E0=B8=A5=20+=20ro?= =?UTF-8?q?les=20=E0=B8=A2=E0=B8=B1=E0=B8=87=20out=20of=20scope;=20/api/*?= =?UTF-8?q?=20=E0=B9=81=E0=B8=A5=E0=B8=B0=20/uploads/*=20=E0=B8=A2?= =?UTF-8?q?=E0=B8=B1=E0=B8=87=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=95=E0=B9=89?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=20login=20=E0=B8=95=E0=B8=B2=E0=B8=A1?= =?UTF-8?q?=E0=B8=82=E0=B8=AD=E0=B8=9A=E0=B9=80=E0=B8=82=E0=B8=95=20mock)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- CLAUDE.md | 16 +++-- README.md | 6 +- app/actions/auth.ts | 41 +++++++++++ app/annotate/[id]/page.tsx | 8 ++- app/login/page.tsx | 21 ++++++ app/page.tsx | 10 ++- components/LoginForm.tsx | 132 +++++++++++++++++++++++++++++++++++ components/LogoutButton.tsx | 29 ++++++++ components/SessionBar.tsx | 15 ++++ components/UserPicker.tsx | 66 ------------------ components/pdf/PDFViewer.tsx | 13 ++-- docs/PLAN.md | 8 +-- lib/session.ts | 8 +++ middleware.ts | 24 +++++++ tests/utils/user.test.ts | 58 +++++++++++++++ utils/user.ts | 90 ++++++++++++------------ 16 files changed, 414 insertions(+), 131 deletions(-) create mode 100644 app/actions/auth.ts create mode 100644 app/login/page.tsx create mode 100644 components/LoginForm.tsx create mode 100644 components/LogoutButton.tsx create mode 100644 components/SessionBar.tsx delete mode 100644 components/UserPicker.tsx create mode 100644 lib/session.ts create mode 100644 middleware.ts create mode 100644 tests/utils/user.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index f2eeafd..56919df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ - 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 -- ไม่มีระบบ auth — anonymous identity จาก localStorage (`utils/user.ts`) +- mock login e-Court — หน้า `/login` (dropdown `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` ตาม format จริงของระบบศาล → identity (userId = `u_userid`, userName = `u_userlogin` — **ต่างกันต่อคน** เพื่อสิทธิ์เจ้าของ) อ่านจาก cookie ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope) ## Invariants ที่ต้องรักษา (ห้ามละเมิด) @@ -34,26 +34,30 @@ ``` db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false) +middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /login (ยกเว้น /login, /api/*, /uploads/*, static ใน public/) lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe) 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/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wrapText + hexToRgb (export route) -utils/user.ts ← anonymous identity (localStorage) +utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {userId: u_userid, userName: u_userlogin} (pure module — middleware ใช้ร่วมได้) 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) hooks/useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe ด้วย id hooks/useHistory.ts ← undo/redo stack ฝั่ง client (F2) app/actions/annotations.ts ← Server Actions CRUD + validation + publish event +app/actions/auth.ts ← Server Action login (mock user_x/Demo1234 → ตั้ง cookie oacCase) / logout (ลบ cookie) app/actions/files.ts ← Server Action uploadFile — validate + เขียนไฟล์ใหม่ที่ public/uploads/ (N4) app/api/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=) app/api/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8) app/api/annotations/export-csv/route.ts ← export สรุป CSV พร้อม BOM (F16) -app/api/bookmarks/export/route.ts ← รายงานรายการ bookmark เป็น PDF (N7 — สร้าง PDF ใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ) +app/api/bookmarks/export/route.ts ← ส่งออกรายการ bookmark เป็น JSON (N7 — ข้อมูลล้วนจาก DB ไม่โหลดไฟล์ต้นฉบับ; เดิมเป็นรายงาน PDF เปลี่ยนตามคำขอผู้ใช้ 2026-09-17) app/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route) app/page.tsx ← รายการไฟล์ (server component) +app/login/page.tsx ← หน้า login (มี session แล้ว redirect /) app/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer app/annotate/[id]/summary/page.tsx ← หน้าสรุปหมายเหตุจัดกลุ่มตามหน้า (F15) components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า) @@ -62,13 +66,15 @@ components/pdf/TextboxStampLayer.tsx ← overlay TEXTBOX + STAMP (F17/F18) components/pdf/AnnotationSelectionLayer.tsx ← กรอบ selection + handles ย้าย/ปรับขนาด (F1) + แถบสี/ความหนา + ปุ่มลบ components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบาย (note) inline ต่อรายการ (PLAN-FEAT.md §7 N1) components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ (N4) +components/LoginForm.tsx ← ฟอร์ม login (client — เรียก action login แล้ว router.replace("/")) +components/SessionBar.tsx ← แถบแสดงผู้ใช้ที่ login + ปุ่มออกจากระบบบนหน้ารายการ (แทน UserPicker เดิม) ``` ## ขอบเขต — ห้ามทำเกิน (Out of Scope) -> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย (N4), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md`) +> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย (N4), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md` — **2026-09-17 เปลี่ยน deliverable เป็นส่งออก JSON** แทนรายงาน PDF ตามคำขอผู้ใช้: flat array `BookmarkDTO` เรียง N5 + envelope fileId/title/exportedAt ผ่าน route เดิม) -- ไม่มี login/roles, ไม่มี presence — undo/redo เป็น history ฝั่ง client ต่อ session ต่อผู้ใช้ ไม่เก็บใน DB (D10) ส่วน version history/กู้คืน (F19) เก็บในตาราง `annotation_versions` +- ไม่มี auth จริง/roles (login เป็น mock หน้า `/login` ตั้ง cookie `oacCase` — ไม่ได้ตรวจกับระบบ e-Court จริง), ไม่มี presence — undo/redo เป็น history ฝั่ง client ต่อ session ต่อผู้ใช้ ไม่เก็บใน DB (D10) ส่วน version history/กู้คืน (F19) เก็บในตาราง `annotation_versions` - Annotation: สร้าง + ลบ + แก้ไข/ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา/แก้ไขข้อความ **ของตัวเอง ในหน้าเดียวกัน** — resolve เป็นสิ่งเดียวที่ใครก็ toggle ได้ (D6) - ปุ่มลบ/แก้ไข/ประวัติแสดงเฉพาะรายการของตัวเอง (`userId` ตรงกัน) เท่านั้น - หน้า `/` อ่านรายการจาก DB + ฟอร์มอัปโหลดไฟล์เดียวแบบง่าย (N4 — PDF/jpg/jpeg/png/tif/tiff ≤ 100MB เขียนไฟล์ใหม่ที่ `public/uploads/` เท่านั้น; ไม่มีหน้าจัดการ/ลบไฟล์ใน UI; sample data sync ด้วย `npm run seed-samples`) diff --git a/README.md b/README.md index ba8a69b..ae13d78 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - **Bookmark** — จดตำแหน่ง (หน้า + % ความสูง) กดแล้ว smooth-scroll กลับตำแหน่งเดิม - **Realtime sync** — เปิด 2 แท็บ วาดในแท็บ A แท็บ B เห็นทันทีผ่าน SSE (`EventSource` ต่อใหม่อัตโนมัติเมื่อขาด) - **Page Virtualization** — render เฉพาะหน้าใน viewport ± 1 หน้า ไฟล์หลายสิบหน้าเลื่อนลื่น -- ไม่มีระบบ login — anonymous identity เก็บใน `localStorage` (ปุ่มลบแสดงเฉพาะรายการของตัวเอง) +- หน้า login (mock e-Court — dropdown `user1`–`user5` รหัสผ่านกลาง `Demo1234`) — identity จาก cookie `oacCase` บังคับทุกหน้า (ปุ่มลบ/แก้ไขแสดงเฉพาะรายการของตัวเอง) ## สิ่งที่ต้องมี (Prerequisites) @@ -85,7 +85,7 @@ npm run dev | bookmark+ | เพิ่ม bookmark ที่ตำแหน่งกึ่งกลางจอปัจจุบัน | | zoom − / + | สเกล 50%–300% (annotation ทั้งหมด scale ตามเพราะเก็บเป็น %) | -Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกรายการเพื่อเลื่อนกลับไปตำแหน่งนั้น และมีปุ่มลบเฉพาะรายการของตัวเอง (anonymous identity จาก `localStorage`) +Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกรายการเพื่อเลื่อนกลับไปตำแหน่งนั้น และมีปุ่มลบเฉพาะรายการของตัวเอง (identity จากหน้า login) ทดสอบ realtime: เปิดหน้า annotate ไฟล์เดียวกันใน 2 แท็บ แล้ววาด/comment จากแท็บหนึ่ง — อีกแท็บเห็นทันทีโดยไม่ต้อง refresh @@ -112,7 +112,7 @@ db/schema.ts ตาราง + JSONB strict union + DTO + toDTO db/index.ts postgres.js + drizzle (prepare: false) lib/annotate-events.ts pub/sub in-process (actions → SSE route) utils/coordinates.ts px ⇄ % + clamp + mergeRectsByLine -utils/user.ts anonymous identity (localStorage) +utils/user.ts parse/build cookie oacCase (mock login e-Court) hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว) app/api/annotations/stream/route.ts SSE endpoint (GET ?fileId=) diff --git a/app/actions/auth.ts b/app/actions/auth.ts new file mode 100644 index 0000000..79499a9 --- /dev/null +++ b/app/actions/auth.ts @@ -0,0 +1,41 @@ +"use server"; + +// mock login e-Court: รับเฉพาะผู้ใช้ใน MOCK_USERS (user1–user5) + Demo1234 +// → ตั้ง cookie oacCase ตาม format จริง (identity ของแอปอ่านผ่าน lib/session.ts +// — บังคับด้วย middleware.ts) +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +import { buildOacCaseCookie, MOCK_USERS, OAC_CASE_COOKIE } from "@/utils/user"; + +const MOCK_PASSWORD = "Demo1234"; + +export async function login( + username: string, + password: string, +): Promise<{ ok: true } | { ok: false; error: string }> { + // mock ตอบเร็วกว่าตา — หน่วงเล็กน้อยให้เห็นสถานะ "กำลังตรวจสอบ…" + await new Promise((resolve) => setTimeout(resolve, 500)); + const name = username.trim(); + // username มาจาก dropdown เลือกผิดไม่ได้ — เช็ครายชื่อฝั่ง server เป็น defense กัน hand-craft + if (!MOCK_USERS.includes(name) || password !== MOCK_PASSWORD) { + return { ok: false, error: "รหัสผ่านไม่ถูกต้อง" }; + } + // u_userid ต่างกันต่อคน (= u_userlogin) — สิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" (guard ด้วย + // userId) ต้องแยกกันจริงทั้ง 5 คน ห้ามแชร์ค่าเดียว + cookies().set(OAC_CASE_COOKIE, buildOacCaseCookie(name, name), { + httpOnly: true, + sameSite: "lax", + path: "/", + maxAge: 8 * 60 * 60, + }); + return { ok: true }; +} + +export async function logout(): Promise { + // ลบ cookie ด้วย path "/" เท่านั้น (ตรงกับตอน set) ไม่งั้น browser ไม่ลบให้ + cookies().set(OAC_CASE_COOKIE, "", { path: "/", maxAge: 0 }); + // redirect ใน action เอง — ให้ Next จัดการ navigation กลไกเดียว + // (ถ้าให้ client router.replace เอง จะชนกับ re-render ของหน้า "/" ที่เพิ่งเสีย session จน navigation ไม่เกิด) + redirect("/login"); +} diff --git a/app/annotate/[id]/page.tsx b/app/annotate/[id]/page.tsx index 6d1e4a3..50c1b4c 100644 --- a/app/annotate/[id]/page.tsx +++ b/app/annotate/[id]/page.tsx @@ -1,5 +1,5 @@ import { asc, eq, inArray } from "drizzle-orm"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { db } from "@/db"; import { @@ -13,6 +13,7 @@ import { } from "@/db/schema"; import PDFViewer from "@/components/pdf/PDFViewer"; import DebugConsole from "@/components/DebugConsole"; // TEMP-DEBUG (ลบพร้อมไฟล์) +import { getSessionUser } from "@/lib/session"; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -27,6 +28,10 @@ export default async function AnnotatePage({ const { id } = params; if (!UUID_RE.test(id)) notFound(); + // defense ซ้อน middleware — identity จาก cookie oacCase ส่งเข้า viewer ตั้งแต่ first paint + const user = getSessionUser(); + if (!user) redirect("/login"); + const [file] = await db.select().from(files).where(eq(files.id, id)); if (!file) notFound(); @@ -62,6 +67,7 @@ export default async function AnnotatePage({ initialAnnotations={annotationRows.map(toAnnotationDTO)} initialBookmarks={bookmarkRows.map(toBookmarkDTO)} initialReplies={replyRows.map(toReplyDTO)} + user={user} /> ); diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..51bf203 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +import LoginForm from "@/components/LoginForm"; +import { getSessionUser } from "@/lib/session"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { title: "เข้าสู่ระบบ | Judge's Note" }; + +export default function LoginPage() { + // เข้าสู่ระบบอยู่แล้ว (cookie ครบ) → ไม่ต้องเห็นฟอร์มอีก + const user = getSessionUser(); + if (user) redirect("/"); + + return ( +
+ +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 64f2e83..7ed5772 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,12 +1,14 @@ import Link from "next/link"; +import { redirect } from "next/navigation"; import { asc } from "drizzle-orm"; import { FileImage, FileText } from "lucide-react"; import { db } from "@/db"; import { files } from "@/db/schema"; -import UserPicker from "@/components/UserPicker"; +import SessionBar from "@/components/SessionBar"; import UploadFileForm from "@/components/UploadFileForm"; import { imageFormatFromUrl } from "@/utils/file-kind"; +import { getSessionUser } from "@/lib/session"; export const dynamic = "force-dynamic"; @@ -25,6 +27,10 @@ function kindBadge(pdfUrl: string): string { } export default async function HomePage() { + // defense ซ้อน middleware — หน้านี้อ่าน cookie อยู่ดีจึงกันซ้ำไม่ให้ render รายการให้คนไม่มี session + const user = getSessionUser(); + if (!user) redirect("/login"); + const rows = await db.select().from(files).orderBy(asc(files.createdAt)); return ( @@ -43,7 +49,7 @@ export default async function HomePage() { - +

เพิ่มเอกสาร

diff --git a/components/LoginForm.tsx b/components/LoginForm.tsx new file mode 100644 index 0000000..08e4365 --- /dev/null +++ b/components/LoginForm.tsx @@ -0,0 +1,132 @@ +"use client"; + +// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace("/") +// (pattern ตาม UploadFileForm: useTransition + error state + ข้อความ fallback ตอน transport fail) +import { useRef, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +import { login } from "@/app/actions/auth"; +import Select from "@/components/Select"; +import { MOCK_USERS } from "@/utils/user"; + +// text input ของแอป — text-base (16px) เสมอ: กัน iOS auto-zoom ตอน focus บน iPad +const INPUT_CLASS = + "mt-1 block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-base text-slate-900 placeholder:text-slate-400 transition focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:bg-slate-50 aria-invalid:border-red-600 aria-invalid:focus:ring-red-600/30"; +// dropdown ผู้ใช้ — เหมือน INPUT_CLASS แต่ไม่มี mt-1 (Select ครอบ div.relative: มาร์จินที่ wrapper ข้างนอกแทน) +const SELECT_CLASS = + "block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-base text-slate-900 transition focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:bg-slate-50"; + +export default function LoginForm() { + const router = useRouter(); + const passwordRef = useRef(null); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const form = e.currentTarget; + setError(null); + const usernameInput = form.elements.namedItem("username") as HTMLSelectElement; + const passwordInput = form.elements.namedItem("password") as HTMLInputElement; + startTransition(async () => { + try { + const result = await login(usernameInput.value, passwordInput.value); + if (result.ok) { + router.replace("/"); + } else { + setError(result.error); + // คง username ไว้ (โอกาสพลาดอยู่ที่ password) — ล้างแล้วโฟกัสให้พิมพ์ทับได้ทันที + passwordInput.value = ""; + passwordRef.current?.focus(); + passwordRef.current?.select(); + } + } catch { + setError("เข้าสู่ระบบไม่สำเร็จ — ลองใหม่อีกครั้ง"); + } + }); + }; + + return ( +
+ {/* brand lockup เดียวกับ header หน้ารายการ (ลดขนาดตัวอักษรให้พอการ์ดแคบ) */} +
+
+ JN +
+
+

Judge's Note

+

ระบบ PDF Annotation สำหรับ e-Court

+
+
+ +

เข้าสู่ระบบ

+ +
+
+ +
+ +
+
+ +
+ + +
+ + + + {/* รหัสผ่านทดสอบ — รายชื่อเห็นใน dropdown แล้ว เหลือบอกแค่รหัสผ่าน (mock ไม่มีระบบสมัคร) */} +

+ รหัสผ่านทดสอบ:{" "} + Demo1234 +

+ + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/components/LogoutButton.tsx b/components/LogoutButton.tsx new file mode 100644 index 0000000..c95704d --- /dev/null +++ b/components/LogoutButton.tsx @@ -0,0 +1,29 @@ +"use client"; + +// ปุ่มออกจากระบบ — server action logout ลบ cookie oacCase แล้ว redirect /login เอง +import { useTransition } from "react"; +import { LogOut } from "lucide-react"; + +import { logout } from "@/app/actions/auth"; + +export default function LogoutButton() { + const [pending, startTransition] = useTransition(); + + const onLogout = () => { + startTransition(async () => { + await logout(); + }); + }; + + return ( + + ); +} diff --git a/components/SessionBar.tsx b/components/SessionBar.tsx new file mode 100644 index 0000000..360d950 --- /dev/null +++ b/components/SessionBar.tsx @@ -0,0 +1,15 @@ +// แถบแสดงผู้ใช้ที่เข้าสู่ระบบบนหน้ารายการ (แทน UserPicker เดิม) — server component +// รับค่าจาก cookie ผ่าน props จากหน้า (middleware การันตีว่ามี session ก่อนถึงหน้านี้) +import LogoutButton from "./LogoutButton"; + +export default function SessionBar({ userName }: { userName: string }) { + return ( +
+

+ เข้าสู่ระบบเป็น{" "} + {userName} +

+ +
+ ); +} diff --git a/components/UserPicker.tsx b/components/UserPicker.tsx deleted file mode 100644 index 1717fa7..0000000 --- a/components/UserPicker.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -// เลือก mock user ที่หน้าแรกก่อนเข้า annotate — เขียนลง localStorage key เดิม -// ที่ getLocalUser() อ่าน (PDFViewer ไม่ต้องแก้อะไร) -import { useEffect, useState } from "react"; - -import Select from "./Select"; -import { - MOCK_USERS, - getStoredLocalUser, - setLocalUser, - type LocalUser, -} from "@/utils/user"; - -export default function UserPicker() { - const [current, setCurrent] = useState(null); - - // ต้องรันใน effect เพราะ SSR ไม่มี localStorage — ก่อน mount แสดงคนแรกไว้ก่อน - useEffect(() => { - const stored = getStoredLocalUser(); - if (!stored) { - // ยังไม่เคยมี identity → เริ่มด้วย mock user คนแรก - setLocalUser(MOCK_USERS[0]); - setCurrent(MOCK_USERS[0]); - return; - } - setCurrent(stored); - }, []); - - // identity สุ่มเดิมไม่อยู่ในรายการ mock → แสดงเป็น option ชั่วคราวให้เห็นว่าปัจจุบันคือใคร - const isMock = current !== null && MOCK_USERS.some((u) => u.userId === current.userId); - - const select = (userId: string) => { - const user = MOCK_USERS.find((u) => u.userId === userId); - if (!user) return; - setLocalUser(user); - setCurrent(user); - }; - - return ( -
-

เลือกผู้ใช้ (mock)

-

- annotation ทั้งหมดจะบันทึกในชื่อผู้ใช้ที่เลือก -

- -
- ); -} diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index aacf4ae..e154660 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -113,7 +113,7 @@ import { type Rect, type Size, } from "@/utils/coordinates"; -import { getLocalUser, type LocalUser } from "@/utils/user"; +import type { OacUser } from "@/utils/user"; import { fileKindFromUrl, imageFormatFromUrl } from "@/utils/file-kind"; import { decodeTiffPages } from "@/utils/tiff-client"; import { @@ -1138,11 +1138,14 @@ export default function PDFViewer({ initialAnnotations, initialBookmarks, initialReplies, + user, }: { file: { id: string; title: string; pdfUrl: string }; initialAnnotations: AnnotationDTO[]; initialBookmarks: BookmarkDTO[]; initialReplies: ReplyDTO[]; + /** identity จาก cookie oacCase (หน้า annotate การันตีให้ — ไม่มีช่วง null หลัง mount) */ + user: OacUser; }) { const [pdfjs, setPdfjs] = useState(null); const [doc, setDoc] = useState(null); @@ -1194,7 +1197,6 @@ export default function PDFViewer({ const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null); const [bookmarkPos, setBookmarkPos] = useState<{ left: number; top: number } | null>(null); const [scale, setScale] = useState(1); - const [user, setUser] = useState(null); const [currentPage, setCurrentPage] = useState(0); // tool highlight — live preview มาร์กตามรูปแบบ/สีที่ตั้งไว้ ระหว่างลากคลุมข้อความ const [liveMark, setLiveMark] = useState<{ pageIndex: number; rects: Rect[] } | null>(null); @@ -1323,11 +1325,10 @@ export default function PDFViewer({ [replies, visibleAnnotations], ); - // anonymous identity จาก localStorage — ต้องรันใน effect (SSR ไม่มี localStorage) + // UX review 2026-09-14 H1: จอ < lg sidebar เป็น overlay บังเอกสาร — ปิดตอน mount + // ให้เห็นเอกสารทันที (คง initial true ไว้กัน hydration mismatch เหมือนเดิม) + // (identity มาจาก prop user แล้ว — ไม่ต้องอ่านใน effect) useEffect(() => { - setUser(getLocalUser()); - // UX review 2026-09-14 H1: จอ < lg sidebar เป็น overlay บังเอกสาร — ปิดตอน mount - // ให้เห็นเอกสารทันที (คง initial true ไว้กัน hydration mismatch เหมือนเดิม) if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false); }, []); diff --git a/docs/PLAN.md b/docs/PLAN.md index f2f6c2e..fd65c3f 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -11,7 +11,7 @@ | A1 | **ตำแหน่งโปรเจกต์** | `D:\dms\judes-note\` — directory แยกต่างหาก (ไม่ใช่ subdirectory ของ dms-main) เพื่อไม่ชนกับโปรเจกต์ Nuxt ที่มีโฟลเดอร์ `app/` อยู่แล้ว | | A2 | **Framework** | Next.js 14 (App Router) + TypeScript strict | | A3 | **PDF Renderer** | `pdfjs-dist` เวอร์ชัน **v4** (`^4.10.38`) — ใช้ built-in `TextLayer` class; worker ตั้ง path ตรง ๆ: คัดลอก `pdf.worker.min.mjs` (จาก `node_modules/pdfjs-dist/build/`) มาไว้ที่ `public/` ด้วย **postinstall script** แล้วตั้ง `pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"` — **ไม่ใช้** `new URL(..., import.meta.url)` เพราะ v4 เป็นไฟล์ `.mjs` และมักชนกับ Turbopack/webpack ใน Next 14 | -| A4 | **Identity** | สเปกไม่ระบุระบบ auth → ใช้ **anonymous user** สร้างครั้งเดียวเก็บใน `localStorage` (`userId` = UUID, `userName` = "ผู้ใช้ XXXX") เพื่อเติม field `userId`/`userName` ในตาราง | +| A4 | **Identity (ปรับปรุง 2026-09-17 — mock login)** | หน้า `/login` mock e-Court (dropdown ผู้ใช้ `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` เป็น query string ตาม format จริงของระบบศาล → identity อ่านจาก cookie: `userId` = `u_userid`, `userName` = `u_userlogin` (**ต่างกันต่อคน** = ชื่อผู้ใช้ เพื่อให้สิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" แยกกันจริง) ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope; เดิมเป็น anonymous user ใน `localStorage` — annotation ที่สร้างสมัย `mock-user-01`/UUID จึงไม่ใช่ของตัวเองอีก) | | A5 | **Upload แบบง่าย (ปรับปรุง 2026-09-15 — N4)** | หน้า `/` อ่านรายการจาก DB + ฟอร์มอัปโหลดไฟล์เดียว (PDF/jpg/jpeg/png/tif/tiff, ≤ 100MB) ผ่าน Server Action เขียนไฟล์**ใหม่**ที่ `public/uploads/.` เท่านั้น (ไม่แตะไฟล์เดิม) — sample เก็บใน `public/samples/` แล้ว sync ด้วย `npm run seed-samples` (รายละเอียดด้านล่าง) | | A6 | **ทุก annotation ลบได้** | แสดงปุ่มลบเฉพาะรายการของตัวเอง (`userId` ตรงกัน) — ไม่มี role/permission ในเฟสนี้ | | A7 | **Database & Realtime (ปรับปรุง 2026-09-08)** | PostgreSQL แบบ **plain container เดียว** (`judes-note-db`, Docker, image `postgres:17-alpine`, พอร์ต 54322) — **เลิกใช้ Supabase stack ทั้งหมด** (ไม่มี CLI/Kong/realtime container) เพราะโปรเจกต์ใช้จริงแค่ DB + sync; realtime sync ใช้ **SSE (Server-Sent Events) + pub/sub in-process** แทน Supabase Realtime | @@ -102,7 +102,7 @@ Percent (จาก DB) ──×size÷100──▶ Pixel (render ลง SVG/div | id | uuid PK | | | file_id | uuid FK → files ON DELETE CASCADE | | | page_index | integer NOT NULL | 0-based | -| user_id | text NOT NULL | anonymous id จาก localStorage | +| user_id | text NOT NULL | จาก cookie oacCase (`u_userid`) | | user_name | text NOT NULL | | | type | annotation_type NOT NULL | | | data | jsonb NOT NULL `$type()` | **strict typed union** (ด้านล่าง) | @@ -171,7 +171,7 @@ judes-note/ │ └── annotate-events.ts ← pub/sub in-process (Server Actions publish → SSE route subscribe) ├── utils/ │ ├── coordinates.ts ← pixel⇄% (point/rect/path) + getPointerPercent + clamp + mergeRectsByLine (รวบ rects บรรทัดเดียวกันเหลือ 1 rect/บรรทัดก่อนบันทึก) -│ └── user.ts ← anonymous user identity (localStorage) +│ └── user.ts ← parse/build ค่า cookie oacCase → identity (mock login e-Court) ├── hooks/ │ └── useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe + expose setters ├── app/ @@ -248,7 +248,7 @@ judes-note/ ## 9. ขอบเขตที่ **ไม่** ทำในเฟสนี้ (Out of Scope) -- ระบบ login/roles (ใช้ anonymous identity) +- auth จริงเทียบระบบ e-Court + roles/สิทธิ์ — login ปัจจุบันเป็น mock หน้า `/login` (ตั้ง cookie `oacCase` ตาม format จริง แต่ไม่ได้ตรวจกับระบบศาลจริง) - แก้ไข/ย้าย annotation ที่วาดแล้ว (มีเฉพาะสร้าง + ลบ) — **ข้อนี้ถูกขยายโดย `docs/PLAN-FEAT.md` F1**: อนุญาตแก้ไข `data` (ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา) ของตัวเอง ในหน้าเดียวกัน ผ่าน `updateAnnotation` - Undo/redo, export PDF ที่ฝัง annotation - แสดงรายชื่อ "ใครออนไลน์อยู่" (presence) diff --git a/lib/session.ts b/lib/session.ts new file mode 100644 index 0000000..90c2029 --- /dev/null +++ b/lib/session.ts @@ -0,0 +1,8 @@ +// อ่าน identity จาก cookie oacCase ฝั่ง server (mock login e-Court) — เรียกจากทุก page +import { cookies } from "next/headers"; + +import { OAC_CASE_COOKIE, parseOacCase, type OacUser } from "@/utils/user"; + +export function getSessionUser(): OacUser | null { + return parseOacCase(cookies().get(OAC_CASE_COOKIE)?.value); +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..bf8b582 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,24 @@ +import { NextResponse, type NextRequest } from "next/server"; + +import { OAC_CASE_COOKIE, parseOacCase } from "@/utils/user"; + +// บังคับ login (mock e-Court) ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /login +// (parse จริง ไม่ใช่แค่มี cookie — cookie ขยะ/ครึ่ง ๆ ถือว่ายังไม่เข้าสู่ระบบ) +export function middleware(req: NextRequest) { + const user = parseOacCase(req.cookies.get(OAC_CASE_COOKIE)?.value); + if (!user) { + const url = req.nextUrl.clone(); + url.pathname = "/login"; + url.search = ""; + return NextResponse.redirect(url); + } + return NextResponse.next(); +} + +// ยกเว้น: หน้า login เอง, API/SSE/export, ไฟล์อัปโหลด, Next internal, +// และ static ใน public/ (pdf.worker, samples, fonts, favicon) +export const config = { + matcher: [ + "/((?!login|api|uploads|_next|samples|fonts|pdf\\.worker\\.min\\.mjs|favicon\\.ico).*)", + ], +}; diff --git a/tests/utils/user.test.ts b/tests/utils/user.test.ts new file mode 100644 index 0000000..3da2828 --- /dev/null +++ b/tests/utils/user.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + buildOacCaseCookie, + OAC_CASE_COOKIE, + parseOacCase, +} from "@/utils/user"; + +/** ตัวอย่าง cookie จริงจากระบบ e-Court (เก็บใน test เพื่อล็อก format ของจริง) */ +const REAL_OAC_CASE = + "u_userid=235821&u_userlogin=tu_2&u_courtid=4&u_password=&u_AdminStatus=&u_perCardNo=5000000000006&u_perCardNoEncript=1800650010005002003&u_userType=4&u_CidAuthen=NNNENNNNNNNNNNNNNNNN&u_hSecId=NNNNNNNNNNNNNNNNNNNN&u_username=&u_courtName=&u_AuditID=1995227&u_PsGroupNum=1&u_PsGroup=11&u_PsGroupName="; + +describe("parseOacCase", () => { + it("cookie e-Court จริง → identity จาก u_userid/u_userlogin", () => { + expect(parseOacCase(REAL_OAC_CASE)).toEqual({ + userId: "235821", + userName: "tu_2", + }); + }); + + it("ค่าจาก buildOacCaseCookie อ่านกลับได้ (round-trip ค่า mock login จริง user1)", () => { + expect(parseOacCase(buildOacCaseCookie("user1", "user1"))).toEqual({ + userId: "user1", + userName: "user1", + }); + }); + + it("ค่าเสีย/ว่าง/ขาด field → null (ถือว่ายังไม่เข้าสู่ระบบ)", () => { + expect(parseOacCase(null)).toBeNull(); + expect(parseOacCase(undefined)).toBeNull(); + expect(parseOacCase("")).toBeNull(); + expect(parseOacCase("garbage")).toBeNull(); + expect(parseOacCase("u_userlogin=user_x")).toBeNull(); // ขาด u_userid + expect(parseOacCase("u_userid=123&u_userlogin=")).toBeNull(); // u_userlogin ว่าง + }); + + it("ค่ารอบด้วย space ถูก trim", () => { + expect(parseOacCase("u_userid= 123 &u_userlogin= tu_2 ")).toEqual({ + userId: "123", + userName: "tu_2", + }); + }); + + it("ชื่อ cookie ตรงกับระบบ e-Court", () => { + expect(OAC_CASE_COOKIE).toBe("oacCase"); + }); +}); + +describe("buildOacCaseCookie", () => { + it("แทนเฉพาะ u_userid/u_userlogin — ฟิลด์อื่นคงค่าตัวอย่างจริงครบทุก field", () => { + const fields = Object.fromEntries(new URLSearchParams(buildOacCaseCookie("xxxxxx", "user_x"))); + expect(fields["u_userid"]).toBe("xxxxxx"); + expect(fields["u_userlogin"]).toBe("user_x"); + expect(fields["u_courtid"]).toBe("4"); + expect(fields["u_perCardNo"]).toBe("5000000000006"); + expect(fields["u_AuditID"]).toBe("1995227"); + expect(Object.keys(fields)).toHaveLength(16); + }); +}); diff --git a/utils/user.ts b/utils/user.ts index 51dca51..a173b20 100644 --- a/utils/user.ts +++ b/utils/user.ts @@ -1,51 +1,53 @@ -// anonymous identity เก็บใน localStorage สร้างครั้งเดียวต่อ browser (PLAN.md §0 A4) -// ใช้เติม field userId/userName ในตาราง annotations/bookmarks — ไม่มีระบบ auth +// identity ของแอป: mock login e-Court หน้า /login — อ่านจาก cookie "oacCase" +// (userId = u_userid, userName = u_userlogin) แทน localStorage เดิม +// ไฟล์นี้เป็น pure module — middleware (edge) และ client import ร่วมได้ ห้ามใส่ next/headers -export type LocalUser = { userId: string; userName: string }; +export type OacUser = { userId: string; userName: string }; -const STORAGE_KEY = "judes-note:user"; +export const OAC_CASE_COOKIE = "oacCase"; -// mock users สำหรับเลือกที่หน้าแรก (demo ไม่มีระบบ auth) — userId คงที่ -// เพื่อให้กรองรายคนและสิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" ทำงานสม่ำเสมอข้าม refresh -export const MOCK_USERS: LocalUser[] = [ - { userId: "mock-user-01", userName: "ผู้พิพากษา สมชาย" }, - { userId: "mock-user-02", userName: "ผู้พิพากษา สมหญิง" }, - { userId: "mock-user-03", userName: "อัยการ วิชัย" }, - { userId: "mock-user-04", userName: "เลขานุการศาล ปิยะ" }, - { userId: "mock-user-05", userName: "ทนายโจทก์ ณัฐ" }, - { userId: "mock-user-06", userName: "ทนายจำเลย อรุณี" }, - { userId: "mock-user-07", userName: "ผู้ช่วยผู้พิพากษา กันต์" }, - { userId: "mock-user-08", userName: "พนักงานสอบสวน เดชา" }, - { userId: "mock-user-09", userName: "ผู้เชี่ยวชาญ มานะ" }, - { userId: "mock-user-10", userName: "ผู้ช่วยเลขานุการ ฝ้าย" }, +/** ผู้ใช้ mock หน้า /login (dropdown) — LoginForm (client) + action login (server) + * ใช้ร่วมกันผ่าน module นี้กันสองที่ drift; รหัสผ่านกลาง Demo1234 อยู่ใน action */ +export const MOCK_USERS: readonly string[] = [ + "user1", + "user2", + "user3", + "user4", + "user5", ]; -export function setLocalUser(user: LocalUser): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(user)); -} - -// อ่าน identity ที่เก็บไว้โดยไม่สร้างใหม่ — ใช้แสดงค่าปัจจุบันในหน้าเลือกผู้ใช้ -export function getStoredLocalUser(): LocalUser | null { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) { - try { - const parsed = JSON.parse(raw) as LocalUser; - if (typeof parsed.userId === "string" && typeof parsed.userName === "string") { - return parsed; - } - } catch { - // ข้อมูลเก่าเสียหาย → ถือว่าไม่มีข้อมูล - } - } - return null; -} - -export function getLocalUser(): LocalUser { - const stored = getStoredLocalUser(); - if (stored) return stored; - - const userId = crypto.randomUUID(); - const userName = `ผู้ใช้ ${userId.slice(0, 4).toUpperCase()}`; - localStorage.setItem(STORAGE_KEY, JSON.stringify({ userId, userName })); +/** แยกค่า query string ของ cookie oacCase → identity + * ขาด/ว่าง u_userid หรือ u_userlogin = ถือว่ายังไม่เข้าสู่ระบบ (null) + * หมายเหตุ: Next (cookies().get / req.cookies.get) decode ค่า cookie กลับมาให้แล้ว + * จึงส่งให้ URLSearchParams ตรง ๆ — ห้าม decodeURIComponent ซ้ำ */ +export function parseOacCase(raw: string | null | undefined): OacUser | null { + if (!raw) return null; + const params = new URLSearchParams(raw); + const userId = params.get("u_userid")?.trim(); + const userName = params.get("u_userlogin")?.trim(); + if (!userId || !userName) return null; return { userId, userName }; } + +/** ค่า cookie oacCase ตาม format จริงของ e-Court (ฟิลด์อื่นคงค่าจากตัวอย่างจริง) + * mock login แทนเฉพาะ u_userid/u_userlogin */ +export function buildOacCaseCookie(userId: string, userLogin: string): string { + return [ + `u_userid=${userId}`, + `u_userlogin=${userLogin}`, + "u_courtid=4", + "u_password=", + "u_AdminStatus=", + "u_perCardNo=5000000000006", + "u_perCardNoEncript=1800650010005002003", + "u_userType=4", + "u_CidAuthen=NNNENNNNNNNNNNNNNNNN", + "u_hSecId=NNNNNNNNNNNNNNNNNNNN", + "u_username=", + "u_courtName=", + "u_AuditID=1995227", + "u_PsGroupNum=1", + "u_PsGroup=11", + "u_PsGroupName=", + ].join("&"); +} From 55d09ce5c3863c16edc9bed917962234e7f274f8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 17 Sep 2026 13:43:00 +0700 Subject: [PATCH 02/55] =?UTF-8?q?N7=20=E0=B8=AA=E0=B9=88=E0=B8=87=E0=B8=AD?= =?UTF-8?q?=E0=B8=AD=E0=B8=81=E0=B8=A3=E0=B8=B2=E0=B8=A2=E0=B8=81=E0=B8=B2?= =?UTF-8?q?=E0=B8=A3=20bookmark=20=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20J?= =?UTF-8?q?SON=20=E0=B9=81=E0=B8=97=E0=B8=99=E0=B8=A3=E0=B8=B2=E0=B8=A2?= =?UTF-8?q?=E0=B8=87=E0=B8=B2=E0=B8=99=20PDF=20(=E0=B9=80=E0=B8=9B?= =?UTF-8?q?=E0=B8=A5=E0=B8=B5=E0=B9=88=E0=B8=A2=E0=B8=99=20deliverable=20?= =?UTF-8?q?=E0=B8=95=E0=B8=B2=E0=B8=A1=E0=B8=84=E0=B8=B3=E0=B8=82=E0=B8=AD?= =?UTF-8?q?=E0=B8=9C=E0=B8=B9=E0=B9=89=E0=B9=83=E0=B8=8A=E0=B9=89=202026-0?= =?UTF-8?q?9-17)=20=E2=80=94=20route=20/api/bookmarks/export=20=E0=B9=80?= =?UTF-8?q?=E0=B8=82=E0=B8=B5=E0=B8=A2=E0=B8=99=E0=B9=83=E0=B8=AB=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B8=88=E0=B8=B2=E0=B8=81=20PDFDocument.create=20?= =?UTF-8?q?=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B8=8A=E0=B8=B8=E0=B8=94?= =?UTF-8?q?=20(=E0=B8=9F=E0=B8=AD=E0=B8=99=E0=B8=95=E0=B9=8C=20Sarabun/lay?= =?UTF-8?q?out=20A4/=E0=B9=81=E0=B8=9A=E0=B9=88=E0=B8=87=E0=B8=AB=E0=B8=99?= =?UTF-8?q?=E0=B9=89=E0=B8=B2/=E0=B8=95=E0=B9=89=E0=B8=99=E0=B9=84?= =?UTF-8?q?=E0=B8=A1=E0=B9=89=20indent=203=20=E0=B8=A3=E0=B8=B0=E0=B8=94?= =?UTF-8?q?=E0=B8=B1=E0=B8=9A=20=E0=B8=A3=E0=B8=A7=E0=B8=A1=20~190=20?= =?UTF-8?q?=E0=B8=9A=E0=B8=A3=E0=B8=A3=E0=B8=97=E0=B8=B1=E0=B8=94)=20?= =?UTF-8?q?=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20JSON=20=E0=B8=82?= =?UTF-8?q?=E0=B9=89=E0=B8=AD=E0=B8=A1=E0=B8=B9=E0=B8=A5=E0=B8=A5=E0=B9=89?= =?UTF-8?q?=E0=B8=A7=E0=B8=99=E0=B8=88=E0=B8=B2=E0=B8=81=20DB:=20validate?= =?UTF-8?q?=20fileId=20(UUID=20=E2=86=92=20400)=20+=20=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B8=9E=E0=B8=9A=E0=B9=84=E0=B8=9F=E0=B8=A5=E0=B9=8C?= =?UTF-8?q?=20=3D=20404=20=E2=86=92=20query=20bookmarks=20=E0=B8=97?= =?UTF-8?q?=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B9=84=E0=B8=9F=E0=B8=A5=E0=B9=8C?= =?UTF-8?q?=20orderBy=20pageIndex=20=E2=86=92=20yOffset=20=E2=86=92=20crea?= =?UTF-8?q?tedAt=20(=E0=B8=A5=E0=B8=B3=E0=B8=94=E0=B8=B1=E0=B8=9A=20N5=20?= =?UTF-8?q?=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B8=81=E0=B8=B1?= =?UTF-8?q?=E0=B8=9A=20sidebar)=20=E2=86=92=20envelope=20{=20fileId,=20tit?= =?UTF-8?q?le,=20exportedAt=20(ISO),=20bookmarks:=20BookmarkDTO[]=20}=20?= =?UTF-8?q?=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=20toBookmarkDTO=20?= =?UTF-8?q?=E0=B8=95=E0=B8=A3=E0=B8=87=20=E0=B9=86=20(invariant=20#5)=20?= =?UTF-8?q?=E0=B9=80=E0=B8=81=E0=B9=87=E0=B8=9A=20parentId=20=E0=B8=84?= =?UTF-8?q?=E0=B8=A3=E0=B8=9A=20lossless=20(reconstruct=20=E0=B8=95?= =?UTF-8?q?=E0=B9=89=E0=B8=99=E0=B9=84=E0=B8=A1=E0=B9=89=E0=B8=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=81=E0=B8=82=E0=B9=89=E0=B8=AD=E0=B8=A1=E0=B8=B9=E0=B8=A5?= =?UTF-8?q?=E0=B9=80=E0=B8=AD=E0=B8=87=E0=B9=84=E0=B8=94=E0=B9=89)=20?= =?UTF-8?q?=E0=B8=88=E0=B8=B6=E0=B8=87=E0=B8=96=E0=B8=AD=E0=B8=94=20build?= =?UTF-8?q?=20tree/cycle=20guard/=E0=B8=9F=E0=B8=AD=E0=B8=99=E0=B8=95?= =?UTF-8?q?=E0=B9=8C/layout=20=E0=B8=97=E0=B8=B4=E0=B9=89=E0=B8=87?= =?UTF-8?q?=E0=B8=AB=E0=B8=A1=E0=B8=94;=20pretty-print=202=20space=20+=20c?= =?UTF-8?q?ontent-type=20application/json;=20charset=3Dutf-8=20+=20attachm?= =?UTF-8?q?ent=20filename*=20=E0=B8=88=E0=B8=B2=E0=B8=81=20encodeRFC5987("?= =?UTF-8?q?{title}-bookmarks.json")=20(pattern=20F8/F16);=200=20bookmark?= =?UTF-8?q?=20=3D=20"bookmarks":=20[]=20=E0=B9=84=E0=B8=A1=E0=B9=88=20404?= =?UTF-8?q?=20(=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1=E0=B8=97=E0=B8=B3?= =?UTF-8?q?=E0=B8=87=E0=B8=B2=E0=B8=99=E0=B9=80=E0=B8=AA=E0=B8=A1=E0=B8=AD?= =?UTF-8?q?);=20=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B9=82=E0=B8=AB=E0=B8=A5?= =?UTF-8?q?=E0=B8=94=E0=B9=84=E0=B8=9F=E0=B8=A5=E0=B9=8C=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=B9=89=E0=B8=99?= =?UTF-8?q?=E0=B8=89=E0=B8=9A=E0=B8=B1=E0=B8=9A=E0=B9=80=E0=B8=A5=E0=B8=A2?= =?UTF-8?q?=20(invariant=20#1)=20+=20read-only=20=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=20publish=20event=20=E0=B9=84=E0=B8=A1=E0=B9=88=20rev?= =?UTF-8?q?alidatePath=20+=20=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1=E0=B8=97?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=A2=E0=B9=81=E0=B8=96=E0=B8=A7=20toggle?= =?UTF-8?q?=20=E0=B9=80=E0=B8=87=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=99?= =?UTF-8?q?=E0=B9=84=E0=B8=82=E0=B8=84=E0=B9=89=E0=B8=99=E0=B8=AB=E0=B8=B2?= =?UTF-8?q?=20N6=20=E0=B9=83=E0=B8=99=E0=B9=81=E0=B8=97=E0=B9=87=E0=B8=9A?= =?UTF-8?q?=20"=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B8=84=E0=B8=B1=E0=B9=88?= =?UTF-8?q?=E0=B8=99=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2"=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=84?= =?UTF-8?q?=E0=B8=AD=E0=B8=84=E0=B8=AD=E0=B8=99=20Download=20=E2=86=92=20B?= =?UTF-8?q?races=20({=20})=20=E0=B8=9E=E0=B8=A3=E0=B9=89=E0=B8=AD=E0=B8=A1?= =?UTF-8?q?=20title/aria-label=20"=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20J?= =?UTF-8?q?SON"=20(=E0=B8=A2=E0=B8=B1=E0=B8=87=E0=B9=80=E0=B8=9B=E0=B9=87?= =?UTF-8?q?=E0=B8=99=E0=B8=A5=E0=B8=B4=E0=B8=87=E0=B8=81=E0=B9=8C=E0=B8=98?= =?UTF-8?q?=E0=B8=A3=E0=B8=A3=E0=B8=A1=E0=B8=94=E0=B8=B2=20pattern=20Expor?= =?UTF-8?q?t=20CSV=20F16=20=E2=80=94=20=E0=B8=AA=E0=B9=88=E0=B8=87?= =?UTF-8?q?=E0=B8=AD=E0=B8=AD=E0=B8=81=E0=B8=97=E0=B8=B8=E0=B8=81=20bookma?= =?UTF-8?q?rk=20=E0=B8=82=E0=B8=AD=E0=B8=87=E0=B9=84=E0=B8=9F=E0=B8=A5?= =?UTF-8?q?=E0=B9=8C=E0=B9=80=E0=B8=AA=E0=B8=A1=E0=B8=AD=20=E0=B9=84?= =?UTF-8?q?=E0=B8=A1=E0=B9=88=E0=B8=9C=E0=B8=B9=E0=B8=81=E0=B9=80=E0=B8=87?= =?UTF-8?q?=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=99=E0=B9=84=E0=B8=82=E0=B8=84?= =?UTF-8?q?=E0=B9=89=E0=B8=99=E0=B8=AB=E0=B8=B2=20N6)=20+=20PLAN-FEAT.md?= =?UTF-8?q?=20=C2=A77=20N7=20=E0=B9=80=E0=B8=82=E0=B8=B5=E0=B8=A2=E0=B8=99?= =?UTF-8?q?=E0=B8=AA=E0=B9=80=E0=B8=9B=E0=B8=81=E0=B9=83=E0=B8=AB=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20JSON=20+=20PLAN?= =?UTF-8?q?-BOOKMARK-PDF.md=20=E0=B8=95=E0=B8=B4=E0=B8=94=20note=20?= =?UTF-8?q?=E0=B8=AB=E0=B8=B1=E0=B8=A7=E0=B9=84=E0=B8=9F=E0=B8=A5=E0=B9=8C?= =?UTF-8?q?=E0=B8=A7=E0=B9=88=E0=B8=B2=E0=B9=82=E0=B8=84=E0=B9=89=E0=B8=94?= =?UTF-8?q?=20PDF=20=E0=B9=83=E0=B8=99=E0=B9=81=E0=B8=9C=E0=B8=99=E0=B9=80?= =?UTF-8?q?=E0=B8=9B=E0=B9=87=E0=B8=99=20historical=20record=20(=E0=B8=AA?= =?UTF-8?q?=E0=B9=80=E0=B8=9B=E0=B8=81=E0=B8=9B=E0=B8=B1=E0=B8=88=E0=B8=88?= =?UTF-8?q?=E0=B8=B8=E0=B8=9A=E0=B8=B1=E0=B8=99=E0=B8=AD=E0=B8=A2=E0=B8=B9?= =?UTF-8?q?=E0=B9=88=20PLAN-FEAT)=20+=20gap-analysis=20=E0=B8=9B=E0=B8=B4?= =?UTF-8?q?=E0=B8=94=20gap=20=E0=B8=81=E0=B8=A5=E0=B8=B8=E0=B9=88=E0=B8=A1?= =?UTF-8?q?=20B=205.3.11=20=E2=9C=85=20(N5)=205.3.12=20=E2=9C=85=20(N6)=20?= =?UTF-8?q?5.3.14=20=E2=9C=85=20(API=20=E0=B8=99=E0=B8=B5=E0=B9=89)=205.3.?= =?UTF-8?q?15=20=E2=9C=85=20(export=20PDF=20=E0=B8=82=E0=B8=AD=E0=B8=87=20?= =?UTF-8?q?F8=20=E0=B8=9D=E0=B8=B1=E0=B8=87=20bookmark=20=E0=B9=80?= =?UTF-8?q?=E0=B8=9B=E0=B9=87=E0=B8=99=20outline=20=E0=B8=95=E0=B8=B4?= =?UTF-8?q?=E0=B8=94=E0=B9=84=E0=B8=9B=E0=B8=81=E0=B8=B1=E0=B8=9A=E0=B9=84?= =?UTF-8?q?=E0=B8=9F=E0=B8=A5=E0=B9=8C)=20=E0=B8=9E=E0=B8=A3=E0=B9=89?= =?UTF-8?q?=E0=B8=AD=E0=B8=A1=20header=20=E0=B8=AD=E0=B8=B1=E0=B8=9B?= =?UTF-8?q?=E0=B9=80=E0=B8=94=E0=B8=95=E0=B8=A5=E0=B9=88=E0=B8=B2=E0=B8=AA?= =?UTF-8?q?=E0=B8=B8=E0=B8=94=202026-09-17=20=E2=80=94=20=E0=B9=84?= =?UTF-8?q?=E0=B8=9F=E0=B8=A5=E0=B9=8C=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=99=E0=B8=A1=E0=B8=B5=205.3.19?= =?UTF-8?q?/5.3.22=20=E2=9C=85=20mock=20login/5.3.23=20=E0=B8=82=E0=B8=AD?= =?UTF-8?q?=E0=B8=87=E0=B8=87=E0=B8=B2=E0=B8=99=20login=20=E0=B8=95?= =?UTF-8?q?=E0=B8=B2=E0=B8=A1=E0=B9=84=E0=B8=9B=E0=B8=94=E0=B9=89=E0=B8=A7?= =?UTF-8?q?=E0=B8=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- app/api/bookmarks/export/route.ts | 206 ++--------------------- components/sidebar/AnnotationSidebar.tsx | 10 +- docs/PLAN-BOOKMARK-PDF.md | 2 + docs/PLAN-FEAT.md | 14 +- docs/requirement/gap-analysis.md | 36 ++-- 5 files changed, 48 insertions(+), 220 deletions(-) diff --git a/app/api/bookmarks/export/route.ts b/app/api/bookmarks/export/route.ts index c3b6aab..675d470 100644 --- a/app/api/bookmarks/export/route.ts +++ b/app/api/bookmarks/export/route.ts @@ -1,38 +1,19 @@ -import fs from "node:fs"; -import path from "node:path"; - -import fontkit from "@pdf-lib/fontkit"; -import { PDFDocument, rgb, type PDFFont, type PDFPage } from "pdf-lib"; import { asc, eq } from "drizzle-orm"; import { db } from "@/db"; -import { bookmarks, files, type BookmarkRow } from "@/db/schema"; -import { encodeRFC5987, wrapTextByWidth } from "@/utils/pdf-export"; +import { bookmarks, files, toBookmarkDTO } from "@/db/schema"; +import { encodeRFC5987 } from "@/utils/pdf-export"; -// รายงานรายการ bookmark เป็น PDF (N7 — PLAN-BOOKMARK-PDF.md / PLAN-FEAT.md §7, requirement 5.3.15) -// - สร้างเอกสาร PDF ใหม่ล้วน (PDFDocument.create) — ไม่โหลดไฟล์เอกสารต้นฉบับเลย -// (invariant #1 โดยการสร้าง ไม่ใช่การก๊อป จึงไม่เกี่ยว readPublicPdf) -// - ต้นไม้ indent 3 ระดับ เรียงตามลำดับหน้า (N5) เหมือน sidebar — แต่ละรายการ: -// ชื่อ + "หน้า N · ผู้บันทึก · วันที่บันทึก" + โน้ตใต้ชื่อ +// ส่งออกรายการ bookmark เป็น JSON (N7 — เดิมเป็นรายงาน PDF ตาม requirement 5.3.15 +// เปลี่ยนตามคำขอผู้ใช้ 2026-09-17) +// - ข้อมูลล้วนจาก DB ไม่โหลดไฟล์เอกสารต้นฉบับเลย (invariant #1) +// - array flat เก็บ parentId ครบ (lossless) เรียงตามลำดับ N5 เหมือน sidebar +// ต้นไม้ reconstruct จาก parentId ได้เอง จึงไม่ต้อง build tree/cycle guard // - read-only: ไม่ publish event ไม่ revalidatePath (ไม่เขียน DB) const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -const SARABUN_REL = path.join("fonts", "Sarabun-Regular.ttf"); - -// ─── layout คงที่ของรายงาน (A4 portrait) ─── -const PAGE_W = 595.28; -const PAGE_H = 841.89; -const MARGIN = 48; -const INDENT_STEP = 14; // indent ต่อระดับของต้นไม้ -const META_SIZE = 9; -const LINE_H = 1.35; // พื้นที่เผื่อสระบน–ล่างของไทย (ไ ใ / ุ ึ) -// slate-900 / slate-500 / slate-200 — โทนเดียวกับ sidebar -const INK = rgb(15 / 255, 23 / 255, 42 / 255); -const MUTED = rgb(100 / 255, 116 / 255, 139 / 255); -const RULE = rgb(226 / 255, 232 / 255, 240 / 255); - export async function GET(req: Request) { const fileId = new URL(req.url).searchParams.get("fileId") ?? ""; if (!UUID_RE.test(fileId)) { @@ -52,173 +33,20 @@ export async function GET(req: Request) { asc(bookmarks.createdAt), ); - // ฟอนต์จำเป็น — รายงานเป็นข้อความล้วน ไม่มี fallback ที่มีความหมาย - // (ต่างจาก D5 ของ F8 ที่ annotation ยังวาดรูปได้จึง optional) - const fontPath = path.join(process.cwd(), "public", SARABUN_REL); - if (!fs.existsSync(fontPath)) { - console.error("ไม่พบ public/fonts/Sarabun-Regular.ttf"); - return new Response("ขาดไฟล์ฟอนต์ Sarabun-Regular.ttf", { status: 500 }); - } - const doc = await PDFDocument.create(); - doc.registerFontkit(fontkit); - let font: PDFFont; - try { - font = await doc.embedFont(fs.readFileSync(fontPath), { subset: true }); - } catch (err) { - console.error("embed Sarabun ไม่สำเร็จ (รายงาน bookmark)", err); - return new Response("ฝังฟอนต์ไม่สำเร็จ สร้างรายงานไม่ได้", { status: 500 }); - } - const widthOf = (s: string, size: number) => font.widthOfTextAtSize(s, size); - - // ต้นไม้ตาม parentId — เดินตามลำดับ array ที่ query เรียงตาม N5 มาแล้ว - // (ลูกในแต่ละแม่เรียงตาม หน้า → yOffset → เวลา อัตโนมัติ) - // orphan (parentId ไม่อยู่ในชุด) → เลื่อนขึ้น root เหมือน sidebar/buildOutline - const ids = new Set(rows.map((b) => b.id)); - const childrenOf = new Map(); - const roots: BookmarkRow[] = []; - for (const b of rows) { - if (b.parentId && ids.has(b.parentId)) { - const list = childrenOf.get(b.parentId) ?? []; - list.push(b); - childrenOf.set(b.parentId, list); - } else { - roots.push(b); - } - } - type Item = { row: BookmarkRow; level: number }; - const items: Item[] = []; - // visited กัน DFS วนไม่รู้จบเมื่อข้อมูลเป็นวงจร (defensive — action กันมาแล้ว) - // ใช้ seen ชุดเดียวร่วมทุก root: แชร์ subtree ข้าม root ไม่เกิด (ลูกมีแม่เดียว) - // เกิดจริงเฉพาะวงจร ซึ่งต้องกันไม่ให้เดินซ้ำ - const seen = new Set(); - const walk = (b: BookmarkRow, level: number): void => { - if (seen.has(b.id)) return; - seen.add(b.id); - items.push({ row: b, level }); - for (const k of childrenOf.get(b.id) ?? []) walk(k, level + 1); - }; - for (const r of roots) walk(r, 1); - // cycle (ข้อมูลถูกแก้ตรง DB หลุด guard ของ action) → โหนดในวงจรไม่มี root เข้าถึง - // เติมเป็นรายการระดับ 1 ให้ครบทุกแถว "ข้อมูลไม่หาย" (ต่างจาก buildOutline ที่ drop - // เงียบ ๆ — รายงานหายไปเฉย ๆ ยิ่งงงกว่า) — เคส roots ว่างทั้งไฟล์ (วงจรล้วน) ก็ - // ออกมาแบนทั้งหมดระดับ 1 จากกลไกนี้เอง - for (const row of rows) { - if (!seen.has(row.id)) items.push({ row, level: 1 }); - } - - // ─── วาด — y คือ "ขอบล่างของกล่องบรรทัดถัดไป" (baseline = y + ส่วนต่างของ line height) - let page: PDFPage = doc.addPage([PAGE_W, PAGE_H]); - let y = PAGE_H - MARGIN; - - // เช็คพื้นที่*ต่อบรรทัด* ไม่ใช่ต่อรายการ — บรรทัดเดียวพอกับหน้าเปล่าเสมอ จึงไม่มี - // เคสแถวสูงเกินหน้าที่ต้องจัดการพิเศษ (โน้ตยาว ๆ ก็ไหลข้ามหน้าเอง) - const advanceLine = (size: number): number => { - const lh = size * LINE_H; - if (y - lh < MARGIN) { - page = doc.addPage([PAGE_W, PAGE_H]); - y = PAGE_H - MARGIN; - } - y -= lh; - return y + (lh - size); // baseline ชิดล่างของกล่องบรรทัด (เผื่อ descender ไทย) - }; - const drawTextLine = ( - text: string, - x: number, - size: number, - color = INK, - ): void => { - // ต้อง advance ก่อนแล้วอ่าน `page` ทีหลัง — ถ้าฝัง advanceLine() ใน argument - // base ของ method call (`page`) ถูก resolve ก่อน arguments ตาม spec - // ทำให้บรรทัดแรกของหน้าใหม่ (ที่ advanceLine เพิ่ง reassign page) ถูกวาด - // กลับลงหน้าเก่าซ้อนบรรทัดบนสุด - const ty = advanceLine(size); - page.drawText(text, { x, y: ty, size, font, color }); + // item เป็น BookmarkDTO ตรง ๆ (camelCase + ISO string — invariant #5) + const payload = { + fileId: file.id, + title: file.title, + exportedAt: new Date().toISOString(), + bookmarks: rows.map(toBookmarkDTO), }; - // หัวรายงาน (หน้าแรกหน้าเดียว) — ชื่อไฟล์ wrap เหมือนรายการ (title ใน DB ยาวได้ถึง 200) - drawTextLine("รายการที่คั่นหน้า", MARGIN, 16); - for (const line of wrapTextByWidth(file.title, PAGE_W - MARGIN * 2, (s) => - widthOf(s, 12), - )) { - drawTextLine(line, MARGIN, 12); - } - const created = new Date().toLocaleDateString("th-TH", { - timeZone: "Asia/Bangkok", - }); - drawTextLine( - `สร้างเมื่อ ${created} · ${rows.length} รายการ`, - MARGIN, - META_SIZE, - MUTED, - ); - y -= 8; - page.drawLine({ - start: { x: MARGIN, y }, - end: { x: PAGE_W - MARGIN, y }, - thickness: 1, - color: RULE, - }); - y -= 18; - - if (items.length === 0) { - drawTextLine("ยังไม่มีรายการที่คั่นหน้าในไฟล์นี้", MARGIN, 11, MUTED); - } - - let first = true; - for (const { row, level } of items) { - // เว้นช่องว่างก่อนรายการ — หัวข้อระดับ 1 เว้นกว้างกว่าให้เห็นกลุ่ม - // (y ตกหลุดขอบล่างจากการเว้น = advanceLine บรรทัดแรกเปิดหน้าใหม่เอง) - y -= first ? 0 : level === 1 ? 12 : 8; - first = false; - - const indent = (level - 1) * INDENT_STEP; - const x = MARGIN + indent; - const maxW = PAGE_W - MARGIN * 2 - indent; - const titleSize = level === 1 ? 13 : 11; - - for (const line of wrapTextByWidth(row.title, maxW, (s) => - widthOf(s, titleSize), - )) { - drawTextLine(line, x, titleSize); - } - const date = row.createdAt.toLocaleDateString("th-TH", { - timeZone: "Asia/Bangkok", - }); - drawTextLine( - `หน้า ${row.pageIndex + 1} · ${row.userName || "ไม่ทราบชื่อ"} · ${date}`, - x, - META_SIZE, - MUTED, - ); - if (row.note) { - for (const line of wrapTextByWidth(`โน้ต: ${row.note}`, maxW, (s) => - widthOf(s, META_SIZE), - )) { - drawTextLine(line, x, META_SIZE, MUTED); - } - } - } - - // footer เลขหน้า — วาดท้ายเมื่อรู้จำนวนหน้าทั้งหมด (เนื้อหาหยุดที่ ≥ MARGIN ไม่ชน 24pt) - const pages = doc.getPages(); - pages.forEach((p, i) => { - const label = `หน้า ${i + 1} จาก ${pages.length}`; - p.drawText(label, { - x: (PAGE_W - widthOf(label, META_SIZE)) / 2, - y: 24, - size: META_SIZE, - font, - color: MUTED, - }); - }); - - const out = await doc.save(); - const filename = encodeRFC5987(`${file.title}-bookmarks.pdf`); - return new Response(new Uint8Array(out), { + const filename = encodeRFC5987(`${file.title}-bookmarks.json`); + return new Response(JSON.stringify(payload, null, 2) + "\n", { headers: { - "content-type": "application/pdf", + "content-type": "application/json; charset=utf-8", // filename เป็น ASCII fallback + filename* UTF-8 สำหรับชื่อไทย (pattern F8/F16) - "content-disposition": `attachment; filename="bookmarks.pdf"; filename*=UTF-8''${filename}`, + "content-disposition": `attachment; filename="bookmarks.json"; filename*=UTF-8''${filename}`, }, }); } diff --git a/components/sidebar/AnnotationSidebar.tsx b/components/sidebar/AnnotationSidebar.tsx index d8067a2..6e9e791 100644 --- a/components/sidebar/AnnotationSidebar.tsx +++ b/components/sidebar/AnnotationSidebar.tsx @@ -5,13 +5,13 @@ import { createPortal } from "react-dom"; import { ArrowRight, Bookmark, + Braces, Check, ChevronDown, ChevronRight, Circle, Clock, Copy, - Download, EyeOff, Highlighter, MessageSquare, @@ -1114,15 +1114,15 @@ export default function AnnotationSidebar({ )} - {/* N7: ส่งออกรายการ bookmark เป็น PDF — ลิงก์ธรรมดา pattern Export CSV (F16) + {/* N7: ส่งออกรายการ bookmark เป็น JSON — ลิงก์ธรรมดา pattern Export CSV (F16) ส่งออกทุกรายการของไฟล์เสมอ ไม่ผูกกับเงื่อนไขค้นหาที่ตั้งไว้ */} - + {bmShowConditions && ( diff --git a/docs/PLAN-BOOKMARK-PDF.md b/docs/PLAN-BOOKMARK-PDF.md index 66c7e26..d5ac927 100644 --- a/docs/PLAN-BOOKMARK-PDF.md +++ b/docs/PLAN-BOOKMARK-PDF.md @@ -1,5 +1,7 @@ # แผน 5.3.15 — รายงานรายการ bookmark เป็น PDF (N7) +> **อัปเดต 2026-09-17:** ผู้ใช้ขอเปลี่ยน deliverable ของ N7 เป็น**ส่งออก JSON** แทนรายงาน PDF — route เดิม (`app/api/bookmarks/export/route.ts`) ถูกเขียนใหม่เป็น JSON (flat array `BookmarkDTO` เรียง N5 + envelope `{ fileId, title, exportedAt, bookmarks }`) โค้ด PDF ในแผนนี้จึงเป็น **historical record** — สเปกปัจจุบันอยู่ที่ `docs/PLAN-FEAT.md` §7 N7 (ถอด: build tree/cycle guard/ฟอนต์/layout ทั้งหมด) + > สถานะ: **implement เสร็จสมบูรณ์** (2026-09-16) — ครบ 4 task, `tsc` ผ่าน, vitest 120/120, plan-reviewer + code-reviewer ผ่าน (findings ทั้งหมดแก้แล้ว: **บรรทัดแรกของหน้าใหม่ถูกวาดกลับลงหน้าเก่า** — `page.drawText({ y: advanceLine(...) })` ประเมิน base ของ method call ก่อน arguments ตาม spec ทำให้ซ้อนบรรทัดบนสุดของหน้าเก่า → ต้อง advance ก่อนแล้วอ่าน `page` ทีหลัง, **cycle guard ซ้ำ/หาย** — seen set ต่อ root ทำให้วงจรล้วนซ้ำ 2 เท่าและวงจรปน root หายเงียบ → seen ร่วมทุก root + เติมโหนดที่ไม่ถูกเดินเป็นระดับ 1, **ชื่อไฟล์ในหัวรายงานไม่ wrap** — title ยาวถึง 200 ตัวล้นขอบขวา → wrap ด้วย `wrapTextByWidth` เหมือนรายการ) — re-verify หลังแก้ด้วยไฟล์ทดสอบ (40 รายการ + วงจรปน root + title ยาว 200): 4 หน้า **ไม่มีข้อความซ้อนทับเลย** ทุกหน้ามีเนื้อหาจริง วงจรโผล่ครบระดับ 1 · ไฟล์วงจรล้วน 2 รายการ = แบนระดับ 1 ทั้งคู่ · ทดสอบเดิมผ่านครบ: ไฟล์จริง 7 bookmarks ต้นไม้ 2 ระดับ indent x48↔x62 ลำดับ N5 ถูก, 40 รายการ = 5 หน้า + footer ทุกหน้า, 0 bookmark = "ยังไม่มีรายการ…", invalid id = 400, ไม่มีไฟล์ = 404, ฟอนต์ไทย render ถูก (ยืนยันสระผสม "คำสั่ง" ผ่าน render PNG + vision OCR — "คำา" ใน text extraction เป็น artifact ของ ToUnicode ตอน subset ไม่กระทบการแสดงผล), ปุ่มโผล่ใน SSR ของหน้า annotate พร้อม href/aria-label ถูก > อ้างอิง: requirement 5.3.15 (gap analysis `docs/requirement/gap-analysis.md` **กลุ่ม B** — ข้อสุดท้ายที่เหลือ: 5.3.11 = N5 ✅, 5.3.12 = N6 ✅) > ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง `docs/PLAN-FEAT.md` §7 (**N7** รายงานรายการ bookmark เป็น PDF) + ขยาย whitelist ใน `CLAUDE.md` ก่อน — เป็น task ที่ 1 ของแผนนี้ แล้วรันผ่าน `/implement-feat N7` diff --git a/docs/PLAN-FEAT.md b/docs/PLAN-FEAT.md index dbf1461..0877b8a 100644 --- a/docs/PLAN-FEAT.md +++ b/docs/PLAN-FEAT.md @@ -326,15 +326,13 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15 - **Server Action `searchBookmarks`** (read-only — **ไม่ publish event ไม่ `revalidatePath`** เพราะไม่เขียน DB): คำค้น ILIKE บน `title`+`note` (escape `%`/`_`/`\` เหมือน F14) AND ช่วงหน้า (input **1-based** ตรงที่ผู้ใช้เห็น) AND ผู้บันทึก (`user_id`) AND ช่วงวันที่บันทึก (`YYYY-MM-DD` ตีความเป็นเขตเวลา **+07:00 คงที่** — ไทยไม่มี DST) — ทุกเงื่อนไข optional แต่ต้องมีอย่างน้อยหนึ่งอย่าง; คู่ช่วงกลับด้าน (from > to) = swap เงียบ ๆ; `orderBy pageIndex, yOffset, createdAt` ตรงลำดับการแสดงผลของ N5 - **UI:** block ค้นหา**เฉพาะแท็บ "ที่คั่นหน้า"** ใน sidebar (แยกจากช่อง F14 ที่อยู่เหนือแท็บ — ช่องเดียวค้นคนละอย่างตามแท็บ = สับสน) — ช่องคำค้น + ปุ่มกางแถบเงื่อนไข (ช่วงหน้า from–to / ผู้บันทึก Select จาก unique `userId` ใน `bookmarks` prop — **ห้ามปนกับ dropdown กรอง F4** ที่ derive จาก annotations / วันที่ from–to แบบ `type="date"` + ปุ่มล้างเงื่อนไข) — q ว่างแต่มีเงื่อนไขอื่น**ค้นได้** (ต่างจาก F14); ผลลัพธ์เป็น list แบนแสดง**แทนต้นไม้ชั่วคราว** (แถว: title + "หน้า N · ผู้บันทึก" + "ใน: `<ชื่อแม่>`" ถ้าเป็น sub + โน้ต clamp, คลิก = `onGoToBookmark`) จนกว่าจะกดปิด — input ทุกตัว `onKeyDown` `stopPropagation` กันชนคีย์ลัด H/D/C/B ของ viewer - **Realtime:** ไม่มี event ใหม่ — `user_name` ไหลไปกับ DTO ของ create/update event เดิมอัตโนมัติ -- **Out of scope ของ N6:** bookmark ในหน้าสรุป F15/CSV (ส่วนรายงาน PDF ทำแล้วใน N7), ค้นข้ามหลายไฟล์ (gap 5.3.10), full-text search +- **Out of scope ของ N6:** bookmark ในหน้าสรุป F15/CSV (ส่วนส่งออก bookmark ทำแล้วใน N7), ค้นข้ามหลายไฟล์ (gap 5.3.10), full-text search -### N7 · รายงานรายการ bookmark เป็น PDF (2026-09-16) +### N7 · ส่งออกรายการ bookmark เป็น JSON (2026-09-16 · เปลี่ยนจากรายงาน PDF 2026-09-17) -> แผนงานฉบับเต็ม: `docs/PLAN-BOOKMARK-PDF.md` — requirement 5.3.15 (gap analysis กลุ่ม B ข้อสุดท้าย) +> แผนงานฉบับเต็ม (ตอนเป็น PDF): `docs/PLAN-BOOKMARK-PDF.md` — requirement 5.3.15 (gap analysis กลุ่ม B ข้อสุดท้าย) · 2026-09-17 ผู้ใช้ขอเปลี่ยน deliverable เป็น JSON แทนรายงาน PDF -- **Route:** `app/api/bookmarks/export/route.ts` (GET `?fileId=`) — สร้างเอกสาร PDF **ใหม่ล้วน** (`PDFDocument.create()` — ไม่โหลดไฟล์เอกสารต้นฉบับเลย: invariant #1 โดยการสร้าง ไม่ใช่การก๊อป ไม่เกี่ยว `readPublicPdf`): หัวรายงาน (หน้าแรกหน้าเดียว: "รายการที่คั่นหน้า" + ชื่อไฟล์ + "สร้างเมื่อ {วันที่} · N รายการ" + เส้นคั่น) + **ต้นไม้ indent 3 ระดับ** (indent 14pt/ระดับ; ระดับ 1 = 13pt, 2–3 = 11pt — Regular ตัวเดียว ลำดับชั้นด้วย indent+ขนาด ไม่ใช้ตัวหนา) เรียงตาม N5 (`pageIndex → yOffset → createdAt` — query เรียงมาเลย ลูกเรียงตามเกณฑ์เดียวกันอัตโนมัติ) แต่ละรายการ: ชื่อ (wrap `wrapTextByWidth`) + metadata 9pt เทา "หน้า N · {userName || "ไม่ทราบชื่อ"} · {วันที่บันทึก `toLocaleDateString("th-TH")`}" + โน้ต "โน้ต: …" ใต้ metadata; A4 + แบ่งหน้าต่อบรรทัด (guard แถวสูงเกินหน้า) + footer "หน้า n จาก N" ทุกหน้า; embed `Sarabun-Regular.ttf` **จำเป็น** (รายงานเป็นข้อความล้วน — ไม่มีไฟล์ = 500 ข้อความชัด ต่างจาก D5 ของ F8 ที่ optional); 0 bookmark = PDF หน้าเดียวข้อความ "ยังไม่มีรายการที่คั่นหน้าในไฟล์นี้" (ไม่ 404 — ปุ่มทำงานเสมอ); response `attachment` + `filename*` จาก `encodeRFC5987("{title}-bookmarks.pdf")` (pattern F8/F16) -- **ต้นไม้ defensive:** orphan `parentId` → เลื่อนขึ้น root เหมือน sidebar/buildOutline; cycle (ข้อมูลแก้ตรง DB หลุด guard ของ action) → DFS ด้วย **seen ชุดเดียวร่วมทุก root** กันเดินซ้ำ/วนไม่รู้จบ แล้ว**เติมโหนดที่ไม่มี root เข้าถึงเป็นรายการระดับ 1 ท้ายรายการ** (ข้อมูลไม่หาย — รวมเคสวงจรปน root ปกติ และเคสวงจรล้วนทั้งไฟล์ = ออกมาแบนทั้งหมดระดับ 1 จากกลไกเดียวกัน) -- **การวาด (ระวัง 2 จุด):** วาดบรรทัดต้อง **advanceLine ก่อนแล้วอ่าน `page` ทีหลัง** — ฝัง `advanceLine()` ใน argument ของ `page.drawText(...)` แล้ว base ของ method call ถูก resolve ก่อน arguments ตาม spec ทำให้บรรทัดแรกของหน้าใหม่ (ที่เพิ่ง reassign `page`) ถูกวาดกลับลง**หน้าเก่า**ซ้อนบรรทัดบนสุด; **ชื่อไฟล์ในหัวรายงาน wrap ด้วย `wrapTextByWidth`** เหมือนรายการ (title ใน DB ยาวได้ถึง 200 ตัวอักษร) -- **UI:** ปุ่มไอคอน `Download` ท้ายแถว toggle เงื่อนไขค้นหา N6 ในแท็บ "ที่คั่นหน้า" — `` ลิงก์ธรรมดา (pattern Export CSV F16 — ไม่มี JS/state) + `coarse-36` — ส่งออก**ทุก bookmark ของไฟล์เสมอ** (ไม่ผูกเงื่อนไขค้นหา N6 ที่ตั้งไว้) +- **Route:** `app/api/bookmarks/export/route.ts` (GET `?fileId=`) — **ข้อมูลล้วนจาก DB ไม่โหลดไฟล์เอกสารต้นฉบับเลย** (invariant #1): validate `fileId` (UUID → 400) + หาไฟล์ไม่เจอ = 404 แล้ว query `bookmarks` ทั้งไฟล์ `orderBy asc(pageIndex), asc(yOffset), asc(createdAt)` (ลำดับเดียวกับ sidebar/N5) → response JSON `{ fileId, title, exportedAt (ISO), bookmarks: BookmarkDTO[] }` — item เป็น `toBookmarkDTO` ตรง ๆ (camelCase + ISO string, invariant #5) เก็บ `parentId` ครบ **lossless** reconstruct ต้นไม้ได้จากข้อมูลเอง จึงไม่ต้อง build tree/cycle guard/ฟอนต์/layout แบบตอนเป็น PDF; pretty-print 2 space; 0 bookmark = `"bookmarks": []` (ไม่ 404 — ปุ่มทำงานเสมอ); `content-type: application/json; charset=utf-8` + `attachment` + `filename*` จาก `encodeRFC5987("{title}-bookmarks.json")` (pattern F8/F16) +- **UI:** ปุ่มไอคอน `Braces` ({ }) ท้ายแถว toggle เงื่อนไขค้นหา N6 ในแท็บ "ที่คั่นหน้า" — `` ลิงก์ธรรมดา (pattern Export CSV F16 — ไม่มี JS/state) + `coarse-36` — ส่งออก**ทุก bookmark ของไฟล์เสมอ** (ไม่ผูกเงื่อนไขค้นหา N6 ที่ตั้งไว้) - **Read-only:** ไม่ publish event ไม่ `revalidatePath` (ไม่เขียน DB — เหมือน `searchBookmarks`/route export เดิม) -- **Out of scope ของ N7:** export ตามเงื่อนไขค้นหา N6, รวม annotation/เพิ่ม bookmark ลงหน้าสรุป F15 หรือ CSV, ตัวหนา (`Sarabun-Bold.ttf`), export ข้ามหลายไฟล์ (gap 5.3.10), ตัวเลือกหน้ากระดาษ/แนวนอน/config +- **Out of scope ของ N7:** export ตามเงื่อนไขค้นหา N6, รวม annotation/เพิ่ม bookmark ลงหน้าสรุป F15 หรือ CSV, กลับไปเป็นรายงาน PDF (ถอดแล้ว — จะเอาคืนดูแผนเดิม `docs/PLAN-BOOKMARK-PDF.md`), export ข้ามหลายไฟล์ (gap 5.3.10) diff --git a/docs/requirement/gap-analysis.md b/docs/requirement/gap-analysis.md index 7b2b8b7..716f74c 100644 --- a/docs/requirement/gap-analysis.md +++ b/docs/requirement/gap-analysis.md @@ -1,9 +1,9 @@ # รายงานเทียบ Requirement กับโปรเจกต์ (Gap Analysis) - **วันที่ตรวจสอบ:** 2026-09-14 -- **อัปเดตล่าสุด:** 2026-09-15 — แก้ไขข้อความข้อ 5.3.18–5.3.25 ตามที่เจ้าของเอกสารยืนยันใหม่ +- **อัปเดตล่าสุด:** 2026-09-17 — ปิด gap 4 ข้อ: 5.3.11 ✅ (N5), 5.3.12 ✅ (N6), 5.3.14 ✅ (API ส่งออก bookmark เป็น JSON), 5.3.15 ✅ (export PDF ของ F8 มี bookmark ติดไป) · 2026-09-15 — แก้ไขข้อความข้อ 5.3.18–5.3.25 ตามที่เจ้าของเอกสารยืนยันใหม่ - **เอกสาร requirement ต้นทาง:** `docs/requirement/1.png`, `docs/requirement/2.png` (ข้อกำหนดระบบบันทึกความเห็นของกรรมการ — Judges' Notes System) -- **โค้ดที่ใช้เทียบ:** branch `forgejo-dev` ณ commit `b3b884c` (implement ครบตาม PLAN.md + PLAN-FEAT.md แล้ว ยกเว้น F11 ที่ถูกถอดออก) +- **โค้ดที่ใช้เทียบ:** branch `forgejo-dev` — ข้อ 5.3.11–5.3.15 อัปเดตตามโค้ดล่าสุด 2026-09-17 (รวม N5–N6 commit `a020e96` และ N7 → JSON export) · ตรวจครั้งแรก ณ commit `b3b884c` (implement ครบตาม PLAN.md + PLAN-FEAT.md แล้ว ยกเว้น F11 ที่ถูกถอดออก) - **ช่วงเลขข้อ (ยืนยันโดยเจ้าของเอกสาร):** ภาพที่ 1 = ข้อ **5.3.1–5.3.6** / ภาพที่ 2 = ข้อ **5.3.7–5.3.25** > ⚠️ **หมายเหตุการอ่าน requirement:** สำนวนภาษาไทยบางคำอาจคลาดเคลื่อนเล็กน้อยจากต้นฉบับ แต่สาระของทุกข้อชัดเจน — การอ่านครั้งแรกสับสนเพราะเลขข้อเป็นเลขอารบิก (5.3.X) ที่ OCR อ่านเป็นอักษรไทย ช่วงเลขข้อได้รับยืนยันจากเจ้าของเอกสารแล้ว และการตรวจซ้ำด้วยการสแกนตำแหน่งบรรทัด + อ่านเลขข้อรายบรรทัดยืนยันว่า **จำนวนข้อ map ตรง 1:1 ครบทั้ง 19 ข้อ** (10 ข้อครึ่งบน + 9 ข้อครึ่งล่าง รวม 5.3.7–5.3.25) — การจับคู่เลข ↔ เนื้อหาในตารางจึงเป็นที่ยืนยันแล้วทั้งหมด จนถึง**ฉบับแก้ไข 2026-09-15** ที่เจ้าของเอกสารให้ข้อความข้อ 5.3.18–5.3.25 ฉบับถูกต้อง: HTML5 แยกจากข้อ Tablet/iPad ออกเป็นข้อ 5.3.19 ของตัวเอง (ข้อถัดมาเลื่อนลง 1 ลำดับ), เพิ่มข้อสิทธิการใช้งานตามระบบปกครอง (5.3.23) และเชื่อมโยงกับระบบปกครอง (5.3.25) — ข้อเดิมเรื่อง API ระบบอื่น, มาตรฐานความมั่นคงปลอดภัย และ Availability 99.5% ไม่ปรากฏในฉบับแก้ไข @@ -12,7 +12,7 @@ **โปรเจกต์ judes-note ตอบแกน functional ของ requirement ได้ค่อนข้างเต็ม** — การ annotate บนเอกสาร PDF, bookmark ต่อหน้าเอกสาร, Text Box + Freehand Drawing, ลบ/แก้ไขความเห็น, แสดงผลรายการ, ใช้งานหลายคนพร้อมกันแบบ realtime, responsive และรองรับ tablet/iPad -**แต่ requirement ฉบับนี้เขียนสำหรับระบบ production เต็มรูปแบบ** ขณะที่ judes-note เป็น MVP เฟสแรกตามขอบเขตใน `CLAUDE.md` / `docs/PLAN.md` — ข้อที่ขาดส่วนใหญ่จึงแบ่งได้เป็น 2 กลุ่ม: (1) ของที่**ตั้งใจไม่ทำ**ในเฟสแรก (auth, API, integration) และ (2) ของที่**เกือบมีแต่ต่างรายละเอียด** (ค้นหาไม่ครอบ bookmark, ลบ bookmark ทีละรายการ) รวมถึง (3) ของใหม่ที่ไม่เคยอยู่ในแผน (รองรับไฟล์รูปภาพ) +**แต่ requirement ฉบับนี้เขียนสำหรับระบบ production เต็มรูปแบบ** ขณะที่ judes-note เป็น MVP เฟสแรกตามขอบเขตใน `CLAUDE.md` / `docs/PLAN.md` — ข้อที่ขาดส่วนใหญ่จึงแบ่งได้เป็น 2 กลุ่ม: (1) ของที่**ตั้งใจไม่ทำ**ในเฟสแรก (auth, integration) และ (2) ของที่**เกือบมีแต่ต่างรายละเอียด** (ลบ bookmark ทีละรายการ) รวมถึง (3) ของใหม่ที่ไม่เคยอยู่ในแผน (รองรับไฟล์รูปภาพ) | สถานะ | ความหมาย | |---|---| @@ -28,7 +28,7 @@ | Requirement | สถานะ | รายละเอียดในโปรเจกต์ | |---|---|---| -| **5.3.1** เรียกดูเอกสารที่เกี่ยวข้องและตรวจสอบจากระบบงานคดีปกครองได้โดยตรง | ⚠️ | ตัว viewer แสดงไฟล์ PDF ที่ระบุใน DB ได้ แต่**ไม่มีการเชื่อมต่อระบบงานคดีปกครอง** (ไม่มี integration/API ดึงเอกสารโดยตรง) และไม่มีหน้า upload — หน้า `/` อ่านรายการจาก DB เท่านั้น (sample data seed ด้วย SQL) — ผูกกับ gap 5.3.14 (API) และ 5.3.16 (integrate) | +| **5.3.1** เรียกดูเอกสารที่เกี่ยวข้องและตรวจสอบจากระบบงานคดีปกครองได้โดยตรง | ⚠️ | ตัว viewer แสดงไฟล์ PDF ที่ระบุใน DB ได้ แต่**ไม่มีการเชื่อมต่อระบบงานคดีปกครอง** (ไม่มี integration/API ดึงเอกสารโดยตรง) — อัปโหลดไฟล์เองได้แล้ว (N4) แต่ไม่ใช่การดึงจากระบบคดีปกครอง — ผูกกับ gap 5.3.16 (integrate) และ 5.3.25 (เชื่อมโยงระบบปกครอง) | | **5.3.2** ระบบต้องไม่สามารถแก้ไขไฟล์ต้นฉบับได้ (Non-Destructive) | ✅ | เป็น **invariant หลักของโปรเจกต์** (CLAUDE.md ข้อ 1): annotation เก็บใน DB เป็นพิกัด % เท่านั้น, ไฟล์ PDF ใน `public/` ถูกอ่านอย่างเดียวผ่าน path guard (`lib/server-pdf.ts`), export PDF (F8) สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ (pdf-lib load แล้ว save คืน bytes ใหม่) — ไม่เขียนทับต้นฉบับเด็ดขาด | | **5.3.3** จัดเก็บข้อมูล Bookmark แยกจากเอกสารโดยเก็บไว้ในฐานข้อมูล และสามารถนำข้อมูลดังกล่าวมาแสดงได้ | ✅ | เป็น**สถาปัตยกรรมแกนกลางของโปรเจกต์** (invariant #1–2): bookmark เก็บใน PostgreSQL ตาราง `bookmarks` เป็นพิกัด/ข้อมูลล้วน (pageIndex, ชื่อ, โครงสร้าง parent_id) แยกจากไฟล์ PDF ใน `public/` — นำกลับมาแสดงได้ทั้งแท็บ Bookmarks ใน sidebar (scroll ไปหน้านั้น), หน้าสรุป F15 และ export | | **5.3.4** รูปแบบเอกสารที่รองรับได้ — ไฟล์เอกสาร PDF และไฟล์รูปภาพ (jpg, jpeg, png, tif) เป็นอย่างน้อย | ✅ | รองรับครบทุกนามสกุลแล้ว (N3 — แผน `docs/PLAN-IMAGE.md`, 2026-09-15): รูปคือ "หน้าเอกสาร" แสดง `` แทน canvas + annotate ครบทุกชนิด + bookmark + realtime + export PDF ฝังรูป/CSV; TIFF รองรับทั้งหลายเฟรม (decode ด้วย utif2) และ EXIF orientation ตอน export; อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการได้ (N4) | @@ -45,21 +45,21 @@ | **5.3.8** ข้อมูลบันทึก bookmark ต้องอ้างถึงไฟล์เอกสาร หน้าเอกสาร วันที่บันทึก และผู้บันทึก | ✅ | ตาราง `bookmarks` มีครบทั้ง 4: `file_id` (FK → ไฟล์เอกสาร), `page_index` (หน้าเอกสาร), `created_at` (วันที่บันทึก), `user_id` (ผู้บันทึก) — นำมาแสดงในแท็บ Bookmarks, หน้าสรุป F15 และ export | | **5.3.9** กำหนดความเป็นส่วนตัวของ bookmark: ส่วนบุคคล (Private) / กลุ่ม (Group) | ❌ | ไม่มี Private/Group visibility — ทุกคนเห็นทุกรายการ (ไม่มี auth/roles โดยเจตนา) | | **5.3.10** แสดง bookmark ของเอกสารแต่ละไฟล์ หรือชุดของเอกสารได้ | ⚠️ | รายไฟล์ ✅ — แท็บ Bookmarks ใน sidebar แสดง bookmark ทั้งหมดของไฟล์ที่เปิด (ผูก `file_id`) + หน้าสรุป F15 / แต่**ชุดของเอกสาร** ❌ — ไม่มี concept การจัดกลุ่มไฟล์ (document set) และไม่มีหน้าแสดง bookmark รวมข้ามหลายไฟล์ | -| **5.3.11** แสดง bookmark ตามลำดับหน้าได้ | ⚠️ | แท็บ Bookmarks แสดงเลขหน้าต่อรายการ ("หน้า N") แต่เรียงตาม**เวลาสร้าง** ไม่ใช่ลำดับหน้า (`AnnotationSidebar.tsx:753` — root เรียงตามเวลาสร้าง) และหน้าสรุป F15 ไม่มี bookmark — แก้ง่าย: sort roots ของต้นไม้ตาม `pageIndex` | -| **5.3.12** สามารถกำหนดเงื่อนไขการค้นหา bookmarks ได้ | ❌ | ช่องค้นหา (F14) ครอบเฉพาะ annotations เท่านั้น (`searchAnnotations` query ตาราง `annotations`) — **ค้นหา bookmark ยังไม่ได้เลย** ยิ่งไม่มีการกำหนดเงื่อนไข (เช่น ตามหน้า/ผู้บันทึก/ช่วงวันที่) | +| **5.3.11** แสดง bookmark ตามลำดับหน้าได้ | ✅ | ทำแล้ว (2026-09-16, N5): แท็บ "ที่คั่นหน้า" เรียงตาม **หน้า → ตำแหน่งบนหน้า (yOffset) → เวลาสร้าง** ทั้ง root ของต้นไม้และลูกในแม่เดียวกัน — ลำดับเดียวกันใช้ทุกที่: PDF outline ตอน export (F8), ผลค้นหา (N6), ส่งออก JSON (N7) | +| **5.3.12** สามารถกำหนดเงื่อนไขการค้นหา bookmarks ได้ | ✅ | ทำแล้ว (2026-09-16, N6): block ค้นหาเฉพาะแท็บ "ที่คั่นหน้า" — คำค้น (ชื่อ+โน้ต) + เงื่อนไข **ช่วงหน้า / ผู้บันทึก / ช่วงวันที่บันทึก** ผสมกันได้ + ปุ่มล้างเงื่อนไข (Server Action `searchBookmarks` — แผน `docs/PLAN-BOOKMARK.md`) | | **5.3.13** แสดงผลรายการหน้าของเอกสารที่ bookmark ไว้ | ✅ | แท็บ Bookmarks ใน sidebar + หน้าสรุป (F15) | -| **5.3.14** จัดทำชุด API ดึงข้อมูล bookmark สำหรับเชื่อมต่อระบบอื่น | ❌ | ไม่มี REST API ภายนอก — มีเฉพาะ SSE stream ภายใน + export routes สำหรับผู้ใช้ (F11 export/import JSON ถูกถอดออกจากโค้ดแล้ว) | -| **5.3.15** ส่งออก (Export) รายการ bookmark เป็น PDF | ⚠️ | ใกล้เคียง: export PDF ฝัง annotation + bookmark เป็น **PDF outline** (F8 — `app/api/annotations/export/route.ts`) + export CSV สรุปรายการ (F16) — แต่ไม่ใช่ "รายงานรายการ bookmark" แบบเอกสาร PDF ตรง ๆ | +| **5.3.14** จัดทำชุด API ดึงข้อมูล bookmark สำหรับเชื่อมต่อระบบอื่น | ✅ | ทำแล้ว (2026-09-17): GET `/api/bookmarks/export?fileId=` คืน **JSON** ข้อมูล bookmark ทั้งไฟล์ (envelope `fileId`/`title`/`exportedAt` + `BookmarkDTO[]` ครบทุก field รวม `parentId` โครงสร้างต้นไม้ เรียงตามลำดับหน้า) — ระบบอื่นดึงข้อมูลไปใช้ต่อได้ทันที (read-only, ยังไม่มี auth ตามขอบเขตเฟสแรก) | +| **5.3.15** ส่งออก (Export) รายการ bookmark เป็น PDF | ✅ | export PDF ของ F8 (`app/api/annotations/export/route.ts`) ฝัง bookmark เป็น **PDF outline** ติดไปกับไฟล์ (ต้นไม้ 3 ระดับ เรียงตามหน้า) — เจ้าของเอกสารยืนยัน (2026-09-17) ถือว่าตอบข้อนี้แล้ว · หมายเหตุ: รายงาน PDF แยกต่างหากของ N7 เคยทำแล้วถูกเปลี่ยนเป็น**ส่งออก JSON** ตามคำขอผู้ใช้ (2026-09-17) | | **5.3.16** หน้าจอเชื่อมต่อกับระบบอื่น (Integrate) เป็น modal หรือ iframe | ❌ | เป็น standalone Next.js app — ไม่มีโหมด embed | | **5.3.17** รองรับ Web Browser: Google Chrome, Microsoft Edge, Safari | ✅ | Web app มาตรฐาน — ทดสอบ desktop + iOS/Android แล้ว | | **5.3.18** ใช้งานบน Tablet, iPad | ✅ | Touch support เต็มรูปแบบ: ไฮไลต์ด้วย custom selection ลาก→ปล่อย=สร้างทันที (`hooks/useCustomSelection.ts` — แทนปุ่มลอยของ commit `b3b884c` ที่ถอดออกแล้ว), ลาก handles, วาดด้วยนิ้ว | -| **5.3.19** รองรับการพัฒนาโปรแกรมแบบ Web Application ที่สนับสนุนเทคโนโลยี HTML5 หรือดีกว่า | ✅ | เป็น Web Application (Next.js 14) สร้างบนเทคโนโลยี HTML5 ทั้งหมด: Canvas 2D (render PDF ผ่าน pdfjs), SVG (InkLayer), Server-Sent Events/EventSource (realtime), Web Storage (anonymous identity), Pointer Events + pressure (สไตลัส) — ไม่พึ่ง plugin เดิม เช่น Flash/Java Applet | +| **5.3.19** รองรับการพัฒนาโปรแกรมแบบ Web Application ที่สนับสนุนเทคโนโลยี HTML5 หรือดีกว่า | ✅ | เป็น Web Application (Next.js 14) สร้างบนเทคโนโลยี HTML5 ทั้งหมด: Canvas 2D (render PDF ผ่าน pdfjs), SVG (InkLayer), Server-Sent Events/EventSource (realtime), HTTP Cookie (mock login e-Court), Pointer Events + pressure (สไตลัส) — ไม่พึ่ง plugin เดิม เช่น Flash/Java Applet | | **5.3.20** Responsive รองรับแสดงผลได้ทุกความละเอียดหน้าจอ | ✅ | sidebar จอกลาง/จอเล็ก, PDF fit จอเล็ก, ปุ่มขนาด touch-44px (commit `1c2f4c8`) | | **5.3.21** ใช้งานหลายผู้ใช้พร้อมกัน (Multi-user) จากหลายเครื่อง | ✅ | Realtime sync ผ่าน SSE (`hooks/useAnnotationRealtime.ts`) — แก้ไข/ลบ ของใครของมันบนไฟล์เดียวกันได้พร้อมกัน | -| **5.3.22** การรับรองตัวผู้ใช้งาน (Authentication) | ❌ | ไม่มีระบบ auth — anonymous identity จาก localStorage โดยเจตนา (out of scope ตามแผน) | -| **5.3.23** สิทธิการใช้งานเป็นไปตามระบบปกครอง | ❌ | ไม่มีระบบสิทธิ์ (roles/permissions) — ไม่มี login จึงกำหนดสิทธิ์ตามระบบปกครองไม่ได้ (ต้องทำ 5.3.22 ก่อน) | +| **5.3.22** การรับรองตัวผู้ใช้งาน (Authentication) | ✅ | ทำแล้วเป็น **mock** (2026-09-17): หน้า `/login` dropdown ผู้ใช้ `user1`–`user5` + รหัสผ่านกลาง `Demo1234` → ตั้ง cookie `oacCase` ตาม format จริงของ e-Court (userId = `u_userid` ต่างกันต่อคน, userName = `u_userlogin`) + `middleware.ts` บังคับทุกหน้า + ปุ่มออกจากระบบ — auth จริงเทียบระบบศาลยังไม่ทำ | +| **5.3.23** สิทธิการใช้งานเป็นไปตามระบบปกครอง | ❌ | ไม่มีระบบสิทธิ์ (roles/permissions) — login เป็น mock ยังไม่ผูกสิทธิ์ตามระบบปกครอง (ต้อง auth จริง 5.3.22 ก่อน) | | **5.3.24** ติดตั้ง On-Premise บน Server ภายใน หรือ Cloud | ⚠️ | สถาปัตยกรรม Next.js + PostgreSQL deploy ได้ทั้งสองรูปแบบทางเทคนิค แต่ไม่มีข้อกำหนด/packaging พิเศษ (หมายเหตุ: SSE pub/sub เป็น in-memory — รันหลาย instance ต้องเพิ่ม Redis/LISTEN-NOTIFY ก่อน) | -| **5.3.25** ระบบที่พัฒนาสามารถเชื่อมโยงกับระบบปกครองได้ | ❌ | เป็น standalone Next.js app — ยังไม่มี integration/API เชื่อมกับระบบงานคดีปกครอง (ผูกกับ gap 5.3.1 เรียกเอกสารโดยตรง และ 5.3.14 ชุด API) | +| **5.3.25** ระบบที่พัฒนาสามารถเชื่อมโยงกับระบบปกครองได้ | ❌ | เป็น standalone Next.js app — ยังไม่มี integration เชื่อมกับระบบงานคดีปกครอง (ผูกกับ gap 5.3.1 เรียกเอกสารโดยตรง และ 5.3.16 โหมด integrate) — ด้านข้อมูลออกมี JSON export รอรับแล้ว (5.3.14) | --- @@ -70,16 +70,16 @@ - Authentication ผู้ใช้งาน + สิทธิการใช้งานตามระบบปกครอง (5.3.22, 5.3.23) - Private/Group visibility ของ bookmark (5.3.9) -- ชุด API สำหรับระบบภายนอก (5.3.14) +- ~~ชุด API สำหรับระบบภายนอก (5.3.14)~~ — **ปิดแล้ว (2026-09-17)**: GET `/api/bookmarks/export` คืน JSON ข้อมูล bookmark ทั้งไฟล์ ระบบอื่นดึงไปใช้ได้ (ดูตาราง 5.3.14) - โหมด Integrate แบบ modal/iframe (5.3.16) - เชื่อมโยงกับระบบงานคดีปกครอง — ทั้งเรียกเอกสารโดยตรงและเชื่อมต่อระบบ (5.3.1, 5.3.25) ### กลุ่ม B — ฟังก์ชันที่มีอยู่แล้ว ต่อยอดเล็กน้อยได้เลย -งานขนาดเล็ก–กลาง ไม่กระทบสถาปัตยกรรม: +งานขนาดเล็ก–กลาง ไม่กระทบสถาปัตยกรรม — **ปิดครบทั้งกลุ่มแล้ว (2026-09-16–17)** เก็บรายการไว้เป็นประวัติ: -- **ค้นหา bookmark พร้อมเงื่อนไข** (5.3.12) — เพิ่มการค้นหาตาราง `bookmarks` (ชื่อ/โน้ต) + รองรับเงื่อนไขเช่นหน้าเอกสาร/ผู้บันทึก/ช่วงวันที่ -- **เรียง bookmark ตามลำดับหน้า** (5.3.11) — sort roots ของต้นไม้ bookmark ตาม `pageIndex` แทนเวลาสร้าง -- **รายงานรายการ bookmark เป็น PDF** (5.3.15) — ต่อจากหน้าสรุป F15 หรือ export route เดิม +- ~~**ค้นหา bookmark พร้อมเงื่อนไข** (5.3.12)~~ — **ทำแล้ว (2026-09-16, N6)**: คำค้น (ชื่อ/โน้ต) + ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ ในแท็บ "ที่คั่นหน้า" +- ~~**เรียง bookmark ตามลำดับหน้า** (5.3.11)~~ — **ทำแล้ว (2026-09-16, N5)**: เรียง หน้า → ตำแหน่งบนหน้า → เวลาสร้าง ทั้งต้นไม้ + ใช้ลำดับเดียวกันใน export PDF outline +- ~~**รายงานรายการ bookmark เป็น PDF** (5.3.15)~~ — **ปิดแล้ว (2026-09-17)**: เจ้าของเอกสารถือว่า export PDF ของ F8 ที่ฝัง bookmark เป็น outline ตอบข้อนี้แล้ว; งาน N7 ที่ทำไว้ (รายงาน PDF แยก) เปลี่ยนเป็นส่งออก JSON แทน (ซึ่งไปปิด 5.3.14 พอดี) ### กลุ่ม C — ของใหม่ที่ไม่เคยอยู่ในแผน - ~~**รองรับไฟล์รูปภาพ jpg/jpeg/png/tif** (5.3.4)~~ — **ทำแล้ว (2026-09-15, N3 + N4 ตามแผน `docs/PLAN-IMAGE.md`)**: รูปคือ "หน้าเอกสาร" (layers เดิมซ้อนบน `` พิกัด % ใช้ของเดิมได้ทันที) + TIFF หลายเฟรม + EXIF orientation + export PDF ฝังรูป + อัปโหลดผ่านหน้ารายการ @@ -92,6 +92,6 @@ ## 4. ข้อเสนอแนะลำดับความสำคัญ -1. **ทำกลุ่ม B ก่อน** — คุ้มค่าต่อแรง เห็นผลไว เสริมฟีเจอร์ bookmark ให้ครบ requirement โดยไม่แตะสถาปัตยกรรม +1. ~~**ทำกลุ่ม B ก่อน**~~ — **ทำเสร็จแล้ว (2026-09-16–17)**: N5+N6 ปิด 5.3.11/5.3.12, 5.3.15 ปิดด้วย export PDF ของ F8 (bookmark เป็น outline), 5.3.14 ปิดด้วย JSON export — สิ่งที่เหลืออยู่ในกลุ่ม A/C/D 2. ~~**ตัดสินใจเรื่องรูปภาพ (กลุ่ม C)**~~ — **ตัดสินใจและทำเสร็จแล้ว (2026-09-15)**: 5.3.4 ครบทุกนามสกุลตาม N3 + อัปโหลดไฟล์ (N4) 3. **กลุ่ม A ตามด้วย D** — ต้องอัปเดต `docs/PLAN.md` ขยายขอบเขตอย่างเป็นทางการก่อน implement ตามกระบวนการของโปรเจกต์ From 6940b3870dce0f4527366494d75e3db075feed1a Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 17 Sep 2026 13:43:16 +0700 Subject: [PATCH 03/55] =?UTF-8?q?docs:=20=E0=B8=A3=E0=B9=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=87=E0=B9=81=E0=B8=9C=E0=B8=99=20N8=20=E0=B8=8A=E0=B8=B8?= =?UTF-8?q?=E0=B8=94=E0=B9=80=E0=B8=AD=E0=B8=81=E0=B8=AA=E0=B8=B2=E0=B8=A3?= =?UTF-8?q?=20(Document=20Set)=20+=20bookmark=20=E0=B8=A3=E0=B8=A7?= =?UTF-8?q?=E0=B8=A1=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B8=8A=E0=B8=B8?= =?UTF-8?q?=E0=B8=94=20=E2=80=94=20=E0=B8=9B=E0=B8=B4=E0=B8=94=20gap=205.3?= =?UTF-8?q?.10=20=E0=B8=84=E0=B8=A3=E0=B8=B6=E0=B9=88=E0=B8=87=E0=B8=AB?= =?UTF-8?q?=E0=B8=A5=E0=B8=B1=E0=B8=87=20("=E0=B8=AB=E0=B8=A3=E0=B8=B7?= =?UTF-8?q?=E0=B8=AD=E0=B8=8A=E0=B8=B8=E0=B8=94=E0=B8=82=E0=B8=AD=E0=B8=87?= =?UTF-8?q?=E0=B9=80=E0=B8=AD=E0=B8=81=E0=B8=AA=E0=B8=B2=E0=B8=A3"):=20?= =?UTF-8?q?=E0=B8=95=E0=B8=B2=E0=B8=A3=E0=B8=B2=E0=B8=87=20document=5Fsets?= =?UTF-8?q?=20+=20files.set=5Fid=20nullable=20(=E0=B9=84=E0=B8=9F=E0=B8=A5?= =?UTF-8?q?=E0=B9=8C=E0=B8=AB=E0=B8=99=E0=B8=B6=E0=B9=88=E0=B8=87=E0=B8=AD?= =?UTF-8?q?=E0=B8=A2=E0=B8=B9=E0=B9=88=E0=B8=8A=E0=B8=B8=E0=B8=94=E0=B9=80?= =?UTF-8?q?=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7=20onDelete=20set=20null=20?= =?UTF-8?q?=E2=80=94=20=E0=B8=A5=E0=B8=9A=E0=B8=8A=E0=B8=B8=E0=B8=94?= =?UTF-8?q?=E0=B9=84=E0=B8=9F=E0=B8=A5=E0=B9=8C=E0=B9=84=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B8=AB=E0=B8=B2=E0=B8=A2=20=E0=B8=84=E0=B8=99=E0=B8=A5?= =?UTF-8?q?=E0=B8=B0=E0=B9=80=E0=B8=A3=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=87?= =?UTF-8?q?=E0=B8=81=E0=B8=B1=E0=B8=9A=20CASCADE=20=E0=B8=82=E0=B8=AD?= =?UTF-8?q?=E0=B8=87=20annotations)=20+=20=E0=B8=AB=E0=B8=99=E0=B9=89?= =?UTF-8?q?=E0=B8=B2=E0=B9=83=E0=B8=AB=E0=B8=A1=E0=B9=88=20/sets/[id]=20bo?= =?UTF-8?q?okmark=20=E0=B8=A3=E0=B8=A7=E0=B8=A1=E0=B8=97=E0=B8=B8=E0=B8=81?= =?UTF-8?q?=E0=B9=84=E0=B8=9F=E0=B8=A5=E0=B9=8C=E0=B8=82=E0=B8=AD=E0=B8=87?= =?UTF-8?q?=E0=B8=8A=E0=B8=B8=E0=B8=94=E0=B8=84=E0=B8=A5=E0=B8=B4=E0=B8=81?= =?UTF-8?q?=E0=B8=81=E0=B8=A3=E0=B8=B0=E0=B9=82=E0=B8=94=E0=B8=94=E0=B8=9C?= =?UTF-8?q?=E0=B9=88=E0=B8=B2=E0=B8=99=20deep=20link=20F12=20+=20=E0=B8=88?= =?UTF-8?q?=E0=B8=B1=E0=B8=94=E0=B8=81=E0=B8=B2=E0=B8=A3=E0=B8=8A=E0=B8=B8?= =?UTF-8?q?=E0=B8=94=E0=B8=9A=E0=B8=99=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2?= =?UTF-8?q?=E0=B8=A3=E0=B8=B2=E0=B8=A2=E0=B8=81=E0=B8=B2=E0=B8=A3=20(?= =?UTF-8?q?=E0=B8=AA=E0=B8=A3=E0=B9=89=E0=B8=B2=E0=B8=87/=E0=B8=A2?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=A2=E0=B9=80=E0=B8=82=E0=B9=89=E0=B8=B2?= =?UTF-8?q?-=E0=B8=AD=E0=B8=AD=E0=B8=81/=E0=B9=81=E0=B8=81=E0=B9=89?= =?UTF-8?q?=E0=B8=8A=E0=B8=B7=E0=B9=88=E0=B8=AD/=E0=B8=A5=E0=B8=9A=20?= =?UTF-8?q?=E2=80=94=20=E0=B8=97=E0=B8=B8=E0=B8=81=E0=B8=84=E0=B8=99?= =?UTF-8?q?=E0=B8=97=E0=B8=B5=E0=B9=88=20login=20=E0=B8=88=E0=B8=B1?= =?UTF-8?q?=E0=B8=94=E0=B8=81=E0=B8=B2=E0=B8=A3=E0=B9=84=E0=B8=94=E0=B9=89?= =?UTF-8?q?=20roles=20=E0=B8=A2=E0=B8=B1=E0=B8=87=20out=20of=20scope=20?= =?UTF-8?q?=E0=B8=95=E0=B8=B2=E0=B8=A1=205.3.23)=20=E2=80=94=20=E0=B8=A2?= =?UTF-8?q?=E0=B8=B1=E0=B8=87=E0=B9=84=E0=B8=A1=E0=B9=88=20implement=20(?= =?UTF-8?q?=E0=B9=80=E0=B8=AA=E0=B8=99=E0=B8=AD=202026-09-17=20=E0=B8=A3?= =?UTF-8?q?=E0=B8=AD=20/implement-feat=20N8=20=E0=B8=95=E0=B8=B2=E0=B8=A1?= =?UTF-8?q?=20governance:=20=E0=B8=95=E0=B9=89=E0=B8=AD=E0=B8=87=E0=B9=80?= =?UTF-8?q?=E0=B8=82=E0=B8=B5=E0=B8=A2=E0=B8=99=E0=B8=AA=E0=B9=80=E0=B8=9B?= =?UTF-8?q?=E0=B8=81=E0=B8=A5=E0=B8=87=20PLAN-FEAT.md=20=C2=A77=20+=20?= =?UTF-8?q?=E0=B8=82=E0=B8=A2=E0=B8=B2=E0=B8=A2=20whitelist=20=E0=B9=83?= =?UTF-8?q?=E0=B8=99=20CLAUDE.md=20=E0=B8=81=E0=B9=88=E0=B8=AD=E0=B8=99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- docs/PLAN-DOCSET.md | 130 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/PLAN-DOCSET.md diff --git a/docs/PLAN-DOCSET.md b/docs/PLAN-DOCSET.md new file mode 100644 index 0000000..63ece3c --- /dev/null +++ b/docs/PLAN-DOCSET.md @@ -0,0 +1,130 @@ +# แผน 5.3.10 — ชุดเอกสาร (Document Set) + bookmark รวมทั้งชุด (N8) + +> สถานะ: **ร่างแผน (ยังไม่ implement)** — เสนอ 2026-09-17 +> อ้างอิง: requirement 5.3.10 (gap analysis `docs/requirement/gap-analysis.md` **กลุ่ม C**): *"แสดง bookmark ของเอกสารแต่ละไฟล์ หรือชุดของเอกสารได้"* — ครึ่งแรก (รายไฟล์) ✅ ทำแล้วใน sidebar ครึ่งหลัง (ชุดของเอกสาร) คือ gap นี้ +> ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง `docs/PLAN-FEAT.md` §7 (**N8**) + ขยาย whitelist ใน `CLAUDE.md` + อัปเดต `docs/PLAN.md` §3 (schema) ก่อน — เป็น task แรกของแผนนี้ แล้วรันผ่าน `/implement-feat N8` + +## Context + +- ตอนนี้ไฟล์เป็น**ลิสต์แบน**หน้าเดียว (หน้า `/` เรียงตาม `createdAt`) — ไม่มี concept การจัดกลุ่มไฟล์ และ bookmark ดูได้เฉพาะภายในไฟล์ที่เปิด (แท็บ "ที่คั่นหน้า" + หน้าสรุป F15 ซึ่งเป็น annotation ไม่ใช่ bookmark) +- กรณีจริงของศาล: คดีหนึ่งงวด = หลายเอกสาร (คำฟ้อง / คำพิพากษา / คำสั่งศาล) — ผู้ใช้อยากเห็น bookmark **รวมทุกไฟล์ของคดีเดียวกันในหน้าเดียว** แล้วคลิกกระโดดเข้าไปอ่านตรงจุดนั้น +- ของพร้อมใช้ทั้งหมด: โครงสร้างต้นไม้ bookmark + ลำดับ N5 (`pageIndex → yOffset → createdAt`), deep link F12 (`?page=` อ่านตอน mount), mock login e-Court (ทุกหน้าถูก `middleware.ts` บังคับ login แล้ว) + +## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-17) + +1. **ไฟล์หนึ่งอยู่ชุดเดียว** — column `set_id` (nullable) บนตาราง `files` — ไม่ทำ many-to-many (เอกสารหนึ่งฉบับผูกคดีเดียวตามธรรมชาติของงาน) +2. **หน้า bookmark รวมชุดเป็นหน้าใหม่ `/sets/[id]`** เข้าจากหน้ารายการ (หน้า `/` จัดกลุ่มการ์ดไฟล์ตามชุด + หัวชุดมีลิงก์เปิด) — ไม่ยัดลง sidebar ของ viewer (แคบ 288px และต้องเปิดไฟล์ก่อนถึงเห็น) +3. **จัดการชุดผ่าน UI บนหน้ารายการ** — ฟอร์มเล็ก "สร้างชุด" + ปุ่มบนการ์ดไฟล์ย้ายเข้า/ออกชุด + เมนูแก้ชื่อ/ลบชุด (ทุกคนที่ login จัดการได้ — roles ยัง out of scope ตาม 5.3.23) + +## Design + +### D1 — Schema (`db/schema.ts` + `drizzle/`) + +```ts +export const documentSets = pgTable("document_sets", { + id: uuid("id").primaryKey().defaultRandom(), + title: text("title").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), +}); + +// files เพิ่ม: +setId: uuid("set_id").references(() => documentSets.id, { onDelete: "set null" }), +// + index ("files_set_id_idx") บน set_id +``` + +- ตาราง `document_sets` ประกาศ**ก่อน** `files` (drizzle `references` ต้องเห็น table ก่อน) +- **ไม่เก็บผู้สร้างชุด** (ไม่มี user_id/user_name) — ชุดเป็นของกลางของคดี ใครก็จัดการได้ และไม่มี UI ใดในสเปกนี้ต้องโชว์ผู้สร้าง +- `onDelete: "set null"` — ลบชุดแล้วไฟล์ในชุดตกกลับเป็น "ไม่อยู่ชุด" **ไฟล์ไม่ถูกลบ** (คนละเรื่องกับ CASCADE ของ annotations ที่ผูกไฟล์) +- ไม่มี DTO/mapper ใหม่ — หน้าที่ใช้เป็น server component ทั้งหมด ส่งข้ามไป client เฉพาะ `{id, title}` ธรรมดา (createdAt ไม่ต้องส่ง — ไม่มี UI โชว์; ถ้าส่งต้อง ISO string ตาม invariant #5) +- migration: `/sync-db` (push) + **`npx drizzle-kit generate`** — rule data-layer: Docker migrate จาก SQL ใน `drizzle/` เท่านั้น + +### D2 — Server Actions (`app/actions/sets.ts`) + +`"use server"` + validate ก่อนทุกครั้ง (pattern `app/actions/files.ts`): + +- `createSet(title)` — title trim, ว่าง = error, ยาว ≤ 200 (เทียบ `files.title`) → insert +- `renameSet(setId, title)` — validate UUID + title เหมือนกัน + ชุุดมีจริง → update +- `deleteSet(setId)` — validate UUID + มีจริง → delete (ไฟล์ในชุดกลายเป็น null อัตโนมัติจาก FK) +- `setFileSet(fileId, setId | null)` — validate ไฟล์มีจริง + (ถ้าไม่ null) ชุดมีจริง → update `files.set_id` +- **ไม่ publish SSE event** — การจัดชุดไม่แตะข้อมูลรายไฟล์ใน viewer (annotation/bookmark เดิมทั้งหมด) จึงไม่มีอะไรให้ sync ผ่าน `/api/annotations/stream` (ซึ่งผูก `fileId` รายไฟล์อยู่แล้ว) — เหมือน `uploadFile` ที่ใช้ `revalidatePath` อย่างเดียว +- **revalidatePath ทุก action:** `/` เสมอ + `/sets/{setId}` เมื่อแก้ชุดนั้น (rename) — กัน Router Cache ฝั่ง client ค้างหน้าเก่า (เหตุผลเดียวกับ `revalidateFilePages`) +- สิทธิ์: ทุกคนที่ login — action เรียก `getSessionUser()` defense ซ้อน middleware (คืน error ถ้าไม่มี session) + +### D3 — หน้ารายการ `/` จัดกลุ่มตามชุด + +- query `files` + `documentSets` → จัดกลุ่ม: **section ต่อชุด** (เรียง `createdAt`) — หัวชุด = ชื่อชุด + จำนวนไฟล์ + **ลิงก์ "bookmark ของชุด" → `/sets/{id}`** (icon Bookmark) + เมนู ⋯ (แก้ชื่อ / ลบชุด — confirm ก่อนลบ) +- การ์ดไฟล์ในชุดเรียง `createdAt` เหมือนเดิม + ปุ่ม Annotate เดิม + **ปุ่ม "ใส่ชุด"** เปิด dropdown เลือกชุดที่มีอยู่ / "ออกจากชุด" (ถ้าอยู่ชุด) +- ไฟล์ที่ `set_id` null → section **"ไม่อยู่ชุด"** ไว้ท้ายสุด (ใกล้ฟอร์มสร้าง/อัปโหลด — workflow อัปโหลดแล้วค่อยย้ายเข้าชุด) — ซ่อนเมื่อว่าง +- ฟอร์มเล็ก **"สร้างชุด"** — section ของตัวเองคู่ "เพิ่มเอกสาร" (client component: input ชื่อ + ปุ่ม, `useTransition` + action `createSet`) +- client component ใหม่: `components/CreateSetForm.tsx` (ฟอร์มสร้างชุด), `components/SetFileMenu.tsx` (dropdown ย้ายเข้า/ออกชุดบนการ์ด + เมนูแก้ชื่อ/ลบของหัวชุด — รับ prop list ชุุด `{id, title}[]` จาก server) + +### D4 — หน้า `/sets/[id]` — bookmark รวมทั้งชุด (`app/sets/[id]/page.tsx`) + +Server component `force-dynamic` (มี login check ซ้อน middleware เหมือนหน้า `/`): + +- โหลด: ชุุด (ไม่มี = 404) + ไฟล์ในชุุด (`orderBy createdAt`) + bookmark ของทุกไฟล์ (`inArray(fileId, …)` `orderBy asc(pageIndex), asc(yOffset), asc(createdAt)` — N5) +- แสดง: หัวชุด (ชื่อ + "N เอกสาร · M รายการ") + ลิงก์กลับ `/` +- **จัดกลุ่มตามไฟล์** (หัวไฟล์ = ชื่อไฟล์) ตามลำดับไฟล์ในชุด → ข้างในแสดง**ต้นไม้ bookmark 3 ระดับ** (indent เหมือน sidebar — build `childrenOf`/`roots` จาก array ที่เรียง N5 แล้ว + orphan → root + cycle guard seen ชุดเดียวร่วมทุก root เหมือน pattern N7/route เดิม) **read-only**: ชื่อ + "หน้า N · ผู้บันทึก · วันที่" + โน้ต — คลิกทั้งแถว = ลิงก์ **`/annotate/{fileId}?page={หน้า}`** กระโดดเข้า viewer ตรงหน้า (deep link F12 — ⚠️ implement ต้องเช็คจาก F12 จริงว่า `?page=` เป็น 1-based หรือ 0-based ให้ตรงกับปุ่มกระโดดของหน้าสรุป F15) +- ไม่มีปุ่มแก้ไข/ลบ bookmark ที่นี่ (จัดการที่ viewer เท่านั้น) + ไม่มี SSE (snapshot ณ ตอนโหลด เหมือนหน้าสรุป F15) +- ปุ่มส่งออก JSON (N7) **ไม่มี**หน้านี้ — N7 รับ `fileId` เดียว การขยายเป็นชุดอยู่ใน out of scope + +### D5 — สิทธิ์/การเข้าถึง + +- `middleware.ts` บังคับ login ทุก path อยู่แล้ว — `/sets/*` + actions เข้า่ายใน net อัตโนมัติ +- ใครก็จัดการชุดได้ (สร้าง/แก้ชื่อ/ลบ/ย้ายไฟล์) — ไม่มี roles (5.3.23 ยัง out of scope) + +## Docs/rules sync (plan-first — ทำเป็น task แรก ก่อนแตะโค้ด) + +| ไฟล์ | สิ่งที่แก้ | +|---|---| +| `docs/PLAN.md` §3 | เพิ่มตาราง `document_sets` + column `files.set_id` (FK SET NULL) — rule data-layer: ห้ามเพิ่มตารางโดยไม่แก้ PLAN.md ก่อน | +| `docs/PLAN-FEAT.md` §7 | หัวข้อใหม่ **N8 · ชุดเอกสาร + bookmark รวมทั้งชุด** (format ตาม N5–N7 + อ้างแผนฉบับเต็ม `docs/PLAN-DOCSET.md`) | +| `CLAUDE.md` | whitelist ฟีเจอร์เสริมหลังแผน เพิ่ม N8 + โครงสร้างไฟล์เพิ่ม `app/actions/sets.ts`, `app/sets/[id]/page.tsx`, `components/CreateSetForm.tsx`, `components/SetFileMenu.tsx` | +| `.claude/rules/data-layer.md` | เพิ่ม: `document_sets` + `files.set_id` (FK **SET NULL** ไม่ใช่ CASCADE), actions ของ sets **ไม่ publish SSE** (ไม่แตะข้อมูลรายไฟล์) แต่ `revalidatePath("/")` (+`/sets/[id]` ตอน rename) เสมอ | + +- `docs/requirement/gap-analysis.md` — ปรับ 5.3.10 ⚠️ → ✊ **หลัง implement เสร็จเท่านั้น** (task ปิดงาน) ไม่ใช่ตอนนี้ +- `.claude/rules/realtime.md`, `pdf-viewer.md`, `coordinates.md` **ไม่แตะ** (ไม่มี SSE event ใหม่ ไม่แตะ viewer/พิกัด) + +## ลำดับ task + verify (ทุก task: `npx tsc --noEmit` ผ่าน) + +| # | งาน | ไฟล์หลัก | Verify เพิ่มเติม | +|---|---|---|---| +| 1 | Docs sync N8 | ตารางด้านบน | สเปก §7 ครบตาม format N5–N7; PLAN.md §3 มีตาราง/column ใหม่ | +| 2 | Schema + migrate | `db/schema.ts`, `drizzle/` | `/sync-db` + `drizzle-kit generate` → psql `\d files` เห็น `set_id` + `\d document_sets` มีตาราง; SQL generated idempotent (`IF NOT EXISTS`) | +| 3 | Server Actions | `app/actions/sets.ts` | ผ่าน UI ใน task 4–5; title ว่าง/ยาว = error; setId/fileId ไม่มีจริง = error ไม่ throw ระเบิดหน้า | +| 4 | หน้ารายการจัดกลุ่มชุด + ฟอร์มสร้าง + ย้ายไฟล์ | `app/page.tsx`, `components/CreateSetForm.tsx`, `components/SetFileMenu.tsx` | Playwright: สร้างชุุด → การ์ดจัดกลุ่มถูก → ย้ายไฟล์เข้า/ออกชุด → แก้ชื่อ/ลบชุดแล้วไฟล์ไม่หาย (กลับ "ไม่อยู่ชุด"); **regression: อัปโหลด N4 + Annotate ยังทำงาน** | +| 5 | หน้า `/sets/[id]` | `app/sets/[id]/page.tsx` | Playwright: bookmark หลายไฟล์แสดงครบ เรียง ไฟล์ → N5 ต้นไม้ indent ถูก; คลิกกระโดด `/annotate/{fileId}?page=` แล้ว viewer ไปถูกหน้า; ชุดว่าง / ไฟล์ไม่มี bookmark / ชุดที่ถูกลบ = 404 | +| 6 | ปิดงาน | — | plan-reviewer + code-reviewer + vitest + `tsc` + gap-analysis 5.3.10 → ✅ | + +## Edge cases (ออกแบบไว้ล่วงหน้า) + +1. **ลบชุดที่มีไฟล์** → FK `ON DELETE SET NULL` — ไฟล์ตกกลับ "ไม่อยู่ชุด" ไม่ถูกลบ (มี confirm dialog กันมือเพื่อน) +2. **เข้า `/sets/[id]` ชุดที่ถูกลบ** → 404 +3. **ชื่อชุดซ้ำ** → อนุญาต (ไม่ unique — คดีต่างงวดชื่อคล้ายกันได้ ไม่มีเหตุผลบังคับ) +4. **ชื่อชุดว่าง/whitespace/ยาวเกิน 200** → action validate (trim → ว่าง = error, clamp 200) +5. **ชุดว่าง (ไม่มีไฟล์)** → หน้า set แสดง "ยังไม่มีเอกสารในชุดนี้" — ชุดว่างยังโชว์หน้า `/` ได้ (สร้างชุดรอไว้ก่อนแล้วค่อยย้ายไฟล์เข้า) +6. **ไฟล์ในชุดไม่มี bookmark** → หัวไฟล์ + "ไม่มีที่คั่นหน้าในไฟล์นี้" (เห็นว่าไฟล์อยู่ชุดแต่ว่าง — ไม่หายเงียบ) +7. **cycle ใน bookmark tree** (แก้ตรง DB หลุด guard ของ action) → เหมือน N7: DFS seen ชุุดเดียวร่วมทุก root + เติมโหนดที่ไม่ถูกเดินเป็นระดับ 1 +8. **`?page=` format ของ F12** → เช็คจากโค้ดจริง (1-based/0-based) ให้ตรงกับปุ่มกระโดดหน้าสรุป F15 — อย่าเดา +9. **Dev DB แชร์กับการทดสอบของผู้ใช้** → เทส Playwright สร้างชุด/ย้ายไฟล์ด้วย id ที่จับเป๊ะ ไม่ลบ/รีเซ็ตข้อมูลทั้งหมด +10. **ไฟล์ถูก "อยู่ชุด" แล้วเลือกย้ายเข้าชุดเดิม** → update ค่าเดิม idempotent ไม่ error + +## Out of scope (ไม่ทำในแผนนี้) + +- ไฟล์อยู่หลายชุด (many-to-many) — ตัดสินใจแล้ว: ชุุดเดียว +- export/JSON bookmark **ตามชุด** (ขยาย N7 รับ `setId`) — N7 ยังรับ `fileId` เดียว +- roles/สิทธิ์การจัดการชุด (gap 5.3.23) +- reorder ไฟล์ในชุด / reorder ชุด / ค้นหาข้ามชุด (ขยาย N6 เป็นหลายไฟล์) +- หน้ารวมทุกชุด `/sets` (ทางเข้าอยู่หน้ารายการแล้ว) +- seed ตัวอย่างชุดใน `npm run seed-samples` (สร้างผ่าน UI ได้ — ไม่บังคับ) +- annotation รวมชุด (bookmark พอตาม requirement 5.3.10) + +## Verification (ภาพรวม end-to-end) + +1. `npm run dev` → login → หน้า `/`: สร้างชุด "คดีตัวอย่าง" → ย้ายไฟล์ 2 ไฟล์เข้าชุุด → การ์ดจัดกลุ่มใต้หัวชุด +2. หัวชุด → "bookmark ของชุด" → `/sets/[id]`: เห็น bookmark ทั้งสองไฟล์ จัดกลุ่มตามไฟล์ เรียง N5 ต้นไม้ indent ถูก +3. คลิกรายการ → กระโดดเข้า viewer ไฟล์นั้น**ถูกหน้า** +4. ลบชุด → ไฟล์ทั้งสองกลับมาอยู่ "ไม่อยู่ชุด" ครบ ไม่มีอะไรหาย +5. Regression: annotate / ค้นหา N6 / export JSON N7 / อัปโหลด N4 ยังทำงานเหมือนเดิม From 2f260e0f3b1cdad544f5b69f4a94eb62efc28095 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 17 Sep 2026 14:06:19 +0700 Subject: [PATCH 04/55] =?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 05/55] 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 06/55] =?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 07/55] =?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 08/55] =?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) */}