Compare commits

..

No commits in common. "dev" and "deploy1.0.1" have entirely different histories.

239 changed files with 2403 additions and 45972 deletions

View file

@ -1,177 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View file

@ -1,71 +0,0 @@
---
name: frontend-design
description: Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults.
license: Complete terms in LICENSE.txt
---
# Frontend Design
Approach this as the design lead at a design studio known for giving every client a distinct visual identity that is not mistaken for anyone else's. This client has already rejected proposals that felt cliché or templated, and is paying for a distinctive point of view: make deliberate, opinionated choices about palette, typography, and layout that are specific to this brief, and take aesthetic risk if justified.
## Ground your designs in the subject matter
If the brief does not identify what the product or subject matter is, identify it yourself before designing, and confirm with the client. You can come up with one concrete subject, the design's audience, and the design's primary job, as a proposal. If there's any information in your memory about the client's preferences or context about what they're building, use that as a hint. The subject's industry, subject matter, materials, and vernacular are where distinctive visual choices come from — a design for a toy for girls aged 8–11 will be very aesthetically different from a dashboard for financial analysts. Build with the brief's real content and subject matter throughout.
## Design principles
For web designs, the hero is the first thing viewers will see. Open with the most characteristic thing in the subject's world, in the form that is most appropriate: a headline, an image, an animation, a live demo, an interactive moment, or other treatments. Be deliberate with your choice: a big number with a small label, supporting stats, and a gradient accent is the default treatment, so only use it if that's truly the best option.
Typography carries the personality of the page. You don't need a different typeface for display or headline text and body content: use one family or two, and if two, make them clearly distinct.
Choose your typefaces deliberately, not the default families you would reach for on any other project, and set a clear type scale following the default guidance of The Elements of Typographic Style with intentional weights, widths, and spacing. When type is used as a headline or visual element, use the type treatment itself as an active part of the design, not a neutral delivery vehicle for the content.
Default to line lengths of less than 80 characters. Serif typefaces can have slightly longer line lengths; give serif body text slightly more line-height than a sans-serif.
Avoid these default typographic treatments; they are the commonest tells of a generated page:
- Accenting just a single word or phrase in a headline, like putting one word in italic/bold or a different color.
- Using all caps for labels.
- Adding unnecessary typographic labels above content.
Visual structure is information. Structural devices like outlines, borders, numbering, eyebrows, dividers, labels, etc., encode useful information about the content rather than decorate it. Many generic designs use numbered markers (01 / 02 / 03), but that's only appropriate if the content actually is a sequence — like a stepped process or a timeline. Before adding numbered markers, check the content really is a sequence.
Use non-user-triggered motion sparingly and deliberately, only to draw attention. A single orchestrated moment — one page-load sequence or one reveal — lands better than scattered effects; fade-and-slide-up entrances on each section and hover transitions on every card are the generic default and read as AI-generated. Motion that answers a person's action (opening, expanding, confirming) is welcome when it shows what changed.
Consider written content carefully. Often a design brief may not contain real content, and it's up to you to come up with copy and placeholder content. Copy can make a design feel as templated as the design itself. See the below section on writing for more guidance.
## Process: plan, review against the brief, build, critique
For calibration, AI-generated design right now clusters around some traits:
1. a warm cream background (near #F4F1EA) with a high-contrast serif display and a terracotta or warm-clay accent (often near #D97757 — Anthropic's own Codex-interaction accent, so on a user's brief it reads as a tell);
2. a near-black background with a single bright acid-green or vermilion accent;
3. a broadsheet-style layout with hairline rules, zero border-radius, and dense newspaper-like columns;
4. the SaaS-card kit: content chopped into identical rounded cards, one border-radius on everything regardless of hierarchy, the same soft grey shadow (rgba(0,0,0,.1)) under each, and gradient washes as decoration;
5. template chrome that appears whatever the subject: a tracked-out ALL-CAPS eyebrow label above every heading; meta strings joined with middle dots ('A · B · C'); labels built as 'WORD — fragment' with a spaced em dash; tinted near-black (#0B0B0B, #111) standing in for black; a monospace face for small data labels; a '→' appended to link and button text.
All traits are legitimate for some briefs, but they are defaults rather than choices, and they appear regardless of subject. Where the brief pins down a visual direction, follow it exactly — the brief's own words always win, including when it asks for one of these looks. Where it leaves an axis free, don't spend that freedom on one of these defaults. As with a hired human designer, there's often a careful balance between doing what you're good at and taking each project as a chance to experiment and learn.
Work in two passes. First, brainstorm a short design plan based on the client's design brief: create a compact token system with color, type, layout, and principles.
- Color: describe the core base palette as 4–6 named hex values.
- Type: the typefaces and their roles.
- Layout: a layout concept, using one-sentence prose descriptions and ASCII wireframes to ideate and compare. Include alignment guidance; should the content be left aligned, center aligned, justified?
- Principles: the high-level guidance for what makes this page unique.
Then review that plan against the brief before building: if any part of it reads like the generic default you would produce for any similar page (work through a similar prompt to see if you arrive somewhere similar) rather than a choice made for this specific brief — revise that part, say what you changed and why. Only after you've confirmed the relative uniqueness of your design plan should you start to write the code, following the revised plan.
When writing the code, be careful of structuring your CSS selector specificities. It's easy to generate CSS classes that cancel each other out (especially with a type-based selector like .section and an element-based selector like .cta). This can happen often with padding/margin between sections.
## Restraint and self-critique
Spend your boldness in one place. Let one element be the memorable thing, keep everything around it quiet and disciplined, and cut any decoration that does not serve the brief. Build to a quality floor without announcing it: responsive down to mobile, visible keyboard focus, reduced motion respected, visually accessible, harmonious color palettes. Critique your own work as you build, taking screenshots to review if your environment supports it — a picture is worth 1000 tokens. Consider Chanel's advice: before leaving the house, take a look in the mirror and remove one accessory. Human creatives have memory and always try to do something new, so if you have a space to quickly jot down notes about what you've tried, it can help you in future passes.
## More on writing in design
Words appear in a design for one reason: to make it easier to understand and use. They are design content, not decoration. Bring the same intentionality and minimalism to copywriting that you would bring to spacing and color. Before writing anything, ask what the design needs to say, and how it can best be said to help the person navigate the experience.
Write from the end user's perspective. Name things by what users will understand in simple language, not by how the system is built. A user manages notifications, not webhook config. Describe what something is or does in plain terms rather than selling it. Being specific and legible to new users is always better than being clever.
Use active voice as default. A CTA says exactly what happens when it is used: "Save changes," not "Submit." An action keeps the same name through the whole flow, so the button that says "Publish" produces a toast that says "Published." The vocabulary of an interface is the signposting for someone navigating the product. Cohesion and consistency are how people learn their way around.
Treat failure and emptiness as moments for direction, not mood. Explain what went wrong and how to fix it, in the interface's voice rather than a person's. Errors don't apologize, and they are never vague about what happened. An empty screen is an invitation to act.
Keep the tone conversational: plain verbs, sentence case, no filler, with tone matched to the brand and the audience. Let each written element do exactly one job.

View file

@ -1,28 +0,0 @@
---
name: implement-feat
description: Implement task F1–F19 จาก docs/PLAN-FEAT.md (แก้ไข/ย้าย A1, undo/redo A2, รูปทรง B1, redaction+export B4/C1, reply A4, resolve A5, ค้นหา/สรุป D1–D3) พร้อม verify ตามเกณฑ์ — ใช้ /implement-feat <F-number หรือ feature id เช่น A1>
argument-hint: "[F1–F19 หรือ feature id เช่น A1, B4]"
---
Implement task ที่ระบุใน arguments จากตาราง `docs/PLAN-FEAT.md` §4 (ถ้าไม่ระบุ ให้ถามก่อน)
## ขั้นตอน
1. **อ่านก่อนเสมอ:** `docs/PLAN-FEAT.md` ทั้งไฟล์ + `AGENTS.md` (invariants) + rules ใน `.Codex/rules/` ที่เกี่ยวกับไฟล์ของ task นั้น — แผนเปลี่ยนได้ อย่าจำจากครั้งก่อน
2. **Map argument → task:** รับได้ทั้ง `F<n>` และ feature id (A1, B4, C1, D2, E1, …) — หาจากตาราง §4 (B4 กับ C1 คือ task เดียวกัน F8 ตามข้อตกลง D4)
3. **ตรวจ dependency ตามคอลัมน์ "ต้องมีก่อน"** — ถ้ายังไม่มี ให้หยุดและแจ้งผู้ใช้ว่าต้องทำ task ใดก่อน (อย่าลัดขั้นตอนไป implement เอง)
4. **Implement เฉพาะสิ่งที่ task ระบุ ตามสเปกในแผนเท่านั้น** — ห้ามเพิ่ม feature, abstraction, หรือ "ความยืดหยุ่น" ที่แผนไม่ได้บอก; ทำตามข้อตกลง D1–D11 ใน §0 เสมอ
5. **แตะ schema (`db/schema.ts`):** รัน `/sync-db` หลังแก้ + เติม validation ใน `validateAnnotationData` ครบทุก type/case ใหม่
6. **Sync invariant/rules ตามตาราง §6 ของแผน** — แก้ CLAUDE.md/PLAN.md/`rules/*.md` ตามแถวของ task นั้น (นี่คือส่วนหนึ่งของงาน ไม่ใช่งานเพิ่มเติม)
7. **เจอ edge case ที่แผนไม่ครอบคลุม (ดู §5 ก่อน):** หยุดเขียนโค้ด → อัปเดต `docs/PLAN-FEAT.md` ก่อน (+ `AGENTS.md` ถ้าเป็น invariant ใหม่) → ถ้าเป็นเรื่องที่ต้องตัดสินใจเชิงออกแบบ ให้ถามผู้ใช้ → ค่อยเขียนโค้ดตามที่ปรับแล้ว
8. **Verify ตามคอลัมน์ Verify จริง ๆ** — รัน `npx tsc --noEmit` ทุก task + `npm test` เมื่อแตะ `utils/` + รัน/ตรวจข้อที่ระบุ; ข้อที่ต้องทดสอบใน browser จริง (เช่น realtime 2 แท็บ, อุปกรณ์สัมผัส, เปิดไฟล์ export) ให้ระบุชัดว่า "ค้างทดสอบด้วยมือ: <รายการ>" อย่าเดาว่าผ่าน
9. **เรียก agent `plan-reviewer`** ตรวจโค้ดของ task นี้ — แก้เฉพาะข้อที่ reviewer ชี้และถูกต้องจริง
10. **สรุปตอนจบ:** ไฟล์ที่สร้าง/แก้, ผล verify แต่ละข้อ (ผ่าน/ไม่ผ่าน/ค้างทดสอบมือ), invariant/rules ที่ sync ไป, สิ่งที่ต้องทำต่อ (task ถัดไปที่ปลดล็อก)
## ข้อห้าม
- ห้าม implement task อื่นรวมด้วย ("ขอทำเพิ่ม" ไม่ใช่ข้ออ้าง) — เดินตามลำดับ F1→F19
- ห้ามแยก B4 (redaction) ออกจาก C1 (export) — เป็น task เดียวกัน (F8) ตามข้อตกลง D4
- ห้าม mark ว่า verify ผ่านโดยไม่ได้รันจริง
- ห้ามบันทึกพิกัด px ลง DB — ทุก type ใหม่เป็น % ของขนาดหน้า (invariant #2)
- ห้ามเขียน/แก้ไฟล์ PDF ต้นฉบับ — export สร้างไฟล์ใหม่เท่านั้น (invariant #1)

View file

@ -1,22 +0,0 @@
---
name: implement-task
description: Implement task ตามลำดับใน docs/PLAN.md §7 (1 Scaffolding+Schema → 2 Coordinate helpers → 3 Server Actions → 4 Realtime hook → 5 UI หลัก → 6 README) พร้อม verify ตามเกณฑ์ของ task นั้น — ใช้ /implement-task <หมายเลข 1-6>
argument-hint: "[หมายเลข task 1-6]"
---
Implement task หมายเลขที่ระบุใน arguments จากตาราง `docs/PLAN.md` §7 (ถ้าไม่ระบุ ให้ถามก่อน)
## ขั้นตอน
1. **อ่านก่อนเสมอ:** `docs/PLAN.md` ทั้งไฟล์ + `AGENTS.md` (invariants) + rules ใน `.Codex/rules/` ที่เกี่ยวกับไฟล์ของ task นั้น
2. **Implement เฉพาะไฟล์ที่ task ระบุ ตามสเปกแผนเท่านั้น** — ห้ามเพิ่ม feature, abstraction, หรือ "ความยืดหยุ่น" ที่แผนไม่ได้บอก (อ้าง Task order: ทำทีละ task ไม่กระโดด)
3. **เจอ edge case ที่แผนไม่ครอบคลุม:** หยุดเขียนโค้ด → อัปเดต `docs/PLAN.md` ก่อน (+ `AGENTS.md` ถ้าเป็น invariant ใหม่) → ถ้าเป็นเรื่องที่ต้องตัดสินใจเชิงออกแบบ ให้ถามผู้ใช้ → ค่อยเขียนโค้ดตามที่ปรับแล้ว
4. **Verify ตามคอลัมน์ Verify ของ task นั้นจริง ๆ** — รันคำสั่งจริง ตรวจผลลัพธ์จริง ถ้ามีข้อที่ต้องทดสอบด้วยมือใน browser ให้ระบุชัดว่า "ค้างทดสอบด้วยมือ: <รายการ>" — ถ้า task แตะ `utils/` ให้รัน `npm test` (vitest — pure utils) เสมอ
5. **เรียก agent `plan-reviewer`** ตรวจโค้ดของ task นี้ — แก้เฉพาะข้อที่ reviewer ชี้และถูกต้องจริง
6. **สรุปตอนจบ:** ไฟล์ที่สร้าง/แก้, ผล verify แต่ละข้อ (ผ่าน/ไม่ผ่าน/ค้างทดสอบมือ), สิ่งที่ต้องทำต่อใน task ถัดไป
## ข้อห้าม
- ห้าม implement task ถัดไปด้วย ("ขอทำเพิ่ม" ไม่ใช่ข้ออ้าง)
- ห้ามเปลี่ยนไฟล์ของ task อื่นที่ไม่เกี่ยวกับ task นี้
- ห้าม mark ว่า verify ผ่านโดยไม่ได้รันจริง

View file

@ -1,17 +0,0 @@
---
name: sync-db
description: Sync Drizzle schema ลง PostgreSQL container judes-note-db + seed ไฟล์ตัวอย่าง — ใช้หลังแก้ db/schema.ts หรือตอน setup ครั้งแรก
---
## ขั้นตอน
1. **Push schema:** รัน `npx drizzle-kit push` (อ่าน config จาก `drizzle.config.ts`) — รายงาน output จริง ถ้าชนของเดิมให้แสดงและถามผู้ใช้ก่อน destructive ทางเลือก
2. **Seed ตัวอย่าง (เฉพาะเมื่อตาราง `files` ว่าง):** INSERT ไฟล์จาก `public/note/samples/` โดย `pdf_url = '/samples/<ชื่อไฟล์>'` (ไฟล์และที่มาดูใน PLAN.md §0 ตาราง "ไฟล์ตัวอย่าง") — ใช้ SQL จาก README ถ้ามีแล้ว รันผ่าน `docker exec judes-note-db psql -U postgres -c "..."`
3. **รายงานผลแต่ละขั้นตามจริง** — ขั้นไหนไม่ได้ทำบอกสาเหตุ อย่าเดา
## ข้อควรระวัง
- `DATABASE_URL` ชี้ที่ container `judes-note-db` (postgres:17-alpine, พอร์ต 54322 — ไม่ใช่ Supabase) — ถ้า container ไม่รันอยู่ ให้สั่ง `docker start judes-note-db` หรือแจ้งผู้ใช้ตามคำสั่ง docker run ใน README
- ห้ามลบ volume `judes-note-db-data` เว้นแต่ผู้ใช้สั่งชัดเจน (มีข้อมูล annotation จริงหายได้)
- ไม่ต้องตั้ง realtime publication/replica identity อีกต่อไป (SSE sync ผ่าน `lib/annotate-events.ts` ไม่ผ่าน DB trigger)

View file

@ -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 ตรง 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 สำเร็จ 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 ตรง ๆ 7. **ISO string DTO** — timestamp ข้าม server→client→realtime เป็น ISO string เสมอ ไม่มี `Date` serialize ตรง ๆ
8. **pdfjs** — dynamic import ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")`; ห้าม `new URL(..., import.meta.url)` 8. **pdfjs** — dynamic import ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"`; ห้าม `new URL(..., import.meta.url)`
9. **InkLayer** — SVG ไม่มี `viewBox`; แปลง % → px ด้วยขนาดจาก ResizeObserver แล้ว re-render เมื่อ resize/zoom 9. **InkLayer** — SVG ไม่มี `viewBox`; แปลง % → px ด้วยขนาดจาก ResizeObserver แล้ว re-render เมื่อ resize/zoom
10. **JSONB strict** — `data` เป็น discriminated union `$type<AnnotationData>()` ตาม enum `type` — ไม่มี `any` 10. **JSONB strict** — `data` เป็น discriminated union `$type<AnnotationData>()` ตาม enum `type` — ไม่มี `any`
11. **Optimistic** — ใช้ `useOptimistic` ภายใน `startTransition`; realtime hook dedupe ด้วย id; ไม่มี id `temp-*` ค้างใน state 11. **Optimistic** — ใช้ `useOptimistic` ภายใน `startTransition`; realtime hook dedupe ด้วย id; ไม่มี id `temp-*` ค้างใน state

View file

@ -11,8 +11,7 @@ paths:
- ทิศทางแปลง: px → % = `÷size×100`, % → px = `×size÷100` — ครบทั้ง point/rect/path - ทิศทางแปลง: px → % = `÷size×100`, % → px = `×size÷100` — ครบทั้ง point/rect/path
- `strokeWidth` เก็บเป็น % ของความกว้างหน้า; `yOffset` เก็บเป็น % ของความสูงหน้า (0–100) - `strokeWidth` เก็บเป็น % ของความกว้างหน้า; `yOffset` เก็บเป็น % ของความสูงหน้า (0–100)
- `mergeRectsByLine(rects)`: จัดกลุ่ม rects ที่ y/height ใกล้กัน (บรรทัดเดียวกัน) → union แกน x → คืน 1 rect ต่อบรรทัด — เรียกทุกครั้ง**ก่อนบันทึก HIGHLIGHT** ลง DB - `mergeRectsByLine(rects)`: จัดกลุ่ม rects ที่ y/height ใกล้กัน (บรรทัดเดียวกัน) → union แกน x → คืน 1 rect ต่อบรรทัด — เรียกทุกครั้ง**ก่อนบันทึก HIGHLIGHT** ลง DB
- `rectsInSweptPath(rects, start, end)` (กรอง selection rects ตามเส้นทางลาก): บรรทัดแรก = จุดเริ่ม→ท้ายบรรทัด, บรรทัดสุดท้าย = ต้นบรรทัด→จุดจบ, **บรรทัดกลางเก็บทั้งบรรทัดเฉพาะช่วงคอลัมน์เดียวกับข้อความที่เก็บได้จริง** — ช่วงคอลัมน์ยึดจากขอบซ้าย/ขวาจริงของ rect ที่เก็บได้จากบรรทัดแรก/สุดท้าย (ไม่ใช่พิกัดจุดลากตรง ๆ — ลากชัน/ลาก margin ซ้ายก็ยังเก็บบรรทัดกลางเต็มตาม native) + ขยายริมละ 2×lineTol ให้ทน justify gap ระหว่าง rect (pad อิงความสูงบรรทัด ตาม zoom) — span ประกอบที่ห่างเกิน pad (✓ checkbox คอลัมน์ขวาสุดของฟอร์มราชการ ที่ pdf.js วางไว้ระหว่างบรรทัดข้อความพอดีจน DOM ของ native selection ครอบติดมาด้วย) ถูกตัดทิ้ง (bug report 2026-10-05 + code review ข้อ 1)
- InkLayer: SVG **ห้ามมี `viewBox`** (ความหนาเส้นจะบิดเบี้ยวเมื่อ scale แกน x/y ไม่เท่ากัน) — render ระบบ px จริง แปลง % → px ด้วยขนาดจาก `ResizeObserver` แล้ว re-render เมื่อ resize/zoom - InkLayer: SVG **ห้ามมี `viewBox`** (ความหนาเส้นจะบิดเบี้ยวเมื่อ scale แกน x/y ไม่เท่ากัน) — render ระบบ px จริง แปลง % → px ด้วยขนาดจาก `ResizeObserver` แล้ว re-render เมื่อ resize/zoom
- พิกัดจาก event ใช้ `getBoundingClientRect()` ของ page element เป็นฐานเสมอ และ clamp ค่า % ให้อยู่ 0–100 - พิกัดจาก event ใช้ `getBoundingClientRect()` ของ page element เป็นฐานเสมอ และ clamp ค่า % ให้อยู่ 0–100
- `utils/geometry.ts` (PLAN-FEAT.md F1) — pure functions บนพิกัด %: `annotationBBox` / `translateAnnotationData` / `scaleAnnotationData` / `hitTestPath` — ก่อนส่ง data ที่แปลงแล้วเข้า `updateAnnotation` ต้อง **clamp offset/scale ให้ bbox ทั้งกลุ่มอยู่ใน 0–100** เพราะ validator ปฏิเสธค่าติดลบ — **ยกเว้น STAMP เอียง (มี `rotation`): `position` เป็นมุมกรอบ*ก่อนหมุน* ซึ่งไม่ถูกครอบโดยกรอบจริงบนจอ — เกณฑ์ความถูกต้อง (ทั้ง clamp ฝั่ง client และ validator) ใช้ **AABB ของกรอบหมุน** (`stampRect`/`rotatedRectAABB`) ตัวเดียวกันทั้งสองฝั่ง ไม่ใช่ position ตรง ๆ — ใช้คนละเกณฑ์แล้วลากถึงขอบหน้าแล้ว commit โดนปฏิเสธ เด้งกลับ (code review 2026-09-28) · ตอนตั้งมุมใหม่ (patch/ลากหมุน) และตอน patch ที่เปลี่ยนขนาดกรอบของตรายางเอียง (`stampScale`/`label` เมื่อ rotation ≠ 0) ต้องเรียก `clampStampIntoPage` เลื่อน position กลับเข้าหน้าทันที (code review 2026-09-29) · validator ฝั่ง server ยึด `stampLayoutEstimate` ขณะ client clamp ยึด measure จริงของ browser — สองตัววัดนี้คลาดกันตามฟอนต์ของเครื่อง validator จึงผ่อน tolerance ให้ AABB ติดลบได้ไม่เกิน 2% ของหน้า ไม่ใช่ปฏิเสธทันที (code review 2026-09-29)** - `utils/geometry.ts` (PLAN-FEAT.md F1) — pure functions บนพิกัด %: `annotationBBox` / `translateAnnotationData` / `scaleAnnotationData` / `hitTestPath` — ก่อนส่ง data ที่แปลงแล้วเข้า `updateAnnotation` ต้อง **clamp offset/scale ให้ bbox ทั้งกลุ่มอยู่ใน 0–100** เพราะ validator ปฏิเสธค่าติดลบ
- AnnotationSelectionLayer: container `pointer-events: none` เสมอ เฉพาะ handle มุม/ขอบกรอบ/แถบเครื่องมือที่ `auto` — ห้ามให้กรอบ selection บัง textLayer - AnnotationSelectionLayer: container `pointer-events: none` เสมอ เฉพาะ handle มุม/ขอบกรอบ/แถบเครื่องมือที่ `auto` — ห้ามให้กรอบ selection บัง textLayer

View file

@ -19,7 +19,5 @@ paths:
- `annotations.resolved` (NOT NULL default false) + `resolved_at` (NULL) — F10/A5: `setAnnotationResolved` **ใครก็ได้ toggle** (D6 คลายคืนได้ ต่างจาก delete ที่จำกัดเจ้าของ) + publish update event; ไม่เข้า history undo ของ F2 - `annotations.resolved` (NOT NULL default false) + `resolved_at` (NULL) — F10/A5: `setAnnotationResolved` **ใครก็ได้ toggle** (D6 คลายคืนได้ ต่างจาก delete ที่จำกัดเจ้าของ) + publish update event; ไม่เข้า history undo ของ F2
- ตาราง `annotation_versions` (F19/A7): `annotation_id` เป็น uuid **ไม่ FK** (D8 — snapshot ตอน delete ต้องรอดจาก CASCADE ของแม่) + `file_id` FK CASCADE + `page_index` (restore ต้องรู้หน้า) + `kind` text `'update' | 'delete'` + index `(file_id)`; `updateAnnotation`/`deleteAnnotation` snapshot row เดิม **ก่อนเขียน/ลบ ใน transaction เดียวกัน**; `restoreVersion(versionId, userId)` — **เจ้าของเท่านั้น (D9)**: guard `userId` ทั้ง row ปัจจุบัน (ต้นทางยังอยู่) และ snapshot (ต้นทางถูกลบ) / ต้นทางยังอยู่ = **เขียนกลับ row เดิม** (snapshot ปัจจุบันก่อน) + publish update event / ต้นทางถูกลบ = สร้างใหม่ id ใหม่ + publish create event (ห้ามสร้างสำเนาใหม่ทุกกรณี — เคยทำให้เกิดรายการซ้ำบนหน้า) - ตาราง `annotation_versions` (F19/A7): `annotation_id` เป็น uuid **ไม่ FK** (D8 — snapshot ตอน delete ต้องรอดจาก CASCADE ของแม่) + `file_id` FK CASCADE + `page_index` (restore ต้องรู้หน้า) + `kind` text `'update' | 'delete'` + index `(file_id)`; `updateAnnotation`/`deleteAnnotation` snapshot row เดิม **ก่อนเขียน/ลบ ใน transaction เดียวกัน**; `restoreVersion(versionId, userId)` — **เจ้าของเท่านั้น (D9)**: guard `userId` ทั้ง row ปัจจุบัน (ต้นทางยังอยู่) และ snapshot (ต้นทางถูกลบ) / ต้นทางยังอยู่ = **เขียนกลับ row เดิม** (snapshot ปัจจุบันก่อน) + publish update event / ต้นทางถูกลบ = สร้างใหม่ id ใหม่ + publish create event (ห้ามสร้างสำเนาใหม่ทุกกรณี — เคยทำให้เกิดรายการซ้ำบนหน้า)
- ห้ามเพิ่มตาราง/column ที่ไม่มีในแผน (ดู out of scope §9) โดยไม่อัปเดต `docs/PLAN.md` ก่อน - ห้ามเพิ่มตาราง/column ที่ไม่มีในแผน (ดู out of scope §9) โดยไม่อัปเดต `docs/PLAN.md` ก่อน
- `files.pdf_url` เก็บ path **รูปภาพ** (jpg/jpeg/png/tif/tiff) ใน `public/note/` ได้เหมือน PDF (N3) — **ไม่มี column kind**: ชนิดไฟล์ derive จาก extension ผ่าน `utils/file-kind.ts` เท่านั้น (จะเพิ่ม column ต้องแก้แผนก่อน); TIFF หลายเฟรม = `pageIndex` ต่อเฟรม (1 เฟรม = 1 หน้า) - `files.pdf_url` เก็บ path **รูปภาพ** (jpg/jpeg/png/tif/tiff) ใน `public/` ได้เหมือน PDF (N3) — **ไม่มี column kind**: ชนิดไฟล์ derive จาก extension ผ่าน `utils/file-kind.ts` เท่านั้น (จะเพิ่ม column ต้องแก้แผนก่อน); TIFF หลายเฟรม = `pageIndex` ต่อเฟรม (1 เฟรม = 1 หน้า)
- Server Action `uploadFile` (`app/actions/files.ts`, N4): validate extension (whitelist `SAMPLE_FILE_EXTENSIONS`) + ≤ 100MB ก่อนเขียน — ชื่อไฟล์ต้องผ่าน `decodeUploadFilename` ของ `utils/upload-name.ts` ก่อนใช้ (ทั้ง extension และ title): undici ถอด `Content-Disposition` เป็น latin1 ทำชื่อ UTF-8 เพี้ยน (ชื่อไทยกลายเป็น "คำ…" — 2026-10-05) — บันทึกเป็นไฟล์**ใหม่** `public/note/uploads/<uuid>.<ext>` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row (รวม `type_id` จากช่องกลุ่มทางเลือกของฟอร์ม — 2026-09-23) แล้ว `revalidatePath("/note")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/` - Server Action `uploadFile` (`app/actions/files.ts`, N4): validate extension (whitelist `SAMPLE_FILE_EXTENSIONS`) + ≤ 100MB ก่อนเขียน — บันทึกเป็นไฟล์**ใหม่** `public/uploads/<uuid>.<ext>` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row แล้ว `revalidatePath("/")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/`
- `files.type_id` (N9 — รหัสกลุ่มเอกสาร จากลิงก์ e-Court `?typeId=` หรือช่อง "กลุ่ม typeId" ของฟอร์มอัปโหลด N4): **เขียนสองที่** — (1) `registerExternalFile` ของ N8 (insert ครั้งแรก + update เมื่อลิงก์รอบใหม่ส่ง `typeId` มา — ลิงก์ไม่ส่ง = คงเดิม ห้ามเคลียร์) (2) `uploadFile` ของ N4 ช่องทางเลือกในฟอร์มอัปโหลด — insert ตอนสร้าง row ใหม่เท่านั้น ไม่มีหน้าทางแก้กลุ่มของไฟล์เดิม; normalize ผ่าน `normDocumentGroup` ของ `utils/external-file.ts` ที่เดียว (trim → ว่าง = NULL, cap 200 — responsive audit 2026-09-24 รวมจากเดิมที่เป็นสองสำเนา mirroring กัน) — N9 อ่านอย่างเดียว (หน้า `/` จัด section + `/groups/[typeId]`) ไม่มี Server Action/SSE ใหม่; กลุ่ม = ค่า distinct ของ `type_id` ณ ตอน query — ไม่มีตารางกลุ่ม
- API ภายนอก (N10 — `app/api/note/external/documents` เส้นเดียว **POST + JSON body** `{fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}}` — ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข): **read-only ทั้งหมด** — ไม่เขียน DB ไม่ publish SSE event ไม่ `revalidatePath` (snapshot ณ ตอนเรียก ต่าง context กับ Server Action ใน invariant #4); auth ผ่าน `requireExternalApiKey` ของ `lib/external-api-auth.ts` เป็นอย่างแรกก่อนแตะ DB เสมอ (header `x-api-key` เทียบ env `EXTERNAL_API_KEY` แบบ timing-safe) — **env ว่าง/ไม่ตั้ง = fail closed 401 ทุก request** ไม่มี mode เปิดฟรี; ผู้เรียกคือ backend e-Court (server-to-server) จึงไม่ใส่ CORS headers; validation ของ body (`parseQueryBody`) + การ query/mapper (`loadDocumentBundles` — bulk `inArray` ทุกไฟล์ในชุด query เดียว ไม่ยิงต่อไฟล์) รวมอยู่ `lib/external-api.ts` จุดเดียว ห้ามแยกสำเนา; route ครอบ try/catch ทุก DB call — query ล้ม = log ฝั่ง server + **500 JSON ตาม contract** ของ endpoint (ไม่ปล่อยเป็น 500 ของ framework); `lib/external-api-auth.ts` ห้าม import module ที่ลาก `@/db` มา (unit test ต้องรันได้ไม่ต้องตั้ง DATABASE_URL)

File diff suppressed because one or more lines are too long

View file

@ -7,7 +7,7 @@ paths:
# Realtime Rules (รายละเอียด: docs/PLAN.md §4) # Realtime Rules (รายละเอียด: docs/PLAN.md §4)
- Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `withBasePath("/api/note/annotations/stream?fileId=<uuid>")` (basePath ตาม invariant #12) และ `es.close()` เมื่อ unmount - Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `/api/annotations/stream?fileId=<uuid>` และ `es.close()` เมื่อ unmount
- `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด — **ห้ามทำ retry logic เอง** - `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด — **ห้ามทำ retry logic เอง**
- Event payload เป็น **DTO (camelCase + ISO string) จาก server ตรง ๆ** — ไม่มีการแปลง snake_case - 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 - 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

View file

@ -6,12 +6,10 @@ description: Sync Drizzle schema ลง PostgreSQL container judes-note-db + see
## ขั้นตอน ## ขั้นตอน
1. **สร้างไฟล์ migration:** รัน `npx drizzle-kit generate` (อ่าน config จาก `drizzle.config.ts` — เทียบ `db/schema.ts` กับไฟล์ที่มีใน `drizzle/` ไม่ต้องต่อ DB) — ถ้าไม่มีการเปลี่ยนแปลงจะขึ้น "No schema changes" แล้วจบเฉย ๆ 1. **Push schema:** รัน `npx drizzle-kit push` (อ่าน config จาก `drizzle.config.ts`) — รายงาน output จริง ถ้าชนของเดิมให้แสดงและถามผู้ใช้ก่อน destructive ทางเลือก
2. **Apply + seed:** รัน `npm run db:prepare` (= compose up + `scripts/migrate.mjs` apply ไฟล์ใหม่ + `scripts/seed-samples.mjs` sync ไฟล์จาก `public/note/samples/`) — idempotent 2. **Seed ตัวอย่าง (เฉพาะเมื่อตาราง `files` ว่าง):** INSERT ไฟล์จาก `public/samples/` โดย `pdf_url = '/samples/<ชื่อไฟล์>'` (ไฟล์และที่มาดูใน PLAN.md §0 ตาราง "ไฟล์ตัวอย่าง") — ใช้ SQL จาก README ถ้ามีแล้ว รันผ่าน `docker exec judes-note-db psql -U postgres -c "..."`
3. **รายงานผลแต่ละขั้นตามจริง** — ขั้นไหนไม่ได้ทำบอกสาเหตุ อย่าเดา 3. **รายงานผลแต่ละขั้นตามจริง** — ขั้นไหนไม่ได้ทำบอกสาเหตุ อย่าเดา
> ห้ามใช้ `npx drizzle-kit push` — production (docker/web.Dockerfile) apply จากไฟล์ `drizzle/*.sql` เท่านั้น ถ้า dev push schema ตรง ๆ ไฟล์ migration จะตามไม่ทันแล้วสองฝั่งเพี้ยนกัน
## ข้อควรระวัง ## ข้อควรระวัง
- `DATABASE_URL` ชี้ที่ container `judes-note-db` (postgres:17-alpine, พอร์ต 54322 — ไม่ใช่ Supabase) — ถ้า container ไม่รันอยู่ ให้สั่ง `docker start judes-note-db` หรือแจ้งผู้ใช้ตามคำสั่ง docker run ใน README - `DATABASE_URL` ชี้ที่ container `judes-note-db` (postgres:17-alpine, พอร์ต 54322 — ไม่ใช่ Supabase) — ถ้า container ไม่รันอยู่ ให้สั่ง `docker start judes-note-db` หรือแจ้งผู้ใช้ตามคำสั่ง docker run ใน README

View file

@ -1,37 +0,0 @@
name = "code-reviewer"
description = "ตรวจคุณภาพโค้ดของ judes-note — bug, edge case, TypeScript strict, React 18/Next.js 14 pitfalls (memory leak, stale closure, server/client boundary), security (path traversal, XSS), error handling + รัน tsc — ใช้คู่กับ plan-reviewer (ตัวนั้นตรวจ invariants ตามแผน ตัวนี้ตรวจคุณภาพโค้ดทั่วไป) รายงานผลเฉย ๆ ไม่แก้โค้ดเอง"
developer_instructions = """
You review code quality of Judge's Note — PDF annotation system for e-Court (Next.js 14 App Router + TypeScript strict + React 18 + pdfjs-dist v4 + Drizzle + PostgreSQL + SSE). ตอบเป็นภาษาไทย เว้น technical terms
## ขอบเขต (แบ่งงานกับ plan-reviewer)
- คุณตรวจ **คุณภาพโค้ด** — ถูกต้อง, ปลอดภัย, ไม่มี leak, อ่านรู้เรื่อง
- **ไม่ต้อง**ไล่ checklist invariant ของแผน (พิกัด %, publish event, DTO ISO, layer stack, virtualization ฯลฯ) — เป็นหน้าที่ของ plan-reviewer; ถ้าเห็นชัด ๆ ระหว่างทาง แจ้งเป็น "ข้อสังเกตให้ plan-reviewer" สั้น ๆ พอ
## ขั้นตอน
1. อ่าน `AGENTS.md` เอาบริบท stack + โครงสร้างไฟล์
2. กำหนดขอบเขตจาก prompt: ถ้าระบุไฟล์/feature มา ตรวจไฟล์นั้น + ไฟล์ที่เรียกใช้ตรง ๆ; ถ้าไม่ระบุ ตรวจทั้ง codebase เริ่มจาก `app/`, `components/`, `hooks/`, `utils/`, `lib/`, `db/` — โปรเจกต์นี้**ไม่มี git** อย่าพยายามดู diff
3. รัน `npx tsc --noEmit` และ `npm test` (vitest — pure utils) — รายงาน output จริง (ถ้าไม่มี node_modules ระบุว่าข้าม อย่าเดา)
4. ไล่ checklist ด้านล่างทีละข้อ — Grep หา pattern ก่อน แล้ว Read ยืนยันทีละจุด อย่ารายงานจากชื่อ function เฉย ๆ ทุกข้อต้องมี scenario จริงที่ทำให้พัง
## Checklist
1. **Correctness** — edge case: array ว่าง, ค่า falsy ที่ตั้งใจ (`0`/`""`), null/undefined ก่อนเข้าถึง, off-by-one, async race (คำตอบกลับมาไม่ตามลำดับ เช่น สลับไฟล์แล้ว event/response เก่ามาถึงช้ากว่า)
2. **TypeScript strict** — `any`, `as` ฝืน type, non-null `!`, `@ts-ignore`/`@ts-expect-error` — แต่ละจุดถามว่าปรับ type ให้ถูกเองได้ไหมแทนการปิดเตา
3. **React/hooks** — useEffect มี cleanup ครบ (EventSource, ResizeObserver, IntersectionObserver, addEventListener, timer); deps array ผิดจนเกิด stale closure; setState หลัง unmount; list key ไม่ stable/unique
4. **Server/client boundary** — โค้ดฝั่ง server (`db/`, `fs`, `lib/server-pdf.ts`) ไม่หลุดเข้า client bundle; server actions ไม่เชื่อ input จาก client — validate ทุก field ก่อนเขียน DB (id เป็น uuid จริง, ความยาว, enum ตรง)
5. **Security** — อ่านไฟล์ต้องผ่าน `lib/server-pdf.ts` เท่านั้น (กัน path traversal), ไม่มี `dangerouslySetInnerHTML` กับข้อมูลผู้ใช้, SQL เป็น Drizzle parameterized ไม่มี string concatenation, ไม่มี secret ฝังในโค้ด
6. **Error handling** — `catch` แล้วกลืนเงียบโดยไม่มีเหตุผล, promise ไม่มี await/handler, error ที่ผู้ใช้ควรเห็นแต่หายไปเงียบ ๆ
7. **Performance ที่เห็นจากโค้ด** — งานหนักใน handler ความถี่สูง (pointermove) ที่ควร throttle/rAF, คำนวณซ้ำทุก render ที่แพงจริง, O(n²) กับ annotation จำนวนมาก — ไม่ต้องแนะนำ memo/optimization กันดาม
8. **ความเรียบง่าย** — dead code, ตรรกะซ้ำที่มี util อยู่แล้ว (`utils/coordinates.ts`, `utils/geometry.ts`, `utils/annotation-display.ts`), abstraction ที่ใช้แค่ที่เดียว — ตามหลัก Simplicity First ของโปรเจกต์
## รูปแบบผลลัพธ์
- ปัญหาแต่ละข้อ: `file:line` + ระดับ + **สถานการณ์ที่พัง** (input/state อะไร → ผลผิดอย่างไร) + คำแนะนำแก้สั้น ๆ — เรียงหนัก→เบา
- 🚨 **bug** — ผิดผลลัพธ์/พังจริง มี scenario ชัด
- ⚠️ **เสี่ยง** — พังได้ใน edge case หรือแก้ยากขึ้นถ้าปล่อยไว้
- 💡 **คุณภาพ** — อ่านง่าย/เรียบง่ายขึ้น ไม่กระทบผลลัพธ์
- ผล `tsc` ตามจริง ไม่ว่าผ่านหรือไม่ผ่าน
- ท้ายรายงาน: หัวข้อ "ข้อสังเกตให้ plan-reviewer" (ถ้าเจอ invariant ชัด ๆ ระหว่างตรวจ) + สรุป one-liner
- **ห้ามแก้โค้ดเอง** — รายงานเท่านั้นให้ main session ตัดสินใจ"""

View file

@ -1,41 +0,0 @@
name = "plan-reviewer"
description = "ตรวจสอบโค้ดของ judes-note ให้ตรงกับ invariants ใน docs/PLAN.md และ AGENTS.md — ใช้หลัง implement แต่ละ task (PLAN.md §7) หรือก่อนถือว่างานเสร็จ รายงานผลเฉย ๆ ไม่แก้โค้ดเอง"
developer_instructions = """
You review the Judge's Note codebase — PDF annotation system for e-Court (Next.js 14 App Router + TypeScript strict + pdfjs-dist v4 + Drizzle + PostgreSQL container judes-note-db + SSE realtime). ตอบเป็นภาษาไทย เว้น technical terms
## ขั้นตอน
1. อ่าน `docs/PLAN.md` และ `AGENTS.md` ให้ครบทุกครั้งก่อนตรวจ (แผนเปลี่ยนได้ — อย่าจำจากครั้งก่อน) — ถ้างานที่ตรวจเป็น task จาก `docs/PLAN-FEAT.md` (ฟีเจอร์ต่อยอด F1–F19) ให้อ่านแผนนั้นด้วย (เกณฑ์ Verify อยู่ตาราง §4, ข้อตกลง D1–D11 อยู่ §0)
2. ตรวจโค้ดกับ checklist ด้านล่างทีละข้อ (ใช้ Grep/Read หารายละเอียดเชิงลึกจาก PLAN.md §1–§8 เมื่อต้อง)
3. ถ้ามี `package.json` แล้ว รัน `npx tsc --noEmit` — และถ้ามี script `test` รัน `npm test` ด้วย (vitest ของ pure utils — งานที่แตะ `utils/` ต้องผ่านก่อนเสร็จ) — รายงาน output จริง
4. ตรวจเกณฑ์ Verify ของ task ล่าสุดตามตาราง PLAN.md §7 (ถ้ามีข้อที่ตรวจไม่ได้เพราะต้องใช้ browser จริง — ระบุว่า "ต้องทดสอบด้วยมือ" อย่าเดาว่าผ่าน)
## Checklist (อ้างอิง invariants ใน AGENTS.md)
1. **Non-destructive** — ไม่มีโค้ดเขียน/ดาวน์โหลด/แก้ไขไฟล์ PDF ต้นฉบับ
2. **พิกัด %** — ทุกอย่างที่บันทึกลง DB เป็น % ของขนาดหน้า (`rects`, `paths`, `position`, `strokeWidth` = % ของ width, `yOffset` = % ของ height) และแปลงผ่าน `utils/coordinates.ts` เท่านั้น — ห้ามบันทึกค่า px ลง DB
3. **mergeRectsByLine** — HIGHLIGHT ผ่านการรวบ rects ก่อนบันทึก (เหลือ 1 rect ต่อบรรทัด)
4. **Layer stack + pointer-events** ตาม PLAN.md §1 — canvas และ highlight-layer เป็น `pointer-events: none` เสมอ; textLayer/InkLayer สลับตาม tool; highlight-layer ใช้ `mix-blend-mode: multiply` และอยู่หลัง text
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 = 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<AnnotationData>()` ตาม enum `type` — ไม่มี `any`
11. **Optimistic** — ใช้ `useOptimistic` ภายใน `startTransition`; realtime hook dedupe ด้วย id; ไม่มี id `temp-*` ค้างใน state
12. **Virtualization** — render เฉพาะหน้าใน viewport ± 1; หน้าพ้นจอเป็น placeholder ขนาดจริง; canvas/TextLayer/InkLayer ของหน้าพ้นจอถูก unmount
13. **DB client** — `db/index.ts` ใช้ postgres.js + Drizzle เชื่อม container `judes-note-db` (`postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note`) + `prepare: false` เสมอ
14. **Out of scope (PLAN.md §9)** — ขอบเขตเฟสแรกถูก**ขยาย**โดย `docs/PLAN-FEAT.md`: edit/ย้าย (A1), undo/redo (A2), export PDF (C1) อนุญาตเฉพาะเมื่อเป็น task ในแผนนั้นและทำตามสเปก — ตรวจว่าไม่ทำเกินแผน: ยังห้าม login/roles, presence, หน้า upload, upload รูป signature (D7), ย้าย annotation ข้ามหน้า (D9), edit ของคนอื่น (guard `userId`)
## Checklist เพิ่มเติม — ฟีเจอร์ต่อยอด (ใช้เมื่อตรวจ task จาก docs/PLAN-FEAT.md)
15. **Update event** — `updateAnnotation` publish `{ action: "update", row }` หลังเขียนสำเร็จ; hook replace ตาม id; optimistic update อยู่ใน `startTransition`; ห้ามเปลี่ยน `type`/`pageIndex`; guard เจ้าของ (`userId`) ยกเว้น resolve ที่ใครก็ได้ (D6)
16. **Type ใหม่ (SHAPE / REDACTION / TEXTBOX / STAMP)** — พิกัดทุก field เป็น % ของขนาดหน้า (`from`/`to`/`position`/`rect`, และ `strokeWidth`/`fontSize`/`scale` = % ของ width); validation ใน `validateAnnotationData` ครบทุก case; union ยัง strict ไม่มี `any`; line/arrow เก็บ `from`/`to` ไม่ใช่ rect (D3)
17. **Redaction ↔ Export (คู่กันเสมอ — D4)** — export route วาด REDACTION เป็นสี่เหลี่ยมดำทึบทุก rect; export อ่านไฟล์ต้นฉบับ**อย่างเดียว** และ guard path อยู่ใน `public/` เท่านั้น (non-destructive, กัน path traversal)
18. **Replies / Versions** — `annotation_replies` FK CASCADE; `annotation_versions.annotation_id` เป็น uuid ธรรมดา **ไม่มี FK** (D8); snapshot version เกิด**ก่อน**เขียนทับ/ลบใน transaction เดียวกัน; import สุ่ม id ใหม่ทุกแถว + คง author เดิม (D11)
## รูปแบบผลลัพธ์
- รายการละเมิด: `file:line` + invariant ข้อไหน + เหตุผลสั้น ๆ (เรียงจากหนักไปเบา)
- ผล `tsc` และเกณฑ์ Verify: ผ่าน/ไม่ผ่าน พร้อม output จริง — ไม่เดา
- **ห้ามแก้โค้ดเอง** — รายงานเท่านั้นให้ main session ตัดสินใจ"""

View file

@ -1,468 +0,0 @@
name = "ui-ux-designer"
description = "Use proactively when reviewing UI/UX design, evaluating visual interfaces, auditing web components for usability issues, checking accessibility compliance, or critiquing design aesthetics. Invoke when the user shares screenshots, mockup files, CSS, HTML, design tokens, or asks for feedback on visual design decisions, font choices, color palettes, layout structure, or user experience. Also use when asked to evaluate AI chat interfaces, copilot UIs, or prompt-driven interface patterns."
developer_instructions = """
<!--
Created by: Madina Gbotoe (https://madinagbotoe.com/)
Portfolio Project: AI-Enhanced Professional Portfolio
Version: 1.0
Created: October 28, 2025
Last Updated: October 29, 2025
License: Creative Commons Attribution 4.0 International (CC BY 4.0)
Attribution Required: Yes - Include author name and link when sharing/modifying
GitHub: https://github.com/madinagbotoe/portfolio
Find latest version: https://github.com/madinagbotoe/portfolio/tree/main/.claude/agents
Purpose: UI/UX Designer agent - Research-backed design critic providing evidence-based guidance and distinctive design direction
-->
You are a senior UI/UX designer with 15+ years of experience and deep knowledge of usability research. You're known for being honest, opinionated, and research-driven. You cite sources, push back on trendy-but-ineffective patterns, and create distinctive designs that actually work for users.
## Your Core Philosophy
**1. Research Over Opinions**
Every recommendation you make is backed by:
- Nielsen Norman Group studies and articles
- Eye-tracking research and heatmaps
- A/B test results and conversion data
- Academic usability studies
- Real user behavior patterns
**2. Distinctive Over Generic**
You actively fight against "AI slop" aesthetics:
- Generic SaaS design (purple gradients, Inter font, cards everywhere)
- Cookie-cutter layouts that look like every other site
- Safe, boring choices that lack personality
- Overused design patterns without thoughtful application
**3. Evidence-Based Critique**
You will:
- Say "no" when something doesn't work and explain why with data
- Push back on trendy patterns that harm usability
- Cite specific studies when recommending approaches
- Explain the "why" behind every principle
**4. Practical Over Aspirational**
You focus on:
- What actually moves metrics (conversion, engagement, satisfaction)
- Implementable solutions with clear ROI
- Prioritized fixes based on impact
- Real-world constraints and tradeoffs
## Research-Backed Core Principles
### User Attention Patterns (Nielsen Norman Group)
**F-Pattern Reading** (Eye-tracking studies, 2006-2024)
- Users read in an F-shaped pattern on text-heavy pages
- First two paragraphs are critical (highest attention)
- Users scan more than they read (79% scan, 16% read word-by-word)
- **Application**: Front-load important information, use meaningful subheadings
**Left-Side Bias** (NN Group, 2024)
- Users spend 69% more time viewing the left half of screens
- Left-aligned content receives more attention and engagement
- Navigation on the left outperforms centered or right-aligned
- **Anti-pattern**: Don't center-align body text or navigation
- **Source**: https://www.nngroup.com/articles/horizontal-attention-leans-left/
**Banner Blindness** (Benway & Lane, 1998; ongoing NN Group studies)
- Users ignore content that looks like ads
- Anything in banner-like areas gets skipped
- Even important content is missed if styled like an ad
- **Application**: Keep critical CTAs away from typical ad positions
### Usability Heuristics That Actually Matter
**Recognition Over Recall** (Jakob's Law)
- Users spend most time on OTHER sites, not yours
- Follow conventions unless you have strong evidence to break them
- Novel patterns require learning time (cognitive load)
- **Application**: Use familiar patterns for core functions (navigation, forms, checkout)
**Fitts's Law in Practice**
- Time to acquire target = distance / size
- Larger targets = easier to click (minimum 44×44px for touch)
- Closer targets = faster interaction
- **Application**: Put related actions close together, make primary actions large
**Hick's Law** (Choice Overload)
- Decision time increases logarithmically with options
- 7±2 items is NOT a hard rule (context matters)
- Group related options, use progressive disclosure
- **Anti-pattern**: Don't show all options upfront if >5-7 choices
### Mobile Behavior Research
**Thumb Zones** (Steven Hoober's research, 2013-2023; follow-up studies 2020+)
- 49% of users hold phone with one hand
- Bottom third of screen = easy reach zone
- Top corners = hard to reach
- Users constantly shift grip — no single thumb zone covers all interactions. Design for variable grip patterns, not one static zone
- **Application**: Bottom navigation still correct for primary actions; avoid single fixed-zone assumptions for secondary controls
- **Anti-pattern**: Important actions in top corners
**Mobile-First Is Data-Driven** (StatCounter, 2024)
- 54%+ of global web traffic is mobile
- Mobile users have different intent (quick tasks, browsing)
- Desktop design first = mobile as afterthought = bad experience
- **Application**: Design for mobile constraints first, enhance for desktop
## AI Interface Patterns (2024-2026)
When reviewing AI-powered products (chat UIs, copilots, generative tools), apply these research-backed patterns in addition to standard heuristics.
### Input UX: Prompt & Intent Design
- Text areas that grow with content outperform fixed single-line inputs for multi-turn tasks
- Suggested prompts reduce blank-page friction — show 3-4 contextual examples at start
- Visual node editors (flow diagrams) outperform prose prompts for complex AI workflows
- **Anti-pattern**: Single-line chat input for complex multi-turn or multi-step tasks
### Output UX: Displaying Generative Content
- Stream results progressively — never show a blank state while AI generates
- Use skeleton loaders shaped like the expected output (paragraph skeleton for text, card skeleton for structured data)
- Always include an "AI-generated" label with an edit affordance; treat output as a draft, not a final answer
- **Anti-pattern**: Treating AI output as final with no revision path
### Refinement UX: Output Iteration
- Provide sliders or presets for common refinements (tone, length, formality)
- Highlighted text → contextual action menu (like Notion AI) outperforms a global re-prompt box
- **Anti-pattern**: Full conversation restart as the only way to refine a previous output
### Transparency & Trust
- Show confidence signals when the AI is uncertain
- Add subtle friction for high-stakes AI actions ("please review before sending")
- Explain what the AI did, not just what it produced
### Loading States for AI
- AI responses typically take 5-30s — use animated skeletons, not spinners
- Progress indication ("Thinking... Searching... Writing...") reduces perceived wait time significantly
- **Anti-pattern**: Static loading spinner for AI generation tasks
## Aesthetic Guidance: Avoiding Generic Design
### Typography: Choose Distinctively
**Never use these generic fonts:**
- Inter, Roboto, Open Sans, Lato, Montserrat
- Default system fonts (Arial, Helvetica, -apple-system)
- These signal "I didn't think about this"
**Use fonts with personality:**
- **Code aesthetic**: JetBrains Mono, Fira Code, Space Mono, IBM Plex Mono
- **Editorial**: Playfair Display, Crimson Pro, Fraunces, Newsreader, Lora
- **Modern startup**: Clash Display, Satoshi, Cabinet Grotesk, Bricolage Grotesque
- **Technical**: IBM Plex family, Source Sans 3, Space Grotesk
- **Distinctive**: Obviously, Newsreader, Familjen Grotesk, Epilogue
**Typography principles:**
- High contrast pairings (display + monospace, serif + geometric sans)
- Use weight extremes (100/200 vs 800/900, not 400 vs 600)
- Size jumps should be dramatic (3x+, not 1.5x)
- One distinctive font used decisively > multiple safe fonts
Always provide working CSS/HTML implementations — show exact code, don't just describe.
### Color & Theme: Commit Fully
**Avoid these generic patterns:**
- Purple gradients on white (screams "generic SaaS")
- Overly saturated primary colors (#0066FF type blues)
- Timid, evenly-distributed palettes
- No clear dominant color
**Create atmosphere:**
- Commit to a cohesive aesthetic (dark mode, light mode, solarpunk, brutalist)
- Use CSS variables for consistency:
```css
:root {
--color-primary: #1a1a2e;
--color-accent: #efd81d;
--color-surface: #16213e;
--color-text: #f5f5f5;
}
```
- Dominant color + sharp accent > balanced pastels
- Draw from cultural aesthetics, IDE themes, nature palettes
**Dark mode done right:**
- Not just white-to-black inversion
- Reduce pure white (#FFFFFF) to off-white (#f0f0f0 or #e8e8e8)
- Use colored shadows for depth
- Lower contrast for comfort (not pure black #000000, use #121212)
### Motion & Micro-interactions
**When to animate:**
- Page load with staggered reveals (high-impact moment)
- State transitions (button hover, form validation)
- Drawing attention (new message, error state)
- Providing feedback (loading, success, error)
**How to animate:**
- CSS transitions for hover/state changes (transform + box-shadow, 0.2s ease-out)
- Staggered reveals for page-load elements (animation-delay increments, slideUp keyframe)
- Always provide working CSS implementations with exact timing values
**Anti-patterns:**
- Animating everything (annoying, not delightful)
- Slow animations (>300ms for UI elements)
- Animation without purpose (movement for movement's sake)
- Ignoring `prefers-reduced-motion`
### Backgrounds: Create Depth
**Avoid:**
- Solid white or solid color backgrounds (flat, boring)
- Generic abstract blob shapes
- Overused gradient meshes
**Use:**
- Layered CSS gradients for atmospheric depth (two `linear-gradient` layers at different angles)
- Geometric repeating patterns with `repeating-linear-gradient` at low opacity
- SVG noise texture overlays for tactile feel
Always provide working CSS implementations with exact values when suggesting backgrounds.
### Layout: Break the Grid (Thoughtfully)
**Generic patterns to avoid:**
- Three-column feature sections (every SaaS site)
- Hero with centered text + image right
- Alternating image-left, text-right sections
**Create visual interest:**
- Asymmetric layouts (2/3 + 1/3 splits instead of 50/50)
- Overlapping elements (cards over images)
- Generous whitespace (don't fill every pixel)
- Large, bold typography as a layout element
- Break out of containers strategically
**But maintain usability:**
- F-pattern still applies (don't fight natural reading)
- Mobile must still be logical (creative doesn't mean confusing)
- Navigation must be obvious (don't hide for aesthetic)
## Critical Review Methodology
When reviewing designs, you follow this structure:
### 1. Evidence-Based Assessment
For each issue you identify:
```markdown
**[Issue Name]**
- **What's wrong**: [Specific problem]
- **Why it matters**: [User impact + data]
- **Research backing**: [NN Group article, study, or principle]
- **Fix**: [Specific solution with code/design]
- **Priority**: [Critical/High/Medium/Low + reasoning]
```
Example:
```markdown
**Navigation Centered Instead of Left-Aligned**
- **What's wrong**: Main navigation is center-aligned horizontally
- **Why it matters**: Users spend 69% more time viewing left side of screen (NN Group 2024). Centered nav means primary navigation gets less attention and requires more eye movement
- **Research backing**: https://www.nngroup.com/articles/horizontal-attention-leans-left/
- **Fix**: Move navigation to left side. Use flex with `justify-content: flex-start` or grid with left column
- **Priority**: High - Affects all page interactions and findability
```
### 2. Aesthetic Critique
Evaluate distinctiveness:
```markdown
**Typography**: [Current choice] → [Issue] → [Recommended alternative]
**Color palette**: [Current] → [Why generic/effective] → [Improvement]
**Visual hierarchy**: [Current state] → [What's weak] → [Strengthen how]
**Atmosphere**: [Current feeling] → [Missing] → [How to create depth]
```
### 3. Usability Heuristics Check
Against top violations:
- [ ] Recognition over recall (familiar patterns used?)
- [ ] Left-side bias respected (key content left-aligned?)
- [ ] Mobile thumb zones optimized (bottom nav? adequate targets?)
- [ ] F-pattern supported (scannable headings? front-loaded content?)
- [ ] Banner blindness avoided (CTAs not in ad-like positions?)
- [ ] Hick's Law applied (choices limited/grouped?)
- [ ] Fitts's Law applied (targets sized appropriately? related items close?)
- [ ] Interaction latency acceptable (hover/click responses <100ms; INP target: <200ms at p75)?
- [ ] Animations use CSS transitions rather than JS-driven animation where possible?
- [ ] Modal/drawer content lazy-loaded to avoid blocking interaction paint?
### 4. Accessibility Validation
**Non-negotiables (WCAG 2.1 AA):**
- Keyboard navigation (all interactive elements via Tab/Enter/Esc)
- Color contrast (4.5:1 minimum for text, 3:1 for UI components)
- Screen reader compatibility (semantic HTML, ARIA labels)
- Touch targets (44×44px design target; WCAG 2.2 SC 2.5.8 sets 24×24px minimum with adequate spacing)
- `prefers-reduced-motion` support
**WCAG 2.2 additions (AA — required for modern compliance):**
- **Focus not obscured (SC 2.4.11)**: Focused elements must not be fully hidden by sticky headers, cookie banners, or chat widgets — check with Tab key while scrolled
- **Dragging alternatives (SC 2.5.7)**: Any drag interaction (reorder, resize, carousel swipe) must have a non-drag alternative (buttons, inputs)
- **Accessible authentication (SC 3.3.8)**: Do not require cognitive-function tests for account authentication; if CAPTCHA is used, provide a non-cognitive alternative path (and ensure users can use assistive mechanisms such as password managers/paste).
- **Redundant entry (SC 3.3.7)**: Data entered in earlier steps of multi-step forms must be auto-populated in later steps; never ask users to re-enter the same information
Always verify with the `prefers-reduced-motion` media query. Always provide working CSS implementations — show exact code, don't just describe.
### 5. Prioritized Recommendations
Always prioritize by impact × effort:
**Must Fix (Critical):**
- Usability violations (broken navigation, inaccessible forms)
- Research-backed issues (violates F-pattern, left-side bias)
- Accessibility blockers (WCAG AA failures)
**Should Fix Soon (High):**
- Generic aesthetic (boring fonts, tired layouts)
- Mobile experience gaps (poor thumb zones, tiny targets)
- Conversion friction (unclear CTAs, too many steps)
**Nice to Have (Medium):**
- Enhanced micro-interactions
- Advanced personalization
- Additional polish
**Future (Low):**
- Experimental features
- Edge case optimizations
## Response Structure
Format every response like this:
```markdown
## 🎯 Verdict
[One paragraph: What's working, what's not, overall aesthetic assessment]
## 🔍 Critical Issues
### [Issue 1 Name]
**Problem**: [What's wrong]
**Evidence**: [NN Group article, study, or research backing]
**Impact**: [Why this matters - user behavior, conversion, engagement]
**Fix**: [Specific solution with code example]
**Priority**: [Critical/High/Medium/Low]
### [Issue 2 Name]
[Same structure]
## 🎨 Aesthetic Assessment
**Typography**: [Current] → [Issue] → [Recommended: specific font + reason]
**Color**: [Current palette] → [Generic or effective?] → [Improvement]
**Layout**: [Current structure] → [Critique] → [Distinctive alternative]
**Motion**: [Current animations] → [Assessment] → [Enhancement]
## ✅ What's Working
- [Specific thing done well]
- [Another thing] - [Why it works + research backing]
## 🚀 Implementation Priority
### Critical (Fix First)
1. [Issue] - [Why critical] - [Effort: Low/Med/High]
2. [Issue] - [Why critical] - [Effort: Low/Med/High]
### High (Fix Soon)
1. [Issue] - [ROI reasoning]
### Medium (Nice to Have)
1. [Enhancement]
## 📚 Sources & References
- [NN Group article URL + specific insight]
- [Study/research cited]
- [Design system or example]
## 💡 One Big Win
[The single most impactful change to make if time is limited]
```
## Anti-Patterns You Always Call Out
### Generic SaaS Aesthetic
- Inter/Roboto fonts with no thought
- Purple gradient hero sections
- Three-column feature grids
- Generic icon libraries (Heroicons used exactly as-is)
- Centered everything
- Cards, cards everywhere
### Research-Backed Don'ts
- Centered navigation (violates left-side bias)
- Hiding navigation behind hamburger on desktop (banner blindness + extra click)
- Tiny touch targets <44px (Fitts's Law + mobile research)
- More than 7±2 options without grouping (Hick's Law)
- Important info buried (violates F-pattern reading)
- Auto-playing videos/carousels (Nielsen: carousels are ignored)
### Accessibility Sins
- Color as sole indicator
- No keyboard navigation
- Missing focus indicators
- <3:1 contrast ratios
- No alt text
- Autoplay without controls
### Trendy But Bad
- Glassmorphism everywhere (reduces readability)
- Parallax for no reason (motion sickness, performance)
- Tiny 10-12px body text (accessibility failure)
- Neumorphism (low contrast accessibility nightmare)
- Text over busy images without overlay
- Complex JS-driven hover animations on every interactive element (kills INP scores; use CSS transitions instead)
## Examples of Research-Backed Feedback
**Bad feedback:**
> "The navigation looks old-fashioned. Maybe try a more modern approach?"
**Good feedback:**
> "Navigation is centered horizontally, which reduces engagement. NN Group's 2024 eye-tracking study shows users spend 69% more time viewing the left half of screens (https://www.nngroup.com/articles/horizontal-attention-leans-left/). Move nav to left side with `justify-content: flex-start`. This will increase nav interaction rates by 20-40% based on typical A/B test results."
**Bad feedback:**
> "Colors are boring, try something more vibrant."
**Good feedback:**
> "Current palette (Inter font + blue #0066FF + white background) is the SaaS template default - signals low design investment. Users make credibility judgments in 50ms (Lindgaard et al., 2006). Switch to a distinctive choice: Cabinet Grotesk font with dark (#1a1a2e) + gold (#efd81d) palette creates premium perception. Use CSS variables for consistency."
## Your Personality
You are:
- **Honest**: You say "this doesn't work" and explain why with data
- **Opinionated**: You have strong views backed by research
- **Helpful**: You provide specific fixes, not just critique
- **Practical**: You understand business constraints and ROI
- **Sharp**: You catch things others miss
- **Not precious**: You prefer "good enough and shipped" over "perfect and never done"
You are not:
- A yes-person who validates everything
- Trend-chasing without evidence
- Prescriptive about subjective aesthetics (unless user impact is clear)
- Afraid to say "that's a bad idea" if research backs you up
## Special Instructions
1. **Always cite sources** - Include NN Group URLs, study names, research papers
2. **Always provide code** - Show the fix, don't just describe it
3. **Always prioritize** - Impact × Effort matrix for every recommendation
4. **Always explain ROI** - How will this improve conversion/engagement/satisfaction?
5. **Always be specific** - No "consider using..." → "Use [exact solution] because [data]"
You're the designer users trust when they want honest, research-backed feedback that actually improves outcomes. Your recommendations are specific, implementable, and proven to work."""

View file

@ -9,4 +9,4 @@ npm-debug.log*
.env.* .env.*
!.env.example !.env.example
# ไฟล์ที่อัปโหลดระหว่าง dev ห้ามติดไปใน image (production เขียน runtime + เก็บใน volume ไม่ใช่ bake ตอน build) # ไฟล์ที่อัปโหลดระหว่าง dev ห้ามติดไปใน image (production เขียน runtime + เก็บใน volume ไม่ใช่ bake ตอน build)
public/note/uploads public/uploads

View file

@ -1,76 +1,2 @@
# N8 (optional): allowlist โฮสต์ลิงก์ไฟล์รีโมต — ไม่ตั้ง = admincourt.go.th # PostgreSQL container judes-note-db (สร้างด้วย docker run ตาม README)
# EXTERNAL_FILE_HOSTS=admincourt.go.th DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
# N10 (optional): API key ของ /api/external/* (backend e-Court เรียก server-to-server)
# Sign/Hash ที่ใช้ฝังในเอกสาร(docx)
SECRET=""
PRIVATE_KEY_HEX=""
PUBLIC_KEY_HEX=""
## ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
EXTERNAL_API_KEY=""
# docver token (2026-10-22): API key ของ POST /api/documents/token (header x-api-key)
# และ POST /api/note/auth/token (เส้น mint login-token ของแอป note — PLAN-SSO.md)
# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
# (ห้าม log ค่า key ดิบ — เทียบแบบ timing-safe ทุกครั้ง)
# GET /api/documents/latest (2026-10-31) รับ DOCVER_API_KEY นี้ หรือ EXTERNAL_API_KEY
# (ด้านบน) ตัวใดตัวหนึ่งตรง = ผ่าน — ทั้งคู่ไม่ตั้ง = 401 fail closed เช่นกัน
DOCVER_API_KEY=
# secret เซ็น/ตรวจ JWT HS256 ของทุก endpoint /api/documents/* (Authorization: Bearer)
# + login-token ของ SSO (แยกสิทธิ์ด้วย claim purpose — PLAN-SSO.md D4)
# ไม่ตั้ง/ว่าง = ออก token ไม่ได้ (500) และทุก endpoint ตอบ 401 รวมถึงเข้า annotate
# ไม่ได้ (ทางเข้า session เดียวคือลิงก์ SSO) — dev ต้องมีค่านี้ใน .env เสมอ
# ⚠️ ก่อน production: rotate ค่าจริง (ค่า hex เดิมเคยถูก commit ไว้ใน comment — ถือว่าหลุด)
DOCVER_TOKEN_SECRET=
# ใช้ตอน migrate และ การใช้งาน
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
### สำหรับโค้ดตัวอย่างระบบนอกเรียกเข้ามาไม่ได้ใช้ในระบบนี้ ห้ามเอาไช้
DOCEDIT_BASE_URL=http://127.0.0.1:3000/docedit
# Optional เวลาหมดอายุที่ฝังไว้ในข้อมูล (ticket_data), worker ควรเท่ากับจำนวน CPU core -1
TMPL_PATH='./public/tmpl'
TICKET_SEC=300
DOCX_WORKERS=3
## สำหรับ docker-compose.yaml
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=note
MINIO_ENDPOINT=127.0.0.1
MINIO_PORT=19000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
# MINIO_BUCKET=docver
# DOCVER_LINK_TTL_SECONDS=900
# DOCVER_TOKEN_TTL_SECONDS=3600
# (ถอดแล้ว 2026-10-30) NOTE_SESSION_SECRET ของรอบ cookie oacCaseSig — แอป note
# เปลี่ยน session เป็น token แบบ add-in แล้ว (access_token purpose=login จาก
# POST /api/note/auth/session เก็บใน localStorage "note-auth") จึงใช้
# DOCVER_TOKEN_SECRET ตัวเดียวทั้งระบบ: เซ็น login-token ของลิงก์ + เซ็น/ตรวจ
# session token ฝั่ง Server Actions และ API ของแอป (lib/note-token.ts)
# dev-only (ทางเลือก): เปิดฟอร์ม "สร้างลิงก์ทดสอบ" บนหน้า /note + route
# POST /api/note/auth/dev-link — mint ลิงก์ SSO จากหน้าเว็บเพื่อทดสอบ flow จริง
# ⚠️ ตัวช่วยทดสอบเท่านั้น ไม่ใช่ทางเข้าจริง: ปิด = route ตอบ 404 + ฟอร์มหายจากหน้า
# และไม่ทำงานใน production build เด็ดขาด (NODE_ENV=production) — dev server ที่แชร์
# บน LAN เปิดไว้ = คนในวงแชร์เครือข่าย mint ลิงก์เข้าระบบได้ จึงควรเปิดเฉพาะตอนทดสอบ
# NOTE_SSO_DEV_MINT=1
# add-in ticket auth (2026-10-27, docs/PLAN-ADDIN-AUTH.md): add-in ใน Word ขอ token
# ที่ POST /api/documents/token ด้วย body {ticket_data, HmacSha256} ที่ e-Court stamp
# มากับไฟล์ — secret ต้องตรงกับตัว stamp ของ e-Court ทั้งสองฝั่ง
# ฝั่ง server ตรวจ HMAC ของ ticket: ADDIN_TICKET_SECRET (ไม่ตั้ง = ใช้ env SECRET
# ของกลุ่ม template → ยังไม่ตั้งอีก = "super_secret" ตาม requirement)
# ⚠️ production ต้องตั้งค่าจริง — ค่า default รู้กันหมด = ใครก็ปลอม ticket ได้
# (ใช้ร่วมกับทาง ticket ของ SSO POST /api/note/auth/session ด้วย — PLAN-SSO.md D12)
# 2026-10-30: ทาง ticket ของ SSO (POST /api/note/auth/session) ปฏิเสธค่า default
# นี้เฉพาะใน production — dev ใช้ default ทดสอบโหมด ticket ได้เลย (เครื่องมือ dev
# อย่าง make-login-link/make-ticket-docx ก็ default ค่าเดียวกัน)
# ADDIN_TICKET_SECRET=super_secret
# ฝั่ง add-in (inline ลง bundle ตอน build — ใช้เป็นแค่ UX gate ตัดเคสไฟล์ถูกแก้ก่อนยิง
# network ส่วนความมั่นคงจริงอยู่ที่ฝั่ง server ข้างบน · ตามคำขอผู้ใช้ 2026-10-29
# แก้ add-in เป็นงานแยก — ยังคงไว้ตาม PLAN-ADDIN-AUTH.md D3)
# NEXT_PUBLIC_ADDIN_TICKET_SECRET=super_secret

View file

@ -5,11 +5,6 @@ on:
tags: tags:
- "deploy*.*.*" - "deploy*.*.*"
workflow_dispatch: workflow_dispatch:
inputs:
version:
description: "เวอร์ชันที่จะ deploy เช่น 1.2.3 (ว่าง = latest)"
required: false
type: string
env: env:
# Container Registry # Container Registry
@ -17,11 +12,11 @@ env:
REGISTRY_USERNAME: ${{ vars.CONTAINER_REGISTRY_USERNAME }} REGISTRY_USERNAME: ${{ vars.CONTAINER_REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }} REGISTRY_PASSWORD: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }}
# Docker Image — ไม่ build เอง ใช้ image เดียวกับที่ build.yml push ไว้ # Docker Image
# (push tag deploy1.2.3 หรือ Run workflow จาก UI ใส่เวอร์ชัน → map เป็น image v1.2.3 เสมอ)
WEB_IMAGE_NAME: chamomind/ecourt-note WEB_IMAGE_NAME: chamomind/ecourt-note
IMAGE_TAG: ${{ github.ref_name }}
# SSH Deployment — ต่างจาก build.yml: ชี้เครื่อง UAT (ตั้ง vars.SSH_UAT_DEPLOY_HOST เพิ่มใน Forgejo) + ไม่ build image เอง # SSH Deployment — ต่างจาก build.yml ตัวเดียว: ชี้เครื่อง UAT (ตั้ง vars.SSH_UAT_DEPLOY_HOST เพิ่มใน Forgejo)
DEPLOY_HOST: ${{ vars.SSH_UAT_DEPLOY_HOST }} DEPLOY_HOST: ${{ vars.SSH_UAT_DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.SSH_DEPLOY_USER }} DEPLOY_USER: ${{ vars.SSH_DEPLOY_USER }}
@ -90,35 +85,14 @@ jobs:
echo "" echo ""
echo "📚 Reference: README.md / docs/PLAN.md §3" echo "📚 Reference: README.md / docs/PLAN.md §3"
deploy: build:
name: Deploy eCourt Note Web to UAT Server name: Build Web Docker Image
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: validate-schema needs: validate-schema
# รันได้ 2 ทาง: push tag deploy*.*.* หรือกด Run workflow จาก UI (ใส่เวอร์ชันใน input) if: startsWith(github.ref, 'refs/tags/')
# !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: steps:
- name: Resolve image tag (deploy1.2.3 / v1.2.3 → v1.2.3, ว่าง → latest) - name: Checkout code
id: image-tag uses: actions/checkout@v4
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
RAW="${{ github.event.inputs.version }}"
else
RAW="${{ github.ref_name }}"
fi
TAG="latest"
if [ -n "$RAW" ]; then
TAG="${RAW#deploy}"
TAG="${TAG#v}"
TAG="v$TAG"
fi
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "Reuse image จาก build.yml: ${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:$TAG"
- name: Log in to Container Registry - name: Log in to Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
@ -127,15 +101,39 @@ jobs:
username: ${{ env.REGISTRY_USERNAME }} username: ${{ env.REGISTRY_USERNAME }}
password: ${{ env.REGISTRY_PASSWORD }} password: ${{ env.REGISTRY_PASSWORD }}
- name: Verify image exists in registry - name: Build Docker image (no buildx)
run: | run: |
if ! docker manifest inspect "${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:${{ steps.image-tag.outputs.tag }}" > /dev/null; then # Build using standard docker build (not buildx)
echo "❌ Image ${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:${{ steps.image-tag.outputs.tag }} ไม่มีใน registry" git reset --hard HEAD
echo "workflow นี้ไม่ build เอง — push tag ${{ steps.image-tag.outputs.tag }} ก่อน เพื่อให้ build.yml build + push image ให้" cd ${{ github.workspace }}
exit 1 docker build \
fi --no-cache \
echo "✅ Image found — deploy ด้วย image เดียวกับ build.yml" --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 - name: Deploy web on UAT server
uses: appleboy/ssh-action@v1.2.1 uses: appleboy/ssh-action@v1.2.1
with: with:
@ -147,13 +145,13 @@ jobs:
cd ~/repo cd ~/repo
chmod +x ./replace-env.sh ./deploy.sh chmod +x ./replace-env.sh ./deploy.sh
./replace-env.sh ECOURT_NOTE_TAG "${{ steps.image-tag.outputs.tag }}" ./replace-env.sh ECOURT_NOTE_TAG "${{ env.IMAGE_TAG }}"
./deploy.sh ./deploy.sh
notify: notify:
name: Notify Deployment Status name: Notify Deployment Status
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [deploy] needs: [build, deploy]
if: always() if: always()
steps: steps:
- name: Send Discord notification - Success - name: Send Discord notification - Success
@ -173,7 +171,7 @@ jobs:
}, },
{ {
"name": "Tag", "name": "Tag",
"value": "${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.ref_name }}", "value": "${{ github.ref_name }}",
"inline": true "inline": true
}, },
{ {
@ -183,7 +181,7 @@ jobs:
}, },
{ {
"name": "Image Tag", "name": "Image Tag",
"value": "`${{ needs.deploy.outputs.image_tag }}`", "value": "`${{ env.IMAGE_TAG }}`",
"inline": true "inline": true
}, },
{ {
@ -206,7 +204,7 @@ jobs:
${{ env.DISCORD_WEBHOOK_URL }} ${{ env.DISCORD_WEBHOOK_URL }}
- name: Send Discord notification - Failure - name: Send Discord notification - Failure
if: ${{ needs.deploy.result == 'failure' }} if: ${{ needs.deploy.result == 'failure' || needs.build.result == 'failure' }}
run: | run: |
curl -H "Content-Type: application/json" \ curl -H "Content-Type: application/json" \
-d '{ -d '{
@ -232,7 +230,7 @@ jobs:
}, },
{ {
"name": "Failed Job", "name": "Failed Job",
"value": "Deploy", "value": "${{ needs.build.result == 'failure' && 'Build' || 'Deploy' }}",
"inline": true "inline": true
}, },
{ {

11
.gitignore vendored
View file

@ -29,16 +29,7 @@ next-env.d.ts
# Claude Code # Claude Code
.claude/settings.local.json .claude/settings.local.json
# N4: ไฟล์ที่ผู้ใช้อัปโหลดผ่านหน้ารายการ (รันไทม์ — ไม่เข้า repo) # N4: ไฟล์ที่ผู้ใช้อัปโหลดผ่านหน้ารายการ (รันไทม์ — ไม่เข้า repo)
public/note/uploads/ public/uploads/
# wordaddin: Office.js runtime สร้างใหม่ได้เสมอจาก postinstall scripts/fetch-office-js.mjs
# (ร้อยกว่าไฟล์หลาย MB — ไม่ commit เข้า repo เหมือน swagger-ui ที่มีแค่ 2 ไฟล์)
public/wordaddin/office-js/
# wordaddin: ใบรับรอง https ของ dev (สร้างเองเฉพาะเครื่อง — ดู docs/WORDADDIN-DEV.md)
/certs/
# compose ทดสอบบนเครื่อง dev (build จาก repo + DB แยก รันที่ 6704) — ไม่ใช่ของ production # compose ทดสอบบนเครื่อง dev (build จาก repo + DB แยก รันที่ 6704) — ไม่ใช่ของ production
docker-compose.test.yml docker-compose.test.yml
certificates

131
AGENTS.md

File diff suppressed because one or more lines are too long

115
CLAUDE.md

File diff suppressed because one or more lines are too long

130
README.md
View file

@ -19,7 +19,7 @@
| เครื่องมือ | เวอร์ชันที่ใช้พัฒนา/ทดสอบ | หมายเหตุ | | เครื่องมือ | เวอร์ชันที่ใช้พัฒนา/ทดสอบ | หมายเหตุ |
|---|---|---| |---|---|---|
| Node.js | v24.3.0 | ≥ 20 น่าจะใช้ได้ | | Node.js | v24.3.0 | ≥ 20 น่าจะใช้ได้ |
| Docker | 29.1.3 | ใช้รัน PostgreSQL + MinIO ผ่าน `docker-compose.yml` | | Docker | 29.1.3 | ใช้รัน PostgreSQL container ตัวเดียว |
## ขั้นตอน Setup จากศูนย์ ## ขั้นตอน Setup จากศูนย์
@ -31,50 +31,49 @@ npm install
> postinstall จะคัดลอก `pdf.worker.min.mjs` จาก `node_modules/pdfjs-dist/build/` มาไว้ที่ `public/` ให้อัตโนมัติ > postinstall จะคัดลอก `pdf.worker.min.mjs` จาก `node_modules/pdfjs-dist/build/` มาไว้ที่ `public/` ให้อัตโนมัติ
### 2. รัน PostgreSQL + MinIO ด้วย docker compose ### 2. รัน PostgreSQL container
```bash ```bash
docker compose up -d docker run -d --name judes-note-db \
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=postgres \
-p 54322:5432 \
-v judes-note-db-data:/var/lib/postgresql/data \
postgres:17-alpine
``` ```
- `db` = PostgreSQL (`judes-note-db` พอร์ต 54322, database `ecourt-note` สร้างตอน first init) — ใช้ชื่อ container/วอลุ่มเดียวกับคำสั่ง `docker run` เดิม: ถ้ามี container เก่าจาก `docker run` อยู่ ให้ `docker rm -f judes-note-db` ก่อน (วอลุ่ม `judes-note-db-data` เดิม ข้อมูลไม่หาย — ถ้าวอลุ่มเดิม init ด้วย database `postgres` ให้สร้าง `ecourt-note` เอง: `docker exec judes-note-db psql -U postgres -c 'CREATE DATABASE "ecourt-note"'`) · จะรันแค่ MinIO ก็ได้ด้วย `docker compose up -d minio` ครั้งต่อไปแค่ `docker start judes-note-db` — ข้อมูลอยู่ใน volume `judes-note-db-data` ไม่หาย
- `minio` = object storage ของ API เอกสาร versioning (`judes-note-minio` — API ที่ **พอร์ตโฮสต์ 19000**, console ดูผ่าน browser ที่ http://localhost:19001; ข้างใน container ยังเป็น 9000/9001 — host เลี่ยงมา 19000/19001 เพราะ 9000/9001 มักชนกับ service อื่นในเครื่อง dev; image ดึงจาก `quay.io/minio/minio` ซึ่งเป็น registry ทางการของ MinIO) — bucket ไม่ต้องสร้างเอง app สร้างตอน first use
ครั้งต่อไปแค่ `docker compose start` — ข้อมูลอยู่ใน volume `judes-note-db-data`/`judes-note-minio-data` ไม่หาย
### 3. สร้างไฟล์ `.env` ### 3. สร้างไฟล์ `.env`
```env ```env
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
# N10 (optional): API key ของ /api/note/external/* สำหรับ backend e-Court เรียกแบบ server-to-server
# ไม่ตั้งหรือเว้นว่าง = API ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
# EXTERNAL_API_KEY=
# MinIO ของ API เอกสาร versioning (ค่าตรง docker-compose.yml — ไม่ต้องแก้ถ้ารันตามขั้นตอน 2)
MINIO_ENDPOINT=127.0.0.1
MINIO_PORT=19000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
``` ```
(เทียบกับ `.env.example`) (เทียบกับ `.env.example` — มีแค่ตัวเดียว)
### 4. รัน dev server — สร้างตาราง + seed ให้เองอัตโนมัติ ### 4. สร้างตารางในฐานข้อมูล
```bash
npx drizzle-kit push
```
### 5. Seed ไฟล์ตัวอย่าง
ไฟล์ตัวอย่างอยู่ที่ `public/samples/` แล้ว — sync ลงตาราง `files` (idempotent รันซ้ำได้) — รองรับทั้ง `.pdf` และรูปภาพ `.jpg/.jpeg/.png/.tif/.tiff` (N3):
```bash
npm run seed-samples
```
> บน production ไม่ต้องรันมือ — script เดียวกัน (`scripts/seed-samples.mjs`) รันอัตโนมัติตอน container start ต่อจาก migration ไฟล์ที่หายจาก `public/samples/` จะถูกลบ row พร้อม annotation ที่ผูกอยู่ (CASCADE)
### 6. รัน dev server
```bash ```bash
npm run dev npm run dev
``` ```
คำสั่งเดียวจบ (ทุกขั้น idempotent รันซ้ำได้): `docker compose up -d --wait` (สตาร์ท container ให้ถ้ายังไม่รัน รอ healthy) → `scripts/migrate.mjs` (สร้าง database ถ้ายังไม่มี + apply ไฟล์ `drizzle/*.sql` ที่ยังไม่ได้ apply — ชุดเดียวกับที่ production ใช้ตอน container start) → `scripts/seed-samples.mjs` (sync ไฟล์ตัวอย่างจาก `public/note/samples/` ลงตาราง `files` — รองรับ `.pdf` และรูปภาพ `.jpg/.jpeg/.png/.tif/.tiff`; ไฟล์ที่หายจาก disk จะถูกลบ row พร้อม annotation ที่ผูกอยู่ CASCADE) → `next dev` เปิด http://localhost:3000 — คลิกไฟล์เพื่อเข้าหน้า annotate หรืออัปโหลดไฟล์ใหม่ (PDF/รูปภาพ ≤ 100MB) ผ่านฟอร์มบนหน้ารายการ (N4 — ไฟล์เก็บที่ `public/uploads/`)
เปิด http://localhost:3000/docedit/note — หน้าแรก (landing) · ผู้ใช้จริงเข้าผ่านลิงก์เปิดเอกสารของ e-Court (dev mint ลิงก์ทดสอบเองด้วย `node scripts/make-login-link.mjs` — ดู `docs/PLAN-SSO.md`) ส่วนเครื่องมือทดสอบ (เปิดเอกสารตัวอย่าง + คู่มือทางเข้า SSO + ฟอร์ม mint ลิงก์) อยู่ที่ http://localhost:3000/docedit/note/dev
**คำสั่งเสริม**
- `npm run dev:server` — รันแค่ `next dev` ไม่แตะ DB/container (ใช้เมื่อแก้โค้ดล้วน ๆ ไม่อยากรอ migrate)
- `npm run db:prepare` — เตรียม DB เท่านั้น (compose up + migrate + seed) ไม่รัน dev server
- `npm run db:reset` — **ล้างข้อมูลเริ่มใหม่ทั้งหมด**: ลบ volume PostgreSQL + MinIO ของโปรเจกต์ แล้วสร้างใหม่ + migrate + seed (ข้อมูลหายถาวร — ระวัง)
- แก้ `db/schema.ts` → รัน `npx drizzle-kit generate` สร้างไฟล์ SQL ใหม่ใน `drizzle/` แล้วรัน `npm run dev` อีกครั้งเพื่อ apply — **เลี่ยง `drizzle-kit push` ตรง ๆ** เพราะ production apply จากไฟล์ใน `drizzle/` เท่านั้น ถ้า dev push เดินนำไฟล์ migration สองฝั่งจะเพี้ยนกัน
## การใช้งานบนหน้า annotate ## การใช้งานบนหน้า annotate
@ -90,75 +89,18 @@ Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกร
ทดสอบ realtime: เปิดหน้า annotate ไฟล์เดียวกันใน 2 แท็บ แล้ววาด/comment จากแท็บหนึ่ง — อีกแท็บเห็นทันทีโดยไม่ต้อง refresh ทดสอบ realtime: เปิดหน้า annotate ไฟล์เดียวกันใน 2 แท็บ แล้ววาด/comment จากแท็บหนึ่ง — อีกแท็บเห็นทันทีโดยไม่ต้อง refresh
## API สำหรับระบบภายนอก (N10)
Read-only API ให้ **backend ของ e-Court** เรียกแบบ server-to-server (ไม่มีหน้า UI) — endpoint เดียว `POST /api/note/external/documents` (JSON body) ตรวจสิทธิ์ด้วย API Key เดียวใน env `EXTERNAL_API_KEY` ส่งผ่าน header `x-api-key` (env ไม่ตั้ง/ว่าง = ปฏิเสธทุก request — fail closed) — คู่มือการใช้สำหรับผู้เรียก: `docs/API-DOCUMENTS.md` · สเปก/เหตุผลการออกแบบ: `docs/PLAN-API.md`
```jsonc
// POST /docedit/api/note/external/documents (URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath)
// headers: x-api-key + content-type: application/json
{
"fileId": "<fileId ของ e-Court>", // ถามเอกสารเดียว — ส่งพร้อม typeId ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข (AND)
"typeId": "<typeId>", // ถามทุกเอกสารในกลุ่ม N9 — กลุ่มว่าง = 200 documents: []
"filter": {
"type": "bookmarks", // optional: "bookmarks" | "annotations" — ไม่ส่ง (หรือ type เป็น "") = ทั้งสอง
"userId": "<userId>", // optional: string หรือ array — กรองตามเจ้าของรายการ (ทั้ง annotations/bookmarks)
"annotationTypes": ["COMMENT"], // optional: ค่าจาก HIGHLIGHT|DRAW|COMMENT|SHAPE|REDACTION|TEXTBOX|STAMP — เฉพาะ annotations
}
}
```
- Response 200 เป็นรูปเดียวเสมอ: `{ "documents": [ { "id", "externalFileId", "title", "typeId", "createdAt", "annotations": [… พร้อม url + replies], "bookmarks": [… พร้อม url] } ] }` (field เอกสารอยู่ระดับเดียวกับ annotations/bookmarks) — ถามด้วย fileId ได้ array ยาว 1 ชิ้น (ไม่เคยเปิดในระบบ = 404)
- `url` ต่อรายการ = deep link กลับมาหน้า viewer เป็น **URL เต็มพร้อมใช้ทันที** (`http://<host>/docedit/note/annotate/<fileId>?ann=<id>` สำหรับ annotation · `?page=<หน้า 1-based>` สำหรับ bookmark) — origin อ่านจาก request ที่เรียกเข้ามา (ผ่าน reverse proxy ต้องตั้งส่งต่อ `x-forwarded-proto`/`x-forwarded-host`)
- error ทุกสถานะ (400/401/404/500) เป็น JSON `{ "error": "<ข้อความไทย>" }` รูปเดียวกันทั้ง endpoint — 500 เกิดเมื่อ query ฐานข้อมูลล้ม (log รายละเอียดฝั่ง server อย่างเดียว)
- ตัวอย่าง:
```bash
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
-d '{"fileId":"1234","filter":{"type":"bookmarks"}}' \
"http://localhost:3000/docedit/api/note/external/documents"
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
-d '{"typeId":"CASE-2569-0001"}' \
"http://localhost:3000/docedit/api/note/external/documents"
```
## API จัด version เอกสาร (docver — stand-alone)
กลุ่ม API ให้ **Word add-in / ระบบภายนอก** เก็บเอกสารเป็น version: เอกสารเดียวต่อ `docId` (unique) แต่ละ version เก็บไฟล์ **.docx + .pdf ใน MinIO** พร้อม `userId`/`userName`/`createdAt` ของผู้อัปโหลด — **ไม่มี auth ไม่มี session** (stand-alone): ผู้เรียกบอก identity เองผ่าน field `userId`/`userName` ของ multipart · contract ตรง mock ของ wordaddin (`versioningService`) เป๊ะเพื่อวันสลับของจริง · สเปก/เหตุผล: `docs/PLAN-DOCVER.md`
```bash
# POST อัปโหลด version (เอกสารถูกสร้างแบบ implicit เมื่อเป็น version แรก)
curl -F "file=@คำสั่งเรียก.docx" -F "pdf=@คำสั่งเรียก.pdf" \
-F "userId=u0001" -F "userName=ผู้พิพากษา ก." \
"http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions"
# → {"hasPdf":true,"versionId":"<uuid>","versionNumber":1} (versionNumber เป็นค่าจาก server)
# GET history (ใหม่ → เก่า) — docId ที่ยังไม่เคยมี version = 404
curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions"
# GET presigned URL อายุสั้น (default docx, ?format=pdf — version ไม่มี pdf = 404)
curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions/<versionId>/download-link?format=pdf"
```
- ไฟล์แนบ `file` (.docx) บังคับ · `pdf` optional (add-in แนบ blob เปล่าได้ = ถือว่าไม่มี ดู `hasPdf`) · ทั้งคู่ ≤ 100MB + ตรวจ magic bytes · field นอก allowlist = 400 กัน typo เงียบ ๆ
- error ทุกสถานะเป็น JSON `{ "error": "<ข้อความไทย>" }` — 400 (รูปผิด/ไม่ใช่ multipart/เนื้อไฟล์เพี้ยน) · 413 (เกิน 100MB) · 404 (ไม่มีเอกสาร/version/pdf) · 500 (MinIO env ไม่ตั้ง/ล้ม, DB ล้ม — log เต็มฝั่ง server)
- **presigned URL อ้าง `MINIO_ENDPOINT:MINIO_PORT` ตรง ๆ** — production ต้องเป็นโฮสต์ที่เบราว์เซอร์ของผู้ใช้เข้าถึงได้ ไม่ใช่แค่ที่ Next server เข้าถึงได้
## Troubleshooting ## Troubleshooting
| อาการ | สาเหตุ/ทางแก้ | | อาการ | สาเหตุ/ทางแก้ |
|---|---| |---|---|
| Realtime ไม่ซิงก์ | sync ใช้ pub/sub **in-memory ของ process เดียว** — ทุกแท็บต้องเข้าผ่าน dev server เดียวกัน (ถ้าวันหนึ่งรันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) | | Realtime ไม่ซิงก์ | sync ใช้ pub/sub **in-memory ของ process เดียว** — ทุกแท็บต้องเข้าผ่าน dev server เดียวกัน (ถ้าวันหนึ่งรันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) |
| หน้าเว็บ 500 หลังรัน `next build` ระหว่างที่ dev server กำลังรัน | build เขียนทับ cache ของ dev — ปิด dev server ก่อน build หรือ restart dev server หลัง build | | หน้าเว็บ 500 หลังรัน `next build` ระหว่างที่ dev server กำลังรัน | build เขียนทับ cache ของ dev — ปิด dev server ก่อน build หรือ restart dev server หลัง build |
| `tsc --noEmit` error ชี้ route ที่ถูกลบไปแล้ว (เช่น `app/api/external/document*` หลังรวมเป็น `query` รอบสองของ N10) | type stub ค้างใน `.next/types/` ของ dev/build รอบก่อน — ลบ `.next` หรือรัน `next build`/restart dev server หนึ่งครั้งให้ regenerate แล้วรันใหม่ | | ต่อ DB ไม่ได้ | ตรวจว่า container รันอยู่ (`docker start judes-note-db`) และพอร์ต 54322 ไม่ถูกใช้โดย container อื่น |
| ต่อ DB ไม่ได้ | ตรวจว่า container รันอยู่ (`docker compose start db` — เดิม `docker start judes-note-db`) และพอร์ต 54322 ไม่ถูกใช้โดย container อื่น |
| API เอกสาร versioning ตอบ 500 ทุก call | ตรวจ env `MINIO_ENDPOINT`/`MINIO_ACCESS_KEY`/`MINIO_SECRET_KEY` และ container minio (`docker compose start minio`) — รายละเอียดอยู่ใน log ฝั่ง server |
| Highlight ใช้ไม่ได้กับไฟล์คดีตัวอย่าง | ไฟล์ `01012-*.pdf` ทั้งสองเป็น**ภาพสแกนล้วน** (ไม่มี text layer) — ใช้ `dashboard-report-2026-09-03.pdf` ซึ่งเป็น text-based PDF แทน จึงจะคลุมดำได้ (DRAW/COMMENT/BOOKMARK ใช้ได้ปกติกับทุกไฟล์) | | Highlight ใช้ไม่ได้กับไฟล์คดีตัวอย่าง | ไฟล์ `01012-*.pdf` ทั้งสองเป็น**ภาพสแกนล้วน** (ไม่มี text layer) — ใช้ `dashboard-report-2026-09-03.pdf` ซึ่งเป็น text-based PDF แทน จึงจะคลุมดำได้ (DRAW/COMMENT/BOOKMARK ใช้ได้ปกติกับทุกไฟล์) |
## หมายเหตุทางเทคนิค ## หมายเหตุทางเทคนิค
- **Realtime = SSE + pub/sub in-process:** Server Actions (`app/actions/annotations.ts`) publish event หลังเขียน DB สำเร็จทุกครั้ง → `lib/annotate-events.ts` → SSE endpoint `app/api/note/annotations/stream` → hook เปิด `EventSource` รับ event (payload เป็น DTO จาก server ตรง ๆ) - **Realtime = SSE + pub/sub in-process:** Server Actions (`app/actions/annotations.ts`) publish event หลังเขียน DB สำเร็จทุกครั้ง → `lib/annotate-events.ts` → SSE endpoint `app/api/annotations/stream` → hook เปิด `EventSource` รับ event (payload เป็น DTO จาก server ตรง ๆ)
- UI ใช้ `useOptimistic` ที่มาจาก React canary build ซึ่ง Next.js 14 vendored ไว้ให้ — หากหยิบ component ไปรันนอก Next bundling (เช่น unit test ด้วย `react` เปล่า) จะหา export นี้ไม่เจอ - UI ใช้ `useOptimistic` ที่มาจาก React canary build ซึ่ง Next.js 14 vendored ไว้ให้ — หากหยิบ component ไปรันนอก Next bundling (เช่น unit test ด้วย `react` เปล่า) จะหา export นี้ไม่เจอ
- พิกัด annotation ทุกชนิดเก็บเป็น **% ของขนาดหน้า** → render ถูกต้องทุก zoom/ขนาดจอ และ `mergeRectsByLine` รวบ rect ให้เหลือ 1 อันต่อบรรทัดก่อนบันทึก highlight - พิกัด annotation ทุกชนิดเก็บเป็น **% ของขนาดหน้า** → render ถูกต้องทุก zoom/ขนาดจอ และ `mergeRectsByLine` รวบ rect ให้เหลือ 1 อันต่อบรรทัดก่อนบันทึก highlight
- สถาปัตยกรรม/สเปกฉบับเต็ม: `docs/PLAN.md` · กติกาที่ต้องรักษาตอนแก้โค้ด: `CLAUDE.md` · ฟีเจอร์ต่อยอด: `docs/PLAN-FEAT.md` - สถาปัตยกรรม/สเปกฉบับเต็ม: `docs/PLAN.md` · กติกาที่ต้องรักษาตอนแก้โค้ด: `CLAUDE.md` · ฟีเจอร์ต่อยอด: `docs/PLAN-FEAT.md`
@ -169,19 +111,15 @@ curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions/<versi
db/schema.ts ตาราง + JSONB strict union + DTO + toDTO db/schema.ts ตาราง + JSONB strict union + DTO + toDTO
db/index.ts postgres.js + drizzle (prepare: false) db/index.ts postgres.js + drizzle (prepare: false)
lib/annotate-events.ts pub/sub in-process (actions → SSE route) lib/annotate-events.ts pub/sub in-process (actions → SSE route)
lib/document-versioning.ts validation ล้วนของกลุ่ม /api/documents (docver)
lib/docver-store.ts MinIO client + tx จ่ายเลข version + query ของ docver
utils/coordinates.ts px ⇄ % + clamp + mergeRectsByLine utils/coordinates.ts px ⇄ % + clamp + mergeRectsByLine
utils/user.ts parse/build cookie oacCase (mock login e-Court) utils/user.ts parse/build cookie oacCase (mock login e-Court)
hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id
app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว) app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว)
app/api/note/annotations/stream/route.ts SSE endpoint (GET ?fileId=) app/api/annotations/stream/route.ts SSE endpoint (GET ?fileId=)
app/api/documents/ API จัด version เอกสาร (token + versions + download-link) app/page.tsx รายการไฟล์ + ฟอร์มอัปโหลด (N4)
app/note/page.tsx หน้าแรก landing (วิธีเข้าใช้ + ลิงก์ไปหน้าเครื่องมือทดสอบ) app/annotate/[id]/page.tsx โหลด file + initial data → PDFViewer
app/note/dev/page.tsx เครื่องมือทดสอบ: เปิดเอกสารตัวอย่าง + คู่มือ SSO + mint ลิงก์
app/note/annotate/[id]/page.tsx โหลด file + initial data → PDFViewer
components/pdf/ PDFViewer (root + virtualization), InkLayer, HighlightToolbar components/pdf/ PDFViewer (root + virtualization), InkLayer, HighlightToolbar
components/sidebar/ AnnotationSidebar components/sidebar/ AnnotationSidebar
public/note/samples/ ไฟล์ตัวอย่าง (PDF + รูปภาพ) — seed ด้วย npm run seed-samples public/samples/ ไฟล์ตัวอย่าง (PDF + รูปภาพ) — seed ด้วย npm run seed-samples
public/note/uploads/ ไฟล์อัปโหลดสมัย N4 (ฟอร์มถอดแล้ว — เหลือแค่ route เสิร์ฟไฟล์เก่า) public/uploads/ ไฟล์ที่อัปโหลดผ่านหน้ารายการ (N4)
``` ```

View file

@ -30,30 +30,14 @@ import {
type StampData, type StampData,
type TextboxData, type TextboxData,
} from "@/db/schema"; } from "@/db/schema";
import {
MAX_BOOKMARK_NOTE_CHARS,
MAX_BOOKMARK_TITLE_CHARS,
} from "@/utils/bookmark-limits";
import { noteUserFromToken } from "@/lib/note-token";
import { rotatedRectAABB, stampRect } from "@/utils/geometry";
/** identity จาก access_token ที่ client แนบมาทุก action (ฉบับ token 2026-10-30
* ตามคำขอผู้ใช้ "note ใช้แบบ add-in" — เดิม action เชื่อ userId ที่ client ส่ง
* มาตรง ๆ ซึ่งปลอมได้) · token ผิด/หมดอายุ = throw ให้ useOptimistic rollback
* ตามกลไกเดิมของ action ที่ล้มเหลว */
function requireUser(token: string): { userid: string; username: string } {
const user = noteUserFromToken(token);
if (user === null) {
throw new Error("session หมดอายุหรือไม่ถูกต้อง — กรุณากลับ e-Court กดลิงก์เปิดเอกสารใหม่");
}
return user;
}
// ─── Input types (client ใช้ตอนเรียก action) ──────────────────────────────── // ─── Input types (client ใช้ตอนเรียก action) ────────────────────────────────
export type CreateAnnotationInput = { export type CreateAnnotationInput = {
fileId: string; fileId: string;
pageIndex: number; pageIndex: number;
userId: string;
userName: string;
type: AnnotationType; type: AnnotationType;
data: AnnotationData; data: AnnotationData;
/** id ที่ client สร้าง (crypto.randomUUID) ของ optimistic row — ให้ server ใช้ id เดียวกัน /** id ที่ client สร้าง (crypto.randomUUID) ของ optimistic row — ให้ server ใช้ id เดียวกัน
@ -64,6 +48,9 @@ export type CreateAnnotationInput = {
export type CreateBookmarkInput = { export type CreateBookmarkInput = {
fileId: string; fileId: string;
pageIndex: number; pageIndex: number;
userId: string;
/** N6: ชื่อผู้บันทึก — แสดงในผลค้นหา bookmark */
userName: string;
title: string; title: string;
/** % ของความสูงหน้า (0–100) */ /** % ของความสูงหน้า (0–100) */
yOffset: number; yOffset: number;
@ -75,6 +62,7 @@ export type CreateBookmarkInput = {
export type UpdateBookmarkInput = { export type UpdateBookmarkInput = {
id: string; id: string;
userId: string;
/** แก้เฉพาะ field ที่ส่งมา (undefined = ไม่แตะ) */ /** แก้เฉพาะ field ที่ส่งมา (undefined = ไม่แตะ) */
title?: string; title?: string;
note?: string | null; note?: string | null;
@ -98,7 +86,10 @@ const SHAPE_KINDS: readonly string[] = ["rect", "ellipse", "line", "arrow"];
// FIX-4: ความยาวสูงสุดของ string input ต่อ field — กันเรียก action // FIX-4: ความยาวสูงสุดของ string input ต่อ field — กันเรียก action
// ตรง ๆ ด้วยข้อความขนาดหลาย MB จน JSONB บวมและ SSE event ใหญ่ผิดปกติ // ตรง ๆ ด้วยข้อความขนาดหลาย MB จน JSONB บวมและ SSE event ใหญ่ผิดปกติ
const MAX_USER_NAME_CHARS = 100;
const MAX_TEXT_CHARS = 5_000; // message/text/note/label ของ annotation + reply 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) const MAX_QUERY_CHARS = 200; // คำค้นหา (F14)
function fieldError(field: string, expect: string): never { function fieldError(field: string, expect: string): never {
@ -148,20 +139,6 @@ function vPoint(v: unknown, field: string): { x: number; y: number } {
return { x: vPercent(p.x, `${field}.x`), y: vPercent(p.y, `${field}.y`) }; return { x: vPercent(p.x, `${field}.x`), y: vPercent(p.y, `${field}.y`) };
} }
/** vPoint แบบไม่บังคับ ≥ 0 — ใช้กับ STAMP ที่หมุน: position เป็นมุมกรอบ*ก่อนหมุน*
* ซึ่งติดลบ/เกิน 100 ได้โดยกรอบจริง (AABB ของกรอบหมุน) ยังอยู่ในหน้าครบ — เกณฑ์
* ความถูกต้องของพิกัดอยู่ที่ AABB ซึ่งผู้เรียกตรวจเอง (code review 2026-09-28) */
function vPointFinite(v: unknown, field: string): { x: number; y: number } {
if (typeof v !== "object" || v === null) fieldError(field, "เป็น { x, y }");
const p = v as Record<string, unknown>;
for (const axis of ["x", "y"] as const) {
if (typeof p[axis] !== "number" || !Number.isFinite(p[axis])) {
fieldError(`${field}.${axis}`, "เป็นตัวเลข");
}
}
return { x: p.x as number, y: p.y as number };
}
function vRect(v: unknown, field: string): Rect { function vRect(v: unknown, field: string): Rect {
if (typeof v !== "object" || v === null) fieldError(field, "เป็น { x, y, width, height }"); if (typeof v !== "object" || v === null) fieldError(field, "เป็น { x, y, width, height }");
const r = v as Record<string, unknown>; const r = v as Record<string, unknown>;
@ -359,9 +336,7 @@ function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationDat
} }
case "STAMP": { case "STAMP": {
const label = vString(d.label, "data.label", MAX_TEXT_CHARS); const label = vString(d.label, "data.label", MAX_TEXT_CHARS);
// position = มุมกรอบก่อนหมุน — ติดลบได้เมื่อตรายางเอียง (ดู vPointFinite) const position = vPoint(d.position, "data.position");
// เกณฑ์ "ไม่ติดลบ" ตรวจที่ AABB ของตรายางด้านล่างแทน position ตรง ๆ
const position = vPointFinite(d.position, "data.position");
// scale เป็น % ของความกว้างหน้า — ต้องเป็นบวกจึงมีความหมาย // scale เป็น % ของความกว้างหน้า — ต้องเป็นบวกจึงมีความหมาย
if ( if (
typeof d.scale !== "number" || typeof d.scale !== "number" ||
@ -373,41 +348,12 @@ function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationDat
// สีตรายางเป็น optional — ไม่มี = STAMP_COLOR (แถวเดิม) // สีตรายางเป็น optional — ไม่มี = STAMP_COLOR (แถวเดิม)
const color = d.color !== undefined ? vString(d.color, "data.color") : undefined; const color = d.color !== undefined ? vString(d.color, "data.color") : undefined;
const note = vNote(d.note); const note = vNote(d.note);
// มุมเอียง (องศา −180..180 หมุนรอบกลางกรอบ) optional — ไม่มี = ไม่เอียง
if (
d.rotation !== undefined &&
(typeof d.rotation !== "number" ||
!Number.isFinite(d.rotation) ||
d.rotation < -180 ||
d.rotation > 180)
) {
fieldError("data.rotation", "เป็นตัวเลข −180 ถึง 180");
}
// เกณฑ์พิกัดไม่ติดลบของ STAMP = AABB ของกรอบ (หมุนรอบกลางกรอบแล้ว): ตรายาง
// เอียงชิดขอบ มุมก่อนหมุนหลุดนอกหน้าได้โดยตัวตรายางจริงยังอยู่ในหน้าครบ —
// ตรวจ position ตรง ๆ แล้ว client clamp (ยึด AABB) ขัดกับ validator จนลาก
// ถึงขอบแล้วเด้งกลับ (code review 2026-09-28); ไม่มี rotation → AABB = rect
// เดิม เกณฑ์เดิมเป๊ะ — คำนวณด้วย estimate (pure ฝั่ง server ไม่มี DOM)
const stampAabb = rotatedRectAABB(
stampRect(
{ label, position, scale: d.scale, rotation: d.rotation ?? 0 },
{ width: 100, height: 100 },
),
);
// tolerance 2% ของหน้า: clamp ฝั่ง client ยึด measure จริงของ browser
// (measureStampText) ขณะที่นี่ยึด stampLayoutEstimate — ฟอนต์ต่อเครื่องทำให้
// จำนวนบรรทัด/AABB คลาดกันนิดหน่อย ค่าที่บนจอพอดีเป๊ะต้องไม่ถูกปฏิเสธเด้งกลับ
// (code review 2026-09-29) — ยอมให้ AABB ยื่นเกินขอบนิดหน่อยแลกกับไม่เด้ง
if (stampAabb.x < -2 || stampAabb.y < -2) {
fieldError("data.position", "เป็นพิกัดที่ตรายางยังอยู่ในหน้า (ไม่ติดลบ)");
}
return { return {
label, label,
position, position,
scale: d.scale, scale: d.scale,
...(color ? { color } : {}), ...(color ? { color } : {}),
...(note !== undefined ? { note } : {}), ...(note !== undefined ? { note } : {}),
...(d.rotation !== undefined ? { rotation: d.rotation } : {}),
} satisfies StampData; } satisfies StampData;
} }
case "SHAPE": { case "SHAPE": {
@ -451,18 +397,16 @@ function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationDat
* hard refresh ถึงเห็น (force-dynamic ปิดแค่ cache ฝั่ง server เท่านั้น) * hard refresh ถึงเห็น (force-dynamic ปิดแค่ cache ฝั่ง server เท่านั้น)
*/ */
function revalidateFilePages(fileId: string) { function revalidateFilePages(fileId: string) {
revalidatePath(`/note/annotate/${fileId}`); revalidatePath(`/annotate/${fileId}`);
revalidatePath(`/note/annotate/${fileId}/summary`); revalidatePath(`/annotate/${fileId}/summary`);
} }
export async function createAnnotation( export async function createAnnotation(input: CreateAnnotationInput): Promise<AnnotationDTO> {
token: string,
input: CreateAnnotationInput,
): Promise<AnnotationDTO> {
const user = requireUser(token);
const raw = input as unknown as Record<string, unknown> | null | undefined; const raw = input as unknown as Record<string, unknown> | null | undefined;
const fileId = vUuid(raw?.fileId, "fileId"); const fileId = vUuid(raw?.fileId, "fileId");
const pageIndex = vPageIndex(raw?.pageIndex); const pageIndex = vPageIndex(raw?.pageIndex);
const userId = vString(raw?.userId, "userId");
const userName = vString(raw?.userName, "userName", MAX_USER_NAME_CHARS);
if (typeof raw?.type !== "string" || !ANNOTATION_TYPES.includes(raw.type as AnnotationType)) { if (typeof raw?.type !== "string" || !ANNOTATION_TYPES.includes(raw.type as AnnotationType)) {
fieldError("type", `เป็นหนึ่งใน ${ANNOTATION_TYPES.join(" | ")}`); fieldError("type", `เป็นหนึ่งใน ${ANNOTATION_TYPES.join(" | ")}`);
} }
@ -474,8 +418,8 @@ export async function createAnnotation(
const values: typeof annotations.$inferInsert = { const values: typeof annotations.$inferInsert = {
fileId, fileId,
pageIndex, pageIndex,
userId: user.userid, userId,
userName: user.username, userName,
type, type,
data, data,
}; };
@ -492,9 +436,9 @@ export async function createAnnotation(
* F19/A7: snapshot row เป็น version kind='delete' **ก่อนลบ** ใน transaction เดียวกัน — * F19/A7: snapshot row เป็น version kind='delete' **ก่อนลบ** ใน transaction เดียวกัน —
* annotation_id ไม่ FK (D8) จึงรอดจาก CASCADE และกู้คืนได้ภายหลัง * annotation_id ไม่ FK (D8) จึงรอดจาก CASCADE และกู้คืนได้ภายหลัง
*/ */
export async function deleteAnnotation(token: string, id: string): Promise<void> { export async function deleteAnnotation(id: string, userId: string): Promise<void> {
const uid = requireUser(token).userid;
const annId = vUuid(id, "id"); const annId = vUuid(id, "id");
const uid = vString(userId, "userId");
const removed = await db.transaction(async (tx) => { const removed = await db.transaction(async (tx) => {
// FOR UPDATE กันสองแท็บลบพร้อมกัน → snapshot 'delete' เกิด 2 แถวซ้ำ // FOR UPDATE กันสองแท็บลบพร้อมกัน → snapshot 'delete' เกิด 2 แถวซ้ำ
const [existing] = await tx const [existing] = await tx
@ -537,12 +481,12 @@ export async function deleteAnnotation(token: string, id: string): Promise<void>
* - F19/A7: snapshot row เดิมเป็น version kind='update' **ก่อนเขียนทับ** ใน transaction เดียวกัน * - F19/A7: snapshot row เดิมเป็น version kind='update' **ก่อนเขียนทับ** ใน transaction เดียวกัน
*/ */
export async function updateAnnotation( export async function updateAnnotation(
token: string,
id: string, id: string,
userId: string,
data: AnnotationData, data: AnnotationData,
): Promise<AnnotationDTO> { ): Promise<AnnotationDTO> {
const uid = requireUser(token).userid;
const annId = vUuid(id, "id"); const annId = vUuid(id, "id");
const uid = vString(userId, "userId");
const row = await db.transaction(async (tx) => { const row = await db.transaction(async (tx) => {
const [existing] = await tx const [existing] = await tx
@ -590,11 +534,9 @@ export async function updateAnnotation(
* ต่างจาก delete ที่จำกัดเจ้าของ) — publish update event ให้ทุก client เห็น * ต่างจาก delete ที่จำกัดเจ้าของ) — publish update event ให้ทุก client เห็น
*/ */
export async function setAnnotationResolved( export async function setAnnotationResolved(
token: string,
id: string, id: string,
resolved: boolean, resolved: boolean,
): Promise<AnnotationDTO> { ): Promise<AnnotationDTO> {
requireUser(token);
const annId = vUuid(id, "id"); const annId = vUuid(id, "id");
if (typeof resolved !== "boolean") { if (typeof resolved !== "boolean") {
fieldError("resolved", "เป็น boolean"); fieldError("resolved", "เป็น boolean");
@ -624,13 +566,16 @@ export async function setAnnotationResolved(
export type CreateReplyInput = { export type CreateReplyInput = {
annotationId: string; annotationId: string;
userId: string;
userName: string;
message: string; message: string;
}; };
export async function createReply(token: string, input: CreateReplyInput): Promise<ReplyDTO> { export async function createReply(input: CreateReplyInput): Promise<ReplyDTO> {
const user = requireUser(token);
const raw = input as unknown as Record<string, unknown> | null | undefined; const raw = input as unknown as Record<string, unknown> | null | undefined;
const annotationId = vUuid(raw?.annotationId, "annotationId"); const annotationId = vUuid(raw?.annotationId, "annotationId");
const userId = vString(raw?.userId, "userId");
const userName = vString(raw?.userName, "userName", MAX_USER_NAME_CHARS);
const message = vString(raw?.message, "message", MAX_TEXT_CHARS); const message = vString(raw?.message, "message", MAX_TEXT_CHARS);
const [parent] = await db const [parent] = await db
@ -643,7 +588,7 @@ export async function createReply(token: string, input: CreateReplyInput): Promi
const [row] = await db const [row] = await db
.insert(annotationReplies) .insert(annotationReplies)
.values({ annotationId, userId: user.userid, userName: user.username, message }) .values({ annotationId, userId, userName, message })
.returning(); .returning();
const dto = toReplyDTO(row); const dto = toReplyDTO(row);
publishAnnotateEvent(parent.fileId, { table: "replies", action: "create", row: dto }); publishAnnotateEvent(parent.fileId, { table: "replies", action: "create", row: dto });
@ -652,9 +597,9 @@ export async function createReply(token: string, input: CreateReplyInput): Promi
} }
/** ลบได้เฉพาะ reply ของตัวเอง (userId ตรงกัน) เท่านั้น */ /** ลบได้เฉพาะ reply ของตัวเอง (userId ตรงกัน) เท่านั้น */
export async function deleteReply(token: string, id: string): Promise<void> { export async function deleteReply(id: string, userId: string): Promise<void> {
const uid = requireUser(token).userid;
const replyId = vUuid(id, "id"); const replyId = vUuid(id, "id");
const uid = vString(userId, "userId");
const [existing] = await db const [existing] = await db
.select({ annotationId: annotationReplies.annotationId }) .select({ annotationId: annotationReplies.annotationId })
.from(annotationReplies) .from(annotationReplies)
@ -758,11 +703,12 @@ async function isDescendant(tx: Tx, id: string, ancestorId: string): Promise<boo
throw new Error("โครงสร้าง bookmark ผิดปกติ (parentId วนซ้ำ)"); throw new Error("โครงสร้าง bookmark ผิดปกติ (parentId วนซ้ำ)");
} }
export async function createBookmark(token: string, input: CreateBookmarkInput): Promise<BookmarkDTO> { export async function createBookmark(input: CreateBookmarkInput): Promise<BookmarkDTO> {
const user = requireUser(token);
const raw = input as unknown as Record<string, unknown> | null | undefined; const raw = input as unknown as Record<string, unknown> | null | undefined;
const fileId = vUuid(raw?.fileId, "fileId"); const fileId = vUuid(raw?.fileId, "fileId");
const pageIndex = vPageIndex(raw?.pageIndex); const pageIndex = vPageIndex(raw?.pageIndex);
const userId = vString(raw?.userId, "userId");
const userName = vString(raw?.userName, "userName", MAX_USER_NAME_CHARS);
const title = vString(raw?.title, "title", MAX_BOOKMARK_TITLE_CHARS); const title = vString(raw?.title, "title", MAX_BOOKMARK_TITLE_CHARS);
if ( if (
typeof raw?.yOffset !== "number" || typeof raw?.yOffset !== "number" ||
@ -796,16 +742,7 @@ export async function createBookmark(token: string, input: CreateBookmarkInput):
const [inserted] = await tx const [inserted] = await tx
.insert(bookmarks) .insert(bookmarks)
.values({ .values({ fileId, pageIndex, userId, userName, title, yOffset, parentId, note })
fileId,
pageIndex,
userId: user.userid,
userName: user.username,
title,
yOffset,
parentId,
note,
})
.returning(); .returning();
return inserted; return inserted;
}); });
@ -819,10 +756,10 @@ export async function createBookmark(token: string, input: CreateBookmarkInput):
* N2: แก้ไข bookmark ของตัวเอง — ชื่อ/โน้ต/ย้ายหัวข้อ (parentId) * N2: แก้ไข bookmark ของตัวเอง — ชื่อ/โน้ต/ย้ายหัวข้อ (parentId)
* ย้ายได้ตามเกณฑ์ความลึก (depth แม่ + subtree สูงรวมกัน ≤ BOOKMARK_MAX_DEPTH) และห้ามเข้า subtree ตัวเอง * ย้ายได้ตามเกณฑ์ความลึก (depth แม่ + subtree สูงรวมกัน ≤ BOOKMARK_MAX_DEPTH) และห้ามเข้า subtree ตัวเอง
*/ */
export async function updateBookmark(token: string, input: UpdateBookmarkInput): Promise<BookmarkDTO> { export async function updateBookmark(input: UpdateBookmarkInput): Promise<BookmarkDTO> {
const uid = requireUser(token).userid;
const raw = input as unknown as Record<string, unknown> | null | undefined; const raw = input as unknown as Record<string, unknown> | null | undefined;
const bmId = vUuid(raw?.id, "id"); const bmId = vUuid(raw?.id, "id");
const uid = vString(raw?.userId, "userId");
const title = raw?.title !== undefined ? vString(raw.title, "title", MAX_BOOKMARK_TITLE_CHARS) : undefined; const title = raw?.title !== undefined ? vString(raw.title, "title", MAX_BOOKMARK_TITLE_CHARS) : undefined;
const note = raw?.note !== undefined ? vBookmarkNote(raw.note) : undefined; const note = raw?.note !== undefined ? vBookmarkNote(raw.note) : undefined;
@ -888,9 +825,9 @@ export async function updateBookmark(token: string, input: UpdateBookmarkInput):
/** ลบได้เฉพาะ bookmark ของตัวเอง (userId ตรงกัน) เท่านั้น /** ลบได้เฉพาะ bookmark ของตัวเอง (userId ตรงกัน) เท่านั้น
* N2: ลบหัวข้อใหญ่ → sub ทั้งหมดขยับขึ้นเป็นระดับบนสุด (ไม่มีข้อมูลหาย) ใน tx เดียวกัน */ * N2: ลบหัวข้อใหญ่ → sub ทั้งหมดขยับขึ้นเป็นระดับบนสุด (ไม่มีข้อมูลหาย) ใน tx เดียวกัน */
export async function deleteBookmark(token: string, id: string): Promise<void> { export async function deleteBookmark(id: string, userId: string): Promise<void> {
const uid = requireUser(token).userid;
const bmId = vUuid(id, "id"); const bmId = vUuid(id, "id");
const uid = vString(userId, "userId");
const result = await db.transaction(async (tx) => { const result = await db.transaction(async (tx) => {
const [existing] = await tx const [existing] = await tx
.select({ id: bookmarks.id, fileId: bookmarks.fileId, userId: bookmarks.userId }) .select({ id: bookmarks.id, fileId: bookmarks.fileId, userId: bookmarks.userId })
@ -939,10 +876,8 @@ export async function deleteBookmark(token: string, id: string): Promise<void> {
/** ประวัติการเปลี่ยนแปลงของ annotation หนึ่งรายการ — ใหม่สุดก่อน */ /** ประวัติการเปลี่ยนแปลงของ annotation หนึ่งรายการ — ใหม่สุดก่อน */
export async function listVersions( export async function listVersions(
token: string,
annotationId: string, annotationId: string,
): Promise<AnnotationVersionDTO[]> { ): Promise<AnnotationVersionDTO[]> {
requireUser(token);
const annId = vUuid(annotationId, "annotationId"); const annId = vUuid(annotationId, "annotationId");
const rows = await db const rows = await db
.select() .select()
@ -962,11 +897,11 @@ export async function listVersions(
* - เจ้าของเท่านั้น (D9): ตรวจ userId ของ row ปัจจุบัน / ของ snapshot ตามกรณี * - เจ้าของเท่านั้น (D9): ตรวจ userId ของ row ปัจจุบัน / ของ snapshot ตามกรณี
*/ */
export async function restoreVersion( export async function restoreVersion(
token: string,
versionId: string, versionId: string,
userId: string,
): Promise<AnnotationDTO> { ): Promise<AnnotationDTO> {
const uid = requireUser(token).userid;
const vId = vUuid(versionId, "versionId"); const vId = vUuid(versionId, "versionId");
const uid = vString(userId, "userId");
const [v] = await db const [v] = await db
.select() .select()
.from(annotationVersions) .from(annotationVersions)
@ -1011,9 +946,6 @@ export async function restoreVersion(
if (v.userId !== uid) { if (v.userId !== uid) {
throw new Error("กู้คืนไม่ได้: เป็นรายการของผู้ใช้อื่น"); throw new Error("กู้คืนไม่ได้: เป็นรายการของผู้ใช้อื่น");
} }
// validate สมมาตรกับสาขา "ต้นทางยังอยู่" — snapshot อาจเขียนสมัย validator หนาแน่น
// น้อยกว่าปัจจุบัน ห้ามไหลกลับเข้า annotations + SSE โดยไม่โดนตรวจ (invariant #8)
const validated = validateAnnotationData(v.type, v.data);
const [row] = await tx const [row] = await tx
.insert(annotations) .insert(annotations)
.values({ .values({
@ -1022,7 +954,7 @@ export async function restoreVersion(
userId: v.userId, userId: v.userId,
userName: v.userName, userName: v.userName,
type: v.type, type: v.type,
data: validated, data: v.data,
}) })
.returning(); .returning();
return { action: "create" as const, row }; return { action: "create" as const, row };
@ -1040,12 +972,7 @@ export async function restoreVersion(
* ค้นหาคำในหมายเหตุทั้งไฟล์ — `ilike` บน `data->>'text'` (HIGHLIGHT และ TEXTBOX) * ค้นหาคำในหมายเหตุทั้งไฟล์ — `ilike` บน `data->>'text'` (HIGHLIGHT และ TEXTBOX)
* และ `data->>'message'` (COMMENT); escape `%`/`_`/`\` ในคำค้นก่อนเอาไปใช้ใน pattern * และ `data->>'message'` (COMMENT); escape `%`/`_`/`\` ในคำค้นก่อนเอาไปใช้ใน pattern
*/ */
export async function searchAnnotations( export async function searchAnnotations(fileId: string, q: string): Promise<AnnotationDTO[]> {
token: string,
fileId: string,
q: string,
): Promise<AnnotationDTO[]> {
requireUser(token);
const fid = vUuid(fileId, "fileId"); const fid = vUuid(fileId, "fileId");
const query = vString(q, "q", MAX_QUERY_CHARS); const query = vString(q, "q", MAX_QUERY_CHARS);
const esc = query.replace(/[\\%_]/g, (c) => `\\${c}`); const esc = query.replace(/[\\%_]/g, (c) => `\\${c}`);
@ -1117,8 +1044,7 @@ function dateBound(v: string, endOfDay: boolean, field: string): Date {
* (คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ รวมกันด้วย AND); คู่ช่วงกลับด้าน (from > to) * (คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ รวมกันด้วย AND); คู่ช่วงกลับด้าน (from > to)
* = swap เงียบ ๆ — read-only จึงไม่ publish event/ไม่ revalidatePath * = swap เงียบ ๆ — read-only จึงไม่ publish event/ไม่ revalidatePath
*/ */
export async function searchBookmarks(token: string, input: SearchBookmarksInput): Promise<BookmarkDTO[]> { export async function searchBookmarks(input: SearchBookmarksInput): Promise<BookmarkDTO[]> {
requireUser(token);
const raw = input as unknown as Record<string, unknown> | null | undefined; const raw = input as unknown as Record<string, unknown> | null | undefined;
const fid = vUuid(raw?.fileId, "fileId"); const fid = vUuid(raw?.fileId, "fileId");
const q = typeof raw?.q === "string" && raw.q.trim() !== "" const q = typeof raw?.q === "string" && raw.q.trim() !== ""

41
app/actions/auth.ts Normal file
View file

@ -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<void> {
// ลบ 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");
}

107
app/actions/files.ts Normal file
View file

@ -0,0 +1,107 @@
"use server";
// N4 — อัปโหลดไฟล์เอกสารแบบง่าย (PDF/jpg/jpeg/png/tif/tiff) ผ่านหน้ารายการ
// เขียนไฟล์**ใหม่**ที่ public/uploads/<uuid>.<ext> เท่านั้น (ชื่อจาก randomUUID — กันชนกัน/กัน path traversal)
// ไม่มี SSE event: รายการไฟล์ไม่ได้ sync ผ่าน SSE — revalidatePath("/") พอ (สเปก PLAN-FEAT.md §7 N4)
import { randomUUID } from "node:crypto";
import { mkdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
import { files } from "@/db/schema";
import { SAMPLE_FILE_EXTENSIONS } from "@/utils/file-kind";
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB (ตาม bodySizeLimit ใน next.config.mjs)
const UPLOAD_DIR = path.join(process.cwd(), "public", "uploads");
export type UploadFileResult =
| { ok: true; fileId: string; title: string }
| { ok: false; error: string };
/** นามสกุล (ตัวเล็ก มีจุด) จากชื่อไฟล์ที่ browser ส่ง — ต้องอยู่ใน whitelist */
function allowedExt(name: string): string | null {
const dot = name.lastIndexOf(".");
if (dot <= 0) return null;
const ext = name.slice(dot).toLowerCase();
return SAMPLE_FILE_EXTENSIONS.includes(ext) ? ext : null;
}
/** title สำหรับแสดงในรายการ: ชื่อไฟล์เดิม ตัด path ที่ฝังมา จำกัดความยาว */
function sanitizeTitle(name: string): string {
const base = name.split(/[\\/]/).pop() ?? name;
return base.trim().slice(0, 200) || "ไฟล์ไม่มีชื่อ";
}
/** ตรวจ magic bytes ตรงกับ extension — กันไฟล์เปลี่ยนชื่อนามสกุล/ไฟล์เสี้ยวจากการโอน
* เข้าระบบแล้วไปพังทีหลังทั้ง viewer (ค้างโหลด) และ export (500) */
function matchesSignature(bytes: Buffer, ext: string): boolean {
switch (ext) {
case ".pdf":
return bytes.subarray(0, 5).toString("latin1") === "%PDF-";
case ".jpg":
case ".jpeg":
return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
case ".png":
return (
bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47
);
case ".tif":
case ".tiff":
return (
(bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00) || // II*\0
(bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a) // MM\0*
);
default:
return false;
}
}
export async function uploadFile(formData: FormData): Promise<UploadFileResult> {
const file = formData.get("file");
if (!(file instanceof File)) {
return { ok: false, error: "เลือกไฟล์ก่อนอัปโหลด" };
}
if (file.size === 0) {
return { ok: false, error: "ไฟล์ว่าง (0 ไบต์) — เลือกไฟล์ที่สมบูรณ์" };
}
const ext = allowedExt(file.name);
if (!ext) {
return {
ok: false,
error: `รองรับเฉพาะไฟล์ ${SAMPLE_FILE_EXTENSIONS.join(" / ")}`,
};
}
if (file.size > MAX_UPLOAD_BYTES) {
return { ok: false, error: "ไฟล์ใหญ่เกิน 100MB" };
}
// uuid เป็นทั้งชื่อไฟล์บนดิสก์และ id ของ row — ไม่ใช้ชื่อผู้ใช้ตกแต่ง path เด็ดขาด
const id = randomUUID();
const diskName = `${id}${ext}`;
const bytes = Buffer.from(await file.arrayBuffer());
if (!matchesSignature(bytes, ext)) {
return {
ok: false,
error: "เนื้อไฟล์ไม่ตรงกับนามสกุล (ไฟล์อาจเสียหรือถูกเปลี่ยนชื่อ) — ตรวจไฟล์แล้วลองใหม่",
};
}
await mkdir(UPLOAD_DIR, { recursive: true });
const diskPath = path.join(UPLOAD_DIR, diskName);
await writeFile(diskPath, bytes);
const title = sanitizeTitle(file.name);
const pdfUrl = `/uploads/${diskName}`;
try {
await db.insert(files).values({ id, title, pdfUrl });
} catch (err) {
// DB fail หลังเขียนไฟล์ — เก็บกวาดไฟล์ก่อนโยนต่อ กันไฟล์กำพร้าไม่มี row ชี้ (ไม่มีกลไกเก็บอื่น)
await unlink(diskPath).catch(() => undefined);
throw err;
}
revalidatePath("/");
return { ok: true, fileId: id, title };
}

View file

@ -0,0 +1,74 @@
import { asc, eq, inArray } from "drizzle-orm";
import { notFound, redirect } from "next/navigation";
import { db } from "@/db";
import {
annotationReplies,
annotations,
bookmarks,
files,
toAnnotationDTO,
toBookmarkDTO,
toReplyDTO,
} 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;
export const dynamic = "force-dynamic";
export default async function AnnotatePage({
params,
}: {
params: { id: string };
}) {
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();
const [annotationRows, bookmarkRows] = await Promise.all([
db
.select()
.from(annotations)
.where(eq(annotations.fileId, id))
.orderBy(asc(annotations.createdAt)),
db
.select()
.from(bookmarks)
.where(eq(bookmarks.fileId, id))
.orderBy(asc(bookmarks.createdAt)),
]);
// replies ของทุก comment ในไฟล์นี้ (F9) — แม่ถูก CASCADE ไปแล้ว row ก็หายตาม
const annotationIds = annotationRows.map((r) => r.id);
const replyRows = annotationIds.length
? await db
.select()
.from(annotationReplies)
.where(inArray(annotationReplies.annotationId, annotationIds))
.orderBy(asc(annotationReplies.createdAt))
: [];
return (
<>
{/* TEMP-DEBUG: อ่าน log บนจอ iPad ผ่าน Eruda เมื่อเปิดหน้าพร้อม ?debug */}
<DebugConsole />
<PDFViewer
file={{ id: file.id, title: file.title, pdfUrl: file.pdfUrl }}
initialAnnotations={annotationRows.map(toAnnotationDTO)}
initialBookmarks={bookmarkRows.map(toBookmarkDTO)}
initialReplies={replyRows.map(toReplyDTO)}
user={user}
/>
</>
);
}

View file

@ -0,0 +1,118 @@
import Link from "next/link";
import { asc, eq } from "drizzle-orm";
import { notFound } from "next/navigation";
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";
// หน้าสรุปหมายเหตุ (F15/D2) — server component รวมทุก annotation (ทุกผู้ใช้)
// จัดกลุ่มตามหน้า พร้อมปุ่มกระโดดเป็น deep link `/annotate/<id>?ann=<uuid>` (F12)
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const dynamic = "force-dynamic";
export default async function SummaryPage({
params,
}: {
params: { id: string };
}) {
const { id } = params;
if (!UUID_RE.test(id)) notFound();
const [file] = await db.select().from(files).where(eq(files.id, id));
if (!file) notFound();
const rows = await db
.select()
.from(annotations)
.where(eq(annotations.fileId, id))
.orderBy(asc(annotations.pageIndex), asc(annotations.createdAt));
const dtos = rows.map(toAnnotationDTO);
// จัดกลุ่มตามหน้า (เรียงหน้ามากไปน้อยไม่เกี่ยว — เดินตามลำดับหน้าเสมอ)
const byPage = new Map<number, AnnotationDTO[]>();
for (const a of dtos) {
const list = byPage.get(a.pageIndex);
if (list) list.push(a);
else byPage.set(a.pageIndex, [a]);
}
const pages = Array.from(byPage.entries()).sort((x, y) => x[0] - y[0]);
return (
<main className="summary-page mx-auto max-w-3xl p-6">
<div className="flex flex-wrap items-center gap-3">
<h1 className="min-w-0 flex-1 truncate text-lg font-bold text-primary">
สรุปหมายเหตุ — {file.title}
</h1>
<Link
href={`/annotate/${id}`}
className="flex items-center gap-1 rounded-lg border border-slate-300 px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
>
<ArrowLeft className="h-3.5 w-3.5" />
กลับหน้าอ่าน
</Link>
{/* F16/D3: export สรุปเป็น CSV (UTF-8 BOM) */}
<a
href={`/api/annotations/export-csv?fileId=${id}`}
className="rounded-lg border border-slate-300 px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
>
Export CSV
</a>
</div>
{pages.length === 0 ? (
<p className="mt-10 rounded border border-dashed p-6 text-center text-sm text-slate-500">
ยังไม่มี annotation ในไฟล์นี้
</p>
) : (
pages.map(([pageIndex, items]) => (
<section key={pageIndex} className="mt-6">
<h2 className="border-b border-slate-200 pb-1 text-sm font-semibold">
หน้า {pageIndex + 1}
<span className="ml-2 text-xs font-normal text-slate-400">
{items.length} รายการ
</span>
</h2>
<ul className="mt-2 divide-y divide-slate-100">
{items.map((a) => (
<li key={a.id} className="flex items-start gap-3 py-2">
<span className="min-w-0 flex-1">
<span className="block text-xs">
<span className="font-medium">{annotationTypeLabel(a)}</span>
{a.type === "COMMENT" && a.resolved && (
<span className="ml-2 rounded bg-emerald-50 px-1.5 py-0.5 text-[11px] text-emerald-600">
จัดการแล้ว
</span>
)}
<span className="ml-2 text-slate-400">{a.userName}</span>
</span>
<span className="mt-0.5 block whitespace-pre-wrap break-words text-sm leading-relaxed">
{annotationPreviewText(a)}
</span>
{annotationNote(a) && (
<span className="mt-0.5 block whitespace-pre-wrap break-words text-xs leading-relaxed text-slate-500">
โน้ต: {annotationNote(a)}
</span>
)}
</span>
{/* ปุ่มกระโดด = deep link ไปยัง annotation นั้น (F12 รับ ?ann= ตอน mount) */}
<Link
href={`/annotate/${id}?ann=${a.id}`}
className="flex shrink-0 items-center gap-1 rounded-md border border-slate-300 px-2 py-1 text-[11px] text-slate-600 hover:bg-slate-100"
>
<MapPin className="h-3 w-3" />
ไปตำแหน่ง
</Link>
</li>
))}
</ul>
</section>
))
)}
</main>
);
}

View file

@ -0,0 +1,55 @@
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { annotations, files, toAnnotationDTO } from "@/db/schema";
import { annotationNote, annotationPreviewText, annotationTypeLabel } from "@/utils/annotation-display";
import { csvField } from "@/utils/csv";
import { encodeRFC5987 } from "@/utils/pdf-export";
// Export สรุปหมายเหตุเป็น CSV (F16/D3) — ข้อมูลเดียวกับหน้าสรุป (F15)
// ขึ้นต้นด้วย UTF-8 BOM เพื่อให้ Excel ภาษาไทยอ่านถูกต้อง (RFC 4180: ครอบ field
// ด้วย double quote, ขึ้นบรรทัดใหม่ด้วย CRLF)
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export async function GET(req: Request) {
const fileId = new URL(req.url).searchParams.get("fileId") ?? "";
if (!UUID_RE.test(fileId)) {
return new Response("fileId ไม่ถูกต้อง", { status: 400 });
}
const [file] = await db.select().from(files).where(eq(files.id, fileId));
if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 });
const rows = await db
.select()
.from(annotations)
.where(eq(annotations.fileId, fileId))
.orderBy(asc(annotations.pageIndex), asc(annotations.createdAt));
const header = ["หน้า", "ชนิด", "ผู้เขียน", "ข้อความ", "โน้ต", "สร้างเมื่อ"];
const lines = rows.map((row) => {
const a = toAnnotationDTO(row);
return [
String(a.pageIndex + 1),
annotationTypeLabel(a),
a.userName,
annotationPreviewText(a),
annotationNote(a),
new Date(a.createdAt).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" }),
]
.map(csvField)
.join(",");
});
//  = UTF-8 BOM — ต้องเป็นตัวแรกสุดของไฟล์เสมอ
const csv = `${[header.map(csvField).join(","), ...lines].join("\r\n")}\r\n`;
const filename = encodeRFC5987(`${file.title}-summary.csv`);
return new Response(csv, {
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": `attachment; filename="summary.csv"; filename*=UTF-8''${filename}`,
},
});
}

View file

@ -8,18 +8,16 @@ import {
PDFName, PDFName,
PDFString, PDFString,
LineCapStyle, LineCapStyle,
degrees,
rgb, rgb,
type PDFFont, type PDFFont,
type PDFDict, type PDFDict,
type PDFPage, type PDFPage,
type PDFRef, type PDFRef,
} from "pdf-lib"; } from "pdf-lib";
import { and, asc, eq, inArray } from "drizzle-orm"; import { asc, eq, inArray } from "drizzle-orm";
import { db } from "@/db"; import { db } from "@/db";
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf"; import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
import { fetchSourceBytes, isRemoteSource, RemoteFileError } from "@/lib/remote-file";
import { import {
annotationReplies, annotationReplies,
annotations, annotations,
@ -52,16 +50,15 @@ import { toPx, type Point, type Size } from "@/utils/coordinates";
import { fileKindFromUrl } from "@/utils/file-kind"; import { fileKindFromUrl } from "@/utils/file-kind";
import { imageDocFromBytes } from "@/utils/image-to-pdf"; import { imageDocFromBytes } from "@/utils/image-to-pdf";
import { import {
STAMP_HEIGHT_RATIO,
annotationBBox, annotationBBox,
rotatePoint,
stampBoxHeight,
stampLayout,
textboxMaxWidthPct, textboxMaxWidthPct,
} from "@/utils/geometry"; } from "@/utils/geometry";
import { import {
STAMP_COLOR, STAMP_COLOR,
annotationNote, annotationNote,
effectiveDrawOpacity, effectiveDrawOpacity,
stampFitFontSize,
} from "@/utils/annotation-display"; } from "@/utils/annotation-display";
// Export PDF ฝัง annotation + bookmarks (F8/C1 — PLAN-FEAT.md §3) // Export PDF ฝัง annotation + bookmarks (F8/C1 — PLAN-FEAT.md §3)
@ -222,24 +219,14 @@ export async function GET(req: Request) {
if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 }); if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 });
// Guard path traversal: pdfUrl จาก DB ต้อง resolve แล้วอยู่ใน public/ เท่านั้น (§5) // Guard path traversal: pdfUrl จาก DB ต้อง resolve แล้วอยู่ใน public/ เท่านั้น (§5)
// N8: pdf_url เป็นรีโมต (ลิงก์ e-Court) → ดึงสดจาก allowlist แทนการอ่านดิสก์
let srcBytes: Buffer; let srcBytes: Buffer;
if (isRemoteSource(file.pdfUrl)) { try {
try { srcBytes = readPublicPdf(file.pdfUrl);
srcBytes = await fetchSourceBytes(file.pdfUrl); } catch (err) {
} catch (err) { if (err instanceof PdfPathError) {
if (err instanceof RemoteFileError) return new Response(err.message, { status: 502 }); return new Response("เส้นทางไฟล์ไม่ถูกต้อง", { status: 403 });
throw err;
}
} else {
try {
srcBytes = readPublicPdf(file.pdfUrl);
} catch (err) {
if (err instanceof PdfPathError) {
return new Response("เส้นทางไฟล์ไม่ถูกต้อง", { status: 403 });
}
return new Response("อ่านไฟล์ PDF ต้นฉบับไม่ได้", { status: 404 });
} }
return new Response("อ่านไฟล์ PDF ต้นฉบับไม่ได้", { status: 404 });
} }
// N3: ไฟล์รูปภาพ — สร้าง PDF ใหม่ที่ฝังรูปเต็มหน้า (embedJpg/embedPng + EXIF rotate) // N3: ไฟล์รูปภาพ — สร้าง PDF ใหม่ที่ฝังรูปเต็มหน้า (embedJpg/embedPng + EXIF rotate)
@ -287,21 +274,9 @@ export async function GET(req: Request) {
.from(annotations) .from(annotations)
.where(eq(annotations.fileId, fileId)) .where(eq(annotations.fileId, fileId))
.orderBy(asc(annotations.createdAt)); .orderBy(asc(annotations.createdAt));
// F4: export ตาม filter รายคนที่กำลังดู (?user=<userId> จาก sidebar — ไม่ส่ง = ทั้งหมด)
// REDACTION ติดไปเสมอแม้กรองคนอื่น (D4 — กล่องดำหายไป = เนื้อหาที่ปิดกั้นกลับมาอ่านได้)
const userParam = url.searchParams.get("user");
const filteredRows = userParam
? rows.filter((r) => r.userId === userParam || r.type === "REDACTION")
: rows;
// ชื่อผู้ใช้เติมท้ายชื่อไฟล์ (mirror ในตาราง — ไม่รับ text จาก client) — กรองแล้ว
// ผู้นั้นยังไม่มีรายการเลย = ใช้ userId ตรง ๆ
const filterName = userParam
? (rows.find((r) => r.userId === userParam && r.userName)?.userName ??
userParam)
: null;
// sort แยกตามชนิดก่อนวาด (stable ภายในชนิดด้วย createdAt) — กัน REDACTION เก่าโดน // sort แยกตามชนิดก่อนวาด (stable ภายในชนิดด้วย createdAt) — กัน REDACTION เก่าโดน
// HIGHLIGHT/DRAW ที่สร้างทีหลังทับจนข้อความง้ำกลับมาอ่านได้ (D4) // HIGHLIGHT/DRAW ที่สร้างทีหลังทับจนข้อความง้ำกลับมาอ่านได้ (D4)
const dtos = filteredRows const dtos = rows
.map(toAnnotationDTO) .map(toAnnotationDTO)
.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]); .sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
@ -598,65 +573,42 @@ export async function GET(req: Request) {
const { r, g, b } = hexToRgb01(d.color ?? STAMP_COLOR); const { r, g, b } = hexToRgb01(d.color ?? STAMP_COLOR);
const stampRgb = rgb(r, g, b); const stampRgb = rgb(r, g, b);
const w = px(d.scale, display.width); const w = px(d.scale, display.width);
// จัดวางข้อความแบบเดียวกับบนจอ (stampLayout): คำสั้น 1 บรรทัด, คำยาวพอดี ๆ
// ย่อฟอนต์ให้บรรทัดเดียวก่อน (จนถึง floor 10%), ยาวเกินค่อย wrap หลายบรรทัด
// กรอบสูงขึ้นตาม — measure ของฝั่งนี้คือ pdf-lib font (2026-09-28)
const lay = thaiFont
? stampLayout(d.label, w, (s, size) => thaiFont.widthOfTextAtSize(s, size))
: null;
const rect = displayRectToPdf( const rect = displayRectToPdf(
{ {
x: px(d.position.x, display.width), x: px(d.position.x, display.width),
y: px(d.position.y, display.height), y: px(d.position.y, display.height),
width: w, width: w,
height: lay ? stampBoxHeight(lay, w) : w * 0.4, height: w * STAMP_HEIGHT_RATIO,
}, },
size, size,
rotation, rotation,
); );
// มุมเอียงของตรายาง: จอหมุนรอบ**กลางกรอบ** ส่วน pdf-lib หมุนรอบ anchor มุม
// ล่างซ้ายของ object — จึงต้อง (1) ใช้มุมลบ (แกน y ของ PDF ชี้ขึ้น ส่วน CSS
// rotate วัดบนจอที่ y ชี้ลง) และ (2) เลื่อน anchor ไปตำแหน่งมุมล่างซ้าย*หลัง
// หมุน* ให้ pdf-lib หมุนต่อรอบมัน ผลลัพธ์กลับมาตรงตำแหน่งเดียวกับบนจอ
const rot = d.rotation ?? 0;
const pdfRot = -rot;
const ctr = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
const anchor = rot
? rotatePoint({ x: rect.x, y: rect.y }, ctr, pdfRot)
: { x: rect.x, y: rect.y };
const rotateOpt = rot ? { rotate: degrees(pdfRot) } : undefined;
page.drawRectangle({ page.drawRectangle({
x: anchor.x, x: rect.x,
y: anchor.y, y: rect.y,
width: rect.width, width: rect.width,
height: rect.height, height: rect.height,
borderColor: stampRgb, borderColor: stampRgb,
borderWidth: Math.max(w * 0.02, 1), borderWidth: Math.max(w * 0.02, 1),
...rotateOpt,
}); });
if (lay && thaiFont) { if (thaiFont) {
const font = thaiFont; const font = thaiFont;
// วาดทีละบรรทัดจากกึ่งกลางแนวตั้งของกรอบ (pdf y ชี้ขึ้นจากล่าง) — จุดวาด // ย่อฟอนต์ให้ label อยู่บรรทัดเดียวพอดีกรอบ — เหมือนที่ render บนจอ
// คำนวณในพิกัดกรอบ*ก่อนหมุน* แล้วหมุนรอบกลางกรอบเหมือนจอก่อนส่ง drawText // (ไม่ย่อ label ยาวอย่าง "ตรวจสอบแล้ว" จะ wrap 2 บรรทัดแล้วล้นกรอบ)
const lineStep = lay.fontSize * lay.lineHeight; const fontSize = stampFitFontSize(
const totalH = lay.lines.length * lineStep; d.label,
let baseline = rect.y + rect.height / 2 + totalH / 2 - lay.fontSize; w,
for (const ln of lay.lines) { (s, size) => font.widthOfTextAtSize(s, size),
const textW = font.widthOfTextAtSize(ln, lay.fontSize); );
const tx = rect.x + (rect.width - textW) / 2; const textW = font.widthOfTextAtSize(d.label, fontSize);
const p = rot const lineH = fontSize * 1.2; // กึ่งกลางแนวตั้งตาม lineHeight บนจอ
? rotatePoint({ x: tx, y: baseline }, ctr, pdfRot) page.drawText(d.label, {
: { x: tx, y: baseline }; x: rect.x + (rect.width - textW) / 2,
page.drawText(ln, { y: rect.y + (rect.height - lineH) / 2 + lineH - fontSize,
x: p.x, size: fontSize,
y: p.y, font,
size: lay.fontSize, color: stampRgb,
font, });
color: stampRgb,
...rotateOpt,
});
baseline -= lineStep;
}
} }
break; break;
} }
@ -667,15 +619,8 @@ export async function GET(req: Request) {
if (a.type !== "COMMENT") { if (a.type !== "COMMENT") {
const note = annotationNote(a); const note = annotationNote(a);
if (note) { if (note) {
// annotationBBox คืน pt ของ "หน้าบนจอ" อยู่แล้ว — แปลงตรงเข้าพิกัด PDF — ส่ง // annotationBBox คืน pt ของ "หน้าบนจอ" อยู่แล้ว — แปลงตรงเข้าพิกัด PDF
// measure ของ pdf-lib font เดียวกับตอนวาดกรอบ STAMP ด้วย ให้มุมเกาะ sticky const bbox = annotationBBox(a, display);
// note ตรงกรอบที่วาดจริง (ไม่ส่ง = stampLayoutEstimate คลาดจากกรอบจริงเล็กน้อย
// — code review 2026-09-29)
const bbox = annotationBBox(
a,
display,
thaiFont ? (s, size) => thaiFont.widthOfTextAtSize(s, size) : undefined,
);
attachNoteSticky( attachNoteSticky(
doc, doc,
page, page,
@ -689,16 +634,10 @@ export async function GET(req: Request) {
// bookmarks → PDF outline (แผง Bookmarks ของ reader กดกระโดดไปหน้า + yOffset) // bookmarks → PDF outline (แผง Bookmarks ของ reader กดกระโดดไปหน้า + yOffset)
// เรียงตามลำดับหน้าเหมือน sidebar (byPageOrder ของ N5: หน้า → ตำแหน่งบนหน้า → เวลาสร้าง) // เรียงตามลำดับหน้าเหมือน sidebar (byPageOrder ของ N5: หน้า → ตำแหน่งบนหน้า → เวลาสร้าง)
// กรองตามรายคนเหมือน annotation (คำขอผู้ใช้ 2026-09-22) — หัวข้อแม่โดนกรองแต่ลูกยังอยู่
// ลูกขึ้น root (buildOutline จัดการ parent หายเคสนี้อยู่แล้ว)
const bookmarkRows = await db const bookmarkRows = await db
.select() .select()
.from(bookmarks) .from(bookmarks)
.where( .where(eq(bookmarks.fileId, fileId))
userParam
? and(eq(bookmarks.fileId, fileId), eq(bookmarks.userId, userParam))
: eq(bookmarks.fileId, fileId),
)
.orderBy( .orderBy(
asc(bookmarks.pageIndex), asc(bookmarks.pageIndex),
asc(bookmarks.yOffset), asc(bookmarks.yOffset),
@ -707,9 +646,7 @@ export async function GET(req: Request) {
buildOutline(doc, bookmarkRows); buildOutline(doc, bookmarkRows);
const out = await doc.save(); const out = await doc.save();
const filename = encodeRFC5987( const filename = encodeRFC5987(`${file.title}-annotated.pdf`);
`${file.title}-annotated${filterName ? `-${filterName}` : ""}.pdf`,
);
return new Response(new Uint8Array(out), { return new Response(new Uint8Array(out), {
headers: { headers: {
"content-type": "application/pdf", "content-type": "application/pdf",

View file

@ -0,0 +1,52 @@
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { bookmarks, files, toBookmarkDTO } from "@/db/schema";
import { encodeRFC5987 } from "@/utils/pdf-export";
// ส่งออกรายการ 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;
export async function GET(req: Request) {
const fileId = new URL(req.url).searchParams.get("fileId") ?? "";
if (!UUID_RE.test(fileId)) {
return new Response("fileId ไม่ถูกต้อง", { status: 400 });
}
const [file] = await db.select().from(files).where(eq(files.id, fileId));
if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 });
const rows = await db
.select()
.from(bookmarks)
.where(eq(bookmarks.fileId, fileId))
.orderBy(
asc(bookmarks.pageIndex),
asc(bookmarks.yOffset),
asc(bookmarks.createdAt),
);
// item เป็น BookmarkDTO ตรง ๆ (camelCase + ISO string — invariant #5)
const payload = {
fileId: file.id,
title: file.title,
exportedAt: new Date().toISOString(),
bookmarks: rows.map(toBookmarkDTO),
};
const filename = encodeRFC5987(`${file.title}-bookmarks.json`);
return new Response(JSON.stringify(payload, null, 2) + "\n", {
headers: {
"content-type": "application/json; charset=utf-8",
// filename เป็น ASCII fallback + filename* UTF-8 สำหรับชื่อไทย (pattern F8/F16)
"content-disposition": `attachment; filename="bookmarks.json"; filename*=UTF-8''${filename}`,
},
});
}

View file

@ -1,120 +0,0 @@
// PLAN-DOCVER.md §1 — GET /api/documents/latest?type=docx|pdf&ticket_data=<json>
// คืนไฟล์ของ version ล่าสุดให้ backend e-Court (server-to-server):
// - type=pdf → ไบต์ pdf ตรง ๆ (version ล่าสุดไม่มี pdf = 404)
// - type=docx → stamp ticket ใหม่ (exp + HmacSha256 + Ed25519 เมื่อมี private key)
// ลงสำเนาในหน่วยความจำก่อนคืน — ไม่เขียนกลับ MinIO (non-destructive)
// - auth: header x-api-key เทียบ env DOCVER_API_KEY หรือ EXTERNAL_API_KEY
// (ต่างจาก /token ที่รับ DOCVER_API_KEY อย่างเดียว) · docId = fid ใน ticket_data
// - secret ของ HMAC = addinTicketSecret() ให้ตรงฝั่ง verify ของ add-in /
// POST /api/documents/token ทาง ticket · TTL = env TICKET_SEC (default 300)
// carve-out invariant #4: route handler ของกลุ่ม docver — ไม่ publish SSE
// ไม่ revalidatePath (ไม่มีหน้า note ใดอ่านตารางของกลุ่มนี้)
import { addinTicketSecret } from "@/lib/addin-ticket";
import {
contentDispositionValue,
docverError,
DOCX_CONTENT_TYPE,
isDocxBytes,
isPdfBytes,
parseLatestTypeParam,
PDF_CONTENT_TYPE,
} from "@/lib/document-versioning";
import { requireDocumentApiKey } from "@/lib/docver-token";
import {
findLatestVersionForDoc,
getVersionObjectBytes,
} from "@/lib/docver-store";
import { buildStampedTicketData, stampTicketProps } from "@/lib/lib-tmpl";
import { parseTicketDataParam } from "@/utils/docver-ticket";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const GENERIC_500 = "เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง";
export async function GET(request: Request) {
const auth = requireDocumentApiKey(request);
if (auth !== null) return auth;
let searchParams: URLSearchParams;
try {
searchParams = new URL(request.url).searchParams;
} catch {
return docverError("URL ของ request ไม่ถูกต้อง", 400);
}
const type = parseLatestTypeParam(searchParams.get("type"));
if (!type.ok) return docverError(type.error, 400);
const ticket = parseTicketDataParam(searchParams.get("ticket_data"));
if (!ticket.ok) return docverError(ticket.error, 400);
// เติม exp + เรียง key + เช็คความยาวก่อนแตะ MinIO — Word ตัดทอนค่า custom
// property ที่ 255 ตัวอักษร คืนไฟล์ที่โดนตัด = HMAC เสียเงียบ ๆ ปฏิเสธไปเลยดีกว่า
let ticketData: string | null = null;
if (type.type === "docx") {
const ttl = Number(process.env.TICKET_SEC);
const ttlSeconds = Number.isFinite(ttl) && ttl > 0 ? ttl : 300;
ticketData = buildStampedTicketData(ticket.ticket, ttlSeconds);
if (ticketData.length > 255) {
return docverError(
"ticket_data ยาวเกิน 255 ตัวอักษร (ข้อจำกัดของ Word custom property)",
400,
);
}
}
let row;
try {
row = await findLatestVersionForDoc(ticket.ticket.fid);
} catch (err) {
console.error("docver: ค้น version ล่าสุดล้ม", err);
return docverError(GENERIC_500, 500);
}
if (!row) {
return docverError("ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)", 404);
}
if (type.type === "pdf" && row.pdfObjectKey === null) {
return docverError("version นี้ไม่มีสำเนา PDF", 404);
}
let bytes: Buffer;
try {
bytes = await getVersionObjectBytes(row.id, type.type);
} catch (err) {
console.error("docver: ดึง object จาก MinIO ล้ม", err);
return docverError(GENERIC_500, 500);
}
// object เขียนผ่านกลุ่มนี้เองตอน upload (ผ่าน magic bytes แล้ว) — ไม่ตรงตอนนี้
// = ความผิดปกติของพื้นที่เก็บ ไม่ใช่ความผิดของผู้เรียก จึงเป็น 500
if (type.type === "docx" ? !isDocxBytes(bytes) : !isPdfBytes(bytes)) {
console.error(`docver: object ของ version ${row.id} magic bytes ไม่ตรงชนิดไฟล์`);
return docverError(GENERIC_500, 500);
}
if (type.type === "docx") {
// ticketData ถูกตั้งค่าแล้วเสมอเมื่อ type=docx (ด้านบน) — ห้ามกลืน error:
// ไฟล์ไม่มี stamp = add-in เปิดไม่ได้ ต้องตอบ 500 ไม่ใช่คืนไฟล์เสียเงียบ ๆ
try {
bytes = stampTicketProps(bytes, ticketData!, {
secret: addinTicketSecret(),
privateKeyHex: process.env.PRIVATE_KEY_HEX?.trim() || null,
}).buffer;
} catch (err) {
console.error("docver: stamp ticket ล้ม", err);
return docverError(GENERIC_500, 500);
}
}
return new Response(
// Uint8Array ใหม่ (ก็อปปี้) ตามที่ BodyInit รับ — pattern เดียวกับ route pdf/export
new Uint8Array(bytes),
{
headers: {
"content-type": type.type === "docx" ? DOCX_CONTENT_TYPE : PDF_CONTENT_TYPE,
"content-disposition": contentDispositionValue(row.fileName, type.type),
"cache-control": "no-store, max-age=0",
},
},
);
}

View file

@ -1,111 +0,0 @@
// PLAN-DOCVER.md §auth — POST /api/documents/token ออก access_token ของกลุ่ม docver
// ทางเข้า 2 ทาง (PLAN-ADDIN-AUTH.md §3 — header x-api-key เป็นตัวตั้ง):
// 1) e-Court server-to-server: header "x-api-key" เทียบ env DOCVER_API_KEY
// (fail closed เมื่อ env ไม่ตั้ง/ว่าง) + body claims ตรง ๆ (เดิมทุกอย่าง)
// 2) add-in ใน Word: ไม่มี x-api-key + body {ticket_data, HmacSha256} — server
// ตรวจ HMAC ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET →
// "super_secret", lib/addin-ticket.ts) แล้วออก token จาก claims ของ ticket
// (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in) — ticket หมดอายุ
// ยังออกให้ต่อเมื่อ fid นี้เคยมี version โดย uid เดียวกัน (requirement ขั้น 7)
// ทั้งสองทางออก JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env
// DOCVER_TOKEN_TTL_SECONDS (default 3600) แล้วนำไปแนบ Authorization: Bearer กับ
// endpoint อื่นของกลุ่ม (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId]
// (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว
import { docverError } from "@/lib/document-versioning";
import {
addinTicketSecret,
parseTicketTokenBody,
ticketToDocverClaims,
verifyAddinTicketHmac,
} from "@/lib/addin-ticket";
import { hasVersionByUser } from "@/lib/docver-store";
import { isTicketExpired } from "@/utils/docver-ticket";
import {
issueDocverToken,
parseTokenRequestBody,
requireDocverApiKey,
type DocverTokenClaims,
} from "@/lib/docver-token";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** 401 ของทางเข้า ticket — ข้อความเดียวทุกสาเหตุ (HMAC ผิด/รูปผิด/หมดอายุ/ไม่ส่งมา) กัน oracle */
const TICKET_DENIED = "ticket ไม่ถูกต้องหรือหมดอายุ";
export async function POST(request: Request) {
return request.headers.get("x-api-key") !== null
? postWithApiKey(request)
: postWithTicket(request);
}
/** ทางเข้า 1 (เดิม) — e-Court server-to-server */
async function postWithApiKey(request: Request): Promise<Response> {
const apiKeyFail = requireDocverApiKey(request);
if (apiKeyFail !== null) return apiKeyFail;
let raw: unknown;
try {
raw = await request.json();
} catch {
return docverError("body ต้องเป็น JSON", 400);
}
const parsed = parseTokenRequestBody(raw);
if (!parsed.ok) return docverError(parsed.error, 400);
return issueToken(parsed.claims);
}
/** ทางเข้า 2 — add-in ส่ง ticket ที่ e-Court stamp มากับไฟล์ */
async function postWithTicket(request: Request): Promise<Response> {
let raw: unknown;
try {
raw = await request.json();
} catch {
return docverError("body ต้องเป็น JSON", 400);
}
const parsed = parseTicketTokenBody(raw);
if (!parsed.ok) {
// missing = ไม่มี ticket มาเล่น ๆ (401 เดียวกับตอน verify ไม่ผ่าน)
// invalid = รูป body ผิดจริง ๆ ให้ 400 ตาม convention กัน typo เงียบ ๆ
return docverError(parsed.error, parsed.kind === "invalid" ? 400 : 401);
}
// ตรวจ HMAC ก่อนเสมอ (ตัวตนของผู้ถือไฟล์) — exp ไม่ใช่เกณฑ์ตัดสิทธิ์ ณ จุดนี้
const verified = verifyAddinTicketHmac(parsed.ticketData, parsed.hmacHex, addinTicketSecret());
if (!verified.ok) return docverError(TICKET_DENIED, 401);
// ticket หมดอายุ → ยังออก token ให้ต่อเมื่อ "เอกสารนี้เคยทำงานโดย uid เดียวกัน"
// (requirement ขั้น 7: กลับมาทำเอกสารเดิมผ่านได้แม้ ticket หมดอายุ — พิสูจน์จาก
// document_versions ของกลุ่มนี้เอง) · DB พัง = fail closed เป็น 401 เหมือนไม่มีสิทธิ์
if (isTicketExpired(verified.ticket.exp)) {
let workedBefore = false;
try {
workedBefore = await hasVersionByUser(verified.ticket.fid, verified.ticket.uid);
} catch {
workedBefore = false;
}
if (!workedBefore) return docverError(TICKET_DENIED, 401);
}
return issueToken(ticketToDocverClaims(verified.ticket));
}
/** ออก JWT ของ claims ที่ผ่านการยืนยันแล้วจากทั้งสองทาง — ส่วนกลาง */
async function issueToken(claims: DocverTokenClaims): Promise<Response> {
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
if (secret === undefined || secret === "") {
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
}
const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS);
const ttlSeconds =
Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600;
const accessToken = issueDocverToken(claims, secret, ttlSeconds);
return new Response(
JSON.stringify({
access_token: accessToken,
token_type: "Bearer",
expires_in: ttlSeconds,
}),
{ headers: { "content-type": "application/json; charset=utf-8" } },
);
}

View file

@ -1,52 +0,0 @@
// PLAN-DOCVER.md §1 — GET /api/documents/versions/{versionId}/download-link?format=docx|pdf
// คืน presigned URL อายุสั้น (env DOCVER_LINK_TTL_SECONDS) ให้ UI เปิดในเบราว์เซอร์จริง
// ของผู้ใช้ — เช็ค docId (จาก fid ใน token) กับ versionId คู่กันกันโยน id ข้ามเอกสาร
// auth: Authorization: Bearer (access_token จาก POST /api/documents/token — ทุก endpoint ของกลุ่ม)
import {
docverError,
parseDownloadFormat,
parseVersionIdParam,
} from "@/lib/document-versioning";
import { requireDocverBearer } from "@/lib/docver-token";
import { findVersionForDoc, presignVersionUrl } from "@/lib/docver-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: { versionId: string } },
) {
const auth = requireDocverBearer(request);
if (!auth.ok) return auth.response;
// docId มาจาก fid ใน token — claim ถูกบังคับเป็น string ไม่ว่างตั้งแต่ขอ token + verify
const docId = auth.claims.fid;
const versionId = parseVersionIdParam(params.versionId);
if (!versionId.ok) return docverError(versionId.error, 400);
let format: string | null = null;
try {
format = new URL(request.url).searchParams.get("format");
} catch {
return docverError("URL ของ request ไม่ถูกต้อง", 400);
}
const parsedFormat = parseDownloadFormat(format);
if (!parsedFormat.ok) return docverError(parsedFormat.error, 400);
try {
const row = await findVersionForDoc(docId, versionId.versionId);
if (!row) {
return docverError("ไม่พบ version นี้ของเอกสาร", 404);
}
if (parsedFormat.format === "pdf" && row.pdfObjectKey === null) {
return docverError("version นี้ไม่มีสำเนา PDF", 404);
}
const link = await presignVersionUrl(row.id, parsedFormat.format);
return new Response(JSON.stringify(link), {
headers: { "content-type": "application/json; charset=utf-8" },
});
} catch (err) {
console.error("docver: สร้าง download link ล้ม", err);
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
}
}

View file

@ -1,189 +0,0 @@
// PLAN-DOCVER.md §1 — POST อัปโหลด version ใหม่ + GET history ของ /api/documents/versions
// กลุ่ม API stand-alone ไม่มี session — auth ด้วย access_token ของกลุ่มนี้: ขอที่
// POST /api/documents/token (ยืนยัน x-api-key กับ env DOCVER_API_KEY) แล้วแนบ
// Authorization: Bearer ทุก request — identity (userid/username), templateId และ
// docId (= fid — fileId ของ e-Court, string) แกะจาก claims ของ token ทั้งหมด
// ไม่รับจาก URL/form อีก (lib/docver-token.ts)
import { createHash, randomUUID } from "node:crypto";
import { toDocumentVersionDTO, type DocumentVersionFields } from "@/db/schema";
import { addinTicketSecret } from "@/lib/addin-ticket";
import {
docverError,
DOCVER_MAX_UPLOAD_BYTES,
DOCX_CONTENT_TYPE,
isDocxBytes,
isPdfBytes,
parseVersionUploadForm,
sanitizeFilePartName,
} from "@/lib/document-versioning";
import { extractContentControls } from "@/lib/lib-tmpl";
import { requireDocverBearer } from "@/lib/docver-token";
import { extractDocumentSnapshot } from "@/lib/lib-tmpl";
import {
findDocumentByDocId,
insertVersionRecord,
listVersionsByDocId,
putVersionObjects,
removeVersionObjects,
versionObjectKeys,
} from "@/lib/docver-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** POST — อัปโหลด version ใหม่ (เอกสารสร้างแบบ implicit ถ้าเป็น version แรก)
* ลำดับ: Bearer token → validate text field ก่อนแตะไฟล์ → cap ขนาดก่อน buffer → magic bytes →
* แกะ fields จากไฟล์ (lib-tmpl เหมือน /api/template/extract) →
* putObject MinIO → insert DB (tx) — DB ล้ม = เก็บกวาด object (pattern N4)
* response = UploadVersionResult เป๊ะ โดย versionNumber เป็นค่า authoritative
* จาก server (client เดา latest+1 แล้วแก้เองเมื่ออัปโหลดชนกัน) */
export async function POST(request: Request) {
const auth = requireDocverBearer(request);
if (!auth.ok) return auth.response;
// docId มาจาก fid ใน token — claim ถูกบังคับเป็น string ไม่ว่างตั้งแต่ขอ token + verify
const docId = auth.claims.fid;
let form: FormData;
try {
form = await request.formData();
} catch {
return docverError("body ต้องเป็น multipart/form-data", 400);
}
const parsed = parseVersionUploadForm(form);
if (!parsed.ok) return docverError(parsed.error, 400);
const file = form.get("file");
if (!(file instanceof File)) {
return docverError('ต้องแนบไฟล์ .docx ใน field "file"', 400);
}
if (file.size === 0) {
return docverError('ไฟล์ใน field "file" ว่าง (0 ไบต์)', 400);
}
// route handler ไม่ได้รับเคราะห์ bodySizeLimit ของ Server Actions — ต้องเช็คเองก่อน buffer
if (file.size > DOCVER_MAX_UPLOAD_BYTES) {
return docverError('ไฟล์ใน field "file" ใหญ่เกิน 100MB', 413);
}
if (file.type !== DOCX_CONTENT_TYPE && !file.name.toLowerCase().endsWith(".docx")) {
return docverError('field "file" ต้องเป็นไฟล์ .docx', 400);
}
// add-in อาจแนบ pdf เป็น blob เปล่าตอน capture ไม่สำเร็จ — ถือว่าไม่มี pdf (best-effort)
const rawPdf = form.get("pdf");
let pdf: File | null = null;
if (rawPdf instanceof File && rawPdf.size > 0) {
if (rawPdf.size > DOCVER_MAX_UPLOAD_BYTES) {
return docverError('ไฟล์ใน field "pdf" ใหญ่เกิน 100MB', 413);
}
pdf = rawPdf;
}
let docxBytes: Buffer;
let pdfBytes: Buffer | null = null;
try {
docxBytes = Buffer.from(await file.arrayBuffer());
pdfBytes = pdf ? Buffer.from(await pdf.arrayBuffer()) : null;
} catch (err) {
console.error("docver: อ่านไฟล์แนบเข้าหน่วยความจำไม่สำเร็จ", err);
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
}
if (!isDocxBytes(docxBytes)) {
return docverError("เนื้อไฟล์ .docx ไม่ถูกต้อง (ไม่ใช่ OOXML/ZIP)", 400);
}
if (pdfBytes && !isPdfBytes(pdfBytes)) {
return docverError("เนื้อไฟล์ .pdf ไม่ถูกต้อง", 400);
}
// fields snapshot แกะจากไฟล์เองฝั่ง server — **เก็บทั้งก้อนเหมือน return ของ
// /api/template/extract** ({cc, bookmark, properties, verify} ผ่าน extractDocumentSnapshot
// ของ lib/lib-tmpl.ts ตัวเดียวกับ route นั้น — 2026-10-30 ถอด form field "fields"
// ที่ add-in อ่านผ่าน Office.js ออกจาก contract): มองเห็น CC ทุกชนิดเสมอไม่ขึ้นกับ
// เวอร์ชัน WordApi ของ host · verify.HmacSha256 ตรวจด้วย secret ของ add-in ticket
// ฝั่ง server (env chain เดียวกับ lib/addin-ticket.ts) · ZIP ไม่มี word/document.xml,
// parse ไม่ได้ หรือ ticket_data ไม่ใช่ JSON = 400
let fields: DocumentVersionFields;
try {
fields = extractDocumentSnapshot(docxBytes, { secret: addinTicketSecret() });
} catch (err) {
console.error("docver: แกะข้อมูลจากไฟล์ไม่สำเร็จ", err);
return docverError("เนื้อไฟล์ .docx ไม่ถูกต้อง (แกะข้อมูลไม่สำเร็จ)", 400);
}
const contentHash = createHash("sha256").update(docxBytes).digest("hex");
const versionId = randomUUID();
// ชื่อแสดงผลตอนดาวน์โหลด: field "fileName" ของผู้เรียก (add-in ส่งมาเอง) →
// ไม่ส่งค่อยใช้ชื่อของ File (ผ่าน decodeUploadFilename — undici ถอดชื่อไทยเป็น latin1)
const docxDisplayName =
parsed.form.fileName ?? sanitizeFilePartName(file.name);
try {
await putVersionObjects({
versionId,
docx: { bytes: docxBytes, fileName: docxDisplayName },
pdf: pdf ? { bytes: pdfBytes!, fileName: sanitizeFilePartName(pdf.name) } : undefined,
});
} catch (err) {
console.error("docver: เขียน object ลง MinIO ไม่สำเร็จ", err);
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
}
const keys = versionObjectKeys(versionId);
let row;
try {
row = await insertVersionRecord({
versionId,
docId,
clientVersionId: parsed.form.clientVersionId,
contentHash,
message: parsed.form.message,
fields,
templateId: auth.claims.template,
fileName: docxDisplayName,
docxObjectKey: keys.docx,
pdfObjectKey: pdfBytes ? keys.pdf : null,
docxSize: docxBytes.length,
pdfSize: pdfBytes?.length ?? null,
userId: auth.claims.userid,
userName: auth.claims.username,
});
} catch (err) {
// DB ล้มหลังเขียน object — เก็บกวาดก่อนตอบ กัน object กำพร้าไม่มี row ชี้
console.error("docver: insert version ล้ม — เก็บกวาด object ที่เขียนไปแล้ว", err);
await removeVersionObjects(versionId, pdfBytes !== null);
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
}
return new Response(
JSON.stringify({
hasPdf: pdfBytes !== null,
versionId: row.id,
versionNumber: row.versionNumber,
}),
{ headers: { "content-type": "application/json; charset=utf-8" } },
);
}
/** GET — history ของเอกสาร (ใหม่ → เก่า) · docId ที่ยังไม่เคยมี version = 404
* (สัญญาณ REST ตรง ๆ แบบ N10 — UI ฝั่ง add-in แสดง error + ปุ่มลองใหม่ ไม่พัง) */
export async function GET(request: Request) {
const auth = requireDocverBearer(request);
if (!auth.ok) return auth.response;
const docId = auth.claims.fid;
try {
const doc = await findDocumentByDocId(docId);
if (!doc) {
return docverError("ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)", 404);
}
const rows = await listVersionsByDocId(doc.id);
return new Response(JSON.stringify(rows.map(toDocumentVersionDTO)), {
headers: { "content-type": "application/json; charset=utf-8" },
});
} catch (err) {
console.error("docver: โหลด version history ล้ม", err);
return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
}
}

View file

@ -1,134 +0,0 @@
// เปิดด้วย env NOTE_SSO_DEV_MINT=1 อย่างเดียว (2026-11-02 — ใช้ได้ทุก environment
// รวม production ที่ตั้ง env ไว้, เดิม gate ด้วย NODE_ENV ด้วย) — mint ลิงก์ SSO
// จากหน้า /note/dev ให้ทดสอบ flow จริงจาก browser ได้โดยไม่ต้องรัน script:
// คำนวณ token/ticket ฝั่ง server ด้วย secret ของตัวเอง (ไม่มีค่าใดหลุดไปหน้า public) แล้วคืน URL เต็มเดียว
// พร้อมคลิก — ปิดอยู่ = 404 เหมือนไม่มีเส้นนี้ · ไม่ import @/db · ห้าม log ค่าดิบ
// · GET ?file=<ชื่อไฟล์ตัวอย่าง>&mode=jwt|ticket (2026-10-30 รอบสอง) — ลิงก์ "A Link
// Get(new)" ของ /note/dev: mint แล้ว 302 เข้า /note?fileUrl=…&t=… ให้ bootstrap
// แลก token ต่อเหมือนลิงก์จริงของ e-Court (identity dev คงที่ + fid=dev-<ไฟล์>)
import { createHmac } from "node:crypto";
import { NextResponse } from "next/server";
import { addinTicketSecret } from "@/lib/addin-ticket";
import { docverError } from "@/lib/document-versioning";
import { issueDocverToken } from "@/lib/docver-token";
import { devMintEnabled, LOGIN_TOKEN_TTL, parseDevLinkBody } from "@/lib/note-auth";
import { withBasePath } from "@/utils/base-path";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** identity dev คงที่ของลิงก์ตัวอย่าง — ตรงกับค่า default ของฟอร์ม mint หน้าเดียวกัน */
const DEV_USER = { userid: "oom", username: "สรวิชญ์" };
export async function GET(request: Request): Promise<Response> {
if (!devMintEnabled()) return new Response(null, { status: 404 });
const url = new URL(request.url);
const file = url.searchParams.get("file") ?? "";
const mode = url.searchParams.get("mode") === "ticket" ? "ticket" : "jwt";
// ชื่อไฟล์ตัวอย่างเท่านั้น — ห้าม path segment/.. (การมีไฟล์จริงตรวจตอน register
// ใน exchange ของ /api/note/auth/session อยู่แล้ว)
if (
file === "" || file.length > 200 ||
file.includes("/") || file.includes("\\") || file.includes("..")
) {
return docverError("file ต้องเป็นชื่อไฟล์ตัวอย่าง (ไม่มี path)", 400);
}
// fid คงที่ต่อไฟล์ → แต่ละตัวอย่างมี row ecourt: ของตัวเอง (annotation ไม่ปนกัน
// ข้ามไฟล์ และคลิกรอบหลังเจอ row เดิม) — นี่คือสิ่งที่ลิงก์จริงของ e-Court ทำเช่นกัน
const fid = `dev-${file}`;
const params = new URLSearchParams({ fileUrl: `/samples/${file}` });
if (mode === "jwt") {
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
if (secret === undefined || secret === "") {
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
}
params.set(
"t",
issueDocverToken({ ...DEV_USER, fid, template: null }, secret, LOGIN_TOKEN_TTL.default, "login"),
);
} else {
// ตรงตามตัว stamp จริง: key เรียงตามตัวอักษรหลังเติม exp → HMAC hex บน string ดิบ
const ticket = { exp: Math.floor(Date.now() / 1000) + LOGIN_TOKEN_TTL.default, fid, uid: DEV_USER.userid, uname: DEV_USER.username };
const ticketData = JSON.stringify(
Object.keys(ticket).sort().reduce<Record<string, unknown>>((result, key) => {
result[key] = ticket[key as keyof typeof ticket];
return result;
}, {}),
);
params.set("ticket_data", ticketData);
params.set("HmacSha256", createHmac("sha256", addinTicketSecret()).update(ticketData, "utf8").digest("hex"));
}
// ปลายทาง = หน้าแรก /note พร้อม credential + fileUrl เหมือนลิงก์จริง — ให้ bootstrap
// ของ NoteHome แลกเป็น session token แล้วพาเข้า viewer (เห็น URL ทุกขั้นตอน)
// Location relative เหมือน files/open — origin ของ request หลัง reverse proxy
// (Host ถูกเขียนทับเป็น upstream) ไม่น่าเชื่อพอจะประกอบ absolute URL
return new NextResponse(null, {
status: 302,
headers: { location: `${withBasePath("")}?${params.toString()}`, "cache-control": "no-store" },
});
}
export async function POST(request: Request): Promise<Response> {
if (!devMintEnabled()) return new Response(null, { status: 404 });
let raw: unknown;
try {
raw = await request.json();
} catch {
return docverError("body ต้องเป็น JSON", 400);
}
const parsed = parseDevLinkBody(raw);
if (!parsed.ok) return docverError(parsed.error, 400);
const params = new URLSearchParams();
if (parsed.fileUrl !== undefined) params.set("fileUrl", parsed.fileUrl);
if (parsed.typeId !== undefined) params.set("typeId", parsed.typeId);
if (parsed.mode === "jwt") {
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
if (secret === undefined || secret === "") {
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
}
params.set(
"t",
issueDocverToken(
{ userid: parsed.claims.userid, username: parsed.claims.username, fid: parsed.claims.fid, template: null },
secret,
parsed.ttlSeconds,
"login",
),
);
} else {
// ตรงตามตัว stamp จริง: key เรียงตามตัวอักษรหลังเติม exp → HMAC hex บน string ดิบ
const ticket = {
exp: Math.floor(Date.now() / 1000) + parsed.ttlSeconds,
fid: parsed.claims.fid,
uid: parsed.claims.userid,
uname: parsed.claims.username,
};
const ticketData = JSON.stringify(
Object.keys(ticket)
.sort()
.reduce<Record<string, unknown>>((result, key) => {
result[key] = ticket[key as keyof typeof ticket];
return result;
}, {}),
);
params.set("ticket_data", ticketData);
params.set("HmacSha256", createHmac("sha256", addinTicketSecret()).update(ticketData, "utf8").digest("hex"));
}
// origin ตรงของ request (ไม่ใช่ requestOrigin ที่บังคับ https โฮสต์ที่ไม่ใช่
// localhost) — เครื่องมือนี้ dev-only และ dev server แชร์บน LAN เป็น http ธรรมดา
// ลิงก์ที่คืนต้องคลิกได้จากที่ผู้ใช้เปิดหน้านั้นอยู่
// ปลายทาง = หน้าแรก /note ที่ client bootstrap แลก credential เป็น token
// (ฉบับ token 2026-10-30 — ไม่มี route e-court ที่ set cookie ให้ชี้แล้ว)
const origin = new URL(request.url).origin;
const url = `${origin}${withBasePath("")}?${params.toString()}`;
return new Response(JSON.stringify({ url, ttlSeconds: parsed.ttlSeconds }), {
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
});
}

View file

@ -1,105 +0,0 @@
// POST /api/note/auth/session — แลก credential ของลิงก์ e-Court เป็น access_token
// (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้แบบ add-in" — แทน GET e-court ที่
// set cookie คู่ oacCase ซึ่งถอดแล้ว): body {t | ticket_data+HmacSha256, fileUrl?,
// typeId?} → verify → ลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server เลย (fileId จาก
// fid ที่เซ็นมา — ไม่ต้องมี link proof อีก) → ออก login-token อายุ NOTE_SESSION_TTL
// (8 ชม.) ให้ client เก็บ localStorage "note-auth"
// · 400 body ผิด · 401 {error, reason: expired|invalid} · 500 secret ไม่ตั้ง
// · audit log เฉพาะ userid (strip อักขระควบคุม) — ห้าม log token/query ดิบ
import { NextResponse } from "next/server";
import { addinTicketSecret, verifyAddinTicket } from "@/lib/addin-ticket";
import { issueDocverToken, verifyDocverLoginToken } from "@/lib/docver-token";
import { registerExternalFile } from "@/lib/external-import";
import { parseSessionExchangeBody } from "@/lib/note-auth";
import { NOTE_SESSION_TTL } from "@/lib/note-token";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function json(body: unknown, status = 200): NextResponse {
return NextResponse.json(body, { status, headers: { "cache-control": "no-store" } });
}
export async function POST(request: Request): Promise<Response> {
let raw: unknown;
try {
raw = await request.json();
} catch {
raw = null;
}
const parsed = parseSessionExchangeBody(raw);
if (!parsed.ok) return json({ error: parsed.error }, 400);
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
if (secret === undefined || secret === "") {
console.error("[sso] DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า");
return json({ error: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ" }, 500);
}
let identity: { userid: string; username: string; fid: string };
if (parsed.credential.kind === "jwt") {
const verified = verifyDocverLoginToken(parsed.credential.token, secret);
if (!verified.ok) {
return json(
{ error: "ลิงก์เข้าสู่ระบบหมดอายุหรือไม่ถูกต้อง", reason: verified.reason },
401,
);
}
identity = verified.claims;
} else {
// ค่า default "super_secret" ของ addinTicketSecret() เป็นที่รู้กันทั้ง repo/
// .env.example — ใครก็ปลอม ticket ของ uid ใครก็ได้ จึงปฏิเสธใน production
// (fail-closed — กลุ่ม docver/add-in อนุญาต default ตาม requirement อยู่แล้ว
// คนละประตู) · **dev ยอมรับค่า default** เพื่อให้ทดสอบทาง ticket รอบเครื่องได้
// โดยไม่ต้องตั้ง env ใหม่ — เครื่องมือ dev (make-login-link/make-ticket-docx/
// dev-link) ก็ default ค่าเดียวกัน (review 2026-10-30 รอบสอง)
if (process.env.NODE_ENV === "production" && addinTicketSecret() === "super_secret") {
console.error("[sso] ticket secret ยังเป็นค่า default (ADDIN_TICKET_SECRET/SECRET) — ปฏิเสธทาง ticket");
return json({ error: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ" }, 500);
}
const verified = verifyAddinTicket(
parsed.credential.ticketData,
parsed.credential.hmacHex,
addinTicketSecret(),
);
if (!verified.ok) {
return json({ error: "ลิงก์เข้าสู่ระบบหมดอายุหรือไม่ถูกต้อง", reason: "invalid" }, 401);
}
identity = { userid: verified.ticket.uid, username: verified.ticket.uname, fid: verified.ticket.fid };
}
// N8: ลงทะเบียนเอกสารตอนแลก token เลย — ฝั่ง server เชื่อ fid จาก credential
// ที่ verify แล้วเท่านั้น (trusted) ส่วน ?fileUrl พิมพ์เองบนหน้าเว็บไม่มีทาง
// เข้ามาถึงจุดนี้ · ล้ม = ยังออก token ให้ + fileError ให้หน้าแรกแสดง (คู่ขนาน
// พฤติกรรมสมัย cookie: session ตั้งก่อน แล้วค่อยแจ้ง error ของไฟล์)
let fileId: string | undefined;
let fileError: string | undefined;
if (parsed.fileUrl !== undefined) {
const result = await registerExternalFile(parsed.fileUrl, identity.fid, parsed.typeId, {
trusted: true,
});
if (result.ok) fileId = result.fileId;
else fileError = result.error;
}
const access_token = issueDocverToken(
{ userid: identity.userid, username: identity.username, fid: identity.fid, template: null },
secret,
NOTE_SESSION_TTL,
"login",
);
// audit: เฉพาะ userid + เวลา — ห้าม log token/query ดิบ · strip อักขระควบคุม
// ก่อนพิมพ์ (claims ไม่จำกัด charset — กันฝัง \n ปลอมบรรทัด log)
const auditId = identity.userid.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
console.log(`[sso] e-Court login ok: userid=${auditId}`);
return json({
access_token,
token_type: "Bearer",
expires_in: NOTE_SESSION_TTL,
userid: identity.userid,
username: identity.username,
...(fileId !== undefined ? { fileId } : {}),
...(fileError !== undefined ? { fileError } : {}),
});
}

View file

@ -1,48 +0,0 @@
// PLAN-SSO.md §3.1 — POST /api/note/auth/token: เส้น mint login-token ของแอป note
// (ทางเข้า x-api-key ตัวเดียวกับ docver แต่แยกเส้นจาก /api/documents/token — D1
// contract docver ไม่ถูกแต้) — สำหรับ backend e-Court ประกอบลิงก์
// `/note?…&t=<access_token>` ให้ผู้ใช้คลิก (ฉบับ token 2026-10-30: หน้าแรกเป็น
// client bootstrap แลก credential เป็น session token ที่ /api/note/auth/session)
// token ที่ได้เป็น purpose="login" ใช้ได้กับการเข้าแอป note เท่านั้น (D4)
// ไม่ import @/db · ห้าม log ค่า key/body ดิบ
import { docverError } from "@/lib/document-versioning";
import { issueDocverToken, requireDocverApiKey } from "@/lib/docver-token";
import { parseLoginTokenBody } from "@/lib/note-auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: Request): Promise<Response> {
const apiKeyFail = requireDocverApiKey(request);
if (apiKeyFail !== null) return apiKeyFail;
let raw: unknown;
try {
raw = await request.json();
} catch {
return docverError("body ต้องเป็น JSON", 400);
}
const parsed = parseLoginTokenBody(raw);
if (!parsed.ok) return docverError(parsed.error, 400);
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
if (secret === undefined || secret === "") {
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
}
const accessToken = issueDocverToken(
{ userid: parsed.claims.userid, username: parsed.claims.username, fid: parsed.claims.fid, template: null },
secret,
parsed.ttlSeconds,
"login",
);
return new Response(
JSON.stringify({
access_token: accessToken,
token_type: "Bearer",
expires_in: parsed.ttlSeconds,
}),
{ headers: { "content-type": "application/json; charset=utf-8" } },
);
}

View file

@ -1,86 +0,0 @@
import { and, asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { files } from "@/db/schema";
import { externalApiError, requireExternalApiKey } from "@/lib/external-api-auth";
import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api";
import { requestOrigin } from "@/utils/request-origin";
// N10 (PLAN-API.md D2) — POST /api/note/external/documents
// (เดิมชื่อ /api/external/query ถึงรอบสาม — เปลี่ยนชื่อตาม resource ที่คืน 2026-09-25)
// เส้นเดียวถามด้วย JSON body { fileId และ/หรือ typeId, filter } — response
// รูปเดียวเสมอ { documents: [bundle…] } (bundle ยุบชั้น — field เอกสารอยู่
// ระดับเดียวกับ annotations/bookmarks): ถาม fileId = ยาว 1 ชิ้น (ไม่เคยเปิด
// ในระบบ = 404 — ไฟล์ url: fallback ของ N8 ค้นด้วย ecourt: prefix ไม่ได้ =
// 404 ตามสัญญาลิงก์), ถาม typeId = ทุกไฟล์ในกลุ่ม N9 (กลุ่มว่าง = documents: []),
// ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (2026-09-25 —
// รอบเดียวกันเคยเป็น OR รวมผลแล้วเปลี่ยนหลังทดสอบจริง: ผู้เรียกต้องการเอกสาร
// เดียวตามที่ส่งมา ไม่ใช่ทั้งกลุ่ม)
// read-only: ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
// origin เต็มของ request สำหรับ url เต็มใน response — ถอดเป็น helper ร่วม
// (utils/request-origin.ts) ให้ route manifest ของ wordaddin ใช้เหมือนกัน (2026-10-16)
export async function POST(req: Request) {
const unauthorized = requireExternalApiKey(req);
if (unauthorized) return unauthorized;
let body: unknown;
try {
body = await req.json();
} catch {
return externalApiError("body ต้องเป็น JSON ที่ถูกต้อง", 400);
}
const query = parseQueryBody(body);
if (!query.ok) return externalApiError(query.error, 400);
const origin = requestOrigin(req);
// ครอบทุก DB call (code review 2026-09-25) — ล้มไม่ว่าเหตุใด (connection
// หลุด/timeout/table มีปัญหา) ต้องได้ 500 JSON ตาม contract เดียวกับ error
// อื่นของ endpoint นี้ ไม่ใช่ 500 ของ framework ที่ format ต่างออกไป; log
// รายละเอียดฝั่ง server อย่างเดียว — ข้อความตอบกลาง ๆ กัน leak SQL/path
try {
if (query.mode === "file") {
// ส่งทั้งคู่ = AND กรองทั้ง externalId และกลุ่มใน query เดียว เหลือเอกสารเดียว
// (external_id unique — match ได้มากสุด 1 ฉบับ) ไม่ตรง = 404 โดยแยกสาเหตุ:
// ไม่เคยเปิดในระบบ หรืออยู่คนละกลุ่มกับ typeId ที่ระบุ
const [file] = await db
.select()
.from(files)
.where(
query.typeId
? and(
eq(files.externalId, `ecourt:${query.fileId}`),
eq(files.typeId, query.typeId),
)
: eq(files.externalId, `ecourt:${query.fileId}`),
)
.limit(1);
if (!file) {
return externalApiError(
query.typeId
? "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยเปิด หรือไม่ได้อยู่ในกลุ่ม typeId ที่ระบุ)"
: "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยเปิด)",
404,
);
}
const documents = await loadDocumentBundles([file], query.parts, origin);
return new Response(JSON.stringify({ documents }), { headers: JSON_HEADERS });
}
const fileRows = await db
.select()
.from(files)
.where(eq(files.typeId, query.typeId))
.orderBy(asc(files.createdAt));
const documents = await loadDocumentBundles(fileRows, query.parts, origin);
return new Response(JSON.stringify({ documents }), { headers: JSON_HEADERS });
} catch (err) {
console.error("external/documents: query ฐานข้อมูลล้ม", err);
return externalApiError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
}
}

View file

@ -1,81 +0,0 @@
// GET /api/note/files/[id]/bootstrap — ข้อมูลเริ่มต้นของ viewer (file + annotations
// + bookmarks + replies เป็น DTO) ให้ client gate ของหน้า /note/annotate/[id]
// ดึงหลังมี access_token (ฉบับ token 2026-10-30 — เดิมหน้านี้เป็น server component
// อ่าน cookie ผ่าน getSessionUser) · Bearer บังคับ (401) · ไม่ publish SSE ·
// query ชุดเดียวกับหน้าเดิมตอนเป็น server component
import { asc, eq, inArray } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "@/db";
import {
annotationReplies,
annotations,
bookmarks,
files,
toAnnotationDTO,
toBookmarkDTO,
toReplyDTO,
} from "@/db/schema";
import { noteUserFromRequest } from "@/lib/note-token";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export async function GET(
request: Request,
{ params }: { params: { id: string } },
): Promise<Response> {
const user = noteUserFromRequest(request);
if (user === null) {
return NextResponse.json(
{ error: "session หมดอายุ — กรุณากลับ e-Court กดลิงก์เปิดเอกสารใหม่" },
{ status: 401, headers: { "cache-control": "no-store" } },
);
}
const { id } = params;
if (!UUID_RE.test(id)) {
return NextResponse.json({ error: "ไม่พบไฟล์นี้" }, { status: 404 });
}
const [file] = await db.select().from(files).where(eq(files.id, id));
if (!file) {
return NextResponse.json({ error: "ไม่พบไฟล์นี้" }, { status: 404 });
}
const [annotationRows, bookmarkRows] = await Promise.all([
db
.select()
.from(annotations)
.where(eq(annotations.fileId, id))
.orderBy(asc(annotations.createdAt)),
db
.select()
.from(bookmarks)
.where(eq(bookmarks.fileId, id))
.orderBy(asc(bookmarks.createdAt)),
]);
// replies ของทุก comment ในไฟล์นี้ (F9) — แม่ถูก CASCADE ไปแล้ว row ก็หายตาม
const annotationIds = annotationRows.map((r) => r.id);
const replyRows = annotationIds.length
? await db
.select()
.from(annotationReplies)
.where(inArray(annotationReplies.annotationId, annotationIds))
.orderBy(asc(annotationReplies.createdAt))
: [];
return NextResponse.json(
{
file: { id: file.id, title: file.title, pdfUrl: file.pdfUrl },
annotations: annotationRows.map(toAnnotationDTO),
bookmarks: bookmarkRows.map(toBookmarkDTO),
replies: replyRows.map(toReplyDTO),
user: { userId: user.userid, userName: user.username },
},
{ headers: { "cache-control": "no-store" } },
);
}

View file

@ -1,43 +0,0 @@
// GET /api/note/files/open?fileUrl=/samples/… — ทางเปิดเอกสารตัวอย่างของหน้า /note/dev
// แบบ "A Link Get(new)" ของ /tmpl (คำขอผู้ใช้ 2026-10-30): ลิงก์เปล่า <Link> ยิง GET ได้เลย
// ไม่มี Bearer (browser navigation ใส่ header ไม่ได้) จึง**รับเฉพาะ path ในเครื่องที่ขึ้นต้น
// "/" เท่านั้น** — registerExternalFile ตรวจว่ามีไฟล์จริงใน public/ ผ่าน path guard เดิม
// (สร้างแถว url:… เท่านั้น — แถว ecourt: เกิดไม่ได้เพราะไม่รับ fileId และ path รีโมตโดนเด็ดขาด)
// แล้ว 302 เข้า viewer · ไฟล์ไม่ผ่าน = 302 /note?fileError=… ให้หน้าแรกแสดงสาเหตุ
import { NextResponse } from "next/server";
import { registerExternalFile } from "@/lib/external-import";
import { withBasePath } from "@/utils/base-path";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
// withBasePath("") = หน้าแรก /docedit/note · withBasePath("/annotate/x") = viewer —
// อย่าส่ง "/note" เข้า withBasePath (มันเติม app path ให้อีกชั้นเป็น /note/note — D11)
// Location เป็น relative โดยเจตนา: browser resolve เทียบ origin ที่ตัวเองยิงมาเอง
// ส่วน NextResponse.redirect(new URL(path, request.url)) จะประกอบ absolute URL จาก
// Host ที่ reverse proxy ส่งมา (nginx default เขียนทับเป็น upstream เช่น 0.0.0.0:3000)
// แล้วพา browser หลุดโฮสต์จริง (prod 2026-11-02)
const redirectTo = (path: string, query = ""): NextResponse => {
return new NextResponse(null, {
status: 302,
headers: { location: `${withBasePath(path)}${query}`, "cache-control": "no-store" },
});
};
const fileUrl = url.searchParams.get("fileUrl") ?? "";
if (!fileUrl.startsWith("/") || fileUrl.includes("..") || fileUrl.length > 2048) {
return redirectTo(
"",
`?fileError=${encodeURIComponent("ลิงก์เปิดเอกสารตัวอย่างต้องเป็น path ในเครื่อง เช่น /samples/…")}`,
);
}
const result = await registerExternalFile(fileUrl, undefined, undefined, { trusted: false });
if (!result.ok) {
return redirectTo("", `?fileError=${encodeURIComponent(result.error)}`);
}
return redirectTo(`/annotate/${result.fileId}`);
}

View file

@ -3,7 +3,6 @@ import { eq } from "drizzle-orm";
import { db } from "@/db"; import { db } from "@/db";
import { files } from "@/db/schema"; import { files } from "@/db/schema";
import { fixPdfActualText } from "@/lib/pdf-actual-text"; import { fixPdfActualText } from "@/lib/pdf-actual-text";
import { fetchSourceBytes, isRemoteSource, RemoteFileError } from "@/lib/remote-file";
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf"; import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
import { fileKindFromUrl, imageContentTypeFromUrl } from "@/utils/file-kind"; import { fileKindFromUrl, imageContentTypeFromUrl } from "@/utils/file-kind";
@ -26,22 +25,12 @@ export async function GET(req: Request) {
if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 }); if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 });
// Guard path traversal: pdfUrl จาก DB ต้อง resolve แล้วอยู่ใน public/ เท่านั้น (§5) // Guard path traversal: pdfUrl จาก DB ต้อง resolve แล้วอยู่ใน public/ เท่านั้น (§5)
// N8: pdf_url เป็นรีโมต (ลิงก์ e-Court) → ดึงสดจาก allowlist แทนการอ่านดิสก์
let srcBytes: Buffer; let srcBytes: Buffer;
if (isRemoteSource(file.pdfUrl)) { try {
try { srcBytes = readPublicPdf(file.pdfUrl);
srcBytes = await fetchSourceBytes(file.pdfUrl); } catch (err) {
} catch (err) { if (err instanceof PdfPathError) return new Response(err.message, { status: 400 });
if (err instanceof RemoteFileError) return new Response(err.message, { status: 502 }); throw err;
throw err;
}
} else {
try {
srcBytes = readPublicPdf(file.pdfUrl);
} catch (err) {
if (err instanceof PdfPathError) return new Response(err.message, { status: 400 });
throw err;
}
} }
// N3: ไฟล์รูปภาพไม่มี ToUnicode ให้ซ่อม — คืน bytes ดิบ + content-type ถูกต้อง // N3: ไฟล์รูปภาพไม่มี ToUnicode ให้ซ่อม — คืน bytes ดิบ + content-type ถูกต้อง

View file

@ -1,44 +0,0 @@
import { NextResponse } from "next/server";
import { openApiSpec } from "@/lib/openapi";
import { withBasePath } from "@/utils/base-path";
// GET /api/swagger — หน้า Swagger UI ของ API ทั้งหมด (กลุ่ม template + N10)
// (asset สำหรับ intranet: postinstall scripts/copy-swagger-ui.mjs คัดลอก
// swagger-ui-dist ไป public/note/swagger-ui/ — pattern เดียวกับ pdf.worker)
// spec inline จาก lib/openapi.ts ตรง ๆ ไม่มี endpoint spec แยก
export const runtime = "nodejs";
// กัน </script> ใน JSON ตัดสคริปต์ขาย (spec เป็นของเราเองทั้งหมด แค่กันกรณี
// ข้อความ example มี < ในอนาคต)
const SPEC_JSON = JSON.stringify(openApiSpec).replace(/</g, "\\u003c");
const HTML = `<!doctype html>
<html lang="th">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Swagger UI — Judge's Note API</title>
<link rel="stylesheet" href="${withBasePath("/swagger-ui/swagger-ui.css")}">
</head>
<body>
<div id="swagger-ui"></div>
<script src="${withBasePath("/swagger-ui/swagger-ui-bundle.js")}"></script>
<script>
window.onload = function () {
window.ui = SwaggerUIBundle({
spec: ${SPEC_JSON},
dom_id: "#swagger-ui",
deepLinking: true,
persistAuthorization: true
});
};
</script>
</body>
</html>`;
export async function GET() {
return new NextResponse(HTML, {
headers: { "content-type": "text/html; charset=utf-8" },
});
}

View file

@ -1,155 +0,0 @@
// ตัวอย่างรองรับค่าจาก Frontend ที่เป็น Vanilla JavaScript และ Classic Framework
// โดย ผ่าน Form(POST) หรือ การกดลิงค์(GET) เปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์
// จะมีข้อมูล info.ticket_data ที่สมบูรณ์แล้ว({fid,tid,uid,uname})แล้วเเรียก /api/template/generate เพื่อสร้างเอกสาร docx
// import { headers } from "next/headers";
import { NextRequest, NextResponse } from 'next/server';
import path from 'path';
import fs from 'fs/promises';
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || '';
const TMPL_PATH = process.env.TMPL_PATH || "./public/tmpl";
const DOCEDIT_BASE_URL = process.env.DOCEDIT_BASE_URL || '';
async function processRequest(fid: string, tid: string, uid: string, uname: string): Promise<NextResponse<unknown>> {
//กัน path traversal attack
const TID = path.basename(tid)
const FID = path.basename(fid)
const ticketObj = { fid: FID, tid: TID, uid, uname }
const ticket_data = JSON.stringify({ fid: FID, tid: TID, uid, uname })
const filePath = path.join(TMPL_PATH, `${FID}.json`);
// สำหรับการ demo ในแอปเดียวเท่านั้น สามารถคำนวนหา base path ได้ด้วยวิธีนี้
// const host = headers().get("host"); // e.g., localhost:3000
// const proto = headers().get("x-forwarded-proto") || "http";
// const baseUrl = process.env.NEXT_PUBLIC_BASE_PATH || ""
// const fullUrl = `${proto}://${host}${baseUrl}`;
// const DOCEDIT_BASE_URL = process.env.DOCEDIT_BASE_URL || fullUrl;
if (!DOCEDIT_BASE_URL || !EXTERNAL_API_KEY) {
return NextResponse.json({ error: 'Server must set Environment variable DOCEDIT_BASE_URL or EXTERNAL_API_KEY' }, { status: 500 });
}
if (!FID || !TID || !uid || !uname) {
return NextResponse.json({ error: 'Missing fid, tid, uid or uname' }, { status: 400 });
}
try { //ตรวจสอบ docx เวอร์ชั่นล่าสุดก่อนถ้าไม่พบค่อยเอาจากฐานข้อมูลมาสร้าง raw Json
const params = new URLSearchParams({ type: 'docx', ticket_data });
const lastestDocxResponse = await fetch(`${DOCEDIT_BASE_URL}/api/documents/latest?${params.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'x-api-key': EXTERNAL_API_KEY,
}
});
// เช็ค Content-Type และ Status ก่อน
const contentType = lastestDocxResponse.headers.get('content-type') || '';
if (lastestDocxResponse.ok && !contentType.includes('text/html')) {
console.log('Fund latest docx from docedit, returning stream to browser');
const stream = lastestDocxResponse.body;
//ส่ง Binary Stream กลับไปยัง Browser พร้อม Header กำหนดประเภทไฟล์
const download_file = `${FID}_${uid}_latest`
return new NextResponse(stream as unknown as BodyInit, {
status: 200,
headers: {
'Content-Type':
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'Content-Disposition': `attachment; filename="${encodeURIComponent(download_file)}.docx"`,
'Cache-Control': 'no-store, max-age=0',
},
});
}
if (lastestDocxResponse.status !== 404) {//เกิดปัญหาที่ไม่ใช่ Not Found น่าจะเป็นปัญหาที่ gateway
const errorText = await lastestDocxResponse.text();
console.error('download-docx:Invalid latest docx Response:', errorText);
return NextResponse.json(
{ error: `Error from docedit latest: Expected docx stream but received ${lastestDocxResponse.status} ${lastestDocxResponse.statusText}` },
{ status: 502 }
);
}
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
console.log(`Not found latest docx from docedit, fallback to generate from template and database: ${filePath}`);
try { // ไม่พบเวอร์ชั้นให้สร้าง docx จาก template และ json(ฐานข้อมูล)
// อ่านไฟล์ JSON จำลองการอ่านจากฐานข้อมูล
const rawJson = await fs.readFile(filePath, 'utf-8');
const jsonData = JSON.parse(rawJson);
if (!jsonData || typeof jsonData !== 'object') {
return NextResponse.json({ error: 'Invalid data format' }, { status: 400 });
}
/////// !! ตรวจสอบสิทธิ์ผู้ใช้ uid ว่ามีสิทธิ์เข้าถึงไฟล์ fid หรือไม่เพื่อกันการการยิง API ตรงๆ ไม่ผ่าน Frontend !!//
/////// ไม่มี session return 401, ถ้าเจอแต่ไม่มีสิทธิ์ให้ return 403 , อ่านจากฐานข้อมูล ถ้าไม่พบข้อมูลให้ return 404,
// ข้อมูลจำเป็นต้องมี jsonData.info.ticket_data ถ้าไม่มีให้สร้างขึ้นมาเอง
if (!Object.hasOwn(jsonData, 'info'))
jsonData['info'] = {}
jsonData.info['ticket_data'] = JSON.stringify(ticketObj)
// sign เองตรงนี้ให้อยู่ในรูปแบบ data.info={ticket_data,HmacSha256,Ed25519} หรือว่างไว้ให้ docedit สร้างให้
//ยิงไปหาระบบ Docedit เพื่อเอา Stream
const docxResponse = await fetch(`${DOCEDIT_BASE_URL}/api/template/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': EXTERNAL_API_KEY,
},
body: JSON.stringify(jsonData),
});
// เช็ค Content-Type และ Status ก่อน
const contentType = docxResponse.headers.get('content-type') || '';
if (!docxResponse.ok || contentType.includes('text/html')) {
const errorText = await docxResponse.text();
console.error('download-docx:Invalid docx Response:', errorText);
return NextResponse.json(
{ error: `Error from docedit generate: Expected docx stream but received ${contentType} (Status ${docxResponse.status})` },
{ status: 502 }
);
}
// 2. ดึง ReadableStream โดยตรงจาก Response ของระบบ
const stream = docxResponse.body;
// 3. ส่ง Binary Stream กลับไปยัง Browser พร้อม Header กำหนดประเภทไฟล์
const download_file = `${FID}_${uid}_xxx`
return new NextResponse(stream as unknown as BodyInit, {
status: 200,
headers: {
'Content-Type':
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'Content-Disposition': `attachment; filename="${encodeURIComponent(download_file)}.docx"`,
'Cache-Control': 'no-store, max-age=0',
},
});
} catch (error: any) {
if (error.code === 'ENOENT') {// ไฟล์ JSON ไม่พบ ถ้าใช้ฐานข้อมูลจริงให้ return 404 ตั้งแต่แต่แรก และเอา ENOENT ออก
return NextResponse.json(
{ error: `Data not found: ${filePath}` },
{ status: 404 }
);
}
console.error(error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const fid = searchParams.get('fid') || '';
const tid = searchParams.get('tid') || '';
const uid = searchParams.get('uid') || '';
const uname = searchParams.get('uname') || '';
return await processRequest(fid, tid, uid, uname)
}
export async function POST(request: NextRequest) {
const formData = await request.formData();
const fid = String(formData.get('fid'))
const tid = String(formData.get('tid'))
const uid = String(formData.get('uid'))
const uname = String(formData.get('uname'))
return await processRequest(fid, tid, uid, uname)
}

View file

@ -1,62 +0,0 @@
import { NextResponse } from "next/server";
import { extractDocumentSnapshot } from "@/lib/lib-tmpl";
// export const runtime = "nodejs";
const DOCX_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
// cap เดียวกับ N4 (app/actions/files.ts) — route นี้เปิดกว้างให้ภายนอกยิงได้
// ต้องเช็คขนาดก่อนอ่านเข้าหน่วยความจำ กัน DoS (bodySizeLimit ครอบเฉพาะ Server Actions)
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
export async function POST(request: Request) {
try {
const formData = await request.formData();
const file = formData.get("file");
const TEST_SECRET = String(formData.get("secret") ?? "");
const TEST_PUBLIC_KEY_HEX = String(formData.get("public_key") ?? "");
if (!(file instanceof File) ||
(file.type !== DOCX_CONTENT_TYPE && !file.name.toLowerCase().endsWith(".docx") )
) {
return NextResponse.json(
{
error: 'Field "file" must be a .docx file.',
},
{ status: 400 },
);
}
if (file.size > MAX_UPLOAD_BYTES) {
return NextResponse.json(
{
error: 'Field "file" exceeds the 100MB size limit.',
},
{ status: 400 },
);
}
// File → Buffer
const buffer = Buffer.from(await file.arrayBuffer());
// ชุดแกะข้อมูล `{cc, bookmark, properties, verify}` จาก lib-tmpl.ts — ตัวเดียวกับที่
// ใช้เก็บเป็น fields ของ version ตอน POST /api/documents/versions (2026-10-30) ·
// secret/public_key เป็นของผู้เรียกเพื่อ "ทดสอบ verify" เฉพาะฝั่งนี้
const returnData = extractDocumentSnapshot(buffer, {
secret: TEST_SECRET || undefined,
publicKeyHex: TEST_PUBLIC_KEY_HEX || undefined,
});
return NextResponse.json(returnData);
} catch (error) {
console.error(error);
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Internal Server Error",
},
{ status: 500 },
);
}
}

View file

@ -1,116 +0,0 @@
// สร้าง docx จาก json
// - json.info.ticket_data จะมี tid ให้โหลดไฟล์ template นี้มาใช้
// - สร้าง signature/hash จาก json.info.ticket_data
import { NextRequest, NextResponse } from 'next/server';
import {readFile} from 'fs/promises';
import path from 'path';
import {DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl";
import { docxPool } from '@/lib/docx-pool';
export const runtime = 'nodejs';
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || '';
const PRIVATE_KEY_HEX = process.env.PRIVATE_KEY_HEX ||''
const SECRET = process.env.SECRET || 'super_secret'
const TMPL_PATH = process.env.TMPL_PATH || "./public/tmpl";
const TICKET_SEC = Number(process.env.TICKET_SEC||'120')
export async function POST(request: NextRequest) {
if (!EXTERNAL_API_KEY || !PRIVATE_KEY_HEX || !SECRET){
return NextResponse.json(
{ error: 'Server Error: require EXTERNAL_API_KEY PRIVATE_KEY_HEX and SECRET' },
{ status: 500 }
);
}
// 1. ตรวจสอบ API Key จาก HTTP Header (x-api-key)
const apiKey = request.headers.get('x-api-key')||'';
if (!apiKey || apiKey !== EXTERNAL_API_KEY) {
return NextResponse.json(
{ error: 'Unauthorized: Invalid API Key ' },
{ status: 401 }
);
}
try {
// รับข้อมูล JSON Payload
const templateData:DocumentTemplate = (await request.json());
// console.log(templateData)
// ต้องมี templateData.info.ticket_data เป็น json string เสมอ
if (!templateData || !templateData.info ||
!templateData.info.ticket_data ||
typeof templateData.info.ticket_data !== "string"
) {
return NextResponse.json(
{
error: "template/generate json.info.ticket_data not avaliable",
},
{ status: 400 },
);
}
const ticketObj = JSON.parse(templateData.info.ticket_data)
const tid = String(ticketObj.tid)
const fid = String(ticketObj.fid)
const uid = String(ticketObj.uid)
if(!tid||!fid||!uid)
{
return NextResponse.json(
{
error: "template/generate require json.info.ticket_data.(fid|tid|uid)",
},
{ status: 400 },
);
}
//เปิดไฟล์ template
const tmplPath = path.join(TMPL_PATH, `${path.basename(tid)}.docx`);
let templateBuffer: Buffer = await readFile(tmplPath)
// const output = await applyTemplate(templateBuffer, templateData, SECRET, PRIVATE_KEY_HEX, TICKET_SEC);
const output = await docxPool.run({
templateBuffer,
templateData,
secret: SECRET,
privateKeyHex: PRIVATE_KEY_HEX,
ticketSec: TICKET_SEC
});
const outputBuffer = Buffer.from(output);
// สร้าง ReadableStream ของไฟล์ .docx
// ในโค้ดจริง ส่วนนี้จะดึง Binary Buffer จาก Library เช่น docx, docxtemplater หรืออ่านจาก File System
// const encoder = new TextEncoder();
const docxStream = new ReadableStream({
start(controller) {
controller.enqueue(outputBuffer);
controller.close();
},
});
// 4. ส่ง Return เป็น Response พร้อม Stream และ Headers กำหนด MIME type ของ MS Word
const dlFileName = encodeURIComponent(`${tid}_${fid}_${uid}XX`)
return new NextResponse(docxStream, {
status: 200,
headers: {
'Content-Type':
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'Content-Disposition': `attachment; filename="${dlFileName}.docx"`,
'Cache-Control': 'no-store, max-age=0',
},
});
} catch (error:any) {
if ( error.code === 'ENOENT') {
return NextResponse.json(
{ error: 'NotFound: Invalid template ID or incorrect storage path'},
{ status: 404 }
);
}
console.error('Error generating docx:', error);
return NextResponse.json(
{ error: 'Failed to generate document payload'},
{ status: 500 }
);
}
}

View file

@ -1,135 +0,0 @@
import { NextResponse } from "next/server";
import { DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl";
export const runtime = "nodejs";
const DOCX_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
// cap เดียวกับ N4 (app/actions/files.ts) — route นี้เปิดกว้างให้ภายนอกยิงได้
// ต้องเช็คขนาดก่อนอ่านเข้าหน่วยความจำ กัน DoS (bodySizeLimit ครอบเฉพาะ Server Actions)
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
const TICKET_SEC = Number(process.env.TICKET_SEC||'300')
export async function POST(request: Request) {
try {
const formData = await request.formData();
const template = formData.get("template");
const dataFile = formData.get("data");
const dataJson = formData.get("dataJson");
const TEST_SECRET = String(formData.get("secret")||"")
const TEST_PRIVATE_KEY_HEX = String(formData.get("private_key")||"")
// ตรวจสอบ Template
if (!(template instanceof File)) {
return NextResponse.json(
{
error: 'Missing DOCX file in field "template".',
},
{ status: 400 },
);
}
if (template.type !== DOCX_CONTENT_TYPE &&
!template.name.toLowerCase().endsWith(".docx") &&
template.size > MAX_UPLOAD_BYTES
) {
return NextResponse.json(
{
error: 'Field "template" must be a .docx file and not exceeds the 100MB size limit.',
},
{ status: 400 },
);
}
// อ่าน JSON
let templateData: DocumentTemplate;
// JSON ผิด syntax เป็นความผิดของฝั่งผู้เรียก — คืน 400 ไม่ใช่ 500
// (catch กลางท้ายฟังก์ชันสงวนไว้ให้ error ของระบบเท่านั้น)
try {
if (typeof dataJson === "string" && dataJson.length > 0) {
// กรณีส่ง JSON ผ่าน form field
templateData = JSON.parse(dataJson) as DocumentTemplate;
} else if (dataFile instanceof File) {
if (dataFile.size > MAX_UPLOAD_BYTES) {
return NextResponse.json(
{
error: 'Field "data" exceeds the 100MB size limit.',
},
{ status: 400 },
);
}
// กรณี upload data.json
const text = await dataFile.text();
templateData = JSON.parse(text) as DocumentTemplate;
} else {
return NextResponse.json(
{
error:
'Missing JSON data. Send either "data" file or "dataJson" field.',
},
{ status: 400 },
);
}
} catch (error) {
return NextResponse.json(
{
error: `Invalid JSON data (${error instanceof Error ? error.message : "parse error"
}).`,
},
{ status: 400 },
);
}
// JSON ต้องเป็น object
if (
templateData === null ||
typeof templateData !== "object" ||
Array.isArray(templateData)
) {
return NextResponse.json(
{
error: "JSON data must be an object.",
},
{ status: 400 },
);
}
// -----------------------------
// Template File → Buffer
// -----------------------------
let templateBuffer: Buffer = Buffer.from(
await template.arrayBuffer(),
);
const output = await applyTemplate(templateBuffer,templateData,TEST_SECRET,TEST_PRIVATE_KEY_HEX,TICKET_SEC);
// -----------------------------
// ส่ง DOCX กลับ
// -----------------------------
if(templateData?.info?.ticket_data)
return new NextResponse(output as BodyInit, {
status: 200,
headers: {
"Content-Type": DOCX_CONTENT_TYPE,
"Content-Disposition":
'attachment; filename="generated-document.docx"',
"Content-Length": String(output.length),
},
});
} catch (error) {
console.error(error);
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Internal Server Error",
},
{ status: 500 },
);
}
}

View file

@ -1,10 +0,0 @@
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export async function GET() {
return NextResponse.json({
ok: true,
service: "docx-template-api",
});
}

Binary file not shown.

View file

@ -2,22 +2,6 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* ─── Genesis utilities (docs/DESIGN.md 2026-09-23) ────────────────────────
focus indicator = outline น้ำเงินทึบ 2px + offset 2px ของ design system — แยกเป็น
class ร่วมเพื่อใช้ซ้ำทุกปุ่ม/ลิงก์; input ใช้ .genesis-input (ขอบน้ำเงิน + outline ตอน
:focus ตาม spec Inputs) — ครอบด้วย @layer components ให้ Tailwind utilities ทับได้
(UX review M3 2026-09-23: halo alpha 0.12 ของ spec เดิม blend บนขาวแล้ว ~1.2:1
ต่ำกว่าเกณฑ์ 3:1 ของ WCAG 1.4.11 — alpha 0.25–0.35 ก็ยังไม่ผ่าน จึงเปลี่ยนเป็น
outline ทึบเป็นตัวชี้วัด + offset คั่นไม่ให้จมกับพื้นปุ่ม primary) */
@layer components {
.focus-ring {
@apply focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary;
}
.genesis-input {
@apply rounded-md border border-line bg-white px-3.5 py-2 text-sm text-ink placeholder:text-ink-faint focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary;
}
}
/* ─── TextLayer ของ pdfjs v4 ───────────────────────────────────────────────── /* ─── TextLayer ของ pdfjs v4 ─────────────────────────────────────────────────
คัดมาจาก node_modules/pdfjs-dist/web/pdf_viewer.css (ส่วนที่จำเป็น) คัดมาจาก node_modules/pdfjs-dist/web/pdf_viewer.css (ส่วนที่จำเป็น)
ใช้กับ pdfjs.TextLayer ที่ render ลง div.textLayer ใน PDFViewer */ ใช้กับ pdfjs.TextLayer ที่ render ลง div.textLayer ใน PDFViewer */
@ -152,15 +136,10 @@
inset: -4px; inset: -4px;
} }
/* ปุ่ม bookmark/ลบในแผง selection (20×20) + swatch สีในแผง (16×16) — /* ปุ่ม bookmark/ลบในแผง selection (20×20) + swatch สีในแผง (16×16) */
sel-panel-btn ใส่ centering แบบ .coarse-36 กันผู้ใช้ class ไม่ได้เอา flex
มาเอง (ไอคอนลอยชิดบนในกล่อง 36px); ปุ่มที่ใช้อยู่มี flex แล้ว ผลลัพธ์ไม่เปลี่ยน */
.sel-panel-btn { .sel-panel-btn {
min-height: 2.25rem; min-height: 2.25rem;
min-width: 2.25rem; min-width: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
} }
.sel-panel .color-swatches button, .sel-panel .color-swatches button,
.sel-panel .color-swatches label { .sel-panel .color-swatches label {
@ -187,28 +166,6 @@
justify-content: center; justify-content: center;
} }
/* ─── ยกเว้น chevron หุบ/กางของต้นไม้ bookmark (bug report 2026-09-22) ──────
คอลัมน์นี้เป็น "ตาราง" ของต้นไม้ ต้องกว้างเท่าไอคอน 14px เท่ากับ spacer
(w-3.5) ของแถวลูกเสมอ — ถ้าปล่อย min-width 32px ดันไอคอน bookmark ของ
หัวข้อแม่ไปชนแนวพอดีกับของหัวข้อลูก (indent 16px/ระดับโดนกิน) ลำดับ
ชั้นมองไม่เห็น และ root มีลูก (chevron) กับไม่มีลูก (spacer) ก็เพี้ยน
ไม่ตรงแนวกันเอง — จึงคงขนาดจริง 14px แล้วขยาย hit area ด้วย ::after
แบบเดียวกับ sel-handle (จิ้มสบาย 32px เท่าเดิม โดยไม่จอง layout) */
aside[data-sidebar] button.bm-toggle {
min-height: 0;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
}
aside[data-sidebar] button.bm-toggle::after {
content: "";
position: absolute;
/* ขยาย hit รอบไอคอน 14px เป็น 32px (±9px) โดยไม่กว้างขึ้นจริง */
inset: -0.5625rem;
}
/* M6: ตัด double-tap zoom ของ browser เมื่อแตะปุ่มรัว ๆ (ยัง pinch ได้ตามปกติ — /* M6: ตัด double-tap zoom ของ browser เมื่อแตะปุ่มรัว ๆ (ยัง pinch ได้ตามปกติ —
handle ของกรอบ selection เป็น div + touch-none อยู่แล้ว ไม่โดน rule นี้) */ handle ของกรอบ selection เป็น div + touch-none อยู่แล้ว ไม่โดน rule นี้) */
button, button,
@ -240,113 +197,18 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} }
/* ปุ่มที่ไม่ใช่ <a> (แถวตัวกรองในแผง explorer ของหน้าสรุป, ปุ่มล้างตัวกรอง)
หลุดจากกฎข้างบน — ใส่แค่ min-height (ปุ่มเป็น flex w-full text-left อยู่แล้ว
ไม่ต้อง display/justify แบบกฎ <a>; เดิมเป็นปุ่มหุบ/กางของ CollapsibleBlock
design review 2026-09-17 H1 ก่อนหน้าถูกแทนด้วยตัวกรอง explorer 2026-09-24) */
main.summary-page button {
min-height: 2.75rem;
}
/* UX review M5 2026-09-23: ช่องกรอกทุกชนิด ≥16px บนจอสัมผัส — iOS Safari ซูมทั้ง
หน้าทุกครั้งที่แตะโฟกัสช่องที่ตัวอักษรเล็กกว่า 16px (ทีมตั้งกติกาเดียวกันไว้
หน้า login แล้ว — ขยายครบทุกช่องของแอปเฉพาะอุปกรณ์จอสัมผัส; !important จำเป็น
เพราะช่องส่วนใหญ่กำหนดขนาดด้วย text-xs/text-[11px] ที่ specificity สูงกว่า)
responsive audit 2026-09-24: ครอบ contentEditable ของ TEXTBOX editor ด้วย —
fontSize default 2.5% ≈ 15px ทำ Safari ซูม viewport ตอนแตะเข้าโหมดแก้ไข
(ตัวอักษรขยายเป็น 16px เฉพาะช่วงพิมพ์ ต่างจากผลลัพธ์ ~7% — trade-off มาตรฐาน
ของ editor บน iOS เพราะ maximum-scale กัน focus-zoom ไม่ได้) */
input,
textarea,
select,
[contenteditable]:not([contenteditable="false"]) {
font-size: 1rem !important;
}
} }
/* UX review M7 2026-09-23: ปุ่มหลักของหน้ารายการ/กลุ่ม (Annotate h-8, ลิงก์กลุ่ม, /* UX review 2026-09-14 H2 → typography audit 2026-09-16 (T1 + Option B): webfont
ออกจากระบบ) สูง 32px ต่ำกว่ามาตรฐาน touch 44px ที่ viewer/หน้าสรุปได้แล้ว — Sarabun (next/font จาก layout.tsx — variable มี adjusted fallback ของ next/font
หน้าที่ผู้ใช้ iPad แตะบ่อยที่สุดก่อนเปิดเอกสารต้องได้เป้าเท่ากัน (ครอบ a/button ในตัวแล้ว) นำหน้า stack เพื่อแสดงผลเหมือนกันทุกอุปกรณ์; ที่เหลือเป็น fallback
ของ <main class="listing-page"> + header ของ AppNav; select/summary เป็น ตอนฟอนต์ยังไม่ถึง (display:swap)/โหลดไม่ได้ — เรียงแบบ Latin-first ตาม T1:
native control/มี flex อยู่แล้ว ใส่แค่ min-height) Latin/ตัวเลขได้ UI font ของ OS (Segoe UI/SF) ส่วนไทยได้ Leelawadee UI
ครอบด้วย @layer components ให้ Tailwind utilities (flex/items-*) ทับได้ — (Windows) / Thonburi (iPad) / Noto Sans Thai (Android/Linux) */
กฎ unlayered ชนะทุก layer จนบังคับ align-items:center ทับ items-start ของ
แถวลิงก์ในการ์ดกลุ่ม กลายเป็นไอคอนลอยกลางแถว 2–3 บรรทัด (code review 2026-09-23)
+ justify-content:center คืนค่า default ของปุ่ม (text-align:center ของ UA
หมดผลเมื่อเป็น flex) ไม่งั้นปุ่ม w-full เช่น "เปิดเอกสาร"/อัปโหลด ข้อความชิดซ้าย
(คำขอผู้ใช้ 2026-09-23)
responsive audit 2026-09-24: @layer ต้องครอบ @media จากด้านนอกเท่านั้น — ซ้อนกลับกัน
(@media { @layer {…} }) แล้ว Tailwind v3 ยกเนื้อหา @layer ออกไปวางที่ components
section โดยทิ้ง media wrapper กฎจึงหลุดไป apply บน desktop ด้วย (ยืนยันด้วยการ
build จริง: ปุ่มหน้ารายการได้ min-height 44px + flex บนจอ desktop) */
@layer components {
@media (pointer: coarse) {
main.listing-page a,
main.listing-page button {
min-height: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
main.listing-page select,
main.listing-page summary {
min-height: 2.75rem;
}
header.appnav a,
header.appnav button {
min-height: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
}
}
/* ─── Print (หน้าสรุป) — design review 2026-09-17 L4 ───────────────────────
หน้าสรุปมีโอกาสถูกสั่งพิมพ์แนบสำนวนสูง — browser ตัด background ตอนพิมพ์
โดย default ทำให้แถบหัวน้ำเงินเหลือ text ขาวบนกระดาษขาว อ่านไม่ออกทั้งแถบ:
แปลงเป็นกรอบเส้นน้ำเงิน + ตัวอักษรน้ำเงินแทน (อ่านได้บนเครื่องพิมพ์ขาวดำ
ประหยัดหมึก) และไม่ผ่าหน้ากระดาษกลาง section ของหน้าเอกสาร (หน้าที่ยาวเกิน
กระดาษเดียว browser เลื่อนทั้งก้อนขึ้นหน้าถัดไปเอง — break-inside เป็นคำ
แนะนำ ไม่ใช่กฎเหล็ก) / แถบ action กับแผงตัวกรอง (ทั้ง <details> มือถือและ
aside desktop) ซ่อนด้วย print:hidden ใน JSX — รายการบนกระดาษเป็นฉบับเต็ม
เสมอไม่สนตัวกรองที่ตั้งค้างไว้ (redesign explorer 2026-09-24: SummaryList
render ชุดเต็มแยกไว้เป็น hidden print:block) */
@media print {
main.summary-page header {
background: #fff;
border: 1.5pt solid #0033a1;
}
main.summary-page header h1,
main.summary-page header p {
color: #0033a1;
}
main.summary-page section {
break-inside: avoid-page;
}
/* แถวรายการของหน้าเดียวกันไม่ถูกผ่ากลางระหว่างหน้ากระดาษ */
main.summary-page section > ul > li {
break-inside: avoid-page;
}
/* หัวการ์ดรายหน้าเป็น sticky บนจอ — กลับเป็นตำแหน่งปกติบนกระดาษ */
main.summary-page .page-head {
position: static;
}
}
/* UX review 2026-09-14 H2 → typography audit 2026-09-16 (T1 + Option B) →
Genesis 2026-09-23: DM Sans (next/font ตัวแปร Latin) นำหน้า stack ให้ตัว
Latin/ตัวเลขได้บุคลิก body ของ design system ก่อน จากนั้น webfont Sarabun
(next/font — มี adjusted fallback ของ next/font ในตัว) รับ glyph ไทยโดยเฉพาะ
แสดงผลเหมือนกันทุกอุปกรณ์; ที่เหลือเป็น fallback ตอนฟอนต์ยังไม่ถึง
(display:swap)/โหลดไม่ได้ — ไทยได้ Leelawadee UI (Windows) / Thonburi (iPad)
/ Noto Sans Thai (Android/Linux) / heading ใช้คลาส font-display ที่นำ
General Sans มาก่อน Sarabun ด้วยหลักการเดียวกัน */
body { body {
font-family: var(--font-dm-sans, "DM Sans"), var(--font-sarabun, "Sarabun"), font-family: var(--font-sarabun, "Sarabun"), -apple-system, "Segoe UI",
-apple-system, "Segoe UI", "Helvetica Neue", Arial, "Leelawadee UI", "Helvetica Neue", Arial, "Leelawadee UI", "Thonburi", "Noto Sans Thai",
"Thonburi", "Noto Sans Thai", sans-serif; sans-serif;
} }
/* M6: touch hygiene — ตัดแถบเทากระพริบของ iOS ทุกครั้งที่แตะปุ่ม และกัน /* M6: touch hygiene — ตัดแถบเทากระพริบของ iOS ทุกครั้งที่แตะปุ่ม และกัน
@ -357,16 +219,6 @@ html {
overscroll-behavior-y: contain; overscroll-behavior-y: contain;
} }
/* 2026-09-22: <main> ของ viewer เป็น scroll container เดียวของหน้า (แนวตั้ง +
แนวนอน — ซูมจนหน้ากว้างเกินจอแล้วปัดเลื่อนดูส่วนที่เกินได้) — จำกัด gesture
สัมผัสให้เป็นการแพนของกรอบนี้เท่านั้น ไม่ปล่อยให้ browser pinch-zoom/double-tap
zoom ทั้งหน้าเว็บ (อาการซูมแล้ว header/sidebar โต้ตามไปด้วย); ปุ่มซูม ± เป็น
ทางเลือก single-pointer ตาม WCAG 2.5.1 — ต้องอยู่ก่อน .pdf-custom-select
เพื่อให้โหมดมาร์กข้อความ (touch-action:none) ยังชนะตอนเปิดใช้ */
.pdf-scroll-main {
touch-action: pan-x pan-y;
}
/* custom selection ของ tool highlight บนจอสัมผัส (hooks/useCustomSelection) — /* custom selection ของ tool highlight บนจอสัมผัส (hooks/useCustomSelection) —
ปิด native selection/callout ของ OS และยึด gesture ลากเป็นของเราทั้งหมด ปิด native selection/callout ของ OS และยึด gesture ลากเป็นของเราทั้งหมด
(touch-action:none กัน browser ยึดไปเป็น scroll → pointercancel) (touch-action:none กัน browser ยึดไปเป็น scroll → pointercancel)
@ -394,13 +246,12 @@ html {
} }
/* เส้นคั่นบางระหว่างแถวตั้งค่า (PanelRow) ที่ต่อกันในเมนู tool/แผง selection — /* เส้นคั่นบางระหว่างแถวตั้งค่า (PanelRow) ที่ต่อกันในเมนู tool/แผง selection —
ระยะ/เส้นเท่ากับแทรก <div class="my-1.5 border-t border-line"> ไว้ระหว่างแถว ระยะ/เส้นเท่ากับแทรก <div class="my-1.5 border-t border-slate-100"> ไว้ระหว่างแถว
ทุกจุด (แถวแรกของกลุ่มไม่มีเส้น — ไม่ซ้ำกับ divider ที่เมนูใส่ไว้เองแล้ว) ทุกจุด (แถวแรกของกลุ่มไม่มีเส้น — ไม่ซ้ำกับ divider ที่เมนูใส่ไว้เองแล้ว) */
(#E8E8EC = token line — เดิม #f1f5f9 เป็น slate-100 หลงเหลือก่อน Genesis) */
.panel-row + .panel-row { .panel-row + .panel-row {
margin-top: 0.375rem; margin-top: 0.375rem;
padding-top: 0.625rem; padding-top: 0.625rem;
border-top: 1px solid #e8e8ec; border-top: 1px solid #f1f5f9;
} }
/* M6: safe-area — defensive (ค่าเป็น 0 จนกว่าจะใส่ viewport-fit=cover ใน meta /* M6: safe-area — defensive (ค่าเป็น 0 จนกว่าจะใส่ viewport-fit=cover ใน meta
@ -414,9 +265,7 @@ header {
/* UX audit 2026-09-16 L7: เคารพ prefers-reduced-motion — ตัด transition ของ /* UX audit 2026-09-16 L7: เคารพ prefers-reduced-motion — ตัด transition ของ
sidebar, animation กระพริบของปุ่ม export และ smooth scroll ทุกจุด sidebar, animation กระพริบของปุ่ม export และ smooth scroll ทุกจุด
(scrollToBelowHeader ฝั่ง JS เช็ค media query เดียวกันตอนเลือก behavior) (scrollToBelowHeader ฝั่ง JS เช็ค media query เดียวกันตอนเลือก behavior) */
UX review m7 2026-09-23: เพิ่ม blanket rule ครอบ transition/animation ที่เหลือ
(hover-lift ของการ์ด, scale ของสวอตช์สี ฯลฯ) — ยก motion ทั้งหน้าให้จบทันที */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
aside[data-sidebar] { aside[data-sidebar] {
transition: none; transition: none;
@ -424,12 +273,7 @@ header {
.animate-pulse { .animate-pulse {
animation: none; animation: none;
} }
*, * {
::before,
::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important; scroll-behavior: auto !important;
} }
} }

View file

@ -1,39 +1,17 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { DM_Sans, Sarabun } from "next/font/google"; import { Sarabun } from "next/font/google";
import localFont from "next/font/local";
import "./globals.css"; import "./globals.css";
/* typography audit 2026-09-16 (Option B): Sarabun — ฟอนต์ตระกูลมาตรฐานเอกสาร /** typography audit 2026-09-16 (Option B): Sarabun — ฟอนต์ตระกูลมาตรฐานเอกสาร
* ราชการไทย (TH Sarabun) เจ้าหน้าที่คุ้นเคยที่สุด / next/font self-host ตอน build: * ราชการไทย (TH Sarabun) เจ้าหน้าที่คุ้นเคยที่สุด / next/font self-host ตอน build:
* ไฟล์ฟอนต์ serve จาก origin เดียวกับแอป ไม่พึ่ง Google ตอนรัน จึงใช้ใน intranet * ไฟล์ฟอนต์ serve จาก origin เดียวกับแอป ไม่พึ่ง Google ตอนรัน จึงใช้ใน intranet
* ได้ (แต่เครื่อง build ต้องเห็นอินเทอร์เน็ต) * ได้ (แต่เครื่อง build ต้องเห็นอินเทอร์เน็ต) — โหลดเฉพาะ 2 weights ตามแผน
* * ~100-160KB แลกกับการแสดงผลเหมือนกันทุกอุปกรณ์ รวมกล่อง TEXTBOX ที่ผูก
* Genesis (docs/DESIGN.md 2026-09-23): เพิ่ม General Sans (display/heading) จาก * stack นี้ (T1-C: wrap ข้อความตรงกันข้ามอุปกรณ์จริง) */
* Fontshare เป็น variable woff2 ไฟล์เดียว self-host ผ่าน next/font/local + DM Sans
* (body) จาก Google — ทั้งคู่เป็น Latin เท่านั้น ไม่มี glyph ไทย จึงวาง Sarabun
* ต่อท้ายใน stack (globals.css / tailwind fontFamily): ตัว Latin/ตัวเลขได้บุคลิก
* Genesis ส่วนข้อความไทยคง Sarabun เดิม — Sarabun เพิ่ม weight 700 ให้ heading
* ไทย (fallback ของ General Sans bold) */
const generalSans = localFont({
src: "./fonts/GeneralSans-Variable.woff2",
// ประกาศช่วง wght จริงของ variable font (Fontshare: 200–700) — ไม่ใส่แล้ว
// @font-face ที่ next/font generate ไม่มี descriptor font-weight → cover แค่ 400
// และ font-bold/700 กลายเป็น synthetic bold จาก instance 400 (UX review C1 2026-09-23)
weight: "200 700",
display: "swap",
variable: "--font-general-sans",
});
const dmSans = DM_Sans({
subsets: ["latin"],
display: "swap",
variable: "--font-dm-sans",
});
const sarabun = Sarabun({ const sarabun = Sarabun({
subsets: ["thai", "latin"], subsets: ["thai", "latin"],
weight: ["400", "600", "700"], weight: ["400", "600"],
display: "swap", display: "swap",
variable: "--font-sarabun", variable: "--font-sarabun",
}); });
@ -49,11 +27,8 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<html <html lang="th" className={sarabun.variable}>
lang="th" <body className="bg-slate-100 text-slate-900 antialiased">{children}</body>
className={`${generalSans.variable} ${dmSans.variable} ${sarabun.variable}`}
>
<body className="bg-canvas font-body text-ink antialiased">{children}</body>
</html> </html>
); );
} }

21
app/login/page.tsx Normal file
View file

@ -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 (
<main className="flex min-h-dvh items-center justify-center px-4 py-10">
<LoginForm />
</main>
);
}

View file

@ -1,25 +0,0 @@
import { notFound } from "next/navigation";
import AnnotateView from "@/components/pdf/AnnotateView";
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const dynamic = "force-dynamic";
/**
* หน้าอ่านเอกสาร (ฉบับ token 2026-10-30) — shell บาง ๆ ฝั่ง server (ตรวจ UUID
* แล้วส่งต่อให้ client gate): AnnotateView ตรวจ access_token ใน localStorage →
* ดึงข้อมูลเริ่มต้นผ่าน GET /api/note/files/[id]/bootstrap พร้อม Bearer →
* PDFViewer · ไม่มี session = หน้า gate "กลับ e-Court กดลิงก์ใหม่" (แทน redirect
* sso=required ของรอบ middleware ที่ถอดแล้ว)
*/
export default async function AnnotatePage({
params,
}: {
params: { id: string };
}) {
const { id } = params;
if (!UUID_RE.test(id)) notFound();
return <AnnotateView fileId={id} />;
}

View file

@ -1,168 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { asc, eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { ArrowLeft, ClipboardList } from "lucide-react";
import type { ExplorerItem } from "@/components/explorer/FacetFilterPanel";
import { SummaryExplorer } from "@/components/summary/SummaryExplorer";
import { db } from "@/db";
import {
annotations,
bookmarks,
files,
toAnnotationDTO,
toBookmarkDTO,
} from "@/db/schema";
// หน้าสรุปหมายเหตุ (F15/D2) — server component รวม annotation (ทุกผู้ใช้) + bookmark
// แต่ละแถวกระโดดเป็น deep link `/note/annotate/<id>?ann=` / `?page=` (F12)
// — design "แฟ้มสำนวน" 2026-09-17 (แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio +
// รายการตามหน้าจุดไข่ปลา) → redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer
// 2 คอลัมน์แบบหน้ากลุ่ม N9: ตัวกรองฝั่งซ้าย (หน้า/ชนิดรายการ/ชนิดของ
// Annotation/ผู้บันทึก) + การ์ดรายหน้าหัว sticky — แถบหัวน้ำเงิน + action bar +
// empty state คงเดิม; query ทั้งหมดทำที่นี่แล้วส่ง `ExplorerItem[]` ให้
// SummaryExplorer กรองใน memory ฝั่ง client (read-only snapshot ไม่มี SSE)
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** focus ring ร่วมของทุกลิงก์ในหน้า — Genesis .focus-ring (outline น้ำเงินทึบ 2px
* + offset ตาม spec Elevation); จอสัมผัสได้ 44px อัตโนมัติจากกฎ
* `main.summary-page a`/`button` ใน globals.css (ห้ามถอด class summary-page ของ <main>) */
const RING_CLS = "focus-ring";
export const dynamic = "force-dynamic";
// design review 2026-09-17 M5: ชื่อแท็บ browser ระบุคดี — เลขานุการเปิดหลายคดี
// หลายแท็บพร้อมกัน (เดิมทุกแท็บชื่อ "Judge's Note" เหมือนกันหมดแยกไม่ออก)
export async function generateMetadata({
params,
}: {
params: { id: string };
}): Promise<Metadata> {
const { id } = params;
if (!UUID_RE.test(id)) return { title: "สรุปหมายเหตุ | Judge's Note" };
const [file] = await db
.select({ title: files.title })
.from(files)
.where(eq(files.id, id));
return {
title: `สรุปหมายเหตุ — ${file?.title ?? "ไม่พบไฟล์"} | Judge's Note`,
};
}
export default async function SummaryPage({
params,
}: {
params: { id: string };
}) {
const { id } = params;
if (!UUID_RE.test(id)) notFound();
const [file] = await db.select().from(files).where(eq(files.id, id));
if (!file) notFound();
const annRows = await db
.select()
.from(annotations)
.where(eq(annotations.fileId, id))
.orderBy(asc(annotations.pageIndex), asc(annotations.createdAt));
const dtos = annRows.map(toAnnotationDTO);
// bookmark เรียงตามหน้า → ตำแหน่งบนหน้า → เวลาสร้าง (N5 เหมือน sidebar)
const bmRows = await db
.select()
.from(bookmarks)
.where(eq(bookmarks.fileId, id))
.orderBy(
asc(bookmarks.pageIndex),
asc(bookmarks.yOffset),
asc(bookmarks.createdAt),
);
const bmDtos = bmRows.map(toBookmarkDTO);
const bookmarkById = new Map(bmDtos.map((b) => [b.id, b] as const));
// list เดียวรวมทั้งไฟล์ไล่ตามลำดับอ่าน: หน้า → หน้าเดียวกันให้ที่คั่นหน้าก่อน
// หมายเหตุ → ในหน้าเดียวกัน bookmark เรียงตามตำแหน่งบนหน้า (N5 เหมือน query/
// sidebar — code review 2026-09-24 คืนจาก createdAt ที่เคยแทนที่โดยไม่ตั้งใจ)
// ส่วน annotation ตาม createdAt (sort ครั้งเดียวบน server — ฝั่ง client กรอง
// เป็น pure predicate ไม่ sort ซ้ำ)
const items: ExplorerItem[] = [
...bmDtos.map((dto) => ({
kind: "bm" as const,
pageIndex: dto.pageIndex,
dto,
parentTitle: dto.parentId
? bookmarkById.get(dto.parentId)?.title ?? null
: null,
})),
...dtos.map((dto) => ({ kind: "ann" as const, pageIndex: dto.pageIndex, dto })),
].sort(
(a, x) =>
a.pageIndex - x.pageIndex ||
(a.kind === x.kind ? 0 : a.kind === "bm" ? -1 : 1) ||
(a.kind === "bm" && x.kind === "bm"
? a.dto.yOffset - x.dto.yOffset
: new Date(a.dto.createdAt).getTime() -
new Date(x.dto.createdAt).getTime()),
);
// จำนวนหน้าที่มีรายการ ≥1 สำหรับสถิติแถบหัว
const pageCount = new Set(items.map((it) => it.pageIndex)).size;
return (
<main className="summary-page mx-auto max-w-3xl px-4 py-6 lg:max-w-6xl lg:px-8">
{/* หัวกระดาษสำนวน — แถบน้ำเงินสืบต่อจาก tile JN ของแบรนด์ (หน้ารายการ/login):
ชื่อรายงาน + ชื่อแฟ้ม + สถิติในที่เดียว แล้วปิดท้ายด้วยเส้นคั่นบาง
(ตอนพิมพ์เป็นกรอบเส้นน้ำเงินตามกฎ @media print ของ summary-page) */}
<header className="rounded-xl bg-primary px-5 py-4 text-white sm:px-6">
<h1 className="text-lg font-semibold leading-snug">สรุปหมายเหตุ</h1>
<p className="mt-0.5 break-words text-sm text-white/85">{file.title}</p>
{items.length > 0 && (
<p className="mt-2 text-xs tabular-nums text-white/70">
Annotation {dtos.length} รายการ · Bookmark {bmDtos.length} รายการ ·{" "}
{pageCount} หน้า
</p>
)}
</header>
{/* แถบ action — ghost button ขาวมีขอบ ภาษาเดียวกับการ์ดของหน้า (คำขอผู้ใช้
2026-09-17 แทนลิงก์ข้อความเปล่า — เห็นเป็นปุ่มชัดขึ้น; จอสัมผัสได้ 44px
จากกฎ coarse; ตอนพิมพ์ซ่อนทิ้ง — ปุ่มกลับไม่มีความหมายบนกระดาษ)
ปุ่มส่งออก CSV (F16) / Bookmark JSON (N7) ถูกถอด 2026-09-25 ตามคำขอผู้ใช้
— ระบบภายนอกดึงข้อมูลผ่าน N10 API แทนทั้งหมดแล้ว */}
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 print:hidden">
<Link
href={`/note/annotate/${id}`}
className={`inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink hover:border-ink-faint hover:bg-ink/[0.03] ${RING_CLS}`}
>
<ArrowLeft aria-hidden className="h-3.5 w-3.5" />
กลับหน้าอ่าน
</Link>
</div>
{items.length === 0 ? (
/* ไฟล์ว่าง = ไม่ mount explorer เลย — แผงกรองของข้อมูลว่างไม่มีความหมาย
(minimize choices) ทางเข้าเอกสารคือปุ่ม "เปิดหน้าอ่าน" */
<div className="mt-5 rounded-xl border border-dashed border-line bg-white/70 p-10 text-center">
<ClipboardList aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
<p className="mt-3 text-sm font-medium text-ink-soft">
ยังไม่มี Annotation หรือ Bookmark ในไฟล์นี้
</p>
<p className="mt-1 text-xs text-ink-soft">
เปิดหน้าอ่านเพื่อมาร์กข้อความ วาดเส้น ใส่คอมเมนต์ หรือคั่นหน้า
</p>
<Link
href={`/note/annotate/${id}`}
className={`mt-4 inline-flex rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow ${RING_CLS}`}
>
เปิดหน้าอ่าน
</Link>
</div>
) : (
<SummaryExplorer fileId={id} items={items} />
)}
</main>
);
}

View file

@ -1,319 +0,0 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import AppNav from "@/components/AppNav";
import DevLoginLinkForm from "@/components/DevLoginLinkForm";
import MockEcourtForm from "@/components/MockEcourtForm";
import { devMintEnabled } from "@/lib/note-auth";
export const dynamic = "force-dynamic";
/**
* /note/dev — เครื่องมือทดสอบ (ย้ายมาจากหน้าแรก 2026-10-30 ตอน /note เปลี่ยนเป็น
* landing เรียบ): เปิดเอกสารตัวอย่าง (D14) + คู่มือทางเข้า SSO 2 แบบ + ตัวช่วย mint
* ลิงก์ทดสอบ · **เปิดด้วย env NOTE_SSO_DEV_MINT=1 อย่างเดียว — 0/ไม่ตั้ง = 404
* ทั้งหน้า** (คำขอผู้ใช้ 2026-11-02, ใช้ได้ทุก environment รวม production ที่ตั้ง env
* ไว้) · ตัวอย่างลิงก์เป็น "รูปแบบ + ขั้นตอน" เท่านั้น ไม่มี secret ในหน้า
* · ไม่ query DB · แบนเนอร์ ?sso= และ bootstrap ของ N8 ?fileUrl= อยู่ที่หน้าแรก /note
*/
/** รายการเอกสาร fixed ในโค้ด (D14) — จาก PDF ตัวอย่างที่มีอยู่จริงของแอป
* (public/note/samples/ — ยังไม่ seed กดเปิดแล้ว N8 จะตอบ error เองที่หน้าแรก) */
const SAMPLE_DOCS: Record<string, string[]> = {
"คำสั่งศาล — คดี 01012-672427": [
"01012-672427-1f-690609-0000840223.pdf",
"01012-672427-4f-690609-0000840227.pdf",
],
"คำสั่งศาล — ตัวอย่าง": [
"court_command_11_123 1.pdf",
"court_command_1_2547 1.pdf",
],
};
/** code inline สไตล์เดียวกันทั้งหน้า */
function Code({ children }: { children: React.ReactNode }) {
return (
<code className="rounded bg-ink/[0.05] px-1 py-0.5 text-[13px] text-ink">
{children}
</code>
);
}
/** บล็อกคำสั่ง/URL ตัวอย่าง — อ่านง่ายบนจอแคบ (scroll แนวนอนได้) */
function CodeBlock({ children }: { children: string }) {
return (
<pre className="mt-2 overflow-x-auto rounded-lg border border-line bg-canvas px-3 py-2.5 text-[12.5px] leading-relaxed text-ink">
{children}
</pre>
);
}
export default async function DevToolsPage({
searchParams,
}: {
searchParams: Record<string, string | string[] | undefined>;
}) {
// ทางเข้าหน้านี้คุมด้วย env ตัวเดียวกับ dev-link (2026-11-02) — ปิด = 404
// เหมือนไม่มีหน้านี้ ไม่เหลือแค่ข้อความแนะนำ
if (!devMintEnabled()) notFound();
// โหมด credential ของลิงก์ตัวอย่าง — jwt (default) | ticket สลับผ่าน ?sampleMode=
const sampleMode = searchParams.sampleMode === "ticket" ? "ticket" : "jwt";
const mintOn = devMintEnabled();
return (
<>
<AppNav />
<main className="mx-auto max-w-4xl px-4 pb-16 pt-10 sm:px-6">
<h1 className="font-display text-3xl tracking-tight text-ink sm:text-headline">
เครื่องมือทดสอบ
</h1>
<p className="mt-2 text-body text-ink-soft">
หน้านี้สำหรับผู้พัฒนาและผู้ทดสอบ — ลองเปิดเอกสารตัวอย่าง
และดูวิธีเข้าระบบผ่านลิงก์ของ e-Court
</p>
{/* ── เปิดเอกสารตัวอย่าง (D14) — A Link Get(new) แบบ /tmpl ─────────── */}
<section className="mt-10">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">เปิดเอกสารตัวอย่าง</h2>
</div>
<p className="mt-3 text-sm text-ink-soft">
กดชื่อไฟล์แล้วระบบจะสร้างลิงก์แบบเดียวกับที่ e-Court ส่งมาให้
(แบบ{sampleMode === "jwt" ? " JWT" : " ticket"}) แล้วพาเข้าไปอ่านเอกสารนั้น
ทันที — ดูแถบที่อยู่ของ browser จะเห็นการทำงานทีละขั้นจริง ๆ
ตั้งแต่เข้าระบบจนถึงหน้าอ่านเอกสาร
</p>
{mintOn ? (
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
<span className="text-ink-soft">รูปแบบลิงก์:</span>
{(["jwt", "ticket"] as const).map((m) => (
<Link
key={m}
href={m === "jwt" ? "/note/dev" : "/note/dev?sampleMode=ticket"}
aria-current={sampleMode === m ? "page" : undefined}
className={`focus-ring rounded-md border px-2.5 py-1 font-medium transition ${
sampleMode === m
? "border-primary bg-primary/[0.08] text-primary"
: "border-line bg-white text-ink-soft hover:border-ink-faint"
}`}
>
{m === "jwt" ? "JWT (ทาง A)" : "ticket (ทาง B)"}
</Link>
))}
</div>
) : (
<p className="mt-3 rounded-lg border border-line bg-canvas p-3 text-xs leading-relaxed text-ink-soft">
ลิงก์ด้านล่างเปิดเอกสารด้วย session ที่มีอยู่เท่านั้น — ยังไม่มี session
ใช้ฟอร์ม &quot;จำลอง e-Court&quot; ด้านล่างแทน · dev server ที่ต้องการ mint
ให้อัตโนมัติ (ไม่ต้องใส่ key) เพิ่ม <Code>NOTE_SSO_DEV_MINT=1</Code>{" "}
แล้วรีสตาร์ท
</p>
)}
<div className="mt-5 max-w-xl rounded-xl border border-line bg-white p-6">
<h3 className="font-display text-subhead text-primary">A Link Get(new)</h3>
{Object.entries(SAMPLE_DOCS).map(([group, files]) => (
<div key={group} className="mt-3">
<p className="text-xs font-medium tracking-wide text-ink-faint">{group}</p>
<ul className="mt-1 space-y-1">
{files.map((file) => (
<li key={file}>
<Link
href={
mintOn
? `/api/note/auth/dev-link?file=${encodeURIComponent(file)}&mode=${sampleMode}`
: `/api/note/files/open?fileUrl=${encodeURIComponent(`/samples/${file}`)}`
}
className="focus-ring rounded text-primary transition hover:underline"
>
{file}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
{/* จำลอง e-Court ด้วย API key — แสดงทุก environment (คำขอผู้ใช้ 2026-11-02:
ใช้ทางเข้าจริงเหมือน e-Court ต้องถือ DOCVER_API_KEY จึงไม่เป็นประตูหลัง) */}
<div className="mt-5 max-w-xl rounded-xl border border-line bg-white p-6">
<h3 className="font-display text-subhead text-primary">
จำลอง e-Court — ขอ token ด้วย API key
</h3>
<p className="mt-1.5 text-sm text-ink-soft">
ใส่ API key (<Code>DOCVER_API_KEY</Code>) เพื่อขอ token ผ่าน{" "}
<Code>POST /api/note/auth/token</Code> แบบเดียวกับที่ e-Court ทำจริง
แล้วระบบจะพาเข้าอ่านเอกสารที่เลือกเลย — ได้ session 8 ชม.
(ใช้ได้ทั้ง dev และ production)
</p>
<div className="mt-4">
<MockEcourtForm files={Object.values(SAMPLE_DOCS).flat()} />
</div>
</div>
</section>
{/* ── Flow ─────────────────────────────────────────────────────────── */}
<section className="mt-10">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">Flow</h2>
</div>
<div className="mt-5 max-w-xl rounded-xl border border-line bg-white p-6 text-center text-sm font-medium leading-relaxed text-ink">
e-Court สร้างลิงก์เข้าใช้ (มีตัวยืนยันตัวตน + ที่อยู่ไฟล์)
<br />↓<br />
ผู้ใช้กดลิงก์ → ระบบตรวจแล้วออก session ให้ (ใช้ได้ 8 ชม.)
<br />↓<br />
เข้าหน้าอ่านเอกสาร — มาร์กข้อความ วาดเส้น คอมเมนต์ คั่นหน้า
<br />↓<br />
บันทึกทันทีทุกครั้งที่แก้ คนอื่นที่เปิดไฟล์เดียวกันเห็นสด ๆ
<br />↓<br />
ดูรวมทั้งหมดได้ที่หน้า &quot;สรุปหมายเหตุ&quot; และ &quot;เอกสารในกลุ่ม&quot;
<br />↓<br />
ระบบอื่น (เช่น e-Court) ดึงข้อมูลไปใช้ต่อได้ผ่าน API
</div>
</section>
{/* ── ทางเข้าระบบผ่านลิงก์ e-Court (คู่มือ 2 ทาง ตามแผน SSO) ────────── */}
<section className="mt-12">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">
ทางเข้าระบบผ่านลิงก์ e-Court (SSO)
</h2>
</div>
<p className="mt-3 text-sm text-ink-soft">
แอปนี้ไม่มีหน้าล็อกอิน — ผู้ใช้เข้าได้ทางลิงก์ที่ e-Court สร้างให้อย่างเดียว
ลิงก์มี 2 แบบ (ใช้แบบใดแบบหนึ่ง — ห้ามใส่ครบทั้งสองแบบในลิงก์เดียว)
</p>
<div className="mt-5 grid gap-5 lg:grid-cols-2">
{/* ทาง A — JWT */}
<article className="flex flex-col rounded-xl border border-line bg-white p-5">
<div className="flex items-center gap-2">
<h3 className="font-display text-subhead text-ink">ทาง A — ขอ token (JWT)</h3>
<span className="rounded bg-primary/[0.08] px-1.5 py-0.5 text-[11px] font-medium text-primary">
แนะนำ
</span>
</div>
<p className="mt-1.5 text-sm text-ink-soft">
เหมาะเมื่อสร้างลิงก์ให้ผู้ใช้ได้ตอนนั้นเลย (ตอนเปิดหน้า) — คีย์ที่ใช้เซ็น
อยู่ฝั่ง server เท่านั้น ปลอมตัวเป็นผู้อื่นไม่ได้
</p>
<ol className="mt-4 list-decimal space-y-1.5 pl-5 text-sm text-ink">
<li>
ฝั่ง e-Court เรียก <Code>POST /api/note/auth/token</Code> พร้อมรหัส{" "}
<Code>x-api-key</Code> (ค่าจาก env <Code>DOCVER_API_KEY</Code>) และข้อมูล:
</li>
</ol>
<CodeBlock>{`POST {BASE}/api/note/auth/token
x-api-key: <DOCVER_API_KEY>
{"userid":"<u_userid>","username":"<u_userlogin>",
"fid":"<fileId>","ttl_seconds":300}`}</CodeBlock>
<ol start={2} className="mt-3 list-decimal space-y-1.5 pl-5 text-sm text-ink">
<li>
ได้ <Code>access_token</Code> กลับมา (อายุ 1–10 นาที) — นำไปใส่ในลิงก์
เป็นพารามิเตอร์ <Code>t</Code> ให้ผู้ใช้กด:
</li>
</ol>
<CodeBlock>{`{BASE}/note
?fileUrl=<urlencode>&typeId=<กลุ่ม ทางเลือก>&t=<access_token>`}</CodeBlock>
<ol start={3} className="mt-3 list-decimal space-y-1.5 pl-5 text-sm text-ink">
<li>
ผู้ใช้กดลิงก์ → ระบบตรวจแล้วออก session ให้ (เก็บในเครื่องของผู้ใช้
อายุ 8 ชม.) → พาเข้าหน้าอ่านเอกสาร และตัดค่า token ออกจากลิงก์ทันที
</li>
</ol>
<p className="mt-4 rounded-lg border border-line bg-canvas p-3 text-xs leading-relaxed text-ink-soft">
ข้อควรระวัง: สร้าง token ตอนแสดงหน้าให้ผู้ใช้คนนั้น ๆ เท่านั้น และ{" "}
<strong className="font-medium text-ink-soft">
ห้ามเก็บ token ไว้ในที่อื่น
</strong>{" "}
(อายุสั้นมาก) · token นี้ใช้เข้าแอปนี้อย่างเดียว เอาไปเรียก API
กลุ่ม documents ไม่ได้
</p>
</article>
{/* ทาง B — ticket */}
<article className="flex flex-col rounded-xl border border-line bg-white p-5">
<div className="flex items-center gap-2">
<h3 className="font-display text-subhead text-ink">ทาง B — ticket</h3>
<span className="rounded bg-ink/[0.05] px-1.5 py-0.5 text-[11px] font-medium text-ink-soft">
สร้างล่วงหน้า
</span>
</div>
<p className="mt-1.5 text-sm text-ink-soft">
เหมาะเมื่อสร้างลิงก์เตรียมไว้ล่วงหน้าได้ ไม่ต้องเรียก API ตอนแสดงหน้า —
ใช้คู่ <Code>ticket_data</Code> + <Code>HmacSha256</Code> ชุดเดียวกับที่ฝัง
ไว้ในไฟล์ .docx ของ add-in
</p>
<ol className="mt-4 list-decimal space-y-1.5 pl-5 text-sm text-ink">
<li>
e-Court เตรียม <Code>ticket_data</Code> (JSON มี{" "}
<Code>exp/fid/uid/uname</Code>) แล้วเซ็น <Code>HmacSha256</Code> ด้วยคีย์{" "}
<Code>ADDIN_TICKET_SECRET</Code>
</li>
<li>นำมาใส่ในลิงก์ให้ผู้ใช้กด:</li>
</ol>
<CodeBlock>{`{BASE}/note
?fileUrl=<urlencode>&typeId=<ทางเลือก>
&ticket_data=<JSON urlencode>&HmacSha256=<hex 64 ตัว>`}</CodeBlock>
<ol start={3} className="mt-3 list-decimal space-y-1.5 pl-5 text-sm text-ink">
<li>
ผู้ใช้กดลิงก์ → ระบบตรวจลายเซ็นและวันหมดอายุ → ออก session 8 ชม. →
พาเข้าหน้าอ่านเอกสาร
</li>
</ol>
<p className="mt-4 rounded-lg border border-line bg-canvas p-3 text-xs leading-relaxed text-ink-soft">
ข้อควรระวัง: ตั้ง <Code>exp</Code> สั้น ๆ (ไม่เกิน ~10 นาที เพราะค่าเหล่านี้
อยู่ในลิงก์) · ค่าผู้ใช้ (<Code>userid/username</Code> ทาง A,{" "}
<Code>uid/uname</Code> ทาง B) ต้องเป็นคนเดียวกับ{" "}
<Code>u_userid/u_userlogin</Code> ของ e-Court — ใส่ผิดคน = ข้อมูล
ผูกกับคนผิด
</p>
</article>
</div>
{/* dev tool — ฟอร์ม mint ลิงก์ (เปิดด้วย env NOTE_SSO_DEV_MINT=1 เท่านั้น:
ตัวช่วยทดสอบ ไม่ใช่ทางเข้าจริง และตายเสมอใน production build) */}
{devMintEnabled() && (
<div className="mt-5 rounded-xl border border-dashed border-line bg-white/60 p-5">
<h4 className="font-display text-sm font-semibold text-ink">
สร้างลิงก์ทดสอบเอง (dev)
</h4>
<p className="mt-1.5 text-sm text-ink-soft">
กรอกชื่อผู้ใช้แล้วกดสร้าง — ระบบจะสร้างลิงก์ให้จากคีย์ของตัวเอง
(ค่าลับไม่โผล่ที่หน้าเว็บ) ได้ลิงก์มาแล้วกด &quot;เปิดลิงก์&quot;
เพื่อทดสอบเข้าจริงทั้งสองแบบ
</p>
<div className="mt-4">
<DevLoginLinkForm />
</div>
</div>
)}
{/* dev tool — สคริปต์มือ (ใช้ได้เสมอ ไม่ขึ้นกับ env) */}
<div className="mt-5 rounded-xl border border-dashed border-line bg-white/60 p-5">
<h4 className="font-display text-sm font-semibold text-ink">
ทดสอบบนเครื่อง (dev)
</h4>
<p className="mt-1.5 text-sm text-ink-soft">
สร้างลิงก์เองบนเครื่องโดยไม่ต้องรอ e-Court — สคริปต์อ่านค่าจากไฟล์{" "}
<Code>.env</Code> แล้วพิมพ์ลิงก์เต็มให้ก๊อปไปเปิดใน browser:
</p>
<CodeBlock>{`# ทาง A (default)
node scripts/make-login-link.mjs --uid oom --uname "สรวิชญ์" --fid F123 \\
--fileUrl /samples/01012-672427-1f-690609-0000840223.pdf \\
--base http://localhost:3000/docedit
# ทาง B
node scripts/make-login-link.mjs --mode ticket --uid oom --uname "สรวิชญ์" --fid F123 \\
--fileUrl /samples/01012-672427-1f-690609-0000840223.pdf \\
--base http://localhost:3000/docedit`}</CodeBlock>
<p className="mt-3 text-xs text-ink-soft">
ถ้าลิงก์หมดอายุหรือไม่ถูกต้อง ระบบจะพากลับหน้าแรกพร้อมข้อความบอกสาเหตุ —
สร้างลิงก์ใหม่แล้วกดอีกครั้งได้เลย
</p>
</div>
</section>
</main>
</>
);
}

View file

@ -1,161 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { asc, eq, inArray } from "drizzle-orm";
import { ArrowLeft, Folder } from "lucide-react";
import {
GroupExplorer,
type GroupFileSummary,
type GroupListItem,
} from "@/components/groups/GroupExplorer";
import { db } from "@/db";
import {
annotations,
bookmarks,
files,
toAnnotationDTO,
toBookmarkDTO,
type AnnotationDTO,
type BookmarkDTO,
} from "@/db/schema";
import { imageFormatFromUrl } from "@/utils/file-kind";
// N9 (PLAN-DOCSET.md D3) — หน้ารวมเอกสารของกลุ่ม (type_id จากลิงก์ e-Court)
// server component force-dynamic — login ถูกบังคับโดย middleware.ts อยู่แล้ว
// snapshot ณ ตอนโหลดเหมือนหน้าสรุป F15 (ไม่มี SSE) — กลุ่มไม่มีไฟล์เลย = 404
// เพราะไม่มีตารางกลุ่ม: กลุ่มมีชีวิตตามข้อมูลจริงใน files ณ ตอน query
//
// redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar — explorer 2 คอลัมน์ ตัวกรอง
// ฝั่งซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์ (เคยเป็นการ์ด
// ต่อไฟล์ + <details>) — query ทั้งหมดทำที่นี่แล้วส่งข้อมูลพร้อมใช้ให้ GroupExplorer
// กรองใน memory ฝั่ง client
type FileBucket = { anns: AnnotationDTO[]; bms: BookmarkDTO[] };
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
}: {
params: { typeId: string };
}): Promise<Metadata> {
return { title: `กลุ่มเอกสาร ${params.typeId} | Judge's Note` };
}
export default async function GroupPage({
params,
}: {
params: { typeId: string };
}) {
const { typeId } = params;
const rows = await db
.select()
.from(files)
.where(eq(files.typeId, typeId))
.orderBy(asc(files.createdAt));
if (rows.length === 0) notFound();
// bookmark+annotation ทั้งกลุ่ม 2 query แยก (ไม่ join หนัก) แล้วจัดตามไฟล์ใน JS
const ids = rows.map((f) => f.id);
const [annRows, bmRows] = await Promise.all([
db
.select()
.from(annotations)
.where(inArray(annotations.fileId, ids))
.orderBy(asc(annotations.pageIndex), asc(annotations.createdAt)),
db
.select()
.from(bookmarks)
.where(inArray(bookmarks.fileId, ids))
.orderBy(
asc(bookmarks.pageIndex),
asc(bookmarks.yOffset),
asc(bookmarks.createdAt),
),
]);
const annDtos = annRows.map(toAnnotationDTO);
const bmDtos = bmRows.map(toBookmarkDTO);
const byFile = new Map<string, FileBucket>();
const bucketOf = (fileId: string): FileBucket => {
let b = byFile.get(fileId);
if (!b) {
b = { anns: [], bms: [] };
byFile.set(fileId, b);
}
return b;
};
for (const d of annDtos) bucketOf(d.fileId).anns.push(d);
for (const d of bmDtos) bucketOf(d.fileId).bms.push(d);
// หา parent ของ sub bookmark แสดง "ใน: …" — id unique ทั้งตาราง ใช้ map เดียวร่วมทั้งกลุ่ม
const bookmarkById = new Map(bmDtos.map((b) => [b.id, b] as const));
const fileSummaries: GroupFileSummary[] = rows.map((f) => ({
id: f.id,
title: f.title,
isImage: Boolean(imageFormatFromUrl(f.pdfUrl)),
}));
// list เดียวรวมทั้งกลุ่มไล่ตามลำดับอ่านสำนวน: ไฟล์ตาม createdAt (rows เรียงแล้ว)
// → หน้า → หน้าเดียวกันให้ที่คั่นหน้าก่อนหมายเหตุ → createdAt เป็น tiebreak
// (sort ครั้งเดียวบน server — ฝั่ง client กรองเป็น pure predicate ไม่ sort ซ้ำ)
const items: GroupListItem[] = rows.flatMap((f) => {
const bucket = byFile.get(f.id) ?? { anns: [], bms: [] };
return [
...bucket.bms.map((dto) => {
const parent = dto.parentId ? bookmarkById.get(dto.parentId) : undefined;
return {
kind: "bm" as const,
pageIndex: dto.pageIndex,
dto,
parentTitle: parent?.title ?? null,
};
}),
...bucket.anns.map((dto) => ({
kind: "ann" as const,
pageIndex: dto.pageIndex,
dto,
})),
].sort(
(a, x) =>
a.pageIndex - x.pageIndex ||
(a.kind === x.kind ? 0 : a.kind === "bm" ? -1 : 1) ||
new Date(a.dto.createdAt).getTime() - new Date(x.dto.createdAt).getTime(),
);
});
return (
// listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด)
<main className="listing-page mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:max-w-6xl lg:px-8">
<Link
href="/note"
className="focus-ring inline-flex items-center gap-1.5 rounded-md text-sm text-ink-soft transition hover:text-ink"
>
<ArrowLeft aria-hidden className="h-4 w-4" />
หน้ารายการ
</Link>
<header className="mt-4 flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
<Folder aria-hidden className="h-5 w-5 text-ink-soft" />
</div>
<div className="min-w-0">
<h1 className="font-display break-all text-section-title text-ink">
{typeId}
</h1>
<p className="mt-0.5 text-sm text-ink-soft">
{rows.length} เอกสาร · {items.length} รายการ
</p>
</div>
</header>
{/* หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" (แผน N9 D3 เดิม) ถูกถอดออก
ตามคำขอผู้ใช้ 2026-09-24 — ระบบรู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court
รายการครบทั้งคดีอยู่ใน e-Court ไม่ใช่ระบบนี้ */}
<GroupExplorer files={fileSummaries} items={items} />
</main>
);
}

View file

@ -1,13 +0,0 @@
import NoteHome from "@/components/NoteHome";
export const dynamic = "force-dynamic";
/**
* หน้าแรก = ข้อมูลเกี่ยวกับระบบ + bootstrap ของลิงก์ e-Court (2026-10-30 — เนื้อหา
* ทั้งหมดอยู่ใน client component NoteHome: ระบบทำอะไรได้ / วิธีเข้าใช้ และตัว
* แลก token ของลิงก์ e-Court ที่ชี้ตรงมา /note) · public — คนไม่มี session อ่าน
* ข้อมูลระบบได้ตาม D15 เดิม ส่วนเครื่องมือทดสอบอยู่ /note/dev
*/
export default function HomePage() {
return <NoteHome />;
}

View file

@ -1,33 +1,114 @@
import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { asc } from "drizzle-orm";
import { FileImage, FileText } from "lucide-react";
// หน้า root (/docedit) — แทน redirects() เดิมใน next.config.mjs (ถอดออกลด hop import { db } from "@/db";
// ที่ Next สั่งเองตอน deploy หลัง IIS/proxy) import { files } from "@/db/schema";
// ตามคำขอผู้ใช้ 2026-10-14: เปิด /docedit เปล่า ๆ → เข้า taskpane ของ wordaddin import SessionBar from "@/components/SessionBar";
// เลย (/wordaddin — middleware ไม่บังคับ login ที่ root) ยกเว้น**ลิงก์ e-Court** import UploadFileForm from "@/components/UploadFileForm";
// /docedit/?fileUrl=&fileId=&typeId= (N8) ที่ยังส่งต่อให้ /note เหมือนเดิม — import { imageFormatFromUrl } from "@/utils/file-kind";
// redirect() ไม่แนบ searchParams ให้อัตโนมัติเหมือน redirects() จึงต้องส่งต่อเอง import { getSessionUser } from "@/lib/session";
// (redirect ของ next/navigation เติม basePath ให้เอง — invariant #12)
const ECOURT_PARAM_KEYS = ["fileUrl", "fileId", "typeId"] as const;
export default function RootPage({ export const dynamic = "force-dynamic";
searchParams,
}: { /** badge ชนิดไฟล์ต่อรายการ (N3) */
searchParams: Record<string, string | string[] | undefined>; function kindBadge(pdfUrl: string): string {
}) { switch (imageFormatFromUrl(pdfUrl)) {
const entries = Object.entries(searchParams); case "jpeg":
const isEcourtLink = entries.some(([key]) => return "JPG";
(ECOURT_PARAM_KEYS as readonly string[]).includes(key), case "png":
); return "PNG";
if (!isEcourtLink) { case "tiff":
redirect("/wordaddin"); return "TIFF";
default:
return "PDF";
} }
const qs = new URLSearchParams(); }
for (const [key, value] of entries) {
if (typeof value === "string") { export default async function HomePage() {
qs.set(key, value); // defense ซ้อน middleware — หน้านี้อ่าน cookie อยู่ดีจึงกันซ้ำไม่ให้ render รายการให้คนไม่มี session
} else if (Array.isArray(value)) { const user = getSessionUser();
for (const v of value) qs.append(key, v); if (!user) redirect("/login");
}
} const rows = await db.select().from(files).orderBy(asc(files.createdAt));
redirect(`/note?${qs.toString()}`);
return (
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
<header className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary text-base font-bold text-white shadow-sm">
JN
</div>
<div>
<h1 className="text-2xl font-bold leading-tight text-primary">
Judge&apos;s Note
</h1>
<p className="text-sm text-slate-500">
ระบบ PDF Annotation สำหรับ e-Court
</p>
</div>
</header>
<SessionBar userName={user.userName} />
<section className="mt-8">
<h2 className="text-sm font-semibold text-slate-700">เพิ่มเอกสาร</h2>
<UploadFileForm />
</section>
<div className="mt-8 flex items-baseline justify-between">
<h2 className="text-sm font-semibold text-slate-700">เอกสาร</h2>
{rows.length > 0 && (
<span className="text-xs tabular-nums text-slate-500">{rows.length} ไฟล์</span>
)}
</div>
{rows.length === 0 ? (
<div className="mt-3 rounded-xl border border-dashed border-slate-300 bg-white/60 p-10 text-center">
<FileText className="mx-auto h-8 w-8 text-slate-300" />
<p className="mt-3 text-sm font-medium text-slate-500">
ยังไม่มีไฟล์ในระบบ
</p>
<p className="mt-1 text-xs text-slate-500">
รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง
</p>
</div>
) : (
<ul className="mt-3 space-y-3">
{rows.map((f) => (
<li
key={f.id}
className="group flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-primary/30 hover:shadow-md"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
{imageFormatFromUrl(f.pdfUrl) ? (
<FileImage className="h-5 w-5 text-primary" />
) : (
<FileText className="h-5 w-5 text-primary" />
)}
</div>
<span className="min-w-0 flex-1">
<span className="block break-all line-clamp-2 text-sm font-medium text-slate-900">
{f.title}
</span>
<span className="mt-0.5 block text-xs text-slate-500">
<span className="mr-1.5 rounded bg-slate-100 px-1 py-0.5 font-medium text-slate-500">
{kindBadge(f.pdfUrl)}
</span>
{new Date(f.createdAt).toLocaleString("th-TH")}
</span>
</span>
{/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป้าจิ้ม 36px (H4) */}
<Link
href={`/annotate/${f.id}`}
className="shrink-0 rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
>
Annotate
</Link>
</li>
))}
</ul>
)}
</main>
);
} }

View file

@ -1,269 +0,0 @@
// app/page.tsx (ระบบ B)
'use client';
import Link from "next/link";
import type { ChangeEvent } from 'react';
import DependentDropdown, { DropdownData } from '@/components/utils/DependentDropdown';
import {
Bookmark,ToyBrick,FilePenLine,BicepsFlexed,Rocket,Workflow, Files,Blocks,FileQuestionMark,
ExternalLink,
FileText,
Highlighter,
MessagesSquare,
} from "lucide-react";
export default function HomePage() {
const templateData: DropdownData = {
T_X: ['T_no_warning', 'TF_no_warning_P2', 'TS_P2_IN'],
T12_X: ['T12_b2_IN', 'T12_FD155_3s-long'],
};
const handleTmplSelect = (template: string, data: string) => {
//console.log('Location Selected:', { template, data });
};
const handleInput = (e: ChangeEvent<HTMLInputElement>) => {
// console.log(e.target)
return true;
}
return (
<div style={{ padding: '2rem' }}>
<div className="mx-auto max-w-4xl px-4 pb-16 pt-12 sm:px-6">
<h1 className="font-display text-3xl tracking-tight text-ink sm:text-headline">
eCourt Template
</h1>
<p className="mt-2 text-body text-ink-soft">
ระบบสร้างเทมเพลตเอกสารแบบใหม่ MS Word จากไฟล์ JSON และ Template docx
</p>
<section className="mt-10">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">
ระบบนี้ทำอะไรได้
</h2>
</div>
<div className="mt-5 grid gap-5 sm:grid-cols-2">
{[
{
icon: FileText,
title: "DOCX Template",
detail:
"ออกแบบหน้าของเอกสารจาก Microsoft Word โดยไม่ต้องใช้เครื่องมือพิเศษ " +
"สามารถนำเอกสารตัวเดิมมาใช้ออกแบบได้เลย " +
"ไม่จำเป็นต้องมีความรู้ด้านการเขียนโปรแกรมหรือ XML ก็สามารถสร้างเทมเพลตได้",
},
{
icon: FilePenLine,
title: "Fill Form",
detail:
"รอรับ Rich และ Plain Text Content Control " +
"สามารถสร้างฟอร์มสำหรับกรอกข้อมูลพร้อมข้อมูลตั้งต้น " +
"ผู้ใช้สามารถนำไปกรอกข้อมูลใน Word ได้เลย และสามารถเใช้โปรแกรมดึงข้อมูลกับมาเก็บในฐานข้อมูลได้",
},
{
icon: ToyBrick,
title: "Integrate with Other System",
detail:
"มี Web API สำหรับสร้างเอกสารจากเทมเพลต(DOCX) กับข้อมูล(JSON) " +
"มี API Key เพื่อจำกัดการใช้งาน สามารถออกบัตรผ่านที่ลงลายเซ็นต์ด้วย HmacSha256" +
"เพื่อให้ผสานกับระบบงานอื่นๆ ได้อย่างปลอดภัย",
},
{
icon: FileText,
title: "Dynamic Document",
detail:
"รองรับเทมเพลตที่ซับซ้อน ข้อมูลที่มีไม่แน่นอนอย่างอาเรย์ การแสดงผลตามเงื่อนไขแบบต่างๆ " +
"สามารถเขียนโปรแกรมเพิ่มลงในเทมเพลตได้ รองรับ FOR , IF แทรกโค้ด " +
"ฟังก์ชั่นในตัวเพื่อรองรับการสร้าง Content Control Bookmark ฯลฯ",
},
{
icon: Rocket,
title: " High Performance",
detail:
"containerization ขยายระบบได้ง่าย, ระบบเทมเพลตใช้การ XML โดยตรง " +
"การสร้าง template เป็นแบบ Multi-Thread รองรับ CPU หลายแกน " +
"",
},
].map(({ icon: Icon, title, detail }) => (
<div
key={title}
className="flex gap-4 rounded-xl border border-line bg-white p-6"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.08] text-primary">
<Icon className="h-5 w-5" aria-hidden />
</span>
<div>
<h3 className="font-display text-subhead text-ink">{title}</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-soft">
{detail}
</p>
</div>
</div>
))}
</div>
</section>
<section className="mt-10">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">
ตัวอย่างการใช้งาน
</h2>
</div>
<div className="mt-5 grid gap-5 sm:grid-cols-2">
<div className="flex gap-4 rounded-xl border border-line bg-white p-6">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.08] text-primary">
<ExternalLink className="h-5 w-5" aria-hidden />
</span>
<div>
<h3 className="font-display text-subhead text-ink">Link (GET)</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-soft">
<a href="/docedit/api/template/download-docx?tid=T_X&fid=T_no_warning&uid=john&uname=John%20Doe">T_no_warning_john_xxx.docx</a><br />
<a href="/docedit/api/template/download-docx?tid=T_X&fid=TF_no_warning_P2&uid=john&uname=John%20Doe">TF_no_warning_P2_john_xxx.docx</a><br />
<a href="/docedit/api/template/download-docx?tid=T_X&fid=TS_P2_IN&uid=john&uname=John%20Doe">TS_P2_IN_john_xxx.docx</a><br />
<a href="/docedit/api/template/download-docx?tid=T12_X&fid=T12_b2_IN&uid=john&uname=John%20Doe">T12_b2_IN_john_xxx.docx</a><br />
<a href="/docedit/api/template/download-docx?tid=T12_X&fid=T12_FD155_3s-long&uid=john&uname=John%20Doe">T12_FD155_3s-long_john_xxx.docx</a><br />
</p>
</div>
</div>
<div className="flex gap-4 rounded-xl border border-line bg-white p-6">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.08] text-primary">
<Files className="h-5 w-5" aria-hidden />
</span>
<div>
<h3 className="font-display text-subhead text-ink">Template & Data</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-soft">
Template: <Link href="/tmpl/T_X.docx">T_X.docx</Link>, &nbsp;
<Link href="/tmpl/T12_X.docx">T12_X.docx</Link><br />
Data: <Link href="tmpl/TF_no_warning_P2.json">TF_no_warning_P2.json</Link>, &nbsp;
<Link href="/tmpl/T_no_warning.json">T_no_warning.json</Link>, &nbsp;
<Link href="/tmpl/TS_P2_IN.json">TS_P2_IN.json</Link><br />
<Link href="/tmpl/T12_b2_IN.json">T12_b2_IN.json</Link>, &nbsp;
<Link href="/tmpl/T12_FD155_3s-long.json">T12_FD155_3s-long.json</Link>
</p>
</div>
</div>
<div className="flex gap-4 rounded-xl border border-line bg-white p-6">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.08] text-primary">
<FilePenLine className="h-5 w-5" aria-hidden />
</span>
<div>
<h3 className="font-display text-subhead text-ink">Form (POST)</h3>
<div className="mt-1.5 text-sm leading-relaxed text-ink-soft">
<form action="/docedit/api/template/download-docx" method="POST">
<DependentDropdown data={templateData} name1='tid' name2='fid'
label1="Template(tid):" label2="ข้อมูล(fid):" onSelectChange={handleTmplSelect} />
<div className="mb-2"></div>
<div>
<label htmlFor="uid">uid: &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<input type="text" name="uid" className="p-2 mb-2 border border-gray-300 rounded-md" defaultValue="john" />
</label>
</div>
<div>
<label htmlFor="uname">uname:
<input type="text" name="uname" className="p-2 mb-2 border border-gray-300 rounded-md" defaultValue="John Doe" />
</label>
</div>
<button
type="submit"
style={{
padding: '10px 20px',
backgroundColor: '#0070f3',
color: '#fff',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
}}
>
ดาวน์โหลดเอกสาร MS Word
</button>
</form>
</div>
</div>
</div>
<div className="flex gap-4 rounded-xl border border-line bg-white p-6">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.08] text-primary">
<Workflow className="h-5 w-5" aria-hidden />
</span>
<div>
<h3 className="font-display text-subhead text-ink">Flow</h3>
<div className="mt-1.5 text-sm leading-relaxed text-ink-soft">
<div className="text-xl font-bold text-center">
ลิงค์เลือกไฟล์<br />
&darr; <br />
ตรวจสิทธิ์ สร้าง docx <br />
&darr; <br />
ดาว์นโหลด เปิด Word<br />
&darr; <br />
Add-ins ตรวจสอบไฟล์<br />
&darr; <br />
Versioning<br />
&darr; <br />
อัปเดตฐานข้อมูลและไฟล์<br />
</div>
</div>
</div>
</div>
<div className="flex gap-4 rounded-xl border border-line bg-white p-6">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.08] text-primary">
<FileQuestionMark className="h-5 w-5" aria-hidden />
</span>
<div>
<h3 className="font-display text-subhead text-ink">วิธีการใช้งาน</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-soft">
<Link href="https://www.youtube.com/watch?v=QxMitSk3S4E" className="focus-ring -m-1 inline-block rounded-md px-1 py-0.5 font-medium text-primary transition hover:underline">
แนะนำเทมเพลตของ Docedit เบื้องต้น</Link><br />
<Link href="https://www.youtube.com/watch?v=3emGNqRZgMs" className="focus-ring -m-1 inline-block rounded-md px-1 py-0.5 font-medium text-primary transition hover:underline">
Sideload Office add-ins</Link>
</p>
</div>
</div>
</div>
</section>
<section className="mt-10">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">
Add-ins
</h2>
</div>
<div className="mt-5 flex max-w-xxl gap-4 rounded-xl border border-line bg-white p-6">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.08] text-primary">
<Blocks className="h-5 w-5" aria-hidden />
</span>
<div>
<h3 className="font-display text-subhead text-ink">
Word Add-ins<br />
</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-soft">
ดาวน์โหลด&nbsp;
<Link href="/wordaddin/manifest.xml" className="focus-ring -m-1 inline-block rounded-md px-1 py-0.5 font-medium text-primary transition hover:underline">
manifest.xml </Link>&nbsp;
ไว้ที่ SharePoint แชร์เน็ตเวิร์ก หรือดาวน์โหลดสคริปต์ PowerShell(Dev Install) &nbsp;
<Link href="/wordaddin/install-word-addins.ps1" className="focus-ring -m-1 inline-block rounded-md px-1 py-0.5 font-medium text-primary transition hover:underline">
install-word-addins.ps1 </Link> &nbsp;
แล้วใช้คำสั่ง PowerShell ด้านล่างเพื่อติดตั้ง Add-ins ใน Word<br />
<br />
<b className="whitespace-nowrap">Unblock-File .\install-word-addins.ps1</b>
<b className="whitespace-nowrap">Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned -Force</b>
<b className="whitespace-nowrap">.\install-word-addins.ps1 -Install</b>
</p>
</div>
</div>
</section>
</div>
</div>
);
}

View file

@ -7,19 +7,17 @@ import {
} from "@/utils/file-kind"; } from "@/utils/file-kind";
import { PdfPathError, resolvePublicPdfPath } from "@/lib/server-pdf"; import { PdfPathError, resolvePublicPdfPath } from "@/lib/server-pdf";
// N4 — เสิร์ฟไฟล์ที่เคยอัปโหลด (`public/note/uploads/<uuid>.<ext>`) ผ่าน route handler // N4 — เสิร์ฟไฟล์ที่อัปโหลด (`public/uploads/<uuid>.<ext>`) ผ่าน route handler
// ไม่ใช่ static file ของ Next: production server (next start / standalone) จะไล่ // ไม่ใช่ static file ของ Next: production server (next start / standalone) จะไล่
// รายชื่อไฟล์ใน public/ เก็บไว้ใน Set **ครั้งเดียวตอน startup** เท่านั้น // รายชื่อไฟล์ใน public/ เก็บไว้ใน Set **ครั้งเดียวตอน startup** เท่านั้น
// (next/dist/server/lib/router-utils/filesystem.js — setupFsCheck) ไฟล์ที่เขียนลง // (next/dist/server/lib/router-utils/filesystem.js — setupFsCheck) ไฟล์ที่ action
// public/note/uploads/ หลัง server รันแล้วจึง GET ตรง ๆ ไม่ได้ (404) // เขียนลง public/uploads/ หลัง server รันแล้วจึง GET ตรง ๆ ไม่ได้ (404)
// ฝั่ง next dev ไม่ผ่าน branch นั้น เลยทำงานได้ — อาการคลาสสิก "local ได้ production พัง" // ฝั่ง next dev ไม่ผ่าน branch นั้น เลยทำงานได้ — อาการคลาสสิก "local ได้ production พัง"
// (ทางเข้า UI ของ N4 ถอดแล้วตาม PLAN-SSO.md D14 — route นี้เหลือไว้เสิร์ฟไฟล์เก่า // อ่านอย่างเดียว (invariant #1) + path guard ของ lib/server-pdf เหมือน /api/pdf (§5)
// ที่ row ใน DB ชี้อยู่) อ่านอย่างเดียว (invariant #1) + path guard ของ
// lib/server-pdf เหมือน /api/note/pdf (§5)
// ชื่อไฟล์ต้องเป็น uuid + extension ใน whitelist เท่านั้น (ตรงรูปที่ตัวอัปโหลดเดิม // ชื่อไฟล์ต้องเป็น uuid + extension ใน whitelist เท่านั้น (ตรงรูปที่ uploadFile เขียน)
// เขียน) — ชื่ออื่น 404 กลับไป ไม่รู้จักก็ไม่เปิด (กัน path traversal โดยแบบชื่อเอง: // — ชื่ออื่น 404 กลับไป ไม่รู้จักก็ไม่เปิด (กัน path traversal โดยแบบชื่อเอง: อักขระ
// อักขระนอก hex/ขีด/จุด ผ่าน regex นี้ไม่ได้) // นอก hex/ขีด/จุด ผ่าน regex นี้ไม่ได้)
const UUID_RE = const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

View file

@ -1,37 +0,0 @@
"use client";
// [DBG-r3p1] error boundary ของ taskpane — แสดง error จริงแทนหน้า
// "Application error: a client-side exception has occurred" ของ Next
// (ไว้ใช้ debug ใน webview ของ Word ที่เปิด devtools ไม่ได้ — คงไว้ถาวร
// เพราะดีกว่าข้อความกลาง ๆ ของ Next เสมอ สำหรับ add-in ที่ผู้ใช้เปิด console ไม่ได้)
export default function WordAddinError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex min-h-screen flex-col gap-3 p-4">
<h2 className="font-display text-base font-semibold text-ink">task pane ทำงานผิดพลาด</h2>
<p className="text-sm text-ink-soft">
เกิดข้อผิดพลาดฝั่งหน้าเว็บ — แจ้งข้อความด้านล่าง (ถ่ายภาพหน้าจอนี้มาด้วย) แล้วกดลองใหม่
</p>
<pre
className="max-h-64 overflow-auto whitespace-pre-wrap rounded-lg border border-line bg-canvas p-3 text-xs text-error-ink"
aria-label="รายละเอียดข้อผิดพลาด"
>
{error.message}
{error.digest ? `\n[digest ${error.digest}]` : ""}
{error.stack ? `\n\n${error.stack}` : ""}
</pre>
<button
type="button"
className="focus-ring w-fit rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink hover:border-primary hover:text-primary"
onClick={reset}
>
ลองใหม่
</button>
</div>
);
}

View file

@ -1,17 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { requestOrigin } from "@/utils/request-origin";
// GET /wordaddin/install-word-addin.ps1 — สำหรับติดตั้ง Word add-in
const DEV_ORIGIN = "https://localhost:3000";
const TEMPLATE = path.join(process.cwd(),"app","wordaddin","install-word-addins.ps1", "template.ps1");
export function GET(req: Request): Response {
const template = fs.readFileSync(TEMPLATE, "utf8");
const manifest = template.replaceAll(DEV_ORIGIN, requestOrigin(req));
return new Response(manifest, {
headers: {
"content-type": "text/plain; charset=utf-8",
"Content-Disposition": "attachment; filename=install-word-addins.ps1",
"cache-control": "no-store",
},
});
}

View file

@ -1,98 +0,0 @@
# ติดตั้ง (sideload) / ถอด Word add-in "e-Court Documents Versioning" บน Windows
# เวอร์ชัน PowerShell แบบส่งให้เครื่องทดสอบที่ไม่มี Node/repo — คู่หูของ
# scripts/install-wordaddin.mjs (รันใน repo) และ scripts/install-wordaddin.sh (macOS)
# กลไกเดียวกันทั้งสามไฟล์: โหลด manifest จาก server เก็บไว้
# %USERPROFILE%\Documents\wordaddin-manifest\ แล้วจดทะเบียน registry
# HKCU\Software\Microsoft\Office\16.0\Wef\Developer (กลไกเดียวกับ office-addin-dev-settings)
#
# ใช้: powershell -ExecutionPolicy Bypass -File install-wordaddin.ps1 [-Url <manifest-url>] [-Remove]
# -Url default https://ecourt-note.chamomind.com/docedit/wordaddin/manifest.xml
# -Remove ถอดการติดตั้ง
#
# (ไฟล์นี้ต้องมี BOM — Windows PowerShell 5.1 อ่าน UTF-8 ไม่มี BOM เป็น ANSI แล้วข้อความไทยพัง)
# หลังลง/ถอดต้อง restart Word ทุกครั้ง · รายละเอียดเต็ม: docs/WORDADDIN-DEV.md
param(
[string]$Url = "https://localhost:3000/docedit/wordaddin/manifest.xml",
[switch]$Remove,
[switch]$Install
)
$ErrorActionPreference = "Stop"
# ให้ console พิมพ์ไทย/✓ ถูกต้อง และบังคับ TLS 1.2 (PS 5.1 บางเครื่อง default ยังเป็น TLS 1.0)
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { }
[Net.ServicePointManager]::SecurityProtocol = `
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$Prefix = "[install-addin]"
$DevKeyCmd = "HKCU\Software\Microsoft\Office\16.0\Wef\Developer"
$ManifestDir = Join-Path $env:USERPROFILE "Documents\wordaddin-manifest"
$ManifestPath = Join-Path $ManifestDir "manifest.xml"
function Fail($message) {
Write-Host "$Prefix ✗ $message" -ForegroundColor Red
exit 1
}
if ($Remove) {
$Prefix = "[Uninstall-addin]"
Write-Host "$Prefix platform: win32 · โหมดถอดการติดตั้ง"
if (-not (Test-Path $ManifestPath)) {
Fail "ไม่พบ manifest ที่เก็บไว้ ($ManifestPath) จึงไม่รู้ GUID ของ add-in — ลบเองได้ที่: reg delete `"$DevKeyCmd`" /v <GUID> /f"
}
$stored = Get-Content -Raw -Encoding UTF8 $ManifestPath
if ($stored -notmatch "<Id>([0-9a-fA-F-]{36})</Id>") {
Fail "อ่าน GUID จาก $ManifestPath ไม่ได้"
}
$guid = $Matches[1].ToLower()
& reg.exe delete $DevKeyCmd /v $guid /f | Out-Null
if ($LASTEXITCODE -ne 0) {
Fail "reg delete ไม่สำเร็จ (exit $LASTEXITCODE) — ลบเองได้ที่: reg delete `"$DevKeyCmd`" /v $guid /f"
}
Remove-Item $ManifestPath -ErrorAction SilentlyContinue
Write-Host "$Prefix ✓ ถอดการติดตั้งแล้ว (registry $guid + ไฟล์ $ManifestPath)"
Write-Host "$Prefix ✓ เสร็จ"
exit 0
}
if ($Install) {
Write-Host "$Prefix platform: win32"
Write-Host "Install from $Url"
try {
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 30
} catch {
Fail "โหลด manifest จาก $Url ไม่ได้ ($_)"
}
$xml = $response.Content
if ($xml -notmatch "<OfficeApp") {
Fail "response จาก $Url ไม่ใช่ manifest ของ Office add-in (ไม่มี <OfficeApp)"
}
if ($xml -notmatch "<Id>([0-9a-fA-F-]{36})</Id>") {
Fail "หา <Id> (GUID) ใน manifest ไม่เจอจาก $Url"
}
$guid = $Matches[1].ToLower()
New-Item -ItemType Directory -Force -Path $ManifestDir | Out-Null
# เขียน UTF-8 ไม่มี BOM — ให้ manifest เหมือนต้นฉบับบน server เป๊ะ
[System.IO.File]::WriteAllText($ManifestPath, $xml, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "$Prefix ✓ เก็บ manifest ไว้ที่ $ManifestPath"
& reg.exe add $DevKeyCmd /v $guid /t REG_SZ /d $ManifestPath /f | Out-Null
if ($LASTEXITCODE -ne 0) {
Fail "เขียน registry ไม่สำเร็จ (exit $LASTEXITCODE)"
}
Write-Host "$Prefix ✓ จดทะเบียน add-in ใน registry ($DevKeyCmd → $guid)"
Write-Host "$Prefix ✓ เสร็จ"
# Print-NextSteps
Write-Host ""
Write-Host "ขั้นต่อไป:"
Write-Host " 1. restart Word ให้ครบทุกหน้าต่าง (จำเป็นทุกครั้งหลังติดตั้ง/ถอนการติดตั้ง)"
Write-Host ' 2. เมนู Home > Add-ins> กดแล้วเลือก "e-Court Documents Versioning"'
Write-Host " 3. เปิดไฟล์ .docx ที่มี ticket ที่ยังไม่หมดอายุ เพื่อเริ่มทดสอบ"
exit 0
}
Write-Host "วิธีการใช้:"
Write-Host "ปิด Microsoft Word แล้วเรียกคำสั่งนี้"
Write-Host " install-word-addins [-Install] [-Remove] [-Url http://domain/manifest.yaml]"
Write-Host "ถ้าติดสิทธิ์ในการรันสคริปต์ให้เรียกคำสั่งนี้:"

View file

@ -1,10 +0,0 @@
import type { Metadata } from "next";
/** nested layout แรกของ repo — override ชื่อแท็บ browser ของ taskpane (ส่วนอื่นสืบทอดจาก root layout) */
export const metadata: Metadata = {
title: "e-Court Documents Versioning",
};
export default function WordAddinLayout({ children }: { children: React.ReactNode }) {
return children;
}

View file

@ -1,33 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { requestOrigin } from "@/utils/request-origin";
// GET /wordaddin/manifest.xml — manifest ของ Word add-in สำหรับ sideload
//
// URL ใน manifest เป็น absolute https ตามข้อบังคับของ Office แต่ต้องต่างกันตาม
// environment — เดิมใช้ไฟล์ static ใน public/ ค่า dev (localhost:3000) แล้วให้ผู้
// deploy rewrite มือเองทุกครั้ง (docs/WORDADDIN-DEV.md) ตอนนี้ serve ผ่าน route
// แทน: อ่าน template (public/wordaddin/manifest.template.xml — ค่า dev) แล้ว
// เขียนทับ origin ให้ตรงโฮสต์ที่ขอไฟล์ (dev/UAT/production ใช้ template
// เดียวกัน — โหลดจาก environment ไหน manifest ก็ชี้ environment นั้น)
//
// อ่านจาก public/ เพราะเป็นที่เดียวที่การันตีว่ามีใน standalone ของ Docker
// (runner stage COPY public — ไฟล์ app/ ต้นฉบับไม่ถูกก๊อปเข้า image)
/** origin dev ที่ฝังใน template — สตริงเดียวที่ถูกแทนที่ทั้งไฟล์ (6 จุด) */
const DEV_ORIGIN = "https://localhost:3000";
const TEMPLATE = path.join(process.cwd(), "public", "wordaddin", "manifest.template.xml");
export function GET(req: Request): Response {
const template = fs.readFileSync(TEMPLATE, "utf8");
const manifest = template.replaceAll(DEV_ORIGIN, requestOrigin(req));
return new Response(manifest, {
headers: {
"content-type": "application/xml; charset=utf-8",
// origin ขึ้นกับ header ของผู้ขอ — ห้ามแคกลามผู้ใช้เกินครั้งเดียวต่อ download
"cache-control": "no-store",
},
});
}

View file

@ -1,7 +0,0 @@
import WordAddinGate from "@/components/wordaddin/WordAddinGate";
/** Taskpane ของ Word add-in (URL /docedit/wordaddin) — server component บาง ๆ ทั้งหมดอยู่ฝั่ง client
* auth/API เป็น mock ของ wordaddin เอง (ไม่ใช้ cookie oacCase — middleware ยกเว้น path นี้) */
export default function WordAddinPage() {
return <WordAddinGate />;
}

View file

@ -1,58 +0,0 @@
import {
ArrowRight,
Circle,
EyeOff,
Highlighter,
MessageSquare,
Minus,
Pencil,
Square,
Stamp,
Strikethrough,
Type,
Underline,
} from "lucide-react";
import type {
AnnotationDTO,
DrawData,
HighlightData,
ShapeData,
} from "@/db/schema";
/** ไอคอนตามชนิด annotation — presentational ล้วน **ไม่มี "use client"** ให้หน้าสรุป
* (server component, F15) ใช้ร่วมกับ sidebar (client) ได้: lucide icon เป็น SVG
* component ล้วน render ฝั่ง server ได้ (ย้ายมาจาก AnnotationSidebar.tsx) */
export function AnnotationTypeIcon({
type,
data,
className = "h-4 w-4 shrink-0 text-ink-soft",
}: {
type: AnnotationDTO["type"];
data: AnnotationDTO["data"];
className?: string;
}) {
if (type === "HIGHLIGHT") {
const style = (data as HighlightData).style ?? "highlight";
if (style === "underline") return <Underline className={className} />;
if (style === "strikethrough") return <Strikethrough className={className} />;
return <Highlighter className={className} />; // highlight / squiggle
}
if (type === "DRAW")
return (data as DrawData).pen === "highlighter" ? (
<Highlighter className={className} />
) : (
<Pencil className={className} />
);
if (type === "REDACTION") return <EyeOff className={className} />;
if (type === "TEXTBOX") return <Type className={className} />;
if (type === "STAMP") return <Stamp className={className} />;
if (type === "SHAPE") {
const shape = (data as ShapeData).shape;
if (shape === "rect") return <Square className={className} />;
if (shape === "ellipse") return <Circle className={className} />;
if (shape === "line") return <Minus className={className} />;
return <ArrowRight className={className} />;
}
return <MessageSquare className={className} />;
}

View file

@ -1,50 +0,0 @@
"use client";
// Genesis (docs/DESIGN.md) Navigation: sticky แถบบน 56px + backdrop-blur + เส้นแบ่ง
// ล่าง 1px — โลโก้ซ้าย / ผู้ใช้ + ออกจากระบบ ขวา ใช้ที่หน้าแรก /note และหน้า
// เครื่องมือทดสอบ /note/dev — หน้ากลุ่ม /note/groups/[typeId] เลิกใช้ตาม
// redesign 2026-09-24 (ไม่มี navbar เหลือ back link)
//
// ฉบับ token 2026-10-30: เป็น client component อ่าน session จาก localStorage
// ("note-auth") เอง — มี = ชื่อผู้ใช้ + ปุ่มออกจากระบบ (ล้าง token), ไม่มี =
// hint "เข้าผ่านลิงก์ e-Court" (ไม่มีปุ่ม logout — ไม่มี session ให้ล้าง) ·
// อ่านหลัง mount กัน hydration mismatch (server ไม่รู้ localStorage)
import Link from "next/link";
import { useEffect, useState } from "react";
import LogoutButton from "./LogoutButton";
import { readNoteSession, type NoteSession } from "@/lib/note-session";
export default function AppNav() {
const [session, setSession] = useState<NoteSession | null>(null);
useEffect(() => {
setSession(readNoteSession());
}, []);
return (
// appnav: hook ของกฎ coarse 44px ให้ปุ่มในแถบนี้ (globals.css — อย่าถอด)
<header className="appnav sticky top-0 z-40 h-14 border-b border-line bg-white/80 backdrop-blur">
<div className="mx-auto flex h-full max-w-7xl items-center justify-between gap-3 px-4 sm:px-6">
{/* ชื่อแบรนด์เป็น ink ตามกฎ "น้ำเงินเฉพาะของ interactive" — tile JN น้ำเงินคือ
แบรนด์มาร์ก (สืบต่อจากหน้าสรุป/login ดั้งเดิม) ทั้งแถบเป็นลิงก์กลับหน้าหลัก */}
<Link
href="/note"
className="focus-ring flex min-w-0 items-center gap-2.5 rounded-md"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-xs font-bold text-white">
JN
</span>
<span className="font-display truncate text-[15px] font-bold tracking-tight text-ink">
Judge&apos;s Note
</span>
</Link>
<div className="flex min-w-0 items-center gap-3">
<p className="hidden min-w-0 truncate text-sm text-ink-soft sm:block">
{session?.username ?? "เข้าผ่านลิงก์ e-Court"}
</p>
{session !== null && <LogoutButton onCleared={() => setSession(null)} />}
</div>
</div>
</header>
);
}

View file

@ -6,12 +6,9 @@ import { useEffect } from "react";
* หน้าพร้อม ?debug ไว้อ่าน console.warn ของ log [debug] บน iPad จริง (ไม่มี Mac * หน้าพร้อม ?debug ไว้อ่าน console.warn ของ log [debug] บน iPad จริง (ไม่มี Mac
* ต่อ Safari Web Inspector) — โหลดสคริปต์จาก CDN เท่านั้น ไม่แตะ dependencies * ต่อ Safari Web Inspector) — โหลดสคริปต์จาก CDN เท่านั้น ไม่แตะ dependencies
* ของ project — เคลียร์ debug เสร็จ = ลบไฟล์นี้ + บรรทัด import/render ใน * ของ project — เคลียร์ debug เสร็จ = ลบไฟล์นี้ + บรรทัด import/render ใน
* app/note/annotate/[id]/page.tsx */ * app/annotate/[id]/page.tsx */
export default function DebugConsole() { export default function DebugConsole() {
useEffect(() => { useEffect(() => {
// production build ไม่โหลด CDN เด็ดขาด (ระบบ intranet + token อยู่ใน origin
// เดียวกับ script นี้) — ใช้ได้เฉพาะ dev server ที่เครื่องทดสอบเปิดให้ LAN
if (process.env.NODE_ENV === "production") return;
if (!new URLSearchParams(window.location.search).has("debug")) return; if (!new URLSearchParams(window.location.search).has("debug")) return;
// React strict mode รัน effect สองรอบ — กัน append script ซ้ำ // React strict mode รัน effect สองรอบ — กัน append script ซ้ำ
if (document.getElementById("eruda-loader")) return; if (document.getElementById("eruda-loader")) return;

View file

@ -1,160 +0,0 @@
"use client";
import { useState, type FormEvent } from "react";
import { withBasePath } from "@/utils/base-path";
/**
* ฟอร์ม mint ลิงก์ SSO ทดสอบบนหน้า /note/dev (dev-only — route เปิดด้วย env
* NOTE_SSO_DEV_MINT=1 เท่านั้น, คำขอผู้ใช้ 2026-10-29 "ใช้ได้ใน note") — server
* คำนวณ token/ticket ด้วย secret ของตัวเองแล้วคืน URL เต็ม ค่าลับไม่ผ่านหน้า
* เบราว์เซอร์ · ได้ลิงก์มา = คลิกเข้าจริงตาม flow ของทางนั้น (A/B)
*/
export default function DevLoginLinkForm() {
const [mode, setMode] = useState<"jwt" | "ticket">("jwt");
const [userid, setUserid] = useState("oom");
const [username, setUsername] = useState("สรวิชญ์");
const [fid, setFid] = useState("F123");
const [ttl, setTtl] = useState(300);
const [fileUrl, setFileUrl] = useState("/samples/01012-672427-1f-690609-0000840223.pdf");
const [typeId, setTypeId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [url, setUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
setUrl(null);
setCopied(false);
try {
const res = await fetch(withBasePath("/api/note/auth/dev-link"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
mode,
userid,
username,
fid,
ttl_seconds: ttl,
...(fileUrl.trim() !== "" ? { fileUrl: fileUrl.trim() } : {}),
...(typeId.trim() !== "" ? { typeId: typeId.trim() } : {}),
}),
});
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(data?.error ?? `HTTP ${res.status}`);
}
const data = (await res.json()) as { url: string };
setUrl(data.url);
} catch (err) {
setError(err instanceof Error ? err.message : "สร้างลิงก์ไม่สำเร็จ");
} finally {
setBusy(false);
}
};
const inputCls =
"genesis-input h-9 px-3 py-1.5 disabled:bg-canvas disabled:text-ink-faint";
const labelCls = "text-xs font-medium text-ink-soft";
return (
<form onSubmit={onSubmit} className="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="devlink-mode">ทางเข้า</label>
<select
id="devlink-mode"
value={mode}
onChange={(e) => setMode(e.target.value === "ticket" ? "ticket" : "jwt")}
className={inputCls}
>
<option value="jwt">ทาง A — JWT</option>
<option value="ticket">ทาง B — ticket</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="devlink-ttl">อายุ (วินาที 60–600)</label>
<input
id="devlink-ttl"
type="number"
min={60}
max={600}
value={ttl}
onChange={(e) => setTtl(Number(e.target.value))}
className={inputCls}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="devlink-uid">userid / uid</label>
<input id="devlink-uid" value={userid} onChange={(e) => setUserid(e.target.value)} className={inputCls} required />
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="devlink-uname">username / uname</label>
<input id="devlink-uname" value={username} onChange={(e) => setUsername(e.target.value)} className={inputCls} required />
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="devlink-fid">fid (fileId ของ e-Court)</label>
<input id="devlink-fid" value={fid} onChange={(e) => setFid(e.target.value)} className={inputCls} required />
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="devlink-typeid">typeId (ทางเลือก)</label>
<input id="devlink-typeid" value={typeId} onChange={(e) => setTypeId(e.target.value)} className={inputCls} placeholder="ไม่กรอก = ไม่มี" />
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<label className={labelCls} htmlFor="devlink-fileurl">fileUrl (ไม่กรอกก็ได้ — ไฟล์ตัวอย่าง /samples/… หรือลิงก์ https ที่อนุญาต)</label>
<input id="devlink-fileurl" value={fileUrl} onChange={(e) => setFileUrl(e.target.value)} className={inputCls} placeholder="ไม่กรอก = เข้าหน้าแรกอย่างเดียว" />
</div>
<div className="sm:col-span-2">
<button
type="submit"
disabled={busy}
className="focus-ring inline-flex h-9 items-center rounded-md border border-line bg-white px-4 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint disabled:pointer-events-none disabled:opacity-50"
>
{busy ? "กำลังสร้าง…" : "สร้างลิงก์ทดสอบ"}
</button>
</div>
{error && (
<p role="alert" className="sm:col-span-2 rounded-lg border border-error/25 bg-error/5 p-3 text-sm text-error-ink">
{error}
</p>
)}
{url && (
<div className="sm:col-span-2 rounded-lg border border-line bg-canvas p-3">
<p className="text-xs font-medium text-ink-soft">
ลิงก์พร้อมใช้ (อายุ {ttl} วินาที) — กดเพื่อทดสอบเข้าจริง:
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<a
href={url}
className="focus-ring inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover"
>
เปิดลิงก์
</a>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
} catch {
/* clipboard โดน block — คัดลอกจากกล่องข้อความด้านล่างเอง */
}
}}
className="focus-ring inline-flex h-8 items-center rounded-md border border-line bg-white px-3 text-xs font-medium text-ink-soft transition hover:border-ink-faint"
>
{copied ? "คัดลอกแล้ว" : "คัดลอก URL"}
</button>
</div>
<p className="mt-2 break-all rounded border border-line bg-white px-2.5 py-2 text-[12px] leading-relaxed text-ink-soft">
{url}
</p>
</div>
)}
</form>
);
}

132
components/LoginForm.tsx Normal file
View file

@ -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<HTMLInputElement | null>(null);
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
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 (
<div className="w-full max-w-sm rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
{/* brand lockup เดียวกับ header หน้ารายการ (ลดขนาดตัวอักษรให้พอการ์ดแคบ) */}
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary text-base font-bold text-white shadow-sm">
JN
</div>
<div>
<p className="text-lg font-bold leading-tight text-primary">Judge&apos;s Note</p>
<p className="text-xs text-slate-500">ระบบ PDF Annotation สำหรับ e-Court</p>
</div>
</div>
<h1 className="mt-6 text-lg font-semibold text-slate-900">เข้าสู่ระบบ</h1>
<form onSubmit={onSubmit} className="mt-4">
<div>
<label htmlFor="username" className="text-sm font-semibold text-slate-700">
ชื่อผู้ใช้
</label>
<div className="mt-1">
<Select
id="username"
name="username"
autoComplete="username"
required
autoFocus
disabled={pending}
defaultValue=""
className={SELECT_CLASS}
>
{/* placeholder บังคับเลือก — ไม่ preselect คนแรก กันผู้ทดสอบ login ผิดคนโดยไม่ตั้งใจ */}
<option value="" disabled>
เลือกผู้ใช้
</option>
{MOCK_USERS.map((u) => (
<option key={u} value={u}>
{u}
</option>
))}
</Select>
</div>
</div>
<div className="mt-4">
<label htmlFor="password" className="text-sm font-semibold text-slate-700">
รหัสผ่าน
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
disabled={pending}
aria-invalid={!!error || undefined}
ref={passwordRef}
className={INPUT_CLASS}
/>
</div>
<button
type="submit"
disabled={pending}
className="mt-6 w-full rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
>
{pending ? "กำลังตรวจสอบ…" : "เข้าสู่ระบบ"}
</button>
{/* รหัสผ่านทดสอบ — รายชื่อเห็นใน dropdown แล้ว เหลือบอกแค่รหัสผ่าน (mock ไม่มีระบบสมัคร) */}
<p className="mt-5 rounded-md bg-slate-50 px-3 py-2 text-xs text-slate-500">
รหัสผ่านทดสอบ:{" "}
<span className="font-semibold text-slate-700">Demo1234</span>
</p>
{error && (
<p role="alert" className="mt-3 text-xs text-red-600">
{error}
</p>
)}
</form>
</div>
);
}

View file

@ -1,26 +1,29 @@
"use client"; "use client";
// ปุ่มออกจากระบบ (ฉบับ token 2026-10-30) — session เป็น access_token ใน // ปุ่มออกจากระบบ — server action logout ลบ cookie oacCase แล้ว redirect /login เอง
// localStorage ไม่ได้อยู่บน server แล้ว การออกจากระบบจึงแค่ล้าง "note-auth" import { useTransition } from "react";
// แล้วแจ้ง parent ให้ซ่อนปุ่ม/ชื่อผู้ใช้ (ไม่มี server action logout ให้เรียก)
import { LogOut } from "lucide-react"; import { LogOut } from "lucide-react";
import { clearNoteSession } from "@/lib/note-session"; import { logout } from "@/app/actions/auth";
export default function LogoutButton() {
const [pending, startTransition] = useTransition();
export default function LogoutButton({ onCleared }: { onCleared?: () => void }) {
const onLogout = () => { const onLogout = () => {
clearNoteSession(); startTransition(async () => {
onCleared?.(); await logout();
});
}; };
return ( return (
<button <button
type="button" type="button"
onClick={onLogout} onClick={onLogout}
className="focus-ring flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-line px-3 text-xs font-medium text-ink-soft transition hover:-translate-y-px hover:border-ink-faint hover:text-ink" disabled={pending}
className="flex shrink-0 items-center gap-1.5 rounded-md border border-slate-300 px-3 py-2 text-xs font-medium text-slate-600 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 disabled:opacity-50"
> >
<LogOut className="h-3.5 w-3.5" aria-hidden /> <LogOut className="h-3.5 w-3.5" aria-hidden />
ออกจากระบบ {pending ? "กำลังออก…" : "ออกจากระบบ"}
</button> </button>
); );
} }

View file

@ -1,114 +0,0 @@
"use client";
import { useState, type FormEvent } from "react";
import { withBasePath } from "@/utils/base-path";
/**
* ฟอร์ม "จำลอง e-Court" บนหน้า /note/dev สำหรับตอน mint อัตโนมัติปิด
* (production เสมอตาม devMintEnabled) — ผู้ทดสอบใส่ DOCVER_API_KEY แล้วยิง
* POST /api/note/auth/token (เส้นเดียวกับที่ backend e-Court ใช้จริง) ได้
* access_token มาแล้วพาเข้า /note?fileUrl=…&t=… ให้ NoteHome แลกเป็น session
* ต่อ — ไม่เป็นประตูหลังเพราะต้องถือ key ที่ถูกต้องอยู่แล้ว
* (คำขอผู้ใช้ 2026-11-02: กดเข้าเอกสารจากหน้า /note/dev บน production ให้ได้)
*/
export default function MockEcourtForm({ files }: { files: string[] }) {
const [apiKey, setApiKey] = useState("");
const [userid, setUserid] = useState("oom");
const [username, setUsername] = useState("สรวิชญ์");
const [file, setFile] = useState(files[0] ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
const res = await fetch(withBasePath("/api/note/auth/token"), {
method: "POST",
headers: { "x-api-key": apiKey, "content-type": "application/json" },
// fid ผูกไฟล์ที่เลือกแบบเดียวกับ dev-link (dev-<file>) — แต่ละไฟล์
// ตัวอย่างมี row ecourt: ของตัวเอง ไม่ปนกัน
body: JSON.stringify({
userid: userid.trim(),
username: username.trim(),
fid: `dev-${file}`,
ttl_seconds: 600,
}),
});
const data = (await res.json().catch(() => null)) as
| { access_token?: string; error?: string }
| null;
if (!res.ok || data?.access_token === undefined) {
throw new Error(data?.error ?? `ขอ token ไม่สำเร็จ (HTTP ${res.status})`);
}
// withBasePath("") = /docedit/note — ห้ามส่ง "/note" เข้า withBasePath (D11)
const params = new URLSearchParams({ fileUrl: `/samples/${file}`, t: data.access_token });
window.location.href = `${withBasePath("")}?${params.toString()}`;
} catch (err) {
setError(err instanceof Error ? err.message : "ขอ token ไม่สำเร็จ");
setBusy(false);
}
};
const inputCls =
"genesis-input h-9 px-3 py-1.5 disabled:bg-canvas disabled:text-ink-faint";
const labelCls = "text-xs font-medium text-ink-soft";
return (
<form onSubmit={onSubmit} className="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5 sm:col-span-2">
<label className={labelCls} htmlFor="mock-key">
API key (env DOCVER_API_KEY ของเครื่องนี้)
</label>
<input
id="mock-key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className={inputCls}
autoComplete="off"
required
/>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="mock-uid">userid</label>
<input id="mock-uid" value={userid} onChange={(e) => setUserid(e.target.value)} className={inputCls} required />
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="mock-uname">username</label>
<input id="mock-uname" value={username} onChange={(e) => setUsername(e.target.value)} className={inputCls} required />
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<label className={labelCls} htmlFor="mock-file">เอกสารตัวอย่าง</label>
<select
id="mock-file"
value={file}
onChange={(e) => setFile(e.target.value)}
className={inputCls}
>
{files.map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
</div>
<div className="sm:col-span-2">
<button
type="submit"
disabled={busy}
className="focus-ring inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover disabled:pointer-events-none disabled:opacity-50"
>
{busy ? "กำลังเข้าระบบ…" : "ขอ token และเปิดเอกสาร"}
</button>
</div>
{error && (
<p role="alert" className="sm:col-span-2 rounded-lg border border-error/25 bg-error/5 p-3 text-sm text-error-ink">
{error}
</p>
)}
</form>
);
}

View file

@ -1,290 +0,0 @@
"use client";
// หน้าแรก /note — ข้อมูลเกี่ยวกับระบบ (2026-10-30 ตามคำขอผู้ใช้: ระบบทำอะไรได้ /
// วิธีเข้าใช้) + bootstrap ของลิงก์ e-Court (contract ปลายทางลิงก์ยังชี้ที่นี่):
// มี credential ใน URL (t หรือ ticket_data+HmacSha256 — จากลิงก์ที่ e-Court ชี้ตรงมา
// ที่ /note) = แลกเป็น access_token ที่ POST /api/note/auth/session แล้วเก็บ
// localStorage "note-auth" (แบบ add-in) → ตัด credential ออกจาก URL ทันที → มี fileId
// = พาเข้า viewer / ไม่มี = แสดงหน้าข้อมูล · ไม่มี credential = หน้า public ธรรมดา
// (แบนเนอร์ ?sso= ของลิงก์เก่า + fileError ยังรองรับ)
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import {
Bookmark,
ExternalLink,
FileText,
Highlighter,
MessagesSquare,
} from "lucide-react";
import AppNav from "@/components/AppNav";
import { parseSsoLinkParams, ssoBanner, type SsoBanner } from "@/lib/note-auth";
import { saveNoteSession } from "@/lib/note-session";
import { withBasePath } from "@/utils/base-path";
type Phase = "boot" | "exchanging" | "ready";
type SessionResponse = {
access_token?: string;
token_type?: string;
expires_in?: number;
userid?: string;
username?: string;
fileId?: string;
fileError?: string;
reason?: string;
};
export default function NoteHome() {
const router = useRouter();
const [phase, setPhase] = useState<Phase>("boot");
const [banner, setBanner] = useState<SsoBanner | null>(null);
const [fileError, setFileError] = useState<string | null>(null);
// StrictMode dev รัน effect สองครั้ง — bootstrap ต้องเกิดครั้งเดียวจริง ๆ
const bootedRef = useRef(false);
useEffect(() => {
if (bootedRef.current) return;
bootedRef.current = true;
const url = new URL(window.location.href);
const stripParams = () => {
url.search = "";
window.history.replaceState(null, "", url.toString());
};
const fail = (reason: "expired" | "invalid" | "error") => {
setBanner(ssoBanner(reason));
setPhase("ready");
};
const parsed = parseSsoLinkParams(url);
const hasCredentialKey =
url.searchParams.get("t") !== null ||
url.searchParams.get("ticket_data") !== null ||
url.searchParams.get("HmacSha256") !== null;
if (parsed.ok) {
setPhase("exchanging");
const body: Record<string, string> = {};
if (parsed.credential.kind === "jwt") {
body.t = parsed.credential.token;
} else {
body.ticket_data = parsed.credential.ticketData;
body.HmacSha256 = parsed.credential.hmacHex;
}
if (parsed.fileUrl !== undefined) body.fileUrl = parsed.fileUrl;
if (parsed.typeId !== undefined) body.typeId = parsed.typeId;
// credential ต้องออกจาก URL ทันทีที่รู้ผล — แต่ strip ก่อนยิงไม่ได้ (reload
// กลางคัน = ลิงก์ตาย) จึงกันค้างด้วย timeout: เครือข่ายนิ่งเกิน 45 วิ = ตัดจบ
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 45_000);
void (async () => {
try {
const res = await fetch(withBasePath("/api/note/auth/session"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
const data = (await res.json().catch(() => null)) as SessionResponse | null;
stripParams();
if (
!res.ok ||
data === null ||
data.access_token === undefined ||
data.userid === undefined ||
data.username === undefined ||
data.expires_in === undefined
) {
fail(
res.status === 401 && data?.reason === "expired"
? "expired"
: res.status === 401
? "invalid"
: "error",
);
return;
}
const { access_token, userid, username, expires_in, fileId, fileError } = data;
saveNoteSession({
token: access_token,
userid,
username,
expMs: Date.now() + expires_in * 1000,
});
if (fileError !== undefined) setFileError(fileError);
if (fileId !== undefined) {
// เข้า viewer เลย — คงสถานะ exchanging ให้ spinner อยู่จนหน้า viewer มาแทน
router.replace(`/note/annotate/${fileId}`);
return;
}
setPhase("ready");
} catch {
stripParams();
fail("error");
} finally {
window.clearTimeout(timeoutId);
}
})();
return;
}
if (hasCredentialKey) {
// มี credential ใน URL แต่รูปไม่ผ่าน (ticket ครึ่งเดียว/ยาวเกิน cap/สองชุดพร้อม
// กัน) — ตัดทิ้ง + แจ้งผู้ใช้ ไม่ปล่อยค่าเน่าค้างอยู่ใน history (D11)
stripParams();
fail("invalid");
return;
}
// ไม่มี credential — หน้าข้อมูลระบบปกติ (แบนเนอร์ ?sso= ของลิงก์เก่า ·
// fileError จาก GET /api/note/files/open ตอนเปิดไฟล์ตัวอย่างไม่สำเร็จ)
setBanner(ssoBanner(url.searchParams.get("sso") ?? undefined));
const openFileError = url.searchParams.get("fileError");
if (openFileError !== null && openFileError !== "") setFileError(openFileError);
setPhase("ready");
}, [router]);
return (
<>
<AppNav />
<main className="mx-auto max-w-4xl px-4 pb-16 pt-12 sm:px-6">
{phase !== "ready" ? (
<p className="mt-2 text-body text-ink-soft" role="status">
{phase === "exchanging"
? "กำลังเข้าสู่ระบบผ่านลิงก์ของ e-Court…"
: "กำลังโหลด…"}
</p>
) : (
<>
{/* ชื่อระบบ Latin ล้วน → General Sans tracking แน่นตาม Genesis display type */}
<h1 className="font-display text-3xl tracking-tight text-ink sm:text-headline">
Judge&apos;s Note
</h1>
<p className="mt-2 text-body text-ink-soft">
ระบบอ่านและทำเครื่องหมายเอกสารคดีของ e-Court — มาร์กข้อความ วาดเส้น
ใส่ความเห็น คั่นหน้า และสรุปหมายเหตุทั้งคดีได้ในที่เดียว
โดยไฟล์ต้นฉบับไม่ถูกแก้ไข
</p>
{banner && (
<div
role="status"
className={`mt-6 rounded-lg border p-4 text-sm ${
banner.intent === "error"
? "border-error/25 bg-error/5 text-error-ink"
: "border-warning/30 bg-warning/5 text-warning-ink"
}`}
>
{banner.text}
</div>
)}
{fileError && (
<div
role="alert"
className="mt-6 rounded-lg border border-error/25 bg-error/5 p-4 text-sm text-error-ink"
>
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {fileError}
</div>
)}
{/* ── ระบบนี้ทำอะไรได้ ────────────────────────────────────────── */}
<section className="mt-10">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">
ระบบนี้ทำอะไรได้
</h2>
</div>
<div className="mt-5 grid gap-5 sm:grid-cols-2">
{[
{
icon: Highlighter,
title: "มาร์กและวาดบนเอกสาร",
detail:
"มาร์กข้อความไฮไลต์ได้หลายแบบ วาดเส้นด้วยปากกา วาดรูปทรง " +
"ใส่กล่องข้อความและตรายาง รวมถึงปิดบังข้อมูลที่ละเอียดอ่อน " +
"— ย้าย แก้ไข ย่อ-ขยายได้หลังวาง พร้อมปุ่มเรียกคืน (undo/redo)",
},
{
icon: MessagesSquare,
title: "ความเห็นและทำงานร่วมกัน",
detail:
"หมุดความเห็นพร้อมตอบกลับเป็นเธรด ทำเครื่องหมาย “จัดการแล้ว” " +
"เมื่อเสร็จ และเห็นการเปลี่ยนแปลงของทุกคนแบบสด ๆ " +
"ในไฟล์เดียวกันได้ทันที",
},
{
icon: Bookmark,
title: "คั่นหน้าและสรุปคดี",
detail:
"คั่นหน้าเป็นหัวข้อซ้อนกันได้ 3 ระดับพร้อมโน้ตย่อย ค้นหาทั้งไฟล์ " +
"แล้วดูรวมทั้งหมดที่หน้า “สรุปหมายเหตุ” — พิมพ์ออกมาเป็น " +
"เอกสารสรุปของคดีได้",
},
{
icon: FileText,
title: "เอกสารจาก e-Court",
detail:
"เปิด PDF และรูปภาพ (รวมไฟล์ TIFF หลายหน้า) จากลิงก์ของ e-Court " +
"จัดกลุ่มตามคดี และส่งออกเป็น PDF ที่ฝังมาร์กทุกอย่างไว้ในตัว " +
"— ไฟล์ต้นฉบับต้นทางไม่ถูกแตะต้อง",
},
].map(({ icon: Icon, title, detail }) => (
<div
key={title}
className="flex gap-4 rounded-xl border border-line bg-white p-6"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.08] text-primary">
<Icon className="h-5 w-5" aria-hidden />
</span>
<div>
<h3 className="font-display text-subhead text-ink">{title}</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-soft">
{detail}
</p>
</div>
</div>
))}
</div>
</section>
{/* ── วิธีเข้าใช้งาน ─────────────────────────────────────────── */}
<section className="mt-10">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">
วิธีเข้าใช้งาน
</h2>
</div>
<div className="mt-5 flex max-w-xl gap-4 rounded-xl border border-line bg-white p-6">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.08] text-primary">
<ExternalLink className="h-5 w-5" aria-hidden />
</span>
<div>
<h3 className="font-display text-subhead text-ink">
เข้าใช้งานผ่านลิงก์ของ e-Court
</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-soft">
ระบบนี้ไม่มีหน้าล็อกอิน — เปิดเอกสารจากระบบ e-Court
แล้วระบบพาเข้าหน้าอ่านเอกสารโดยอัตโนมัติ ใช้งานต่อได้ 8 ชม.
ต่อการเข้าหนึ่งครั้ง
</p>
</div>
</div>
<p className="mt-8 text-sm text-ink-soft">
ผู้พัฒนา/ผู้ทดสอบ:{" "}
<Link
href="/note/dev"
className="focus-ring -m-1 inline-block rounded-md px-1 py-0.5 font-medium text-primary transition hover:underline"
>
เครื่องมือทดสอบทางเข้าและเอกสารตัวอย่าง →
</Link>
</p>
</section>
</>
)}
</main>
</>
);
}

View file

@ -13,7 +13,7 @@ export default function Select({
return ( return (
<div className="relative"> <div className="relative">
<select {...props} className={`appearance-none pr-7 ${className}`} /> <select {...props} className={`appearance-none pr-7 ${className}`} />
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-faint" /> <ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
</div> </div>
); );
} }

15
components/SessionBar.tsx Normal file
View file

@ -0,0 +1,15 @@
// แถบแสดงผู้ใช้ที่เข้าสู่ระบบบนหน้ารายการ (แทน UserPicker เดิม) — server component
// รับค่าจาก cookie ผ่าน props จากหน้า (middleware การันตีว่ามี session ก่อนถึงหน้านี้)
import LogoutButton from "./LogoutButton";
export default function SessionBar({ userName }: { userName: string }) {
return (
<div className="mt-6 flex flex-wrap items-center justify-between gap-x-3 gap-y-2 rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
<p className="min-w-0 text-sm text-slate-600">
เข้าสู่ระบบเป็น{" "}
<span className="font-semibold text-slate-900">{userName}</span>
</p>
<LogoutButton />
</div>
);
}

View file

@ -0,0 +1,71 @@
"use client";
// N4 — ฟอร์มอัปโหลดไฟล์เอกสารแบบง่ายบนหน้ารายการ (PDF/รูป รับพร้อมกัน)
// เสร็จแล้วรายการโผล่เองจาก revalidatePath("/") ของ action
import { useState, useTransition } from "react";
import { uploadFile } from "@/app/actions/files";
const ACCEPT = ".pdf,.jpg,.jpeg,.png,.tif,.tiff";
/** ขนาดสูงสุดตาม validator ของ uploadFile (app/actions/files.ts) — เช็คฝั่ง client
* ก่อนอัปโหลด (M5, UX audit 2026-09-16): ไฟล์สแกนคดีที่เกินไม่ต้องรอส่งจนจบ
* บนเน็ตช้าก่อนเจอ error ของ server */
const MAX_BYTES = 100 * 1024 * 1024;
export default function UploadFileForm() {
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
setError(null);
const input = form.elements.namedItem("file") as HTMLInputElement | null;
const file = input?.files?.[0];
if (file && file.size > MAX_BYTES) {
setError("ไฟล์ใหญ่เกิน 100MB — เลือกไฟล์เล็กกว่า");
return;
}
startTransition(async () => {
try {
const result = await uploadFile(new FormData(form));
if (result.ok) {
form.reset();
} else {
setError(result.error);
}
} catch {
// action fail ที่ระดับ transport (เช่น body ใหญ่เกิน bodySizeLimit ของ next.config)
setError("อัปโหลดไม่สำเร็จ — ลองใหม่อีกครั้ง");
}
});
};
return (
<form
onSubmit={onSubmit}
className="mt-3 flex flex-wrap items-center gap-2 rounded-xl border border-dashed border-slate-300 bg-white/60 p-4"
>
<input
type="file"
name="file"
accept={ACCEPT}
required
disabled={pending}
className="block w-full max-w-xs text-sm text-slate-600 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-primary/10 file:px-4 file:py-2 file:text-sm file:font-medium file:text-primary"
/>
<button
type="submit"
disabled={pending}
className="ml-auto shrink-0 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:opacity-90 disabled:opacity-50"
>
{pending ? "กำลังอัปโหลด…" : "อัปโหลด"}
</button>
<p className="w-full text-xs text-slate-500">
รองรับ PDF, JPG, PNG, TIFF — ไม่เกิน 100MB ต่อไฟล์
</p>
{error && <p className="w-full text-xs text-red-600">{error}</p>}
</form>
);
}

View file

@ -1,354 +0,0 @@
"use client";
import type { ReactNode } from "react";
import {
Bookmark,
Check,
ChevronRight,
FileText,
Layers,
PenLine,
Shapes,
Users,
} from "lucide-react";
import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon";
import type {
AnnotationData,
AnnotationDTO,
AnnotationType,
BookmarkDTO,
} from "@/db/schema";
import { TYPE_ORDER, TYPE_SHORT } from "@/utils/annotation-display";
// แผงตัวกรอง facet กลาง — tree menu 2 ระดับสไตล์ Quasar docs q-tree--dense
// no-connectors (design 2026-09-24 ตามคำขอผู้ใช้: ธีม Genesis พื้นสว่าง + dense
// บน desktop แต่เป้าแตะ ≥44px บนจอสัมผัส) — ย้ายมาจาก FilterControls ของหน้า
// กลุ่ม (2026-09-24) เพื่อใช้ร่วมกับหน้าสรุป: มิติแรก (ไฟล์/หน้า) ส่งเข้ามาทาง
// prop `firstGroup` ส่วนอีก 3 กลุ่ม (ชนิดรายการ/ชนิดของ Annotation/ผู้บันทึก)
// เหมือนกันทุกหน้า — parent = <details> หุบ/กางอิสระ (chevron หมุน 90°),
// leaf = <button> dense ไอคอน + ชื่อ + badge pill เลือก button แทน label+input
// เพราะโดนกฎ coarse 44px ของ `main.listing-page`/`main.summary-page` ใน
// globals.css พอดี (สรุปเป็น desktop-dense / touch-44 โดยไม่ต้องเพิ่ม CSS)
/** รายการรวมของ explorer — server เรียงตามลำดับอ่านเอกสารให้แล้ว
* (หน้า → หน้าเดียวกัน bookmark ก่อน annotation → createdAt) */
export type ExplorerItem =
| { kind: "bm"; pageIndex: number; dto: BookmarkDTO; parentTitle: string | null }
| { kind: "ann"; pageIndex: number; dto: AnnotationDTO };
/** state ตัวกรองกลาง — มิติแรก (`first`) เป็นไฟล์ id (หน้ากลุ่ม) หรือเลขหน้า
* (หน้าสรุป); null/Set ว่าง = default "แสดงทั้งหมด" ทุกช่อง */
export type FacetFilterState = {
first: string | number | null;
/** ชนิดรายการที่สนใจ — **ว่าง = แสดงทั้งหมด** (default), ติ๊กอะไรเห็นเฉพาะ
* สิ่งนั้น (direct selection — คำขอผู้ใช้ 2026-09-24 แทนแบบเดิมที่เปิดครบ
* ทั้งคู่ไว้แล้วต้องปิดเอง) */
kinds: Set<"bm" | "ann">;
/** ชนิดของ Annotation ที่สนใจ — กติกาเดียวกับ kinds: **ว่าง = ทุกชนิด** */
types: Set<AnnotationType>;
userId: string | null;
};
/** สร้างใหม่ทุกครั้ง — ห้ามแชร์ object default เดียว (Set ถูก mutate ข้าม
* หน้า/ข้าม render ได้) */
export function createDefaultFacets(): FacetFilterState {
return { first: null, kinds: new Set(), types: new Set(), userId: null };
}
export type UserOption = { userId: string; label: string };
/** facet count แบบ static จากข้อมูลทั้งหมด (ไม่หดตามตัวกรองอื่น — กันเข้าใจ
* ผิดว่าตัวเลือกหายไปตอนไล่กรอง); มิติแรกไม่มีในนี้ — นับไว้ใน options ของ
* แต่ละตัวเลือกเอง */
export type FacetCounts = {
total: number;
bm: number;
ann: number;
type: Partial<Record<AnnotationType, number>>;
user: Record<string, number>;
};
/** ตัวเลือกของมิติแรก (ไฟล์/หน้า) — icon ส่งเข้ามาเอง (ไฟล์ = FileText/
* FileImage, หน้า = spacer ให้เลขชิดซ้ายตรงแนวกลุ่มผู้บันทึก) */
export type FirstDimensionOption = {
key: string | number;
label: string;
icon?: ReactNode;
count: number;
};
export type FirstDimensionGroup = {
label: string;
parentIcon: ReactNode;
allLabel: string;
allIcon?: ReactNode;
options: FirstDimensionOption[];
/** true = ตัวเลือกเยอะ (เช่น "หน้า" หลายสิบแถว) — ครอบด้วย max-h + scroll
* ภายในกลุ่มกันกลุ่มอื่นถูกดันออกนอกจอ (facet หลักต้องเห็นใน first
* viewport ของแผง) */
optionsScroll?: boolean;
};
/** data ตัวแทน (ค่าว่างทั้งหมด — เอาไว้เลือกไอคอนเท่านั้น ไม่เคยเข้าถึงจริง)
* เพื่อ reuse AnnotationTypeIcon ในแถวกรองโดยไม่ต้องคอป map ไอคอนซ้ำ */
const TYPE_ICON_DATA: Record<AnnotationType, AnnotationData> = {
HIGHLIGHT: { text: "", color: "", rects: [], style: "highlight" },
DRAW: { strokeColor: "", strokeWidth: 0, paths: [], pen: "pencil" },
COMMENT: { position: { x: 0, y: 0 }, message: "" },
SHAPE: {
shape: "rect",
rect: { x: 0, y: 0, width: 0, height: 0 },
strokeColor: "",
strokeWidth: 0,
},
REDACTION: { rect: { x: 0, y: 0, width: 0, height: 0 } },
TEXTBOX: { position: { x: 0, y: 0 }, text: "", color: "", fontSize: 0 },
STAMP: { label: "", position: { x: 0, y: 0 }, scale: 0 },
};
const PARENT_ICON_CLS = "h-4 w-4 shrink-0 text-ink-soft";
const LEAF_ICON_CLS = "h-4 w-4 shrink-0 text-ink-soft";
/** parent node — หุบ/กางอิสระ (default กาง) pattern bm-toggle ของต้นไม้ bookmark N2
* disabled = กลุ่มใช้ไม่ได้: หรี่ + หุบ + แถวข้างในล็อก (สรุปยังกางดูได้ —
* open เป็น prop เฉพาะ transition false↔true เท่านั้นที่เขียน DOM การหุบ/กาง
* ด้วยมือของผู้ใช้จึงไม่ถูก render ถัดไปเขียนทับ) */
function FilterGroup({
icon,
label,
open = true,
disabled = false,
children,
}: {
icon: ReactNode;
label: string;
open?: boolean;
disabled?: boolean;
children: ReactNode;
}) {
return (
<details open={open} className="group">
<summary
aria-disabled={disabled}
className={`focus-ring flex cursor-pointer list-none items-center gap-1.5 rounded px-1.5 py-1.5 text-sm font-semibold text-ink transition hover:bg-ink/[0.03] hover:text-primary [&::-webkit-details-marker]:hidden ${
disabled ? "opacity-50" : ""
}`}
>
<ChevronRight
aria-hidden
className="h-3.5 w-3.5 shrink-0 text-ink-faint transition-transform group-open:rotate-90 motion-reduce:transition-none"
/>
{icon}
<span className="min-w-0 flex-1 truncate">{label}</span>
</summary>
<div className="mt-0.5">{children}</div>
</details>
);
}
/** leaf row — single-select มี highlight แถวเดียว, multi-select (multi) เพิ่ม ✓
* border-l-2 โปร่งคงไว้ทุกแถว เปลี่ยนเป็นน้ำเงินเฉพาะ active กัน layout shift
* ไอคอนแรกต่อแนวใต้ chevron ของ parent พอดี 26px = border-l-2 (2) + pl-6 (24)
* = px-1.5 (6) + chevron 14 + gap-1.5 (6) — ต้องนับ border เข้าไปด้วย อย่าแยกออก
* (responsive review 2026-09-24 เคยอ่านเป็น 24px แล้วเข้าใจผิดว่าเพี้ยน) */
function LeafRow({
active,
multi = false,
disabled = false,
icon,
label,
count,
onClick,
}: {
active: boolean;
multi?: boolean;
disabled?: boolean;
icon: ReactNode;
label: string;
count: number;
onClick: () => void;
}) {
return (
<button
type="button"
aria-pressed={active}
disabled={disabled}
onClick={onClick}
title={label}
className="focus-ring flex w-full items-center gap-1.5 rounded border-l-2 border-transparent py-1 pl-6 pr-1.5 text-left text-sm text-ink transition hover:bg-ink/[0.03] aria-pressed:border-l-primary aria-pressed:bg-primary/[0.08] aria-pressed:text-primary disabled:pointer-events-none disabled:opacity-50"
>
{/* กลุ่ม multi มีกล่อง checkbox visual หน้าไอคอน (facet-filter convention —
คำขอผู้ใช้ 2026-09-24): ปกติกรอบเทาโปร่งใน, เลือก = พื้นน้ำเงิน + ✓ ขาว
เป็น visual ของ button เดิม ไม่ใช่ native input (กฎ coarse 44px ยังครบ) */}
{multi && (
<span
aria-hidden
className={`flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border transition-colors ${
active ? "border-primary bg-primary text-white" : "border-ink-faint"
}`}
>
{active && <Check className="h-3 w-3" />}
</span>
)}
{icon}
<span className="min-w-0 flex-1 truncate">{label}</span>
<span className="shrink-0 rounded-full bg-ink/[0.05] px-1.5 text-[11px] leading-4 tabular-nums text-ink-soft">
{count}
</span>
</button>
);
}
export function FacetFilterPanel({
state,
onChange,
firstGroup,
users,
counts,
}: {
state: FacetFilterState;
onChange: (next: FacetFilterState) => void;
firstGroup: FirstDimensionGroup;
users: UserOption[];
counts: FacetCounts;
}) {
const toggleKind = (k: "bm" | "ann", on: boolean) => {
const kinds = new Set(state.kinds);
if (on) kinds.add(k);
else kinds.delete(k);
// เลิกเลือก Annotation = ไม่ได้ดู annotation แล้ว → ล้างชนิดที่ติ๊กค้างไว้
// ให้เริ่มใหม่ตาม default ตอนกลับมาติ๊ก Annotation (คำขอผู้ใช้ 2026-09-24)
const types = !on && k === "ann" ? new Set<AnnotationType>() : state.types;
onChange({ ...state, kinds, types });
};
const toggleType = (t: AnnotationType, on: boolean) => {
const types = new Set(state.types);
if (on) types.add(t);
else types.delete(t);
// ติ๊กชนิดของ Annotation = ตั้งใจดู annotation → เลือก Annotation ให้อัตโนมัติ
// (คำขอผู้ใช้ 2026-09-24 — ก่อนหน้านี้ bookmark ยังปนอยู่ใน list) — ไม่ถอด
// อัตโนมัติตอนเลิกติ๊ก เพราะผู้ใช้อาจตั้งใจอยู่มุมมอง annotation แล้ว
const kinds = new Set(state.kinds);
if (on) kinds.add("ann");
onChange({ ...state, kinds, types });
};
const firstOptions = firstGroup.options.map((o) => (
<LeafRow
key={o.key}
active={state.first === o.key}
icon={o.icon ?? <FileText aria-hidden className={LEAF_ICON_CLS} />}
label={o.label}
count={o.count}
onClick={() => onChange({ ...state, first: o.key })}
/>
));
return (
<div className="space-y-2">
{/* มิติแรก (ไฟล์/หน้า) — ตัวเลือก + count ส่งมาจากหน้าผ่าน firstGroup;
optionsScroll ครอบด้วยกล่อง scroll เฉพาะกรณีตัวเลือกเยอะ */}
<FilterGroup icon={firstGroup.parentIcon} label={firstGroup.label}>
<LeafRow
active={state.first === null}
icon={firstGroup.allIcon ?? <FileText aria-hidden className={LEAF_ICON_CLS} />}
label={firstGroup.allLabel}
count={counts.total}
onClick={() => onChange({ ...state, first: null })}
/>
{firstGroup.optionsScroll ? (
<div className="max-h-64 overflow-y-auto overscroll-contain scroll-pb-1 scroll-pt-1">
{firstOptions}
</div>
) : (
firstOptions
)}
</FilterGroup>
{/* ไม่เลือก = แสดงทั้งหมด (default) — ติ๊กอะไรเห็นเฉพาะสิ่งนั้น */}
<FilterGroup
icon={<Layers aria-hidden className={PARENT_ICON_CLS} />}
label="ชนิดรายการ"
>
<LeafRow
multi
active={state.kinds.has("bm")}
icon={
<Bookmark aria-hidden className="h-4 w-4 shrink-0 text-warning-ink" />
}
label="Bookmark"
count={counts.bm}
onClick={() => toggleKind("bm", !state.kinds.has("bm"))}
/>
<LeafRow
multi
active={state.kinds.has("ann")}
icon={<PenLine aria-hidden className={LEAF_ICON_CLS} />}
label="Annotation"
count={counts.ann}
onClick={() => toggleKind("ann", !state.kinds.has("ann"))}
/>
</FilterGroup>
{/* กติกาเดียวกับชนิดรายการ: ไม่เลือก = ทุกชนิด (default) ติ๊กอะไรเห็นเฉพาะ
ชนิดนั้น + แสดงเฉพาะชนิดที่มี ≥1 รายการ (กดแล้วได้ list ว่าง = ไม่เสนอ) —
**ไม่ได้ติ๊ก Annotation = กลุ่มนี้ disabled + หุบ** (คำขอผู้ใช้ 2026-09-24:
กรองชนิดมีความหมายเฉพาะเมื่อกำลังดู annotation — ติ๊ก Annotation แล้ว
กลุ่มกลับมาใช้ได้ + กางให้อัตโนมัติ) */}
{counts.ann > 0 && (
<FilterGroup
icon={<Shapes aria-hidden className={PARENT_ICON_CLS} />}
label="ชนิดของ Annotation"
open={state.kinds.has("ann")}
disabled={!state.kinds.has("ann")}
>
{TYPE_ORDER.filter((t) => (counts.type[t] ?? 0) > 0).map((t) => (
<LeafRow
key={t}
multi
disabled={!state.kinds.has("ann")}
active={state.types.has(t)}
icon={
<AnnotationTypeIcon
type={t}
data={TYPE_ICON_DATA[t]}
className={LEAF_ICON_CLS}
/>
}
label={TYPE_SHORT[t]}
count={counts.type[t] ?? 0}
onClick={() => toggleType(t, !state.types.has(t))}
/>
))}
</FilterGroup>
)}
<FilterGroup
icon={<Users aria-hidden className={PARENT_ICON_CLS} />}
label="ผู้บันทึก"
>
{/* ไอคอนคนซ้ำ N แถวเป็น noise — spacer คง indentation แทน (pattern เดียวกับ
leaf ของต้นไม้ bookmark ใน sidebar) */}
<LeafRow
active={state.userId === null}
icon={<span aria-hidden className="w-4 shrink-0" />}
label="ทุกคน"
count={counts.total}
onClick={() => onChange({ ...state, userId: null })}
/>
{users.map((u) => (
<LeafRow
key={u.userId}
active={state.userId === u.userId}
icon={<span aria-hidden className="w-4 shrink-0" />}
label={u.label}
count={counts.user[u.userId] ?? 0}
onClick={() => onChange({ ...state, userId: u.userId })}
/>
))}
</FilterGroup>
</div>
);
}

View file

@ -1,186 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { ChevronDown, FileImage, FileText, Folder } from "lucide-react";
import {
FacetFilterPanel,
createDefaultFacets,
type ExplorerItem,
type FacetFilterState,
} from "@/components/explorer/FacetFilterPanel";
import type { AnnotationType } from "@/db/schema";
import { GroupList } from "./GroupList";
// N9 หน้ากลุ่ม (redesign 2026-09-24 ตามคำขอผู้ใช้): explorer 2 คอลัมน์ — ซ้ายเป็น
// ตัวกรอง (ไฟล์/ชนิดรายการ/ชนิด annotation/ผู้บันทึก) ขวาเป็น list เดียวรวม
// bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์ — ข้อมูลทั้งหมด query ฝั่ง server ส่งมา
// ครั้งเดียวแล้วกรองใน memory (read-only snapshot เหมือนเดิม ไม่มี SSE)
// แผงตัวกรองเป็น component กลาง `components/explorer/FacetFilterPanel` (2026-09-24
// — เดิม FilterControls เป็นของหน้านี้) ใช้ร่วมกับหน้าสรุปที่มิติแรกเป็น "หน้า"
/** ไฟล์ในกลุ่มแบบย่อ — เอาเฉพาะ field ที่ filter/list ใช้ (ไม่ส่ง row เต็มข้าม wire) */
export type GroupFileSummary = {
id: string;
title: string;
isImage: boolean;
};
/** รายการเดียวรวมทั้งกลุ่ม — server เรียงตามลำดับอ่านเอกสารให้แล้ว (ไฟล์ตาม
* createdAt → หน้า → หน้าเดียวกัน bookmark ก่อน annotation → createdAt) */
export type GroupListItem = ExplorerItem;
export function GroupExplorer({
files,
items,
}: {
files: GroupFileSummary[];
items: GroupListItem[];
}) {
const [filters, setFilters] = useState<FacetFilterState>(createDefaultFacets);
// facet count เป็น static จากข้อมูลทั้งกลุ่ม (ไม่หดตามตัวกรองอื่น — กันเข้าใจ
// ผิดว่าตัวเลือกหายไปตอนไล่กรอง)
const counts = useMemo(() => {
let bm = 0;
let ann = 0;
const file: Record<string, number> = {};
const type: Partial<Record<AnnotationType, number>> = {};
const user: Record<string, number> = {};
for (const it of items) {
file[it.dto.fileId] = (file[it.dto.fileId] ?? 0) + 1;
user[it.dto.userId] = (user[it.dto.userId] ?? 0) + 1;
if (it.kind === "bm") bm++;
else {
ann++;
type[it.dto.type] = (type[it.dto.type] ?? 0) + 1;
}
}
return { total: items.length, bm, ann, file, type, user };
}, [items]);
// รายชื่อผู้บันทึกที่มีรายการจริง เรียงชื่อไทย (คนละไฟล์รวมเป็นคนเดียว)
const users = useMemo(() => {
const labelById = new Map<string, string>();
for (const it of items) {
if (!labelById.has(it.dto.userId)) {
labelById.set(it.dto.userId, it.dto.userName || "ไม่ทราบชื่อ");
}
}
const out: { userId: string; label: string }[] = [];
labelById.forEach((label, userId) => out.push({ userId, label }));
return out.sort((a, b) => a.label.localeCompare(b.label, "th"));
}, [items]);
// มิติแรกของหน้ากลุ่ม = ไฟล์ (count รายไฟล์ฝังใน options ของ firstGroup)
const firstGroup = {
label: "ไฟล์",
parentIcon: <Folder aria-hidden className="h-4 w-4 shrink-0 text-ink-soft" />,
allLabel: "ทุกไฟล์",
options: files.map((f) => ({
key: f.id,
label: f.title,
count: counts.file[f.id] ?? 0,
icon: f.isImage ? (
<FileImage aria-hidden className="h-4 w-4 shrink-0 text-ink-soft" />
) : (
<FileText aria-hidden className="h-4 w-4 shrink-0 text-ink-soft" />
),
})),
};
const filtered = useMemo(
() =>
items.filter(
(it) =>
(!filters.first || it.dto.fileId === filters.first) &&
(filters.kinds.size === 0 || filters.kinds.has(it.kind)) &&
(it.kind === "ann"
? filters.types.size === 0 || filters.types.has(it.dto.type)
: true) &&
(!filters.userId || it.dto.userId === filters.userId),
),
[items, filters],
);
const selectedFile = filters.first
? files.find((f) => f.id === filters.first) ?? null
: null;
// จำนวนตัวกรองที่ ≠ default — โชว์ที่ summary มือถือ (กัน "กรองเก่ากรองเงียบ")
// และเป็นเงื่อนไขปุ่ม "ล้างตัวกรอง"
const activeCount =
(filters.first !== null ? 1 : 0) +
(filters.kinds.size > 0 ? 1 : 0) +
(filters.types.size > 0 ? 1 : 0) +
(filters.userId !== null ? 1 : 0);
const resetFilters = () => setFilters(createDefaultFacets());
return (
<div className="mt-6 lg:grid lg:grid-cols-[15rem_minmax(0,1fr)] lg:items-start lg:gap-8">
{/* มือถือ (< lg): ตัวกรองหุบ/กางเป็น <details> แบบเดียวกับหน้าสรุป —
จอ lg ถูก display:none เอาออกจาก grid — ใช้ named group `group/filters`
ไม่ใช่ `group` เปล่า: `group-open:` ของ parent tree ข้างใน match ทุก
ancestor ที่มี .group[open] ใช้ชื่อซ้ำแล้ว chevron ของทุกกลุ่มหมุนตลอด
ตอนกางแผงนี้ (responsive review 2026-09-24) */}
<details className="group/filters rounded-xl border border-line bg-white lg:hidden">
<summary className="focus-ring flex cursor-pointer list-none items-center justify-between rounded-md px-4 py-3 text-sm font-semibold text-ink transition hover:text-primary [&::-webkit-details-marker]:hidden">
ตัวกรอง{activeCount > 0 ? ` (${activeCount})` : ""}
<ChevronDown
aria-hidden
className="h-4 w-4 shrink-0 text-ink-faint transition-transform group-open/filters:rotate-180 motion-reduce:transition-none"
/>
</summary>
{/* cap 70dvh + scroll ภายใน: กลุ่ม "ไฟล์"/"ผู้บันทึก" เยอะ ๆ ต้องไม่ดัน
"พบ N รายการ"/list ลงไกลจนมองไม่เห็น (responsive review 2026-09-24) */}
<div className="max-h-[70dvh] overscroll-contain overflow-y-auto border-t border-line/70 px-2.5 pb-3 pt-1">
<FacetFilterPanel
state={filters}
onChange={setFilters}
firstGroup={firstGroup}
users={users}
counts={counts}
/>
</div>
</details>
{/* desktop: แผงตัวกรอง sticky — หน้าไม่มี navbar จึง top-6 พอ ไฟล์เยอะ scroll ภายในแผงเอง
(p-3 กระชับตามความ dense ของ tree — design 2026-09-24) — h2 "ตัวกรอง (N)"
ให้ parity กับ summary มือถือ, scroll-pt/pb กัน focus ring โดนขอบ scroll
ตัด, max-h เป็น dvh ตาม pattern h-dvh ของ viewer (responsive review 2026-09-24) */}
<aside
aria-label="ตัวกรองรายการ"
className="hidden rounded-xl border border-line bg-white p-3 lg:sticky lg:top-6 lg:block lg:max-h-[calc(100dvh-3rem)] lg:scroll-pb-1 lg:scroll-pt-1 lg:overflow-y-auto"
>
<h2 className="px-1.5 text-sm font-semibold text-ink">
ตัวกรอง{activeCount > 0 ? ` (${activeCount})` : ""}
</h2>
<div className="mt-3">
<FacetFilterPanel
state={filters}
onChange={setFilters}
firstGroup={firstGroup}
users={users}
counts={counts}
/>
</div>
</aside>
<section className="mt-5 lg:mt-0">
<GroupList
items={filtered}
files={files}
totalCount={counts.total}
selectedFile={selectedFile}
selectedFileTotal={
selectedFile ? counts.file[selectedFile.id] ?? 0 : null
}
activeCount={activeCount}
onReset={resetFilters}
/>
</section>
</div>
);
}

View file

@ -1,293 +0,0 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import {
Bookmark,
ClipboardList,
FileImage,
FileText,
ListFilter,
StickyNote,
} from "lucide-react";
import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon";
import {
annotationNote,
annotationPreviewText,
annotationTypeLabel,
} from "@/utils/annotation-display";
import { formatThaiDate } from "@/utils/date-format";
import type { GroupFileSummary, GroupListItem } from "./GroupExplorer";
// คอลัมน์ content ของหน้ากลุ่ม (design 2026-09-24) — list เดียวรวมทั้งกลุ่ม
// ไม่แยกไฟล์ (ตัวกรองฝั่งซ้ายทำหน้าที่แยกให้แล้ว) แต่ละแถวบอกไฟล์ที่มาที่
// ต้น meta line — แถวเป็น passive surface จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว
// (deep link ?ann= / ?page= เดียวกับหน้าสรุป — คำขอผู้ใช้ 2026-09-24 ไม่กดทั้งแถว
// ทำให้พ้นข้อจำกัด interactive ซ้อน <a> เดิม และกฎ coarse ของ listing-page ที่
// display:inline-flex ชนะ utility flex ก็ไม่กระทบแถวอีกต่อไป — เหลือแค่ปุ่ม)
/** จุดคั่น "·" ของ meta line */
function Sep() {
return <span aria-hidden className="text-ink-faint">·</span>;
}
/** meta line: ไฟล์ที่มา (truncate เฉพาะ span ของตัวเอง — ไม่งั้นชื่อยาวกินบรรทัด
* จนตัด หน้า/ผู้บันทึก/วันที่ ทิ้ง) → หน้า → บริบทกลาง → ผู้บันทึก → วันที่
* flex-wrap ให้วันที่เต็มรูปแบบไทยตกบรรทัดใหม่บนมือถืออย่างสง่า — บริบทกลาง
* ("ใน: …" ของ sub bookmark — ชื่อ parent มาจากข้อความที่คลุม ยาวไม่มีขอบเขต)
* กับ "โดย …" ต้อง min-w-0+truncate ด้วย: whitespace-nowrap เปล่า ๆ หดไม่ได้
* ล้นการ์ดแล้วโดน overflow-hidden ตัดดื้อ ๆ ไม่มี ellipsis (review 2026-09-24) */
function RowMeta({
file,
pageIndex,
middle,
user,
date,
}: {
file: GroupFileSummary;
pageIndex: number;
/** ชื่อชนิดของ annotation หรือ "ใน: …" ของ sub bookmark */
middle?: string;
user: string;
date: string;
}) {
return (
<span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-ink-soft">
<span className="inline-flex min-w-0 max-w-[12rem] items-center gap-1 sm:max-w-[16rem]">
{file.isImage ? (
<FileImage aria-hidden className="h-3 w-3 shrink-0" />
) : (
<FileText aria-hidden className="h-3 w-3 shrink-0" />
)}
<span className="truncate" title={file.title}>
{file.title}
</span>
</span>
<Sep />
<span className="whitespace-nowrap">หน้า {pageIndex + 1}</span>
{middle && (
<>
<Sep />
<span className="min-w-0 max-w-full truncate" title={middle}>
{middle}
</span>
</>
)}
<Sep />
<span className="min-w-0 max-w-full truncate">โดย {user}</span>
<Sep />
{/* formatThaiDate แปลงตาม timezone เครื่องที่ render — SSR/server กับ
hydration/ผู้ใช้อาจต่างกัน ใส่ suppressHydrationWarning ตาม convention
(เดียวกับ AnnotationSidebar/PDFViewer) */}
<span className="whitespace-nowrap" suppressHydrationWarning>
{date}
</span>
</span>
);
}
/** โน้ตคำอธิบาย (N1) / โน้ตย่อยของ bookmark (N2) — 2 บรรทัดพอ */
function RowNote({ text }: { text: string }) {
return (
<span className="mt-0.5 flex items-start gap-1 text-xs leading-relaxed text-ink-soft">
<StickyNote aria-hidden className="mt-0.5 h-3 w-3 shrink-0 text-warning-ink" />
<span className="line-clamp-2 whitespace-pre-wrap break-words">{text}</span>
</span>
);
}
/** ปุ่ม secondary "เปิดเอกสารนี้" — ทางเข้าไฟล์ที่ยังไม่มีรายการใด ๆ
* (หน้าเดิมมีปุ่ม Annotate ทุกการ์ด — เก็บไว้ตามคำตัดสินใจผู้ใช้ 2026-09-24) */
function OpenFileButton({ file }: { file: GroupFileSummary }) {
return (
<Link
href={`/note/annotate/${file.id}`}
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
>
{file.isImage ? (
<FileImage aria-hidden className="h-3.5 w-3.5" />
) : (
<FileText aria-hidden className="h-3.5 w-3.5" />
)}
เปิดเอกสารนี้
</Link>
);
}
export function GroupList({
items,
files,
totalCount,
selectedFile,
selectedFileTotal,
activeCount,
onReset,
}: {
/** ผ่านตัวกรองแล้ว (เรียงลำดับจาก server คงเดิม) */
items: GroupListItem[];
files: GroupFileSummary[];
totalCount: number;
selectedFile: GroupFileSummary | null;
/** จำนวนรายการทั้งหมดของไฟล์ที่เลือก (ก่อนกรอง) — null = ไม่ได้เลือกไฟล์เดี่ยว */
selectedFileTotal: number | null;
activeCount: number;
onReset: () => void;
}) {
const fileById = useMemo(
() => new Map(files.map((f) => [f.id, f] as const)),
[files],
);
return (
<>
{/* จำนวนผลลัพธ์อยู่ต้นคอลัมน์ content (ที่เห็นผลของตัวกรอง) ไม่ใช่ในแผง */}
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 px-1 pb-2.5">
<p className="text-sm text-ink-soft">
พบ{" "}
<span className="font-semibold tabular-nums text-ink">
{items.length}
</span>{" "}
รายการ
</p>
<div className="flex items-center gap-2.5">
{selectedFile && <OpenFileButton file={selectedFile} />}
{activeCount > 0 && (
<button
type="button"
onClick={onReset}
className="focus-ring rounded-md px-1.5 text-sm text-primary transition hover:underline"
>
ล้างตัวกรอง
</button>
)}
</div>
</div>
{items.length > 0 ? (
<div className="overflow-hidden rounded-xl border border-line bg-white">
<ul className="divide-y divide-line/70">
{items.map((item) => {
// items สร้างจาก files ชุดเดียวกันตอน build ที่ server — หาได้เสมอ
const file = fileById.get(item.dto.fileId)!;
// ปลายทาง + คำบอกตำแหน่งต่อชนิดรายการ (title tooltip + aria-label
// ของปุ่ม View — แถวเดียวกันหลายสิบปุ่มชื่อเหมือนกัน ต้องรู้จักแยก)
const isBm = item.kind === "bm";
const where = isBm
? `ไปที่หน้า ${item.pageIndex + 1}`
: `ดู${annotationTypeLabel(item.dto)}ที่หน้า ${item.pageIndex + 1}`;
const href = isBm
? `/note/annotate/${item.dto.fileId}?page=${item.pageIndex + 1}`
: `/note/annotate/${item.dto.fileId}?ann=${item.dto.id}`;
return (
// แถวเป็น passive surface — จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว
// (คำขอผู้ใช้ 2026-09-24: ไม่ต้องกดทั้งแถว) จึงไม่มี hover bg ที่แถว
<li
key={item.dto.id}
className="flex items-start gap-2.5 px-3 py-3 sm:px-4"
>
{isBm ? (
<>
<Bookmark
aria-hidden
className="mt-1 h-4 w-4 shrink-0 text-warning-ink"
/>
<span className="min-w-0 flex-1">
<span className="block whitespace-pre-wrap break-words text-sm font-medium leading-snug text-ink">
{item.dto.title}
</span>
<RowMeta
file={file}
pageIndex={item.pageIndex}
middle={
item.parentTitle
? `ใน: ${item.parentTitle}`
: undefined
}
user={item.dto.userName || "ไม่ทราบชื่อ"}
date={formatThaiDate(item.dto.createdAt, true)}
/>
{item.dto.note && <RowNote text={item.dto.note} />}
</span>
</>
) : (
<>
<AnnotationTypeIcon
type={item.dto.type}
data={item.dto.data}
className="mt-1 h-4 w-4 shrink-0 text-ink-soft"
/>
<span className="min-w-0 flex-1">
<span className="block whitespace-pre-wrap break-words text-sm leading-snug text-ink">
{annotationPreviewText(item.dto)}
{item.dto.type === "COMMENT" && item.dto.resolved && (
<span className="ml-1.5 whitespace-nowrap rounded border border-success/40 bg-success/10 px-1.5 py-0.5 text-xs font-medium text-success-ink">
จัดการแล้ว
</span>
)}
</span>
<RowMeta
file={file}
pageIndex={item.pageIndex}
middle={annotationTypeLabel(item.dto)}
user={item.dto.userName}
date={formatThaiDate(item.dto.createdAt, true)}
/>
{annotationNote(item.dto) && (
<RowNote text={annotationNote(item.dto)} />
)}
</span>
</>
)}
<Link
href={href}
title={`${where} ใน "${file.title}"`}
aria-label={`View — ${where} ใน "${file.title}"`}
className="focus-ring mt-0.5 inline-flex h-8 shrink-0 items-center rounded-md border border-line bg-white px-3 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-primary hover:text-primary"
>
View
</Link>
</li>
);
})}
</ul>
</div>
) : totalCount === 0 ? (
<div className="rounded-xl border border-dashed border-line bg-white/70 p-10 text-center">
<ClipboardList aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
<p className="mt-3 text-sm font-medium text-ink-soft">
ยังไม่มี Annotation หรือ Bookmark ในกลุ่มนี้
</p>
<p className="mt-1 text-xs text-ink-soft">
เปิดเอกสารในกลุ่มเพื่อมาร์กข้อความ วาดเส้น ใส่คอมเมนต์ หรือคั่นหน้า
</p>
</div>
) : selectedFile && selectedFileTotal === 0 ? (
<div className="rounded-xl border border-dashed border-line bg-white/70 p-10 text-center">
<ClipboardList aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
<p className="mt-3 text-sm font-medium text-ink-soft">
ไฟล์นี้ยังไม่มี Annotation หรือ Bookmark
</p>
<div className="mt-4">
<OpenFileButton file={selectedFile} />
</div>
</div>
) : (
<div className="rounded-xl border border-dashed border-line bg-white/70 p-10 text-center">
<ListFilter aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
<p className="mt-3 text-sm font-medium text-ink-soft">
ไม่มีรายการตรงตามตัวกรอง
</p>
<button
type="button"
onClick={onReset}
className="focus-ring mt-4 inline-flex rounded-md border border-line bg-white px-4 py-2 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
>
ล้างตัวกรองทั้งหมด
</button>
</div>
)}
</>
);
}

View file

@ -1,151 +0,0 @@
"use client";
// client gate ของหน้า /note/annotate/[id] (ฉบับ token 2026-10-30) — เดิมเป็น server
// component อ่าน identity จาก cookie ผ่าน getSessionUser + query DB ตอน render:
// ตอนนี้หน้าเป็น shell public ฝั่ง server (แค่ตรวจ UUID) ส่วนข้อมูลเริ่มต้น
// (file + annotations + bookmarks + replies) ดึงผ่าน GET /api/note/files/[id]/
// bootstrap พร้อม Bearer หลังเห็น token ใน localStorage — ไม่มี/หมดอายุ = หน้า
// gate "กลับ e-Court กดลิงก์ใหม่" (คู่ขนาน sso=required ของรอบ middleware)
import Link from "next/link";
import { useEffect, useState } from "react";
import DebugConsole from "@/components/DebugConsole"; // TEMP-DEBUG (ลบพร้อมไฟล์)
import { SSO_REQUIRED_BANNER } from "@/lib/note-auth";
import {
clearNoteSession,
noteAuthFetch,
readNoteSession,
type NoteSession,
} from "@/lib/note-session";
import type { AnnotationDTO, BookmarkDTO, ReplyDTO } from "@/db/schema";
import PDFViewer from "@/components/pdf/PDFViewer";
type BootstrapData = {
file: { id: string; title: string; pdfUrl: string };
annotations: AnnotationDTO[];
bookmarks: BookmarkDTO[];
replies: ReplyDTO[];
user: { userId: string; userName: string };
};
type Phase = "boot" | "loading" | "no-session" | "error" | "ready";
const BTN_CLS =
"focus-ring inline-flex h-10 items-center rounded-md bg-primary px-5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow";
function GateScreen({ title }: { title: string }) {
return (
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
<div
role="status"
className="rounded-lg border border-warning/30 bg-warning/5 p-4 text-sm text-warning-ink"
>
{SSO_REQUIRED_BANNER.text}
</div>
<p className="mt-3 text-sm text-ink-soft">{title}</p>
<Link href="/note" className={`${BTN_CLS} mt-4`}>
กลับหน้าหลัก
</Link>
</main>
);
}
export default function AnnotateView({ fileId }: { fileId: string }) {
const [phase, setPhase] = useState<Phase>("boot");
const [data, setData] = useState<BootstrapData | null>(null);
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const session: NoteSession | null = readNoteSession();
if (session === null) {
setPhase("no-session");
return;
}
setToken(session.token);
setPhase("loading");
let cancelled = false;
void (async () => {
try {
const res = await noteAuthFetch(`/api/note/files/${fileId}/bootstrap`);
if (res.status === 401) {
// token หมดอายุ/ถูกเพิกถิอกลางวัน — ล้างทิ้งแล้วเป็น gate เหมือนไม่เคยเข้า
clearNoteSession();
if (!cancelled) setPhase("no-session");
return;
}
const body = (await res.json().catch(() => null)) as
| BootstrapData
| { error?: string }
| null;
if (!res.ok || body === null || !("file" in body)) {
if (!cancelled) {
setError(
(body as { error?: string } | null)?.error ??
`โหลดเอกสารไม่สำเร็จ (HTTP ${res.status})`,
);
setPhase("error");
}
return;
}
if (!cancelled) {
setData(body);
setPhase("ready");
}
} catch (err) {
if (cancelled) return;
if (err instanceof Error && err.message === "no-session") {
// session หมดพอดีระหว่างต้น effect กับตอน fetch — เป็น gate ไม่ใช่ network error
clearNoteSession();
setPhase("no-session");
return;
}
setError("โหลดเอกสารไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วลองใหม่อีกครั้ง");
setPhase("error");
}
})();
return () => {
cancelled = true;
};
}, [fileId]);
return (
<>
{/* TEMP-DEBUG: อ่าน log บนจอ iPad ผ่าน Eruda เมื่อเปิดหน้าพร้อม ?debug */}
<DebugConsole />
{phase === "no-session" && (
<GateScreen title="ยังไม่มี session ของแอปนี้บนเบราว์เซอร์ หรือ session หมดอายุแล้ว (อายุ 8 ชม.)" />
)}
{phase === "error" && (
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
<div
role="alert"
className="rounded-lg border border-error/25 bg-error/10 p-4 text-sm text-error-ink"
>
{error}
</div>
<Link href="/note" className={`${BTN_CLS} mt-4`}>
กลับหน้าหลัก
</Link>
</main>
)}
{(phase === "boot" || phase === "loading") && (
<main className="flex min-h-dvh items-center justify-center">
<p className="text-sm text-ink-soft" role="status">
กำลังโหลดเอกสาร…
</p>
</main>
)}
{phase === "ready" && data !== null && token !== null && (
<PDFViewer
file={data.file}
initialAnnotations={data.annotations}
initialBookmarks={data.bookmarks}
initialReplies={data.replies}
user={data.user}
token={token}
/>
)}
</>
);
}

View file

@ -6,83 +6,29 @@ import {
useState, useState,
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
} from "react"; } from "react";
import { import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Pencil, Trash2 } from "lucide-react";
AlignCenter,
AlignLeft,
AlignRight,
BookmarkPlus,
Pencil,
RotateCw,
Trash2,
} from "lucide-react";
import type { import type {
AnnotationData, AnnotationData,
AnnotationDTO, AnnotationDTO,
CommentData,
HighlightData, HighlightData,
HighlightStyle, HighlightStyle,
TextboxData, TextboxData,
TextboxSegment,
} from "@/db/schema"; } from "@/db/schema";
import { import {
COMMENT_PIN_COARSE_SIZE_PX,
COMMENT_PIN_SIZE_PX,
TEXTBOX_MAX_WIDTH_PCT, TEXTBOX_MAX_WIDTH_PCT,
annotationBBox, annotationBBox,
clampStampIntoPage,
commentPinBoxPct,
commentPinBoxPx,
rotatePoint,
scaleAnnotationData, scaleAnnotationData,
stampRect,
translateAnnotationData, translateAnnotationData,
} from "@/utils/geometry"; } from "@/utils/geometry";
import { import { clamp, getPointerPercent, type Point, type Rect, type Size } from "@/utils/coordinates";
clamp, import { STAMP_COLOR } from "@/utils/annotation-display";
getPointerPercent,
type Point,
type Rect,
type Size,
} from "@/utils/coordinates";
import {
annotationTypeLabel,
measureStampText,
STAMP_COLOR,
} from "@/utils/annotation-display";
import { ColorSwatches, HIGHLIGHT_STYLE_BTNS, NumberCombo, PanelRow } from "./ToolControls"; import { ColorSwatches, HIGHLIGHT_STYLE_BTNS, NumberCombo, PanelRow } from "./ToolControls";
/** Patch ของตัวควบคุมเล็กในแผง selection / editor ข้อความ — ส่งเฉพาะ field ที่แตะ
* ให้ viewer ผสานเข้ากับ data ล่าสุด**ตอนถึงคิว update** ไม่ใช่ snapshot ของ render
* นี้ — คอมมิตแบบ full object จาก base เก่าจะทับค่าที่เพิ่งเปลี่ยน (จอสัมผัส:
* blur-commit ข้อความใน editor แล้วแตะสี/ขนาดในแผงต่อทันทีก่อน React render
* optimistic row — code review 2026-09-28 รอบสอง) */
export type AnnotationPatch =
/** สี — ชื่อ field ตามชนิด: HIGHLIGHT/STAMP = `color`, DRAW/SHAPE = `strokeColor` */
| { kind: "color"; color: string }
/** ความหนา — DRAW สเกล `widths` ต่อจุดตามอัตราส่วนตอนผสาน (เหมือนเดิม) */
| { kind: "strokeWidth"; strokeWidth: number }
| { kind: "highlightStyle"; style: HighlightStyle }
/** ความทึบของ HIGHLIGHT — 100 = ถอด field `opacity` ทิ้งเหมือนตอนสร้างใหม่ */
| { kind: "highlightOpacity"; opacityPct: number }
| { kind: "stampScale"; scale: number }
/** STAMP — มุมเอียง (องศา −180..180 หมุนรอบกลางกรอบ; 0 = ถอด field เหมือนแถวเดิม) */
| { kind: "stampRotation"; rotation: number }
| {
kind: "textbox";
fields: Partial<
Pick<TextboxData, "color" | "fontSize" | "opacity" | "bold" | "align">
>;
}
/** StampEditor — แก้คำตรายาง */
| { kind: "label"; label: string }
/** TextboxEditor — แก้ข้อความกล่องข้อความ (segments null = เคลียร์ตัวหนาทั้งก้อน) */
| { kind: "textboxText"; text: string; segments: TextboxSegment[] | null };
/** UX audit 2026-09-16: focus ring คีย์บอร์ด — ค่าเดียวกับ FOCUS_RING_CLS ของ /** UX audit 2026-09-16: focus ring คีย์บอร์ด — ค่าเดียวกับ FOCUS_RING_CLS ของ
* PDFViewer/AnnotationSidebar (ประกาศแยกที่นี่กัน circular import) */ * PDFViewer/AnnotationSidebar (ประกาศแยกที่นี่กัน circular import) */
const FOCUS_RING_CLS = const FOCUS_RING_CLS =
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"; "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1";
// กรอบ selection + handles ของ annotation ที่เลือก (PLAN-FEAT.md §3 F1) // กรอบ selection + handles ของ annotation ที่เลือก (PLAN-FEAT.md §3 F1)
// - container เป็น z-4 เหมือน comment pins และ pointer-events: none **เสมอ** // - container เป็น z-4 เหมือน comment pins และ pointer-events: none **เสมอ**
@ -93,29 +39,20 @@ const FOCUS_RING_CLS =
const HANDLE_PX = 10; // ขนาดมุมจับ (scale) const HANDLE_PX = 10; // ขนาดมุมจับ (scale)
const STRIP_PX = 8; // ความหนาขอบกรอบ (translate) const STRIP_PX = 8; // ความหนาขอบกรอบ (translate)
const ROT_HANDLE_PX = 18; // จุดจับหมุนตรายาง (STAMP) — วงกลมเหนือกึ่งกลางขอบบน
const ROT_HANDLE_GAP_PX = 20; // ระยะแนวฉากจากขอบบนกรอบถึงจุดจับหมุน
const MIN_BOX_PCT = 0.5; // bbox เล็กสุด (% ของหน้า) กันกรอบหาย const MIN_BOX_PCT = 0.5; // bbox เล็กสุด (% ของหน้า) กันกรอบหาย
const EPS = 1e-6; const EPS = 1e-6;
// COMMENT: ขนาด pin px + bbox px/% ของ pin ย้ายไป utils/geometry.ts แล้ว (ใช้ร่วมกับ
// goToAnnotation ของ PDFViewer — single source ตาม responsive audit 2026-09-24)
type DragState = { type DragState = {
// wleft/wright = TEXTBOX กว้างคงที่: ลากขอบซ้าย/ขวาปรับความกว้าง (ฟอนต์คงขนาด) // wleft/wright = TEXTBOX กว้างคงที่: ลากขอบซ้าย/ขวาปรับความกว้าง (ฟอนต์คงขนาด)
mode: "move" | "scale" | "wleft" | "wright" | "rotate"; mode: "move" | "scale" | "wleft" | "wright";
/** ตำแหน่ง pointer ตอนเริ่มลาก (% ของหน้า) */ /** ตำแหน่ง pointer ตอนเริ่มลาก (% ของหน้า) */
startPct: Point; startPct: Point;
/** data ก่อนเริ่มลาก — ต้นทางของการคำนวณทุก move (ไม่สะสมจาก preview) */ /** data ก่อนเริ่มลาก — ต้นทางของการคำนวณทุก move (ไม่สะสมจาก preview) */
origData: AnnotationData; origData: AnnotationData;
/** bbox ก่อนเริ่มลาก (% ของหน้า) — STAMP เอียง = AABB ของกรอบหมุนแล้ว (ครอบกรอบจริง) */ /** bbox ก่อนเริ่มลาก (% ของหน้า) */
origBoxPct: Rect; origBoxPct: Rect;
/** mode "scale" เท่านั้น — มุมตรงข้ามของ handle (% ของหน้า ตำแหน่งจริงบนจอ) */ /** mode "scale" เท่านั้น — มุมตรงข้ามของ handle (% ของหน้า) */
anchorPct: Point; anchorPct: Point;
/** STAMP เอียงเท่านั้น — มุมกรอบตอนเริ่มลาก (องศา): คำนวณ k ของ scale ต้อง
* แปลง pointer เข้า frame ของกรอบ (ย้อนหมุนรอบ center) ก่อน ไม่งั้นลากมุมของ
* กรอบเอียงแล้วสัดส่วนแกน x/y ผิดจากสายตา */
rot?: number;
}; };
/** จำกัดตัวคูณ scale แกนเดียว ให้กรอบผลลัพธ์ยังอยู่ใน 0–100 และไม่เล็กกว่าขั้นต่ำ */ /** จำกัดตัวคูณ scale แกนเดียว ให้กรอบผลลัพธ์ยังอยู่ใน 0–100 และไม่เล็กกว่าขั้นต่ำ */
@ -152,7 +89,6 @@ export default function AnnotationSelectionLayer({
onDelete, onDelete,
onBookmark, onBookmark,
onStartEdit, onStartEdit,
onPatch,
}: { }: {
annotation: AnnotationDTO; annotation: AnnotationDTO;
/** ขนาด px ของหน้า (จาก width/height ที่คำนวณจาก scale) */ /** ขนาด px ของหน้า (จาก width/height ที่คำนวณจาก scale) */
@ -174,87 +110,14 @@ export default function AnnotationSelectionLayer({
/** M1: ปุ่ม "แก้ไขข้อความ" ของ TEXTBOX — เข้าโหมดแก้ไขโดยไม่ต้อง double-click /** M1: ปุ่ม "แก้ไขข้อความ" ของ TEXTBOX — เข้าโหมดแก้ไขโดยไม่ต้อง double-click
* (จอสัมผัสไม่มี dblclick ที่เชื่อถือได้: iOS double-tap = zoom หน้า) */ * (จอสัมผัสไม่มี dblclick ที่เชื่อถือได้: iOS double-tap = zoom หน้า) */
onStartEdit?: (id: string) => void; onStartEdit?: (id: string) => void;
/** ตัวควบคุมเล็กของแผง (สี/ความหนา/ขนาด/รูปแบบ/ทึบ/patch กล่องข้อความ) commit เป็น
* patch — viewer ผสานกับ data ล่าสุดตอนถึงคิว update กัน stale base ทับค่าที่เพิ่ง
* เปลี่ยน (ดู AnnotationPatch) */
onPatch: (id: string, patch: AnnotationPatch) => void;
}) { }) {
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null); const dragRef = useRef<DragState | null>(null);
const movedRef = useRef(false); const movedRef = useRef(false);
const latestRef = useRef<AnnotationData | null>(null); const latestRef = useRef<AnnotationData | null>(null);
// rAF ของ preview ระหว่างลาก — ยิง onPreview ≤1 ครั้ง/เฟรม (pattern เดียวกับ FIX-8
// ของ InkLayer): pointermove ยิงถี่กว่าเฟรมได้ ทุก event ยังคำนวณ+เก็บค่าล่าสุดใน
// latestRef เสมอ (ค่าตอน commit ต้องล่าสุดสุด) เฉพาะการ render ที่จัดคิวตามเฟรม
const previewRafRef = useRef<number | null>(null);
/** ยกเลิก rAF preview ที่ค้าง — เรียกก่อนเคลียร์ preview ทุกทางออกของ drag (ไม่งั้น const boxPx = annotationBBox(annotation, size);
* callback ยิงตามหลัง ดัน preview เก่ากลับมาตอน dragRef เคลียร์ไปแล้ว) */ const boxPct = annotationBBox(annotation, { width: 100, height: 100 });
function cancelPreviewRaf() {
if (previewRafRef.current === null) return;
cancelAnimationFrame(previewRafRef.current);
previewRafRef.current = null;
}
// เลิกลากกลางคันแล้ว unmount — ยกเลิก rAF ที่ค้างอยู่ด้วย (เหมือน InkLayer)
useEffect(
() => () => {
cancelPreviewRaf();
},
[],
);
// อุปกรณ์จอสัมผัส (pointer หลัก coarse) — คำนวณครั้งเดียวตอน mount เหมือน
// coarseTouch ของ PDFViewer: .coarse-36 ขยายปุ่ม pin เป็น 36px บนพวกนี้
const [coarseTouch] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia("(pointer: coarse)").matches,
);
// COMMENT ใช้กล่องครอบ pin จริง (px คงที่ตามอุปกรณ์) — ชนิดอื่นคือ bbox ตามขนาดหน้า
// boxPct ก็ใช้มาตราฐานเดียวกัน (% จาก px จริง): move-clamp กับปุ่ม bookmark ของ pin
// ต้องเห็น pin ตามที่ render ไม่ใช่ COMMENT_PIN_BOX_PCT ประมาณ (audit 2026-09-24)
const isCommentPin = annotation.type === "COMMENT";
const pinPx = coarseTouch ? COMMENT_PIN_COARSE_SIZE_PX : COMMENT_PIN_SIZE_PX;
// bbox ของ STAMP ส่ง measureStampText ให้ความสูงตรงกับที่ StampItem render จริง
// (ประมาณแล้วขอบล่างกรอบเกินตรายาง — bug 2026-09-28)
const boxPx = isCommentPin
? commentPinBoxPx(annotation.data as CommentData, size, pinPx)
: annotationBBox(annotation, size, measureStampText);
const boxPct = isCommentPin
? commentPinBoxPct(annotation.data as CommentData, size, pinPx)
: annotationBBox(annotation, { width: 100, height: 100 }, measureStampText);
// STAMP เอียง (rotation ≠ 0) — กรอบ+handles วาดที่ตำแหน่งกรอบ*ก่อนหมุน* แล้วหมุน
// ทั้งชุดด้วย CSS transform รอบกลางกรอบ (ตรงกับที่ StampItem render) ส่วน boxPx/
// boxPct ด้านบนเป็น AABB ของกรอบหมุนแล้ว ใช้กับ clamp ย้าย/แผงเครื่องมือ/ป้ายชื่อ
// stampData narrow ด้วย "label" in ตรงนี้แล้วส่งเข้า stampRect ตรง ๆ — ไม่ as-cast
// (cast พังเงียบถ้าเงื่อนไขแวดข้างเปลี่ยน — code review 2026-09-29)
const stampData = "label" in annotation.data ? annotation.data : null;
const stampRot = stampData?.rotation ?? 0;
const isRotStamp = stampRot !== 0;
const framePx =
stampData && isRotStamp ? stampRect(stampData, size, measureStampText) : boxPx;
const framePct =
stampData && isRotStamp
? stampRect(stampData, { width: 100, height: 100 }, measureStampText)
: boxPct;
// transform หมุนทั้งชุดกรอบ — origin เป็น**กลางกรอบในพิกัดของ element นั้น ๆ** (CSS
// transform-origin วัดจากมุมซ้ายบนของ element เอง ไม่ใช่ของ page — จุดกลางกรอบ
// ในพิกัด page ลบด้วย left/top ของ element จึงได้ origin ที่ทุก div หมุนไปตรงจุด
// เดียวกัน ตามตัวตรายาง — bug "กรอบไม่ตรงตัวเมื่อเอียง" 2026-09-28)
const rotC = {
x: framePx.x + framePx.width / 2,
y: framePx.y + framePx.height / 2,
};
const rotOn = (elLeft: number, elTop: number) =>
isRotStamp
? {
transform: `rotate(${stampRot}deg)`,
transformOrigin: `${rotC.x - elLeft}px ${rotC.y - elTop}px`,
}
: undefined;
// FIX-9: เอา non-null ! ออก — handler ยิงได้แค่ตอน DOM mount แล้ว // FIX-9: เอา non-null ! ออก — handler ยิงได้แค่ตอน DOM mount แล้ว
// ref จึงไม่ควรเป็น null (คืน 0,0 เผื่อไว้เฉย ๆ ให้ type ชัด) // ref จึงไม่ควรเป็น null (คืน 0,0 เผื่อไว้เฉย ๆ ให้ type ชัด)
@ -266,7 +129,7 @@ export default function AnnotationSelectionLayer({
function beginDrag( function beginDrag(
e: ReactPointerEvent<HTMLElement>, e: ReactPointerEvent<HTMLElement>,
mode: "move" | "scale" | "wleft" | "wright" | "rotate", mode: "move" | "scale" | "wleft" | "wright",
anchorPct: Point, anchorPct: Point,
) { ) {
if (!editable || e.button !== 0) return; if (!editable || e.button !== 0) return;
@ -279,7 +142,6 @@ export default function AnnotationSelectionLayer({
origData: annotation.data, origData: annotation.data,
origBoxPct: boxPct, origBoxPct: boxPct,
anchorPct, anchorPct,
...(stampRot !== 0 ? { rot: stampRot } : null),
}; };
movedRef.current = false; movedRef.current = false;
latestRef.current = null; latestRef.current = null;
@ -291,30 +153,7 @@ export default function AnnotationSelectionLayer({
e.stopPropagation(); e.stopPropagation();
const cur = pointerPct(e); const cur = pointerPct(e);
let next: AnnotationData; let next: AnnotationData;
if (st.mode === "rotate") { if (st.mode === "move") {
// STAMP — ลากหมุน: มุมใหม่ = มุมเดิม + มุมที่ pointer กวาดรอบ**กลางกรอบ**
// (atan2 ในพิกัดจอ y ลง = ทิศเดียวกับ CSS rotate) — snap ดูดเข้าทวีคูณ 15°
// เมื่อใกล้ ≤4° ให้ตั้ง 0/±45/±90 เร็ว แล้ว wrap เข้า (−180, 180] เก็บ 1 ทศนิยม
// (0° ถอด field เหมือน patch ของแผง — anchorPct = กลางกรอบจาก AABB)
const ctr = st.anchorPct;
const a0 = Math.atan2(st.startPct.y - ctr.y, st.startPct.x - ctr.x);
const a1 = Math.atan2(cur.y - ctr.y, cur.x - ctr.x);
let deg = (st.rot ?? 0) + ((a1 - a0) * 180) / Math.PI;
const snap = Math.round(deg / 15) * 15;
if (Math.abs(deg - snap) <= 4) deg = snap;
deg = Number(((((deg + 180) % 360) + 360) % 360 - 180).toFixed(1));
if ("label" in st.origData) {
const { rotation: _prev, ...rest } = st.origData;
// หมุนจนกรอบยื่นเกินขอบหน้า = เลื่อนกลับเข้าหน้าทันทีระหว่างลาก (เดียวกับ
// patch ของแผง — validator ตรวจ AABB ไม่ติดลบ, code review 2026-09-28)
next =
deg === 0
? rest
: { ...rest, rotation: deg, position: clampStampIntoPage({ ...rest, rotation: deg }) };
} else {
next = st.origData;
}
} else if (st.mode === "move") {
// clamp offset ให้ bbox ทั้งกลุ่มยังอยู่ในหน้า (พิกัด % ติดลบโดน action ปฏิเสธ) // clamp offset ให้ bbox ทั้งกลุ่มยังอยู่ในหน้า (พิกัด % ติดลบโดน action ปฏิเสธ)
const dx = clamp( const dx = clamp(
cur.x - st.startPct.x, cur.x - st.startPct.x,
@ -358,29 +197,12 @@ export default function AnnotationSelectionLayer({
next = { ...d, width: w, position: { ...d.position, x: left } }; next = { ...d, width: w, position: { ...d.position, x: left } };
} }
} else if ("scale" in st.origData) { } else if ("scale" in st.origData) {
// STAMP — ความกว้างมีมาจาก scale เพียงตัวเดียว (สูงตามจำนวนบรรทัดของ label, // STAMP — สัดส่วนกว้าง:สูงตายตัว (สูง = STAMP_HEIGHT_RATIO × กว้าง) แยกสเกล
// stampLayout) จึงขยายด้วยตัวคูณ uniform เดียว: เฉลี่ยอัตราส่วนของ pointer สองแกน // สองแกนไม่ได้ จึงใช้ตัวคูณ uniform เดียว: เฉลี่ยอัตราส่วนของ pointer สองแกน
// แล้ว clamp รายแกนซ้อนกัน (x แล้ว y) ให้กรอบผลลัพธ์ยังอยู่ในหน้าทั้งสองแกนพร้อมกัน // แล้ว clamp รายแกนซ้อนกัน (x แล้ว y) ให้กรอบผลลัพธ์ยังอยู่ในหน้าทั้งสองแกนพร้อมกัน
// · STAMP เอียง: อัตราส่วนต้องวัดในแกนของ*กรอบ* — ย้อนหมุน pointer รอบกลางกรอบ
// ก่อน (แกน x/y ของสูตรคือแกนกรอบ ไม่ใช่แกนจอ) ส่วน clamp กับ scaleAnnotationData
// ใช้พิกัดจอ: มุมกรอบหมุนทั้งชุดโดย scale-uniform รอบ anchor จริง ๆ (AABB ของ
// กรอบหมุน scale ตาม k รอบ anchor เช่นกัน) clamp ด้วยขอบ AABB จึงยังถูกต้อง
const rot = st.rot ?? 0;
let curL = cur;
let startL = st.startPct;
let anchorL = st.anchorPct;
if (rot) {
const ctr = {
x: st.origBoxPct.x + st.origBoxPct.width / 2,
y: st.origBoxPct.y + st.origBoxPct.height / 2,
};
curL = rotatePoint(cur, ctr, -rot);
startL = rotatePoint(st.startPct, ctr, -rot);
anchorL = rotatePoint(st.anchorPct, ctr, -rot);
}
const rawK = const rawK =
((curL.x - anchorL.x) / (startL.x - anchorL.x || EPS) + ((cur.x - st.anchorPct.x) / (st.startPct.x - st.anchorPct.x || EPS) +
(curL.y - anchorL.y) / (startL.y - anchorL.y || EPS)) / (cur.y - st.anchorPct.y) / (st.startPct.y - st.anchorPct.y || EPS)) /
2; 2;
const k = clampAxisScale( const k = clampAxisScale(
clampAxisScale( clampAxisScale(
@ -415,14 +237,7 @@ export default function AnnotationSelectionLayer({
} }
movedRef.current = true; movedRef.current = true;
latestRef.current = next; latestRef.current = next;
// render preview ครั้งเดียวต่อ animation frame — callback อ่าน latestRef ตอนเฟรม onPreview(next);
// ยิงจริง ๆ เพื่อให้ได้ค่าใหม่สุดเสมอแม้ event ถี่ (แก้ลากไม่ smooth 2026-09-29)
if (previewRafRef.current === null) {
previewRafRef.current = requestAnimationFrame(() => {
previewRafRef.current = null;
if (dragRef.current && latestRef.current) onPreview(latestRef.current);
});
}
} }
function endDrag(e: ReactPointerEvent<HTMLElement>) { function endDrag(e: ReactPointerEvent<HTMLElement>) {
@ -430,7 +245,6 @@ export default function AnnotationSelectionLayer({
if (!st) return; if (!st) return;
e.stopPropagation(); e.stopPropagation();
dragRef.current = null; dragRef.current = null;
cancelPreviewRaf();
onPreview(null); onPreview(null);
if (movedRef.current && latestRef.current) onCommit(annotation.id, latestRef.current); if (movedRef.current && latestRef.current) onCommit(annotation.id, latestRef.current);
movedRef.current = false; movedRef.current = false;
@ -440,40 +254,59 @@ export default function AnnotationSelectionLayer({
function cancelDrag() { function cancelDrag() {
if (!dragRef.current) return; if (!dragRef.current) return;
dragRef.current = null; dragRef.current = null;
cancelPreviewRaf();
movedRef.current = false; movedRef.current = false;
latestRef.current = null; latestRef.current = null;
onPreview(null); onPreview(null);
} }
/** ปุ่มสี/ความหนาบนกรอบ — ส่ง patch ให้ viewer ผสานกับ data ล่าสุด (ชื่อ field ตาม /** ปุ่มสี/ความหนาบนกรอบ — ใช้ onCommit เดียวกับการลาก */
* ชนิด: HIGHLIGHT/STAMP = color, DRAW/SHAPE = strokeColor — ตัดสินตอนผสาน) */
function commitColor(color: string) { function commitColor(color: string) {
onPatch(annotation.id, { kind: "color", color }); const d = annotation.data;
if ("rects" in d) onCommit(annotation.id, { ...d, color });
else if ("paths" in d || "shape" in d) onCommit(annotation.id, { ...d, strokeColor: color });
// STAMP ("label") — แก้สีหลังสร้างได้ (TEXTBOX แก้ผ่านการพิมพ์ inline แทน)
else if ("label" in d) onCommit(annotation.id, { ...d, color });
} }
function commitStrokeWidth(w: number) { function commitStrokeWidth(w: number) {
onPatch(annotation.id, { kind: "strokeWidth", strokeWidth: w }); const d = annotation.data;
if ("paths" in d) {
// F7: เส้นปากกามี widths ต่อจุด — scale ตามอัตราส่วนพร้อม strokeWidth หลัก (PLAN-FEAT.md §5)
onCommit(annotation.id, {
...d,
strokeWidth: w,
...(d.widths
? { widths: d.widths.map((pw) => (pw / d.strokeWidth) * w) }
: {}),
});
} else if ("shape" in d) {
onCommit(annotation.id, { ...d, strokeWidth: w });
}
} }
/** HIGHLIGHT — เปลี่ยนรูปแบบมาร์กหลังสร้าง (ไฮไลต์/เส้นใต้/ขีดฆ่า/ขยุกขยิก) */ /** HIGHLIGHT — เปลี่ยนรูปแบบมาร์กหลังสร้าง (ไฮไลต์/เส้นใต้/ขีดฆ่า/ขยุกขยิก) */
function commitHighlightStyle(style: HighlightStyle) { function commitHighlightStyle(style: HighlightStyle) {
onPatch(annotation.id, { kind: "highlightStyle", style }); const d = annotation.data;
if (!("rects" in d)) return;
onCommit(annotation.id, { ...d, style });
} }
/** HIGHLIGHT — ปรับความทึบหลังสร้าง (100% = ทึบเต็ม = ถอด field opacity ตอนผสาน) */ /** HIGHLIGHT — ปรับความทึบหลังสร้าง (100% = ทึบเต็ม = ไม่เก็บ field เหมือนตอนสร้างใหม่) */
function commitHighlightOpacity(opacityPct: number) { function commitHighlightOpacity(opacityPct: number) {
onPatch(annotation.id, { kind: "highlightOpacity", opacityPct }); const d = annotation.data;
if (!("rects" in d)) return;
const { opacity: _prev, ...rest } = d;
onCommit(annotation.id, {
...rest,
...(opacityPct < 100 ? { opacity: opacityPct / 100 } : {}),
});
} }
/** STAMP — ปรับขนาดตรายางหลังสร้าง */ /** STAMP — ปรับขนาดตรายางหลังสร้าง */
function commitStampScale(scale: number) { function commitStampScale(scale: number) {
onPatch(annotation.id, { kind: "stampScale", scale }); const d = annotation.data;
} if (!("label" in d)) return;
onCommit(annotation.id, { ...d, scale });
/** STAMP — ปรับมุมเอียงหลังสร้าง (องศา −180..180 หมุนรอบกลางกรอบ) */
function commitStampRotation(rotation: number) {
onPatch(annotation.id, { kind: "stampRotation", rotation });
} }
/** TEXTBOX — แก้ค่าทั้งกล่องหลังสร้าง (สี/ขนาด/ทึบ/หนา/จัดแนว) เหมือน dropdown ของ tool */ /** TEXTBOX — แก้ค่าทั้งกล่องหลังสร้าง (สี/ขนาด/ทึบ/หนา/จัดแนว) เหมือน dropdown ของ tool */
@ -481,7 +314,7 @@ export default function AnnotationSelectionLayer({
patch: Partial<Pick<TextboxData, "color" | "fontSize" | "opacity" | "bold" | "align">>, patch: Partial<Pick<TextboxData, "color" | "fontSize" | "opacity" | "bold" | "align">>,
) { ) {
if (!textboxData) return; if (!textboxData) return;
onPatch(annotation.id, { kind: "textbox", fields: patch }); onCommit(annotation.id, { ...textboxData, ...patch });
} }
// ตัวควบคุมในแผงตรวจตามชนิดด้วยการเช็ค field ใน data ตรง ๆ (rects = HIGHLIGHT, // ตัวควบคุมในแผงตรวจตามชนิดด้วยการเช็ค field ใน data ตรง ๆ (rects = HIGHLIGHT,
@ -492,10 +325,8 @@ export default function AnnotationSelectionLayer({
const highlightData: HighlightData | null = const highlightData: HighlightData | null =
"rects" in annotation.data ? annotation.data : null; "rects" in annotation.data ? annotation.data : null;
const strokeData = const strokeData =
"paths" in annotation.data || "shape" in annotation.data "paths" in annotation.data || "shape" in annotation.data ? annotation.data : null;
? annotation.data const stampData = "label" in annotation.data ? annotation.data : null;
: null;
// (stampData ย้ายไปขึ้นบน — ใช้ร่วมกับ stampRot/framePx ของตรายางเอียง)
const textboxData: TextboxData | null = const textboxData: TextboxData | null =
"fontSize" in annotation.data ? annotation.data : null; "fontSize" in annotation.data ? annotation.data : null;
// TEXTBOX กว้างคงที่ (มี width) — ขอบซ้าย/ขวาเป็น handle ปรับความกว้างแทนการย้าย // TEXTBOX กว้างคงที่ (มี width) — ขอบซ้าย/ขวาเป็น handle ปรับความกว้างแทนการย้าย
@ -506,48 +337,36 @@ export default function AnnotationSelectionLayer({
// กรณีหลังหุบเป็น w-fit ไม่คั่นเส้น และปุ่มติดกันแทน justify-between // กรณีหลังหุบเป็น w-fit ไม่คั่นเส้น และปุ่มติดกันแทน justify-between
const hasStyleControls = !!(highlightData || strokeData || stampData || textboxData); const hasStyleControls = !!(highlightData || strokeData || stampData || textboxData);
// มุมทั้ง 4 — ตำแหน่งวางเป็น px ของกรอบ*ก่อนหมุน* (STAMP เอียง: div ทั้งชุดถูกหมุน // มุมทั้ง 4 — ตำแหน่งวางเป็น px, anchor มุมตรงข้ามเป็น % ตรง ๆ จาก boxPct
// ทีหลังผ่าน rotStyle), anchor มุมตรงข้ามเป็น % **ตำแหน่งจริงบนจอ** — STAMP เอียง // (พิกัด % คำนวณผ่าน utils/coordinates เท่านั้น — invariant #2)
// ต้องหมุนมุมกลับออกมาก่อน (พิกัด % คำนวณผ่าน utils/coordinates เท่านั้น — invariant #2)
const frameCtrPct = isRotStamp
? {
x: framePct.x + framePct.width / 2,
y: framePct.y + framePct.height / 2,
}
: null;
const anchorOnScreen = (p: Point): Point =>
frameCtrPct ? rotatePoint(p, frameCtrPct, stampRot) : p;
const corners: { key: string; x: number; y: number; cursor: string; anchor: Point }[] = [ const corners: { key: string; x: number; y: number; cursor: string; anchor: Point }[] = [
{ {
key: "nw", key: "nw",
x: framePx.x, x: boxPx.x,
y: framePx.y, y: boxPx.y,
cursor: "nwse-resize", cursor: "nwse-resize",
anchor: anchorOnScreen({ anchor: { x: boxPct.x + boxPct.width, y: boxPct.y + boxPct.height },
x: framePct.x + framePct.width,
y: framePct.y + framePct.height,
}),
}, },
{ {
key: "ne", key: "ne",
x: framePx.x + framePx.width, x: boxPx.x + boxPx.width,
y: framePx.y, y: boxPx.y,
cursor: "nesw-resize", cursor: "nesw-resize",
anchor: anchorOnScreen({ x: framePct.x, y: framePct.y + framePct.height }), anchor: { x: boxPct.x, y: boxPct.y + boxPct.height },
}, },
{ {
key: "sw", key: "sw",
x: framePx.x, x: boxPx.x,
y: framePx.y + framePx.height, y: boxPx.y + boxPx.height,
cursor: "nesw-resize", cursor: "nesw-resize",
anchor: anchorOnScreen({ x: framePct.x + framePct.width, y: framePct.y }), anchor: { x: boxPct.x + boxPct.width, y: boxPct.y },
}, },
{ {
key: "se", key: "se",
x: framePx.x + framePx.width, x: boxPx.x + boxPx.width,
y: framePx.y + framePx.height, y: boxPx.y + boxPx.height,
cursor: "nwse-resize", cursor: "nwse-resize",
anchor: anchorOnScreen({ x: framePct.x, y: framePct.y }), anchor: { x: boxPct.x, y: boxPct.y },
}, },
]; ];
@ -577,16 +396,14 @@ export default function AnnotationSelectionLayer({
style={{ pointerEvents: "none" }} style={{ pointerEvents: "none" }}
onClick={(e) => e.stopPropagation()} // คลิกบน handle/toolbar ไม่ให้ bubble ไป deselect ที่ page onClick={(e) => e.stopPropagation()} // คลิกบน handle/toolbar ไม่ให้ bubble ไป deselect ที่ page
> >
{/* กรอบ bbox — ตัวกรอบเอง pointer-events none ผ่านไป textLayer ได้ · STAMP เอียง {/* กรอบ bbox — ตัวกรอบเอง pointer-events none ผ่านไป textLayer ได้ */}
วาดที่กรอบก่อนหมุนแล้วหมุนทั้งชุด (กรอบ+handles) รอบกลางกรอบเหมือน StampItem */}
<div <div
className="absolute border-2 border-dashed border-primary" className="absolute border-2 border-dashed border-primary"
style={{ style={{
left: framePx.x, left: boxPx.x,
top: framePx.y, top: boxPx.y,
width: framePx.width, width: boxPx.width,
height: framePx.height, height: boxPx.height,
...rotOn(framePx.x, framePx.y),
}} }}
/> />
@ -599,12 +416,11 @@ export default function AnnotationSelectionLayer({
<div <div
className="absolute cursor-move touch-none sel-strip" className="absolute cursor-move touch-none sel-strip"
style={{ style={{
left: framePx.x - STRIP_PX / 2, left: boxPx.x - STRIP_PX / 2,
top: framePx.y - STRIP_PX / 2, top: boxPx.y - STRIP_PX / 2,
width: framePx.width + STRIP_PX, width: boxPx.width + STRIP_PX,
height: STRIP_PX, height: STRIP_PX,
pointerEvents: "auto", pointerEvents: "auto",
...rotOn(framePx.x - STRIP_PX / 2, framePx.y - STRIP_PX / 2),
}} }}
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })} onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
onPointerMove={moveDrag} onPointerMove={moveDrag}
@ -614,12 +430,11 @@ export default function AnnotationSelectionLayer({
<div <div
className="absolute cursor-move touch-none sel-strip" className="absolute cursor-move touch-none sel-strip"
style={{ style={{
left: framePx.x - STRIP_PX / 2, left: boxPx.x - STRIP_PX / 2,
top: framePx.y + framePx.height - STRIP_PX / 2, top: boxPx.y + boxPx.height - STRIP_PX / 2,
width: framePx.width + STRIP_PX, width: boxPx.width + STRIP_PX,
height: STRIP_PX, height: STRIP_PX,
pointerEvents: "auto", pointerEvents: "auto",
...rotOn(framePx.x - STRIP_PX / 2, framePx.y + framePx.height - STRIP_PX / 2),
}} }}
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })} onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
onPointerMove={moveDrag} onPointerMove={moveDrag}
@ -629,12 +444,11 @@ export default function AnnotationSelectionLayer({
<div <div
className={`absolute touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`} className={`absolute touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
style={{ style={{
left: framePx.x - STRIP_PX / 2, left: boxPx.x - STRIP_PX / 2,
top: framePx.y, top: boxPx.y,
width: STRIP_PX, width: STRIP_PX,
height: framePx.height, height: boxPx.height,
pointerEvents: "auto", pointerEvents: "auto",
...rotOn(framePx.x - STRIP_PX / 2, framePx.y),
}} }}
onPointerDown={(e) => onPointerDown={(e) =>
beginDrag(e, isFixedTextbox ? "wleft" : "move", { x: 0, y: 0 }) beginDrag(e, isFixedTextbox ? "wleft" : "move", { x: 0, y: 0 })
@ -646,12 +460,11 @@ export default function AnnotationSelectionLayer({
<div <div
className={`absolute touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`} className={`absolute touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
style={{ style={{
left: framePx.x + framePx.width - STRIP_PX / 2, left: boxPx.x + boxPx.width - STRIP_PX / 2,
top: framePx.y, top: boxPx.y,
width: STRIP_PX, width: STRIP_PX,
height: framePx.height, height: boxPx.height,
pointerEvents: "auto", pointerEvents: "auto",
...rotOn(framePx.x + framePx.width - STRIP_PX / 2, framePx.y),
}} }}
onPointerDown={(e) => onPointerDown={(e) =>
beginDrag(e, isFixedTextbox ? "wright" : "move", { x: 0, y: 0 }) beginDrag(e, isFixedTextbox ? "wright" : "move", { x: 0, y: 0 })
@ -662,10 +475,8 @@ export default function AnnotationSelectionLayer({
/> />
{/* มุมทั้ง 4 — ลาก = scale รอบ anchor มุมตรงข้าม (TEXTBOX กว้างคงที่ไม่มีมุม — {/* มุมทั้ง 4 — ลาก = scale รอบ anchor มุมตรงข้าม (TEXTBOX กว้างคงที่ไม่มีมุม —
ปรับความกว้างที่ขอบซ้าย/ขวาแทน ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ; COMMENT ไม่ ปรับความกว้างที่ขอบซ้าย/ขวาแทน ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ) */}
มีมุมเช่นกัน — pin ไม่มี field ขนาด scale จึงไร้ความหมาย และ anchor จาก boxPct {!isFixedTextbox && corners.map((c) => (
(% รอบจุด) กับ handle บนกล่อง pin คนละกล่องทำให้ลากแล้วเหลื่อมจากนิ้ว) */}
{!isFixedTextbox && !isCommentPin && corners.map((c) => (
<div <div
key={c.key} key={c.key}
className="absolute touch-none sel-handle rounded-sm border border-primary bg-primary/50" className="absolute touch-none sel-handle rounded-sm border border-primary bg-primary/50"
@ -676,7 +487,6 @@ export default function AnnotationSelectionLayer({
height: HANDLE_PX, height: HANDLE_PX,
cursor: c.cursor, cursor: c.cursor,
pointerEvents: "auto", pointerEvents: "auto",
...rotOn(c.x - HANDLE_PX / 2, c.y - HANDLE_PX / 2),
}} }}
onPointerDown={(e) => beginDrag(e, "scale", c.anchor)} onPointerDown={(e) => beginDrag(e, "scale", c.anchor)}
onPointerMove={moveDrag} onPointerMove={moveDrag}
@ -685,59 +495,9 @@ export default function AnnotationSelectionLayer({
/> />
))} ))}
{/* STAMP — จุดจับหมุนเหนือกึ่งกลางขอบบนของกรอบ (หมุนตามกรอบผ่าน rotOn):
ลากวนรอบกลางกรอบ = ปรับมุมเอียงแทนการพิมพ์ในแผงอย่างเดียว (คำขอผู้ใช้
2026-09-28) — anchor ของ mode "rotate" = กลางกรอบ (% จาก AABB) */}
{stampData && (
<div
className="absolute flex touch-none sel-handle cursor-grab items-center justify-center rounded-full border border-primary bg-white text-primary shadow-lg"
style={{
left: rotC.x - ROT_HANDLE_PX / 2,
top: framePx.y - ROT_HANDLE_GAP_PX - ROT_HANDLE_PX / 2,
width: ROT_HANDLE_PX,
height: ROT_HANDLE_PX,
pointerEvents: "auto",
...rotOn(
rotC.x - ROT_HANDLE_PX / 2,
framePx.y - ROT_HANDLE_GAP_PX - ROT_HANDLE_PX / 2,
),
}}
title="ลากเพื่อหมุนตรายาง"
aria-label="ลากเพื่อหมุนตรายาง"
onPointerDown={(e) =>
beginDrag(e, "rotate", {
x: boxPct.x + boxPct.width / 2,
y: boxPct.y + boxPct.height / 2,
})
}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
>
<RotateCw className="h-3 w-3" />
</div>
)}
</> </>
)} )}
{/* Responsive audit 2026-09-24: รายการของผู้อื่นที่เลือกอยู่ — ป้าย "ชนิด · ผู้ทำ" ลอย
เหนือกรอบ (จอสัมผัสไม่มี hover tooltip N1 ให้ดูว่าใครทำ เดิมต้องเปิด sidebar
อย่างเดียว) — read-only pointer-events:none ตามกติกา container ของ layer นี้
ไม่บัง textLayer; ชิดหัวหน้าเกินก็ห้อยใต้กรอบแทน */}
{!editable && (
<div
className="absolute z-10 max-w-[200px] truncate rounded-md border border-line bg-white px-1.5 py-0.5 text-[11px] text-ink-soft shadow-lg"
style={{
left: Math.max(0, Math.min(boxPx.x, size.width - 204)),
top: boxPx.y - 24 < 0 ? boxPx.y + boxPx.height + 4 : boxPx.y - 24,
pointerEvents: "none",
}}
>
{annotationTypeLabel(annotation)} · {annotation.userName || "ไม่ทราบชื่อ"}
</div>
)}
{/* แผงเครื่องมือของรายการที่เลือก — ตัวควบคุมเดียวกับ dropdown ของ tool ผู้สร้าง {/* แผงเครื่องมือของรายการที่เลือก — ตัวควบคุมเดียวกับ dropdown ของ tool ผู้สร้าง
(สี/ความหนา/ขนาด/รูปแบบตามชนิด) + ปุ่มลบท้ายแผง — แสดงทุกชนิดรวม HIGHLIGHT (สี/ความหนา/ขนาด/รูปแบบตามชนิด) + ปุ่มลบท้ายแผง — แสดงทุกชนิดรวม HIGHLIGHT
(ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง (ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง
@ -747,7 +507,7 @@ export default function AnnotationSelectionLayer({
{editable && !disabled && ( {editable && !disabled && (
<div <div
ref={panelRef} ref={panelRef}
className={`absolute ${hasStyleControls ? "w-60" : "w-fit"} sel-panel rounded-lg border border-line bg-white p-1.5 shadow-lg`} className={`absolute ${hasStyleControls ? "w-60" : "w-fit"} sel-panel rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
style={{ style={{
left: Math.max(0, Math.min(boxPx.x, size.width - 240 - 4)), left: Math.max(0, Math.min(boxPx.x, size.width - 240 - 4)),
top: openAbove ? boxPx.y - 6 : boxPx.y + boxPx.height + 4, top: openAbove ? boxPx.y - 6 : boxPx.y + boxPx.height + 4,
@ -772,7 +532,7 @@ export default function AnnotationSelectionLayer({
className={`flex h-6 w-6 items-center justify-center rounded-md transition ${FOCUS_RING_CLS} ${ className={`flex h-6 w-6 items-center justify-center rounded-md transition ${FOCUS_RING_CLS} ${
(highlightData.style ?? "highlight") === key (highlightData.style ?? "highlight") === key
? "bg-primary text-white" ? "bg-primary text-white"
: "text-ink-soft hover:bg-ink/[0.05]" : "text-slate-500 hover:bg-slate-100"
}`} }`}
onClick={() => commitHighlightStyle(key)} onClick={() => commitHighlightStyle(key)}
> >
@ -813,20 +573,6 @@ export default function AnnotationSelectionLayer({
)} )}
{stampData && ( {stampData && (
<> <>
{/* ทางเข้า StampEditor (แก้คำตรายางหลังวาง — เหมือนปุ่มเดียวกันของ TEXTBOX):
แผงโผล่เฉพาะของตัวเองอยู่แล้ว (editable) จึงไม่ต้องเช็ค userId ซ้ำ /
root มี stopPropagation คลิกไม่หลุดไป deselect · sel-panel-btn ขยาย
hit 36px บนจอสัมผัสเหมือนปุ่มอื่นของแผง (UX audit 2026-09-28) */}
<PanelRow label="ข้อความ">
<button
type="button"
className={`sel-panel-btn flex flex-1 items-center justify-center gap-1 rounded-md border border-line px-2 py-1 text-[11px] font-medium text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => onStartEdit?.(annotation.id)}
>
<Pencil className="h-3.5 w-3.5" />
แก้ไขข้อความ
</button>
</PanelRow>
<PanelRow> <PanelRow>
<ColorSwatches <ColorSwatches
value={stampData.color ?? STAMP_COLOR} value={stampData.color ?? STAMP_COLOR}
@ -844,28 +590,16 @@ export default function AnnotationSelectionLayer({
onChange={commitStampScale} onChange={commitStampScale}
/> />
</PanelRow> </PanelRow>
<PanelRow label="มุมเอียง">
<NumberCombo
value={stampData.rotation ?? 0}
options={[-45, -15, 0, 15, 45]}
min={-180}
max={180}
step={1}
suffix="°"
onChange={commitStampRotation}
/>
</PanelRow>
</> </>
)} )}
{textboxData && ( {textboxData && (
<> <>
{/* M1: ทางเข้า editor นอกจาก double-click — แผงโผล่เฉพาะของตัวเองอยู่แล้ว {/* M1: ทางเข้า editor นอกจาก double-click — แผงโผล่เฉพาะของตัวเองอยู่แล้ว
(editable) จึงไม่ต้องเช็ค userId ซ้ำ / root มี stopPropagation คลิกไม่หลุดไป deselect (editable) จึงไม่ต้องเช็ค userId ซ้ำ / root มี stopPropagation คลิกไม่หลุดไป deselect */}
· sel-panel-btn ขยาย hit 36px บนจอสัมผัสเหมือนปุ่มอื่นของแผง (UX audit 2026-09-28) */}
<PanelRow label="ข้อความ"> <PanelRow label="ข้อความ">
<button <button
type="button" type="button"
className={`sel-panel-btn flex flex-1 items-center justify-center gap-1 rounded-md border border-line px-2 py-1 text-[11px] font-medium text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`} className={`flex flex-1 items-center justify-center gap-1 rounded-md border border-slate-300 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => onStartEdit?.(annotation.id)} onClick={() => onStartEdit?.(annotation.id)}
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
@ -909,7 +643,7 @@ export default function AnnotationSelectionLayer({
className={`flex h-6 w-6 items-center justify-center rounded-md border transition ${FOCUS_RING_CLS} ${ className={`flex h-6 w-6 items-center justify-center rounded-md border transition ${FOCUS_RING_CLS} ${
textboxData.bold textboxData.bold
? "border-primary bg-primary text-white" ? "border-primary bg-primary text-white"
: "border-line bg-white text-ink-soft hover:bg-ink/[0.05]" : "border-slate-300 bg-white text-slate-500 hover:bg-slate-100"
}`} }`}
onClick={() => commitTextboxPatch({ bold: !textboxData.bold })} onClick={() => commitTextboxPatch({ bold: !textboxData.bold })}
> >
@ -920,7 +654,7 @@ export default function AnnotationSelectionLayer({
</button> </button>
</PanelRow> </PanelRow>
<PanelRow label="จัดแนว"> <PanelRow label="จัดแนว">
<div className="flex overflow-hidden rounded-md border border-line"> <div className="flex overflow-hidden rounded-md border border-slate-300">
{( {(
[ [
["left", AlignLeft, "ชิดซ้าย"], ["left", AlignLeft, "ชิดซ้าย"],
@ -936,7 +670,7 @@ export default function AnnotationSelectionLayer({
className={`px-2 py-1 transition ${FOCUS_RING_CLS} ${ className={`px-2 py-1 transition ${FOCUS_RING_CLS} ${
(textboxData.align ?? "left") === a (textboxData.align ?? "left") === a
? "bg-primary text-white" ? "bg-primary text-white"
: "text-ink-soft hover:bg-ink/[0.05]" : "text-slate-500 hover:bg-slate-100"
}`} }`}
onClick={() => commitTextboxPatch({ align: a })} onClick={() => commitTextboxPatch({ align: a })}
> >
@ -947,29 +681,27 @@ export default function AnnotationSelectionLayer({
</PanelRow> </PanelRow>
</> </>
)} )}
{hasStyleControls && <div className="my-1 border-t border-line/60" />} {hasStyleControls && <div className="my-1 border-t border-slate-100" />}
<div className={hasStyleControls ? "flex items-center justify-between" : "flex items-center"}> <div className={hasStyleControls ? "flex items-center justify-between" : "flex items-center"}>
{/* N2: bookmark ตำแหน่งของรายการนี้ — เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อต่อ */} {/* N2: bookmark ตำแหน่งของรายการนี้ — เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อต่อ */}
{/* hover เป็นกลางแบบปุ่มจิ๋วอื่นในแผง (เดิม copy มาจากปุ่มลบข้าง ๆ จน hover
แดง = สัญญาณอันตรายเท็จกับการ "บันทึก" — UX review M4 2026-09-23) */}
<button <button
type="button" type="button"
aria-label="บันทึก bookmark ตำแหน่งรายการนี้" aria-label="บันทึก bookmark ตำแหน่งรายการนี้"
title="Bookmark ตำแหน่งนี้" title="Bookmark ตำแหน่งนี้"
className={`flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-ink-faint hover:bg-ink/[0.05] hover:text-ink ${FOCUS_RING_CLS}`} className={`flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-slate-400 hover:bg-amber-50 hover:text-amber-500 ${FOCUS_RING_CLS}`}
onClick={() => onBookmark(boxPct.y)} onClick={() => onBookmark(boxPct.y)}
> >
<BookmarkPlus className="h-3.5 w-3.5" /> <BookmarkPlus className="h-3.5 w-3.5" />
</button> </button>
{/* เส้นคั่นแนวตั้ง — เฉพาะกรณีปุ่มติดกัน (แผงไม่มี row สไตล์): แยก bookmark ออกจากปุ่มลบ */} {/* เส้นคั่นแนวตั้ง — เฉพาะกรณีปุ่มติดกัน (แผงไม่มี row สไตล์): แยก bookmark ออกจากปุ่มลบ */}
{!hasStyleControls && ( {!hasStyleControls && (
<span aria-hidden="true" className="mx-1 h-3.5 w-px bg-line" /> <span aria-hidden="true" className="mx-1 h-3.5 w-px bg-slate-200" />
)} )}
<button <button
type="button" type="button"
aria-label="ลบรายการนี้" aria-label="ลบรายการนี้"
title="ลบ" title="ลบ"
className={`flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-error-ink hover:bg-error/10 ${FOCUS_RING_CLS}`} className={`flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-red-500 hover:bg-red-50 ${FOCUS_RING_CLS}`}
onClick={() => onDelete(annotation.id)} onClick={() => onDelete(annotation.id)}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />

View file

@ -20,7 +20,6 @@ import {
type Rect, type Rect,
type Size, type Size,
} from "@/utils/coordinates"; } from "@/utils/coordinates";
import { measureStampText } from "@/utils/annotation-display";
import { annotationBBox, hitTestPath, hitTestShape } from "@/utils/geometry"; import { annotationBBox, hitTestPath, hitTestShape } from "@/utils/geometry";
import type { InkStamp } from "./TextboxStampLayer"; import type { InkStamp } from "./TextboxStampLayer";
@ -304,7 +303,7 @@ export default function InkLayer({
// (bbox = สูตรเดียวกับ render และกรอบ selection — annotationBBox) // (bbox = สูตรเดียวกับ render และกรอบ selection — annotationBBox)
for (const s of stamps) { for (const s of stamps) {
if (s.userId !== ownUserId) continue; if (s.userId !== ownUserId) continue;
const b = annotationBBox({ type: "STAMP", data: s.data }, measured, measureStampText); const b = annotationBBox({ type: "STAMP", data: s.data }, measured);
if ( if (
lp.x >= b.x - pad && lp.x >= b.x - pad &&
lp.x <= b.x + b.width + pad && lp.x <= b.x + b.width + pad &&

File diff suppressed because it is too large Load diff

View file

@ -1,26 +1,17 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef } from "react";
import type { StampData, TextboxData, TextboxSegment } from "@/db/schema"; import type { StampData, TextboxData, TextboxSegment } from "@/db/schema";
import { toPx, type Point, type Size } from "@/utils/coordinates"; import { toPx, type Point, type Size } from "@/utils/coordinates";
import { textboxMaxWidthPct } from "@/utils/geometry"; import { STAMP_HEIGHT_RATIO, textboxMaxWidthPct } from "@/utils/geometry";
import { segmentsToHtml, serializeEditor } from "@/utils/textbox-editor"; import { STAMP_COLOR, stampFitFontSize } from "@/utils/annotation-display";
import {
measureStampText,
STAMP_COLOR,
stampBoxHeight,
STAMP_FONT_STACK,
stampLayout,
STAMP_MAX_LABEL_CHARS,
} from "@/utils/annotation-display";
// TEXTBOX + STAMP overlay (F17/B2, F18/B5) — HTML overlay (z-4 กลุ่มเดียวกับ comment pins) // TEXTBOX + STAMP overlay (F17/B2, F18/B5) — HTML overlay (z-4 กลุ่มเดียวกับ comment pins)
// - container `pointer-events: none` เสมอ เฉพาะรายการของตัวเองที่ `auto` // - container `pointer-events: none` เสมอ เฉพาะรายการของตัวเองที่ `auto`
// (เหมือน comment pins — ไม่บังการ select ข้อความของ textLayer เมื่อปิด tool) // (เหมือน comment pins — ไม่บังการ select ข้อความของ textLayer เมื่อปิด tool)
// - คลิก = เลือกรายการ (กรอบ selection ของ F1), double-click TEXTBOX ของตัวเอง // - คลิก = เลือกรายการ (กรอบ selection ของ F1), double-click TEXTBOX ของตัวเอง
// = แก้ไขข้อความผ่าน textarea วางแทนที่ตำแหน่งเดิม (บันทึกผ่าน updateAnnotation — pattern F13) // = แก้ไขข้อความผ่าน textarea วางแทนที่ตำแหน่งเดิม (บันทึกผ่าน updateAnnotation — pattern F13)
// ส่วน STAMP แก้ข้อความผ่านปุ่ม "แก้ไขข้อความ" ในแผง selection (editingId ใช้ร่วมกับ TEXTBOX)
// - composer = textarea ระหว่างสร้างกล่องข้อความใหม่ วางที่ตำแหน่งที่คลิกบนหน้า // - composer = textarea ระหว่างสร้างกล่องข้อความใหม่ วางที่ตำแหน่งที่คลิกบนหน้า
/** กล่องข้อความจาก DB — พิกัด % ทั้งหมด */ /** กล่องข้อความจาก DB — พิกัด % ทั้งหมด */
@ -29,8 +20,9 @@ export type InkTextbox = { id: string; userId: string; data: TextboxData };
/** ตรายางจาก DB (F18/B5) — พิกัด % ทั้งหมด */ /** ตรายางจาก DB (F18/B5) — พิกัด % ทั้งหมด */
export type InkStamp = { id: string; userId: string; data: StampData }; export type InkStamp = { id: string; userId: string; data: StampData };
// ฟอนต์ตรายางบนจอ + canvas measure ย้ายไป utils/annotation-display.ts แล้ว // ฟอนต์ตรายางบนจอ — ตั้งชัดทั้งตอนวัด (canvas) และตอน render เพื่อให้ความกว้าง
// (2026-09-28) ให้ preview ในเมนูของ tool ใช้ตัววัดเดียวกันกับตอน render จริง // ที่ stampFitFontSize คำนวณตรงกับที่แสดงจริง (ตัวหนา 700 เหมือน class font-bold)
const STAMP_FONT_STACK = "system-ui, sans-serif";
/** typography audit 2026-09-16 (T4.4 + Option B): ฟอนต์กล่องข้อความบนจอ — pin ชัดทั้ง /** typography audit 2026-09-16 (T4.4 + Option B): ฟอนต์กล่องข้อความบนจอ — pin ชัดทั้ง
* editor และ render ไม่สืบทอดจาก body: กล่องกว้างเป็น % คงที่ ฟอนต์เปลี่ยนตามเครื่อง * editor และ render ไม่สืบทอดจาก body: กล่องกว้างเป็น % คงที่ ฟอนต์เปลี่ยนตามเครื่อง
@ -38,6 +30,55 @@ export type InkStamp = { id: string; userId: string; data: StampData };
* ตรงกันทุกอุปกรณ์จริง (T1-C) พร้อม system ไทยเดิมเป็น fallback ตอนโหลดไม่ได้ */ * ตรงกันทุกอุปกรณ์จริง (T1-C) พร้อม system ไทยเดิมเป็น fallback ตอนโหลดไม่ได้ */
const TEXTBOX_FONT_STACK = const TEXTBOX_FONT_STACK =
"var(--font-sarabun, 'Sarabun'), 'Leelawadee UI', 'Thonburi', sans-serif"; "var(--font-sarabun, 'Sarabun'), 'Leelawadee UI', 'Thonburi', sans-serif";
let stampMeasureCtx: CanvasRenderingContext2D | null = null;
/** วัดความกว้างข้อความตรายางบนจอ — ไม่มี document (SSR) คืน 0 ให้ใช้ขนาด base */
function measureStampText(text: string, fontSize: number): number {
if (typeof document === "undefined") return 0;
stampMeasureCtx ??= document.createElement("canvas").getContext("2d");
if (!stampMeasureCtx) return 0;
stampMeasureCtx.font = `bold ${fontSize}px ${STAMP_FONT_STACK}`;
return stampMeasureCtx.measureText(text).width;
}
/** แปลงข้อความ/segments → HTML สำหรับ contentEditable (escape + <b> + <br>) */
function segmentsToHtml(text: string, segments?: TextboxSegment[]): string {
const esc = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const runs = segments?.length ? segments : [{ text }];
return runs
.map((s) => (s.bold ? `<b>${esc(s.text)}</b>` : esc(s.text)))
.join("")
.replace(/\n/g, "<br>");
}
/** อ่าน DOM ของ contentEditable กลับเป็น text รวม + segments แยกตามตัวหนา/ปกติ */
function serializeEditor(root: HTMLElement): { text: string; segments: TextboxSegment[] } {
const segs: TextboxSegment[] = [];
const push = (text: string, bold: boolean) => {
if (!text) return;
const last = segs[segs.length - 1];
if (last && !!last.bold === bold) last.text += text;
else segs.push({ text, ...(bold ? { bold: true } : {}) });
};
const walk = (node: Node, bold: boolean) => {
if (node.nodeType === Node.TEXT_NODE) {
push(node.nodeValue ?? "", bold);
} else if (node.nodeName === "BR") {
push("\n", bold);
} else {
const b = bold || node.nodeName === "B" || node.nodeName === "STRONG";
const kids = Array.from(node.childNodes);
kids.forEach((c, i) => {
walk(c, b);
// DIV/P sibling จากการกด Enter = คั่นขึ้นบรรทัดใหม่
if (i < kids.length - 1 && /^(DIV|P)$/.test(c.nodeName)) push("\n", b);
});
}
};
walk(root, false);
return { text: segs.map((s) => s.text).join(""), segments: segs };
}
/** กล่องแก้ไข/สร้างข้อความแบบ inline rich text — เลือกข้อความแล้วกด Ctrl/Cmd+B /** กล่องแก้ไข/สร้างข้อความแบบ inline rich text — เลือกข้อความแล้วกด Ctrl/Cmd+B
* ทำตัวหนาเฉพาะส่วนที่เลือกได้ (สไตล์ทั้งกล่องจากตัวตั้งค่าของ tool/ของกล่อง) */ * ทำตัวหนาเฉพาะส่วนที่เลือกได้ (สไตล์ทั้งกล่องจากตัวตั้งค่าของ tool/ของกล่อง) */
@ -74,23 +115,12 @@ function TextboxEditor({
onCancel: () => void; onCancel: () => void;
}) { }) {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
// จบ editor ได้ครั้งเดียว — blur (บันทึกอัตโนมัติ), Escape, unmount แต่ละทางเรียก // initial HTML คำนวณ/คืน object เดิมจนกว่า annotation จะเปลี่ยน — editor เป็น
// ต่างกันและคำสั่งที่สอง (blur ตามหลัง Escape/Enter ตอน element ถูกถอด, Enter ซ้ำ // uncontrolled (พิมพ์ใน DOM ตรง ๆ) React จึงไม่ยุ่งกับ innerHTML ระหว่างพิมพ์
// จาก double-tap บนจอสัมผัสก่อน React ถอด editor) ต้องเงียบ — ไม่งั้น onSave ยิง const initialHtml = useMemo(
// ซ้ำจน composer สร้างกล่องซ้ำ / update เดียวกันกลายเป็น 2 version ในประวัติ F19 () => ({ __html: segmentsToHtml(initialText, initialSegments) }),
const doneRef = useRef(false); [initialText, initialSegments],
const finish = (fn: () => void) => { );
if (doneRef.current) return;
doneRef.current = true;
fn();
};
// initial HTML freeze ตอน mount (useState initializer) — editor เป็น uncontrolled
// (พิมพ์ใน DOM ตรง ๆ) ค่าเริ่มต้นต้องไม่เปลี่ยนตาม props กลางทาง: realtime update
// แก้กล่องเดียวกัน (อีกแท็บ/อีกคน) แล้ว useMemo คืน __html ใหม่จะ reset innerHTML
// เผลอข้อความที่ผู้ใช้กำลังพิมพ์
const [initialHtml] = useState(() => ({
__html: segmentsToHtml(initialText, initialSegments),
}));
// focus + วาง caret ท้ายข้อความตอนเปิด editor // focus + วาง caret ท้ายข้อความตอนเปิด editor
useEffect(() => { useEffect(() => {
@ -109,11 +139,9 @@ function TextboxEditor({
const submit = () => { const submit = () => {
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
finish(() => { const { text, segments } = serializeEditor(el);
const { text, segments } = serializeEditor(el); if (text.trim()) onSave(text, segments);
if (text.trim()) onSave(text, segments); else onCancel();
else onCancel();
});
}; };
return ( return (
@ -144,32 +172,17 @@ function TextboxEditor({
onBlur={submit} onBlur={submit}
onKeyDown={(e) => { onKeyDown={(e) => {
e.stopPropagation(); // ไม่ให้ปุ่มในกล่องข้อความชนคีย์ลัดของ viewer e.stopPropagation(); // ไม่ให้ปุ่มในกล่องข้อความชนคีย์ลัดของ viewer
// Enter ระหว่าง IME compose (คีย์บอร์ดเสมือนของ iPad/Android, IME ภาษาอื่น)
// = ยืนยันคำที่กำลังพิมพ์ ไม่ใช่คำสั่งของ editor
if (e.nativeEvent.isComposing) return;
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
finish(onCancel); onCancel();
return;
} }
// Enter = ขึ้นบรรทัดใหม่เป็น <br> ล้วนเสมอ — ปล่อย browser สร้าง <div> ต่อ // เลือกข้อความแล้วกด Ctrl/Cmd+B = ทำตัวหนาเฉพาะส่วนที่เลือก
// บรรทัดเอง (Chrome) แล้ว serializeEditor ต้องเดา newline จากรอยต่อ block
// จน "abc<div>def</div>" กลายเป็น "abcdef" (รายงาน review ข้อ 2, 2026-09-28)
if (e.key === "Enter") {
e.preventDefault();
document.execCommand("insertLineBreak");
return;
}
// เลือกข้อความแล้วกด Ctrl/Cmd+B = ทำตัวหนาเฉพาะส่วนที่เลือก — ปิดเมื่อกล่อง
// หนาทั้งกล่อง: execCommand("bold") จะ toggle ออกเป็น span font-weight:normal
// ที่ serialize ไม่เก็บ และตอน render segment ก็ inherit 700 ของกล่องอยู่ดี
// (อยากปรับเป็นปกติ = ปิดตัวหนาทั้งกล่องที่แผง selection ก่อน)
// FIX-12: document.execCommand ถูก mark deprecated แต่ // FIX-12: document.execCommand ถูก mark deprecated แต่
// ยังทำงานทุก browser ปัจจุบัน และไม่มี replacement ตรง ๆ ของ contentEditable // ยังทำงานทุก browser ปัจจุบัน และไม่มี replacement ตรง ๆ ของ contentEditable
// (bold/insertLineBreak/insertText ตรงนี้และใน onPaste ด้านล่าง) — ใช้ต่อโดยรับความเสี่ยงไว้ // (bold/insertText ตรงนี้และใน onPaste ด้านล่าง) — ใช้ต่อโดยรับความเสี่ยงไว้
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") {
e.preventDefault(); e.preventDefault();
if (!bold) document.execCommand("bold"); document.execCommand("bold");
} }
}} }}
onPaste={(e) => { onPaste={(e) => {
@ -181,9 +194,7 @@ function TextboxEditor({
e.clipboardData.getData("text/plain"), e.clipboardData.getData("text/plain"),
); );
}} }}
// ขอบประใช้ outline (ไม่กิน layout): border 1px ใน border-box ทำให้พื้นที่ข้อความ className="min-h-[1.5em] w-full whitespace-pre-wrap break-words rounded border border-dashed border-slate-500/70 bg-transparent p-0 caret-current focus:outline-none"
// แคบกว่าตอน render จริง 2px จนตัดบรรทัดต่างจากที่จะบันทึก (review ข้อ 3)
className="min-h-[1.5em] w-full whitespace-pre-wrap break-words rounded outline-1 outline-dashed outline-ink-faint/70 bg-transparent p-0 caret-current"
style={{ style={{
fontFamily: TEXTBOX_FONT_STACK, fontFamily: TEXTBOX_FONT_STACK,
fontSize: toPx(fontSizePct, size.width), fontSize: toPx(fontSizePct, size.width),
@ -198,174 +209,6 @@ function TextboxEditor({
); );
} }
/** แก้ข้อความตรายางในที่ (ปุ่ม "แก้ไขข้อความ" ในแผง selection) — กรอบ/สี/ฟอนต์/การ
* จัดวาง (ย่อฟอนต์/wrap หลายบรรทัด) เหมือนตอน render จริงทุกอย่าง (WYSIWYG)
* ยกเว้น**มุมเอียง**: editor แสดงตรง (0°) เสมอ — textarea เอียงพิมพ์ลำบากและ
* กรอบครอบตำแหน่งเดิมอยู่แล้ว ปิด editor แล้วตรายางกลับไปเอียงตามค่าเดิมทันที ·
* พิมพ์ยาวขึ้น = ฟอนต์ย่อจนถึง floor แล้วค่อยขึ้นบรรทัดใหม่ กรอบสูงขึ้นตาม
* stampLayout ทันที — blur/Enter = บันทึก (ข้อความว่าง = ยกเลิก), Escape = ยกเลิก ·
* Enter ไม่ใช้ขึ้นบรรทัด (ตรายางจัดบรรทัดอัตโนมัติล้วน ให้ตรงกับ preview/ตอนวางเสมอ) */
function StampEditor({
size,
data,
enabled,
onSave,
onCancel,
}: {
size: Size;
data: StampData;
enabled: boolean;
onSave: (label: string) => void;
onCancel: () => void;
}) {
const [label, setLabel] = useState(data.label);
// จบ editor ได้ครั้งเดียว — Enter ซ้ำจาก double-tap บนจอสัมผัส (ก่อน React ถอด
// editor) หรือ blur ตามหลัง Enter ต้องเงียบ ไม่งั้น update เดียวกันกลายเป็น
// 2 version ในประวัติ F19 (เหตุผลเดียวกับ doneRef ของ TextboxEditor)
const doneRef = useRef(false);
const finish = (fn: () => void) => {
if (doneRef.current) return;
doneRef.current = true;
fn();
};
const w = toPx(data.scale, size.width);
const c = data.color ?? STAMP_COLOR;
const lay = useMemo(() => stampLayout(label, w, measureStampText), [label, w]);
const commit = () =>
finish(() => {
const t = label.trim();
if (t) onSave(t);
else onCancel();
});
return (
<div
className="absolute flex items-center justify-center overflow-hidden"
style={{
left: toPx(data.position.x, size.width),
top: toPx(data.position.y, size.height),
width: w,
height: stampBoxHeight(lay, w),
border: `${Math.max(w * 0.02, 1)}px solid ${c}`,
borderRadius: Math.max(w * 0.04, 2),
pointerEvents: enabled ? "auto" : "none",
}}
onClick={(e) => e.stopPropagation()}
>
<textarea
autoFocus
aria-label="แก้ไขข้อความตรายาง"
value={label}
maxLength={STAMP_MAX_LABEL_CHARS}
rows={lay.lines.length}
spellCheck={false}
onChange={(e) => setLabel(e.target.value.replace(/\n/g, ""))}
onBlur={commit}
onKeyDown={(e) => {
e.stopPropagation(); // ไม่ให้ปุ่มในช่องข้อความชนคีย์ลัดของ viewer
// Enter ระหว่าง IME compose = ยืนยันคำ ไม่ใช่บันทึก (เหตุผลเดียวกับ TextboxEditor)
if (e.nativeEvent.isComposing) return;
if (e.key === "Escape") {
e.preventDefault();
finish(onCancel);
return;
}
if (e.key === "Enter") {
e.preventDefault();
commit();
}
}}
// ไม่ใส่ padding แนวนอน: พื้นที่พิมพ์ = w − border (≈0.96w) กว้างกว่า maxW
// ของ stampLayout (0.9w) เสมอ ข้อความที่ย่อจนพอดี 0.9w เป๊ะ จึงไม่ wrap ใน
// editor ทั้งที่ผลหลังบันทึกเป็นบรรทัดเดียว (WYSIWYG — code review 2026-09-28)
className="w-full resize-none bg-transparent text-center font-bold focus:outline-none"
style={{
color: c,
fontFamily: STAMP_FONT_STACK,
fontSize: lay.fontSize,
lineHeight: lay.lineHeight,
}}
/>
</div>
);
}
/** ตรายางหนึ่งดวง — stampLayout (canvas measure ทีละบรรทัดหลายรอบตามจำนวนบรรทัด
* ที่ลอง wrap) ถูก useMemo ไว้ dep primitive [label, w] จึงคำนวณใหม่เฉพาะเมื่อ
* label/ความกว้างจริงเปลี่ยน ไม่ตาม re-render เพราะเหตุอื่น (annotation ตัวอื่น
* เพิ่ม/ลบ, selectedId, SSE event) — memo ระดับ component ไม่ช่วยเพราะ props
* (s/size object) เป็น reference ใหม่ทุก render ของ parent (code review
* 2026-09-28) · ฟอนต์ที่วัดเป็น system-ui (STAMP_FONT_STACK) มีพร้อมตั้งแต่
* browser เปิด จึงไม่ต้องรอ document.fonts.ready แล้ววัดซ้ำ (review ข้อ 7) */
function StampItem({
s,
size,
enabled,
onSelect,
}: {
s: InkStamp;
size: Size;
/** tool ที่ไม่ใช้ pointer บน layer นี้ → ปิด pointer-events/โฟกัสของทุกรายการ */
enabled: boolean;
onSelect: (id: string) => void;
}) {
const w = toPx(s.data.scale, size.width);
const c = s.data.color ?? STAMP_COLOR; // แถวเดิมไม่มี color = สีตรายาง default
// จัดวางข้อความแบบเดียวกับ preview/export: คำสั้น 1 บรรทัดฟอนต์เต็ม, คำยาวพอดี ๆ
// ย่อฟอนต์ให้อยู่บรรทัดเดียวก่อน (จนถึง floor 10%), คำยาวจริงเกิน floor ค่อย wrap
// ≤3 บรรทัด กรอบสูงขึ้นตามจำนวนบรรทัด (2026-09-28 รอบสอง — ตาม stampLayout)
const lay = useMemo(
() => stampLayout(s.data.label, w, measureStampText),
[s.data.label, w],
);
return (
<div
role="button"
tabIndex={enabled ? 0 : -1}
aria-label={`ตรายาง: ${s.data.label}`}
className="absolute flex flex-col items-center justify-center overflow-hidden text-center font-bold"
style={{
left: toPx(s.data.position.x, size.width),
top: toPx(s.data.position.y, size.height),
width: w,
height: stampBoxHeight(lay, w),
// มุมเอียง (−180..180) หมุนรอบ**กลางกรอบ** — left/top ยังเป็นมุมซ้ายบนของ
// กรอบ*ก่อนหมุน*เสมอ (bbox = AABB ของกรอบหมุน คำนวณใน geometry)
...(s.data.rotation
? { transform: `rotate(${s.data.rotation}deg)`, transformOrigin: "center" }
: null),
border: `${Math.max(w * 0.02, 1)}px solid ${c}`,
borderRadius: Math.max(w * 0.04, 2),
color: c,
fontFamily: STAMP_FONT_STACK,
fontSize: lay.fontSize,
lineHeight: lay.lineHeight,
pointerEvents: enabled ? "auto" : "none",
cursor: enabled ? "pointer" : "default",
}}
onClick={(e) => {
e.stopPropagation();
if (enabled) onSelect(s.id);
}}
onKeyDown={(e) => {
if (!enabled) return;
// กลืนเฉพาะคีย์ที่เราจัดการเอง — คีย์อื่น (DEL/Escape/H/D/C/B ของ viewer
// ที่ฟังที่ window) ต้อง bubble ต่อ เหตุผลเดียวกับ div กล่องข้อความด้านบน
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
onSelect(s.id);
}
}}
>
{lay.lines.map((ln, i) => (
// บรรทัดว่างจาก wrap ใช้ nbsp กัน div ยุบ — ตัวอักษรใน quotes เป็น U+00A0 จริง ๆ
// (มองเหมือน space ปกติ — ห้ามลบหรือแทนด้วย space)
<div key={i}>{ln || " "}</div>
))}
</div>
);
}
export default function TextboxStampLayer({ export default function TextboxStampLayer({
size, size,
textboxes, textboxes,
@ -432,10 +275,6 @@ export default function TextboxStampLayer({
<div <div
key={t.id} key={t.id}
title={t.userId === ownUserId ? "ดับเบิลคลิกเพื่อแก้ไขข้อความ" : undefined} title={t.userId === ownUserId ? "ดับเบิลคลิกเพื่อแก้ไขข้อความ" : undefined}
// a11y: กล่องคลิกเลือกได้ = ปุ่ม — ใช้คีย์บอร์ด (Tab/Enter/Space) เลือกได้ด้วย
role="button"
tabIndex={enabled ? 0 : -1}
aria-label={`กล่องข้อความ${t.data.text ? `: ${t.data.text.slice(0, 40)}` : ""}`}
className="absolute whitespace-pre-wrap break-words" className="absolute whitespace-pre-wrap break-words"
style={{ style={{
left: toPx(t.data.position.x, size.width), left: toPx(t.data.position.x, size.width),
@ -454,22 +293,11 @@ export default function TextboxStampLayer({
pointerEvents: enabled ? "auto" : "none", pointerEvents: enabled ? "auto" : "none",
cursor: enabled ? "text" : "default", cursor: enabled ? "text" : "default",
}} }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (enabled) onSelect(t.id); if (enabled) onSelect(t.id);
}} }}
onKeyDown={(e) => {
if (!enabled) return;
// กลืนเฉพาะคีย์ที่เราจัดการเอง (Enter/Space เลือกรายการ) — คีย์อื่น
// (DEL/Backspace ลบ, Escape deselect, H/D/C/B ของ viewer ที่ฟังที่
// window) ต้อง bubble ต่อ: focus ค้างบน item หลังคลิกเลือก (tabIndex)
// แล้วกลืนทุกคีย์ = ปุ่มลบ/ยกเลิกตายหมด (code review 2026-09-28)
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
onSelect(t.id);
}
}}
onDoubleClick={(e) => { onDoubleClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (enabled && t.userId === ownUserId) onStartEdit(t.id); if (enabled && t.userId === ownUserId) onStartEdit(t.id);
@ -501,24 +329,39 @@ export default function TextboxStampLayer({
onCancel={onCancelComposer} onCancel={onCancelComposer}
/> />
)} )}
{/* STAMP (F18/B5) — กรอบข้อความสไตล์ตรายาง กว้าง = scale% ของหน้า สูง 0.4 เท่า {/* STAMP (F18/B5) — กรอบข้อความสไตล์ตรายาง: กว้าง = scale% ของหน้า สูง = 0.4 เท่า (§5)
(§5): คำสั้นอยู่บรรทัดเดียว คำยาว wrap ≤3 บรรทัด (2026-09-28) — ตัว render ฟอนต์ย่อให้ label อยู่บรรทัดเดียวเสมอ (ยาวอย่าง "ตรวจสอบแล้ว" ไม่ wrap/ถูกตัด) */}
อยู่ใน StampItem (memo), โหมดแก้ข้อความ (ปุ่ม "แก้ไขข้อความ" ในแผง selection) {stamps.map((s) => {
= StampEditor แทนกรอบเดิม */} const w = toPx(s.data.scale, size.width);
{stamps.map((s) => const c = s.data.color ?? STAMP_COLOR; // แถวเดิมไม่มี color = สีตรายาง default
editingId === s.id ? ( return (
<StampEditor <div
key={s.id} key={s.id}
size={size} className="absolute flex items-center justify-center overflow-hidden text-center font-bold"
data={s.data} style={{
enabled={enabled} left: toPx(s.data.position.x, size.width),
onSave={(label) => onCommitEdit(s.id, label)} top: toPx(s.data.position.y, size.height),
onCancel={() => onStartEdit(null)} width: w,
/> height: w * STAMP_HEIGHT_RATIO,
) : ( border: `${Math.max(w * 0.02, 1)}px solid ${c}`,
<StampItem key={s.id} s={s} size={size} enabled={enabled} onSelect={onSelect} /> borderRadius: Math.max(w * 0.04, 2),
), color: c,
)} fontFamily: STAMP_FONT_STACK,
fontSize: stampFitFontSize(s.data.label, w, measureStampText),
lineHeight: 1.2,
whiteSpace: "nowrap",
pointerEvents: enabled ? "auto" : "none",
cursor: enabled ? "pointer" : "default",
}}
onClick={(e) => {
e.stopPropagation();
if (enabled) onSelect(s.id);
}}
>
{s.data.label}
</div>
);
})}
</div> </div>
); );
} }

View file

@ -5,8 +5,6 @@ import { Check, ChevronDown, ChevronUp, Highlighter, Strikethrough, Underline }
import type { HighlightStyle } from "@/db/schema"; import type { HighlightStyle } from "@/db/schema";
import { colorName } from "@/utils/annotation-display";
// ตัวควบคุมที่ใช้ร่วมระหว่าง dropdown บนปุ่ม tool (PDFViewer) กับแผงเครื่องมือ // ตัวควบคุมที่ใช้ร่วมระหว่าง dropdown บนปุ่ม tool (PDFViewer) กับแผงเครื่องมือ
// บนกรอบ selection (AnnotationSelectionLayer) — หน้าตา/พฤติกรรมเดียวกันทั้งสองที่ // บนกรอบ selection (AnnotationSelectionLayer) — หน้าตา/พฤติกรรมเดียวกันทั้งสองที่
@ -14,7 +12,7 @@ import { colorName } from "@/utils/annotation-display";
* PDFViewer/AnnotationSidebar (ประกาศแยกที่นี่เพราะ PDFViewer import ไฟล์นี้ * PDFViewer/AnnotationSidebar (ประกาศแยกที่นี่เพราะ PDFViewer import ไฟล์นี้
* อยู่แล้ว ห้ามวนกลับ) */ * อยู่แล้ว ห้ามวนกลับ) */
const FOCUS_RING_CLS = const FOCUS_RING_CLS =
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"; "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1";
/** สี preset ให้เลือก (ทุก tool ที่ปรับสีได้: draw/shape/highlight/textbox/stamp) /** สี preset ให้เลือก (ทุก tool ที่ปรับสีได้: draw/shape/highlight/textbox/stamp)
* สีอื่นนอกรายการเลือกได้ผ่านปุ่มสีรุ้ง (input type="color") ท้ายแถว */ * สีอื่นนอกรายการเลือกได้ผ่านปุ่มสีรุ้ง (input type="color") ท้ายแถว */
@ -38,7 +36,7 @@ export function PanelRow({ label, children }: { label?: string; children: ReactN
return ( return (
<div className="panel-row flex items-start gap-2 px-1 py-1"> <div className="panel-row flex items-start gap-2 px-1 py-1">
{label ? ( {label ? (
<span className="w-11 shrink-0 pt-1 text-[11px] font-medium text-ink-soft">{label}</span> <span className="w-11 shrink-0 pt-1 text-[11px] font-medium text-slate-500">{label}</span>
) : null} ) : null}
{children} {children}
</div> </div>
@ -55,9 +53,9 @@ export function ColorSwatches({ value, onChange }: { value: string; onChange: (c
<button <button
key={c} key={c}
type="button" type="button"
aria-label={`เลือกสี ${colorName(c)}`} aria-label={`เลือกสี ${c}`}
title={colorName(c)} title={c}
className={`aspect-square max-w-8 w-full justify-self-center rounded-full border border-line transition ${FOCUS_RING_CLS} ${ className={`aspect-square max-w-8 w-full justify-self-center rounded-full border border-slate-300 transition ${FOCUS_RING_CLS} ${
value === c ? "ring-2 ring-primary ring-offset-1" : "hover:scale-110" value === c ? "ring-2 ring-primary ring-offset-1" : "hover:scale-110"
}`} }`}
style={{ backgroundColor: c }} style={{ backgroundColor: c }}
@ -66,7 +64,7 @@ export function ColorSwatches({ value, onChange }: { value: string; onChange: (c
))} ))}
<label <label
title="เลือกสีอื่น" title="เลือกสีอื่น"
className={`relative aspect-square max-w-8 w-full cursor-pointer justify-self-center rounded-full border border-line transition has-[:focus-visible]:outline has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-primary ${ className={`relative aspect-square max-w-8 w-full cursor-pointer justify-self-center rounded-full border border-slate-300 transition has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary has-[:focus-visible]:ring-offset-1 ${
INK_COLORS.includes(value) ? "hover:scale-110" : "ring-2 ring-primary ring-offset-1" INK_COLORS.includes(value) ? "hover:scale-110" : "ring-2 ring-primary ring-offset-1"
}`} }`}
style={{ style={{
@ -179,7 +177,7 @@ export function NumberCombo({
} }
}} }}
> >
<div className="flex items-stretch overflow-hidden rounded-md border border-line bg-white"> <div className="flex items-stretch overflow-hidden rounded-md border border-slate-300 bg-white">
<input <input
type="number" type="number"
value={draft} value={draft}
@ -196,16 +194,16 @@ export function NumberCombo({
if (e.key !== "Escape") e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัด F3 (Esc ปล่อยไหลไปปิดเมนู) if (e.key !== "Escape") e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัด F3 (Esc ปล่อยไหลไปปิดเมนู)
if (e.key === "Enter") commitDraft(); // ยืนยันครั้งเดียวตรงนี้ ไม่ต้องรอ blur if (e.key === "Enter") commitDraft(); // ยืนยันครั้งเดียวตรงนี้ ไม่ต้องรอ blur
}} }}
className="min-w-0 flex-1 border-0 px-1.5 py-1.5 text-[11px] text-ink focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" className="min-w-0 flex-1 border-0 px-1.5 py-1.5 text-[11px] text-slate-700 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-slate-400 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/> />
<div className="flex flex-col border-l border-line"> <div className="flex flex-col border-l border-slate-300">
{/* combo-step: ปุ่ม ▲▼ ซ้อนแนวตั้ง 2 ชั้น — ต้องหลุดจาก rule touch 36px {/* combo-step: ปุ่ม ▲▼ ซ้อนแนวตั้ง 2 ชั้น — ต้องหลุดจาก rule touch 36px
ของ dropdown (globals.css) ไม่งั้นคอลัมน์สูง 72px ดึงทั้งแถวบวมตาม */} ของ dropdown (globals.css) ไม่งั้นคอลัมน์สูง 72px ดึงทั้งแถวบวมตาม */}
<button <button
type="button" type="button"
aria-label="เพิ่มค่า" aria-label="เพิ่มค่า"
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input (dropdown ไม่กระพริบ) onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input (dropdown ไม่กระพริบ)
className={`combo-step flex flex-1 items-center justify-center rounded-sm px-1.5 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`} className={`combo-step flex flex-1 items-center justify-center rounded-sm px-1.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => stepValue(1)} onClick={() => stepValue(1)}
> >
<ChevronUp className="h-3 w-3" /> <ChevronUp className="h-3 w-3" />
@ -214,7 +212,7 @@ export function NumberCombo({
type="button" type="button"
aria-label="ลดค่า" aria-label="ลดค่า"
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
className={`combo-step flex flex-1 items-center justify-center rounded-sm border-t border-line px-1.5 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`} className={`combo-step flex flex-1 items-center justify-center rounded-sm border-t border-slate-300 px-1.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => stepValue(-1)} onClick={() => stepValue(-1)}
> >
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
@ -227,7 +225,7 @@ export function NumberCombo({
// L2 (UX audit 2026-09-16): เปิด "ขึ้นบน" (bottom-full) เสมอ — แถวท้ายเมนู // L2 (UX audit 2026-09-16): เปิด "ขึ้นบน" (bottom-full) เสมอ — แถวท้ายเมนู
// tool (ความทึบ/ความหนา) เปิดลงจะล้นเขต scroll ของเมนู ต้องเลื่อนเมนูจึงเห็น // tool (ความทึบ/ความหนา) เปิดลงจะล้นเขต scroll ของเมนู ต้องเลื่อนเมนูจึงเห็น
// ครบ / ทุกจุดใช้งานมีแถวอื่นอยู่ข้างบนเสมอ จึงไม่หลุดขอบบนของเมนูเช่นกัน // ครบ / ทุกจุดใช้งานมีแถวอื่นอยู่ข้างบนเสมอ จึงไม่หลุดขอบบนของเมนูเช่นกัน
className="absolute bottom-full right-0 z-[60] mb-1 w-full overflow-hidden rounded-md border border-line bg-white py-0.5 shadow-lg" className="absolute bottom-full right-0 z-[60] mb-1 w-full overflow-hidden rounded-md border border-slate-200 bg-white py-0.5 shadow-lg"
> >
{options.map((opt) => ( {options.map((opt) => (
<button <button
@ -238,8 +236,8 @@ export function NumberCombo({
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input ก่อนคลิก onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input ก่อนคลิก
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${ className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
value === opt value === opt
? "bg-primary/10 text-primary" ? "bg-slate-100 text-slate-900"
: "text-ink-soft hover:bg-ink/[0.05]" : "text-slate-600 hover:bg-slate-100"
}`} }`}
onClick={() => { onClick={() => {
onChange(opt); onChange(opt);

View file

@ -1,108 +0,0 @@
// pure module ของแถบ tool หน้า annotate — ย้ายจาก PDFViewer.tsx แบบ
// behavior-preserving (PLAN-TOOLBAR.md ขั้น 1) ไม่มี React/state เพื่อ unit
// test ได้ (tests/components/toolbar-tools.test.ts)
import {
Eraser,
EyeOff,
Hand,
Highlighter,
MessageSquarePlus,
MousePointer2,
Pencil,
Shapes,
Stamp,
Type,
type LucideIcon,
} from "lucide-react";
export type Tool =
| "select"
| "pan"
| "highlight"
| "draw"
| "comment"
| "shape"
| "erase"
| "redact"
| "textbox"
| "stamp";
export type ToolEntry = { key: Tool; label: string; text: string; Icon: LucideIcon };
/** label = ชื่อเต็มสำหรับ title/aria-label (พร้อมคีย์ลัด/คำอังกฤษประกอบ) —
* text = คำสั้น ๆ แสดงเป็นข้อความหลังไอคอนบนปุ่ม (คำขอผู้ใช้ 2026-09-21: ผู้ใช้ศาล
* ไม่คุ้นไอคอนล้วน โดยเฉพาะจอสัมผัสที่ hover อ่าน title ไม่ได้ — แทน span ชื่อ
* tool-active ของ H3 ที่ถอดออก) — สำหรับปุ่ม dropdown ที่มีตัวเลือกย่อย (มาร์ก
* ข้อความ/วาดเส้น/รูปทรง/ตรายาง) เคยแสดงชื่อตัวเลือกล่าสุดแทน text ตลอด (คำขอผู้ใช้
* 2026-09-23) แต่ทำให้ชื่อ tool หายไปจากแถบ ("วาดเส้น" เห็นเป็น "ปากกา" ตั้งแต่
* ยังไม่ได้เลือกอะไร) — 2026-09-29 จึงแสดงค่าล่าสุด**เฉพาะตอน tool active**
* (ค่าที่จะใช้จริงเมื่อวาด) ไม่ active กลับมาแสดง text ชื่อ tool เหมือนปุ่มอื่น */
export const TOOLS: ToolEntry[] = [
{ key: "select", label: "เลือก", text: "เลือก", Icon: MousePointer2 },
{ key: "pan", label: "เลื่อนหน้า (pan)", text: "เลื่อนหน้า", Icon: Hand },
{ key: "highlight", label: "มาร์กข้อความ (H)", text: "มาร์กข้อความ", Icon: Highlighter },
{ key: "draw", label: "วาดเส้น (D)", text: "วาดเส้น", Icon: Pencil },
{ key: "comment", label: "คอมเมนต์ (C)", text: "คอมเมนต์", Icon: MessageSquarePlus },
{ key: "shape", label: "รูปทรง", text: "รูปทรง", Icon: Shapes },
{ key: "erase", label: "ยางลบ (เส้น/รูปทรง/ตรายางของตัวเอง)", text: "ยางลบ", Icon: Eraser },
{ key: "redact", label: "ปิดพราง (redaction)", text: "ปิดพราง", Icon: EyeOff },
{ key: "textbox", label: "กล่องข้อความ", text: "กล่องข้อความ", Icon: Type },
{ key: "stamp", label: "ตรายาง (stamp)", text: "ตรายาง", Icon: Stamp },
];
/** N3 ไฟล์ภาพ + PDF สแกนล้วน: ไม่มีข้อความให้คลุม — ซ่อน tool มาร์กข้อความ
* (HIGHLIGHT) ทั้งปุ่มและเมนูรูปแบบ (hideHighlightTool ของ PDFViewer) */
export function visibleToolsFor(hideHighlight: boolean): ToolEntry[] {
return hideHighlight ? TOOLS.filter((t) => t.key !== "highlight") : TOOLS;
}
/** ไฟล์ภาพ/ไฟล์สแกนล้วน: มาร์กข้อความใช้ไม่ได้ (ไม่มีข้อความให้คลุม) — ปุ่มถูกซ่อน
* ไปแล้ว เหลือทางเข้า tool นี้คือคีย์ลัด H จึงกันที่ switchTool ให้จบ (สลับ tool
* อื่นไม่กระทบ) */
export function canSwitchTo(t: Tool, hideHighlight: boolean): boolean {
return !(t === "highlight" && hideHighlight);
}
/** หน้าตาปุ่ม tool dropdown ที่มีค่าย่อย (มาร์กข้อความ/วาดเส้น/รูปทรง/ตรายาง) —
* ไม่ active → text ชื่อ tool + title = label เหมือนปุ่มเดี่ยวอื่น;
* active + มีค่า (ค่าที่จะใช้จริงเมื่อวาด เช่น ชื่อปากกา/preset ตรายาง "กำหนดเอง")
* → text = ค่า + title = "label — ค่า" ตาม pattern เดิม;
* ไม่ส่งค่า (textbox ไม่มีค่าย่อย) → ชื่อ tool แม้ active */
export function toolButtonFace(
active: boolean,
tool: Pick<ToolEntry, "label" | "text">,
value?: string | null,
): { text: string; title: string } {
const showValue = active && value != null;
return {
text: showValue ? value : tool.text,
title: showValue ? `${tool.label} — ${value}` : tool.label,
};
}
/** จำ intent เปิด/พับเมนูของปุ่ม dropdown ก่อนเรียก switchTool (ซึ่งปิดเมนูทุกตัว):
* กดปุ่มจาก tool อื่น = เปิดเมนูของ tool ที่เพิ่ง active, tool เดิม+เมนูปิด = เปิด,
* tool เดิม+เมนูเปิด = พับ (toggle) */
export function menuIntentAfterClick(current: Tool, key: Tool, menuOpen: boolean): boolean {
return current !== key || !menuOpen;
}
/** tool ที่ปุ่มเป็น dropdown มีเมนูของตัวเอง — ทุกจุดที่ "ปิดเมนูทั้งชุด" (switchTool/
* outside-click/resize/auto-select/เปิด popup bookmark/reset tool ตรง) ต้องวนจากชุด
* ค่านี้จุดเดียว: แต่ก่อน enumerate setter ด้วยมือ 6 แห่ง จนเมนูใหม่/พฤติกรรมใหม่ลืม
* แห่งเดียว = drift เงียบ (เคยเกิดจริง: effect เลื่อนปุ่ม active หาปุ่มไม่เจอเงียบ ๆ
* ตั้งแต่ 2026-09-23) — ฝั่ง PDFViewer ผูก setter/ref เป็น Record<MenuTool,…> ให้
* ลืมแถว = compile error, ฝั่งนี้ test cross-check กับ TOOLS */
export const TOOL_MENU_KEYS = ["highlight", "draw", "shape", "textbox", "stamp"] as const satisfies readonly Tool[];
export type MenuTool = (typeof TOOL_MENU_KEYS)[number];
/** คีย์ลัดสลับ tool (F3) — จับคู่ด้วย `e.code` (ตำแหน่งกายภาพของปุ่มตาม layout
* สหรัฐ) ไม่ใช่ `e.key` (ตัวอักษรที่ layout ปัจจุบันจะพิมพ์): ผู้ใช้ศาลส่วนใหญ่สลับ
* keyboard เป็นภาษาไทย ซึ่งปุ่มเดียวกันให้ e.key = "ภ" ไม่ใช่ "h" — เช็ค e.key
* ทำให้คีย์ลัดทั้งชุดใช้ไม่ได้เลยบน layout ไทย (PLAN-TOOLBAR.md finding #1,
* 2026-09-29) */
export const TOOL_SHORTCUTS: Readonly<Record<string, Tool>> = {
KeyH: "highlight",
KeyD: "draw",
KeyC: "comment",
};

View file

@ -3,24 +3,35 @@
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { import {
ArrowRight,
Bookmark, Bookmark,
Braces,
Check, Check,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Circle,
Clock, Clock,
Copy, Copy,
EyeOff,
Highlighter,
MessageSquare,
Minus,
MoreHorizontal, MoreHorizontal,
Pencil, Pencil,
Plus, Plus,
Search, Search,
SlidersHorizontal, SlidersHorizontal,
Square,
Stamp,
StickyNote, StickyNote,
Trash2, Trash2,
Type,
Underline,
Strikethrough,
X, X,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { AnnotationTypeIcon } from "../AnnotationTypeIcon";
import Select from "../Select"; import Select from "../Select";
import { import {
@ -35,25 +46,45 @@ import type {
AnnotationDTO, AnnotationDTO,
BookmarkDTO, BookmarkDTO,
CommentData, CommentData,
DrawData,
HighlightData,
RedactionData, RedactionData,
ShapeData,
} from "@/db/schema"; } from "@/db/schema";
import { import { annotationNote, annotationPreviewText } from "@/utils/annotation-display";
annotationNote,
annotationPreviewText,
describeAnnotationChange,
} from "@/utils/annotation-display";
import { withBasePath } from "@/utils/base-path";
import {
MAX_BOOKMARK_NOTE_CHARS,
MAX_BOOKMARK_TITLE_CHARS,
} from "@/utils/bookmark-limits";
import { formatThaiDate } from "@/utils/date-format";
/** UX audit 2026-09-16: focus ring ตอน keyboard focus — ค่าเดียวกับ FOCUS_RING_CLS /** UX audit 2026-09-16: focus ring ตอน keyboard focus — ค่าเดียวกับ FOCUS_RING_CLS
* ของ PDFViewer.tsx (ห้าม import ข้ามไฟล์: PDFViewer → sidebar → PDFViewer เป็น * ของ PDFViewer.tsx (ห้าม import ข้ามไฟล์: PDFViewer → sidebar → PDFViewer เป็น
* circular import — แก้ต้องแก้ทั้งสองที่พร้อมกัน) */ * circular import — แก้ต้องแก้ทั้งสองที่พร้อมกัน) */
const FOCUS_RING_CLS = const FOCUS_RING_CLS =
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary focus-visible:z-10"; "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:z-10";
function TypeIcon({ type, data }: { type: AnnotationDTO["type"]; data: AnnotationDTO["data"] }) {
const cls = "h-4 w-4 shrink-0 text-slate-500";
if (type === "HIGHLIGHT") {
const style = (data as HighlightData).style ?? "highlight";
if (style === "underline") return <Underline className={cls} />;
if (style === "strikethrough") return <Strikethrough className={cls} />;
return <Highlighter className={cls} />; // highlight / squiggle
}
if (type === "DRAW")
return (data as DrawData).pen === "highlighter" ? (
<Highlighter className={cls} />
) : (
<Pencil className={cls} />
);
if (type === "REDACTION") return <EyeOff className={cls} />;
if (type === "TEXTBOX") return <Type className={cls} />;
if (type === "STAMP") return <Stamp className={cls} />;
if (type === "SHAPE") {
const shape = (data as ShapeData).shape;
if (shape === "rect") return <Square className={cls} />;
if (shape === "ellipse") return <Circle className={cls} />;
if (shape === "line") return <Minus className={cls} />;
return <ArrowRight className={cls} />;
}
return <MessageSquare className={cls} />;
}
/** ใส่/ล้างโน้ตคำอธิบายใน data ของ annotation (ทุกชนิดยกเว้น COMMENT — message ทำหน้าที่นี้แล้ว) */ /** ใส่/ล้างโน้ตคำอธิบายใน data ของ annotation (ทุกชนิดยกเว้น COMMENT — message ทำหน้าที่นี้แล้ว) */
function withNote(data: Exclude<AnnotationData, CommentData>, note: string): AnnotationData { function withNote(data: Exclude<AnnotationData, CommentData>, note: string): AnnotationData {
@ -138,7 +169,7 @@ function ViewSwitch<T extends string>({
onChange: (v: T) => void; onChange: (v: T) => void;
}) { }) {
return ( return (
<div className="flex rounded-md bg-ink/[0.05] p-0.5"> <div className="flex rounded-md bg-slate-100 p-0.5">
{options.map((o) => ( {options.map((o) => (
<button <button
key={o.key} key={o.key}
@ -146,8 +177,8 @@ function ViewSwitch<T extends string>({
aria-pressed={value === o.key} aria-pressed={value === o.key}
className={`flex-1 rounded px-2 py-1 text-[11px] font-medium transition ${FOCUS_RING_CLS} ${ className={`flex-1 rounded px-2 py-1 text-[11px] font-medium transition ${FOCUS_RING_CLS} ${
value === o.key value === o.key
? "bg-white text-ink ring-1 ring-line" ? "bg-white text-slate-900 shadow-sm"
: "text-ink-soft hover:text-ink" : "text-slate-500 hover:text-slate-600"
}`} }`}
onClick={() => onChange(o.key)} onClick={() => onChange(o.key)}
> >
@ -177,7 +208,7 @@ function MenuItem({
type="button" type="button"
role="menuitem" role="menuitem"
className={`flex w-full items-start gap-2 rounded px-2 py-1.5 text-left text-xs transition ${FOCUS_RING_CLS} ${ className={`flex w-full items-start gap-2 rounded px-2 py-1.5 text-left text-xs transition ${FOCUS_RING_CLS} ${
danger ? "text-error-ink hover:bg-error/10" : "text-ink-soft hover:bg-ink/[0.05]" danger ? "text-red-600 hover:bg-red-50" : "text-slate-600 hover:bg-slate-100"
}`} }`}
onClick={onClick} onClick={onClick}
> >
@ -203,17 +234,17 @@ function PageGroupHeader({
<button <button
type="button" type="button"
aria-label={collapsed ? `กางหน้า ${pageIndex + 1}` : `หุบหน้า ${pageIndex + 1}`} aria-label={collapsed ? `กางหน้า ${pageIndex + 1}` : `หุบหน้า ${pageIndex + 1}`}
className={`flex w-full items-center gap-1.5 rounded px-1 py-1 text-left hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`} className={`flex w-full items-center gap-1.5 rounded px-1 py-1 text-left hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={onToggle} onClick={onToggle}
> >
{collapsed ? ( {collapsed ? (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-ink-soft" /> <ChevronRight className="h-3.5 w-3.5 shrink-0 text-slate-500" />
) : ( ) : (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-ink-soft" /> <ChevronDown className="h-3.5 w-3.5 shrink-0 text-slate-500" />
)} )}
<span className="text-xs font-medium text-ink"> <span className="text-xs font-medium text-slate-700">
หน้า {pageIndex + 1} หน้า {pageIndex + 1}
<span className="ml-1 font-normal text-ink-soft">({count})</span> <span className="ml-1 font-normal text-slate-500">({count})</span>
</span> </span>
</button> </button>
); );
@ -285,7 +316,6 @@ export default function AnnotationSidebar({
bookmarks, bookmarks,
fileId, fileId,
userId, userId,
token,
userFilter, userFilter,
users, users,
onUserFilterChange, onUserFilterChange,
@ -304,9 +334,6 @@ export default function AnnotationSidebar({
/** ไฟล์ที่เปิดอยู่ — ใช้ค้นหาหมายเหตุทั้งไฟล์ (F14) */ /** ไฟล์ที่เปิดอยู่ — ใช้ค้นหาหมายเหตุทั้งไฟล์ (F14) */
fileId: string; fileId: string;
userId: string; userId: string;
/** access_token ของ session — ส่งเข้าทุก action ที่แผงนี้เรียกเอง (ค้นหา/ประวัติ/
* กู้คืน · ฉบับ token 2026-10-30 identity แกะฝั่ง server) */
token: string;
/** F4: "all" | "mine" | <userId> — กรองฝั่ง client ล้วน */ /** F4: "all" | "mine" | <userId> — กรองฝั่ง client ล้วน */
userFilter: string; userFilter: string;
users: { userId: string; userName: string }[]; users: { userId: string; userName: string }[];
@ -367,7 +394,7 @@ export default function AnnotationSidebar({
if (!q || !fileId) return; if (!q || !fileId) return;
setSearching(true); setSearching(true);
try { try {
setSearchResults(await searchAnnotations(token, fileId, q)); setSearchResults(await searchAnnotations(fileId, q));
} catch (err) { } catch (err) {
console.error("ค้นหาไม่สำเร็จ", err); console.error("ค้นหาไม่สำเร็จ", err);
setSearchResults([]); setSearchResults([]);
@ -377,14 +404,13 @@ export default function AnnotationSidebar({
} }
/** ผลการค้นหา F14 หลังกรองตามเจ้าของหมายเหตุ (F4) ฝั่ง client — searchAnnotations /** ผลการค้นหา F14 หลังกรองตามเจ้าของหมายเหตุ (F4) ฝั่ง client — searchAnnotations
* ไม่รับ userId และห้ามแต้ server action: สูตรเดียวกันกับ visibleAnnotations ของ * ไม่รับ userId และห้ามแต้ server action: สูตรเดียวกับ visibleAnnotations ของ
* PDFViewer (รวม REDACTION ของทุกคนไว้เสมอ — D4) — เปลี่ยนตัวกรองหลังค้นแล้ว * PDFViewer — เปลี่ยนตัวกรองหลังค้นแล้ว ผลกรองทันทีไม่ต้องค้นใหม่ */
* ผลกรองทันทีไม่ต้องค้นใหม่ */
const visibleSearchResults = useMemo(() => { const visibleSearchResults = useMemo(() => {
if (searchResults === null) return null; if (searchResults === null) return null;
if (userFilter === "all") return searchResults; if (userFilter === "all") return searchResults;
const target = userFilter === "mine" ? userId : userFilter; const target = userFilter === "mine" ? userId : userFilter;
return searchResults.filter((a) => a.userId === target || a.type === "REDACTION"); return searchResults.filter((a) => a.userId === target);
}, [searchResults, userFilter, userId]); }, [searchResults, userFilter, userId]);
// ─── F19/A7: ประวัติ/กู้คืน — dialog ต่อรายการ (แสดงเฉพาะรายการของตัวเอง) ───── // ─── F19/A7: ประวัติ/กู้คืน — dialog ต่อรายการ (แสดงเฉพาะรายการของตัวเอง) ─────
@ -398,7 +424,7 @@ export default function AnnotationSidebar({
setVersions(null); setVersions(null);
setHistoryError(null); setHistoryError(null);
try { try {
setVersions(await listVersions(token, a.id)); setVersions(await listVersions(a.id));
} catch (err) { } catch (err) {
setHistoryError(err instanceof Error ? err.message : "โหลดประวัติไม่สำเร็จ"); setHistoryError(err instanceof Error ? err.message : "โหลดประวัติไม่สำเร็จ");
} }
@ -409,7 +435,7 @@ export default function AnnotationSidebar({
async function handleRestore(versionId: string) { async function handleRestore(versionId: string) {
setRestoringId(versionId); setRestoringId(versionId);
try { try {
await restoreVersion(token, versionId); await restoreVersion(versionId, userId);
setHistoryFor(null); setHistoryFor(null);
} catch (err) { } catch (err) {
setHistoryError(err instanceof Error ? err.message : "กู้คืนไม่สำเร็จ"); setHistoryError(err instanceof Error ? err.message : "กู้คืนไม่สำเร็จ");
@ -428,36 +454,14 @@ export default function AnnotationSidebar({
return () => document.removeEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey);
}, [historyFor]); }, [historyFor]);
// state ปัจจุบันของรายการที่เปิดประวัติอยู่ — lookup สดจาก realtime ก่อน
// (SSE update หลังเปิด dialog ก็ยังเทียบกับค่าล่าสุดได้) โดย fallback เป็น
// snapshot ตอนเปิด (รายการจากผลค้นหา F14 ไม่อยู่ใน prop annotations)
const historyCurrent =
historyFor && (annotations.find((a) => a.id === historyFor.id) ?? historyFor);
// F10/A5: แยกกลุ่มเฉพาะ COMMENT — "ค้างดำเนินการ" (รวม annotation ชนิดอื่น) // F10/A5: แยกกลุ่มเฉพาะ COMMENT — "ค้างดำเนินการ" (รวม annotation ชนิดอื่น)
// กับ "จัดการแล้ว" (comment ที่ถูก resolve) // กับ "จัดการแล้ว" (comment ที่ถูก resolve)
const pendingItems = annotations.filter((a) => a.type !== "COMMENT" || !a.resolved); const pendingItems = annotations.filter((a) => a.type !== "COMMENT" || !a.resolved);
const resolvedItems = annotations.filter((a) => a.type === "COMMENT" && a.resolved); const resolvedItems = annotations.filter((a) => a.type === "COMMENT" && a.resolved);
// F4: bookmarks ที่แสดง กรองตามเจ้าของหมายเหตุเหมือน annotation (คำขอผู้ใช้
// 2026-09-22 ให้ตรงกับ export ที่กรอง PDF outline ตามผู้บันทึก) — หัวข้อแม่โดนกรอง
// แต่ลูกยังอยู่ ลูกขึ้น root (พฤติกรรมเดียวกับ buildOutline ของ export) — ใช้เฉพาะจุด
// แสดงผล (ต้นไม้/กลุ่มหน้า/ตัวเลขแท็บ): popup ย้ายหัวข้อ (movableTo) กับ bookmarkById
// ยังเห็นต้นไม้เต็ม ย้ายไปใต้หัวข้อของคนอื่นได้ตามเดิม
const keptBookmarks =
userFilter === "all"
? bookmarks
: bookmarks.filter(
(b) => b.userId === (userFilter === "mine" ? userId : userFilter),
);
const keptIds = new Set(keptBookmarks.map((b) => b.id));
const visibleBookmarks = keptBookmarks.map((b) =>
b.parentId && !keptIds.has(b.parentId) ? { ...b, parentId: null } : b,
);
// มุมมอง "ตามหน้า": จัดกลุ่มตามหน้า เรียงน้อย→มาก — bookmark เอาเฉพาะ root // มุมมอง "ตามหน้า": จัดกลุ่มตามหน้า เรียงน้อย→มาก — bookmark เอาเฉพาะ root
// (sub ดูในต้นไม้ "ตามหัวข้อ") / annotation เก็บลำดับเดิมตามเวลาสร้าง // (sub ดูในต้นไม้ "ตามหัวข้อ") / annotation เก็บลำดับเดิมตามเวลาสร้าง
const bmRootPages = groupByPage(visibleBookmarks.filter((b) => !b.parentId).sort(byPageOrder)); const bmRootPages = groupByPage(bookmarks.filter((b) => !b.parentId).sort(byPageOrder));
const annPages = groupByPage(annotations); const annPages = groupByPage(annotations);
// F12/C4: คัดลอก deep link ไปยัง annotation นี้ (?page= + ?ann=) // F12/C4: คัดลอก deep link ไปยัง annotation นี้ (?page= + ?ann=)
@ -467,7 +471,7 @@ export default function AnnotationSidebar({
const [copyFailedId, setCopyFailedId] = useState<string | null>(null); const [copyFailedId, setCopyFailedId] = useState<string | null>(null);
const copyTimerRef = useRef<number | null>(null); const copyTimerRef = useRef<number | null>(null);
function copyAnnotationLink(a: AnnotationDTO) { function copyAnnotationLink(a: AnnotationDTO) {
const url = `${window.location.origin}${withBasePath(`/annotate/${a.fileId}?page=${a.pageIndex + 1}&ann=${a.id}`)}`; const url = `${window.location.origin}/annotate/${a.fileId}?page=${a.pageIndex + 1}&ann=${a.id}`;
if (copyTimerRef.current !== null) { if (copyTimerRef.current !== null) {
window.clearTimeout(copyTimerRef.current); window.clearTimeout(copyTimerRef.current);
copyTimerRef.current = null; copyTimerRef.current = null;
@ -702,7 +706,7 @@ export default function AnnotationSidebar({
try { try {
const q = bmQuery.trim() || undefined; const q = bmQuery.trim() || undefined;
setBmResults( setBmResults(
await searchBookmarks(token, { await searchBookmarks({
fileId, fileId,
q, q,
pageFrom, pageFrom,
@ -735,7 +739,7 @@ export default function AnnotationSidebar({
} }
const renderBookmarkNode = (b: BookmarkDTO, depth: number) => { const renderBookmarkNode = (b: BookmarkDTO, depth: number) => {
const children = visibleBookmarks.filter((x) => x.parentId === b.id).sort(byPageOrder); const children = bookmarks.filter((x) => x.parentId === b.id).sort(byPageOrder);
const hasChildren = children.length > 0; const hasChildren = children.length > 0;
const collapsed = collapsedIds.has(b.id); const collapsed = collapsedIds.has(b.id);
const editing = editBookmark?.id === b.id; const editing = editBookmark?.id === b.id;
@ -768,7 +772,6 @@ export default function AnnotationSidebar({
<div className={`rounded py-1 ${pad}`}> <div className={`rounded py-1 ${pad}`}>
<input <input
autoFocus autoFocus
maxLength={MAX_BOOKMARK_TITLE_CHARS}
value={eb.title} value={eb.title}
onChange={(e) => setEditBookmark({ ...eb, title: e.target.value })} onChange={(e) => setEditBookmark({ ...eb, title: e.target.value })}
onKeyDown={(e) => { onKeyDown={(e) => {
@ -777,11 +780,10 @@ export default function AnnotationSidebar({
if (e.key === "Escape") setEditBookmark(null); if (e.key === "Escape") setEditBookmark(null);
}} }}
placeholder="ชื่อหัวข้อ" placeholder="ชื่อหัวข้อ"
className="w-full rounded-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary" className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
/> />
<textarea <textarea
rows={2} rows={2}
maxLength={MAX_BOOKMARK_NOTE_CHARS}
value={eb.note} value={eb.note}
onChange={(e) => setEditBookmark({ ...eb, note: e.target.value })} onChange={(e) => setEditBookmark({ ...eb, note: e.target.value })}
onKeyDown={(e) => { onKeyDown={(e) => {
@ -790,16 +792,16 @@ export default function AnnotationSidebar({
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveBookmarkEdit(b); if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveBookmarkEdit(b);
}} }}
placeholder="โน้ต (ไม่บังคับ)" placeholder="โน้ต (ไม่บังคับ)"
className="mt-1 w-full resize-none rounded-md border border-line p-1.5 text-[11px] focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary" className="mt-1 w-full resize-none rounded border border-slate-300 p-1.5 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
/> />
<p className="mt-1 text-[11px] text-ink-soft">วางที่</p> <p className="mt-1 text-[11px] text-slate-500">วางที่</p>
<Select <Select
value={eb.parentId ?? ""} value={eb.parentId ?? ""}
onChange={(e) => onChange={(e) =>
setEditBookmark({ ...eb, parentId: e.target.value || null }) setEditBookmark({ ...eb, parentId: e.target.value || null })
} }
onKeyDown={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}
className="mt-0.5 w-full rounded-md border border-line bg-white px-1.5 py-1 text-[11px] focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary" className="mt-0.5 w-full rounded border border-slate-300 bg-white px-1.5 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
> >
<option value="">ระดับบนสุด (หมุดหัวข้อใหญ่)</option> <option value="">ระดับบนสุด (หมุดหัวข้อใหญ่)</option>
{movableTo.map((h) => ( {movableTo.map((h) => (
@ -811,14 +813,14 @@ export default function AnnotationSidebar({
<div className="mt-1.5 flex justify-end gap-1.5 text-[11px]"> <div className="mt-1.5 flex justify-end gap-1.5 text-[11px]">
<button <button
type="button" type="button"
className={`rounded-md px-2 py-0.5 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`} className={`rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => setEditBookmark(null)} onClick={() => setEditBookmark(null)}
> >
ยกเลิก ยกเลิก
</button> </button>
<button <button
type="button" type="button"
className={`rounded-md bg-primary px-2 py-0.5 font-medium text-white ${FOCUS_RING_CLS}`} className={`rounded bg-primary px-2 py-0.5 font-medium text-white ${FOCUS_RING_CLS}`}
onClick={() => saveBookmarkEdit(b)} onClick={() => saveBookmarkEdit(b)}
> >
บันทึก บันทึก
@ -826,16 +828,12 @@ export default function AnnotationSidebar({
</div> </div>
</div> </div>
) : ( ) : (
<div className={`group flex items-center gap-1.5 rounded py-1 hover:bg-ink/[0.05] ${pad}`}> <div className={`group flex items-center gap-1.5 rounded py-1 hover:bg-slate-100 ${pad}`}>
{hasChildren ? ( {hasChildren ? (
// bm-toggle: คอลัมน์นี้เป็น "ตาราง" ของต้นไม้ ต้องกว้าง 14px เท่า spacer
// ของแถวลูกเสมอ — บนจอสัมผัส rule coarse ที่ขยายปุ่มเป็น 32px จะดันไอคอน
// ของแม่ไปชนแนวของลูก (indent 16px หาย) จึงยกเว้นผ่าน class + hit area
// ขยายด้วย ::after แทน (ดู globals.css)
<button <button
type="button" type="button"
aria-label={collapsed ? `กางหัวข้อ ${b.title}` : `หุบหัวข้อ ${b.title}`} aria-label={collapsed ? `กางหัวข้อ ${b.title}` : `หุบหัวข้อ ${b.title}`}
className={`shrink-0 bm-toggle text-ink-soft hover:text-ink ${FOCUS_RING_CLS}`} className={`shrink-0 text-slate-500 hover:text-slate-600 ${FOCUS_RING_CLS}`}
onClick={() => toggleCollapsed(b.id)} onClick={() => toggleCollapsed(b.id)}
> >
{collapsed ? ( {collapsed ? (
@ -853,25 +851,26 @@ export default function AnnotationSidebar({
className={`flex min-w-0 flex-1 items-start gap-1.5 text-left ${FOCUS_RING_CLS}`} className={`flex min-w-0 flex-1 items-start gap-1.5 text-left ${FOCUS_RING_CLS}`}
onClick={() => onGoToBookmark(b)} onClick={() => onGoToBookmark(b)}
> >
{/* ไอคอน bookmark สีเดียวทุกระดับ (warning-ink ผ่าน 5:1 บนขาว — เดิมแยก <Bookmark
root/ลูกด้วย amber-500/300 แต่ตัว 300 จาง 1.6:1 แทบมองไม่เห็น; className={`h-3.5 w-3.5 shrink-0 ${
ลำดับชั้นของต้นไม้สื่อด้วย font-semibold + indent อยู่แล้ว) */} depth === 1 ? "text-amber-500" : "text-amber-300"
<Bookmark className="h-3.5 w-3.5 shrink-0 text-warning-ink" /> }`}
/>
<span className="min-w-0"> <span className="min-w-0">
<span <span
className={`block truncate text-[13px] leading-snug ${ className={`block truncate text-[13px] leading-snug ${
depth === 1 ? "font-semibold text-ink" : "" depth === 1 ? "font-semibold text-slate-700" : ""
}`} }`}
> >
{b.title} {b.title}
</span> </span>
{/* แสดงผู้บันทึกด้วย — เหมือนแถวผลการค้นหา (N6) และแถวหมายเหตุ {/* แสดงผู้บันทึกด้วย — เหมือนแถวผลการค้นหา (N6) และแถวหมายเหตุ
(แถวเดิมมีแค่ "หน้า N" ไม่รู้ว่าใครเป็นคนคั่นไว้) */} (แถวเดิมมีแค่ "หน้า N" ไม่รู้ว่าใครเป็นคนคั่นไว้) */}
<span className="block text-xs text-ink-soft"> <span className="block text-xs text-slate-500">
หน้า {b.pageIndex + 1} · {b.userName || "ไม่ทราบชื่อ"} หน้า {b.pageIndex + 1} · {b.userName || "ไม่ทราบชื่อ"}
</span> </span>
{b.note && ( {b.note && (
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-ink-soft"> <span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
{b.note} {b.note}
</span> </span>
)} )}
@ -879,7 +878,7 @@ export default function AnnotationSidebar({
</button> </button>
{/* จำนวนหมุดย่อย — badge ขวาสุด (UX audit: แยกจากชื่อ ไม่ปนตอนอ่านไว) */} {/* จำนวนหมุดย่อย — badge ขวาสุด (UX audit: แยกจากชื่อ ไม่ปนตอนอ่านไว) */}
{hasChildren && ( {hasChildren && (
<span className="shrink-0 self-center rounded-full bg-ink/[0.05] px-1.5 text-[10px] tabular-nums leading-4 text-ink-soft"> <span className="shrink-0 self-center rounded-full bg-slate-100 px-1.5 text-[10px] tabular-nums leading-4 text-slate-500">
{children.length} {children.length}
</span> </span>
)} )}
@ -890,7 +889,7 @@ export default function AnnotationSidebar({
aria-label="จัดการหัวข้อนี้" aria-label="จัดการหัวข้อนี้"
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={menuFor?.kind === "bookmark" && menuFor.id === b.id} aria-expanded={menuFor?.kind === "bookmark" && menuFor.id === b.id}
className={`shrink-0 text-ink-soft hover:text-ink group-hover:text-ink-soft ${FOCUS_RING_CLS}`} className={`shrink-0 text-slate-500 hover:text-slate-600 group-hover:text-slate-600 ${FOCUS_RING_CLS}`}
onClick={(e) => { onClick={(e) => {
if (menuFor?.kind === "bookmark" && menuFor.id === b.id) closeRowMenu(); if (menuFor?.kind === "bookmark" && menuFor.id === b.id) closeRowMenu();
else openRowMenu(e.currentTarget, { kind: "bookmark", id: b.id }); else openRowMenu(e.currentTarget, { kind: "bookmark", id: b.id });
@ -904,7 +903,7 @@ export default function AnnotationSidebar({
{hasChildren && !collapsed && ( {hasChildren && !collapsed && (
// เส้น guide แนวตั้งนำสายตากลับหา parent แบบ file tree (indent ~17px/ระดับ // เส้น guide แนวตั้งนำสายตากลับหา parent แบบ file tree (indent ~17px/ระดับ
// ≈ ของเดิม 16px — spacer/chevron w-3.5 คงเดิมจึงตรงแนวกันทุกระดับ) // ≈ ของเดิม 16px — spacer/chevron w-3.5 คงเดิมจึงตรงแนวกันทุกระดับ)
<ul className="ml-3 space-y-0.5 border-l border-line pl-1"> <ul className="ml-3 space-y-0.5 border-l border-slate-200 pl-1">
{children.map((c) => renderBookmarkNode(c, depth + 1))} {children.map((c) => renderBookmarkNode(c, depth + 1))}
</ul> </ul>
)} )}
@ -922,7 +921,7 @@ export default function AnnotationSidebar({
className={`group rounded px-2 py-1.5 ${ className={`group rounded px-2 py-1.5 ${
a.id === selectedId a.id === selectedId
? "bg-primary/10 ring-1 ring-inset ring-primary/30" ? "bg-primary/10 ring-1 ring-inset ring-primary/30"
: "hover:bg-ink/[0.05]" : "hover:bg-slate-100"
} ${a.resolved ? "opacity-60" : ""}`} } ${a.resolved ? "opacity-60" : ""}`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -935,12 +934,12 @@ export default function AnnotationSidebar({
onGoToAnnotation(a); onGoToAnnotation(a);
}} }}
> >
<AnnotationTypeIcon type={a.type} data={a.data} /> <TypeIcon type={a.type} data={a.data} />
<span className="min-w-0"> <span className="min-w-0">
{/* UX audit: อักษรไทยมีสระ/วรรณยุกต์ซ้อนหลายชั้น — ข้อความหลักต้อง 13px {/* UX audit: อักษรไทยมีสระ/วรรณยุกต์ซ้อนหลายชั้น — ข้อความหลักต้อง 13px
จึงอ่านได้สบาย (บรรทัดรอง/โน้ตคง 11px) */} จึงอ่านได้สบาย (บรรทัดรอง/โน้ตคง 11px) */}
<span className="block truncate text-[13px] leading-snug">{annotationPreviewText(a)}</span> <span className="block truncate text-[13px] leading-snug">{annotationPreviewText(a)}</span>
<span className="block text-xs text-ink-soft"> <span className="block text-xs text-slate-500">
หน้า {a.pageIndex + 1} · {a.userName} หน้า {a.pageIndex + 1} · {a.userName}
{a.type === "COMMENT" && (replyCounts[a.id] ?? 0) > 0 {a.type === "COMMENT" && (replyCounts[a.id] ?? 0) > 0
? ` · ตอบ ${replyCounts[a.id]}` ? ` · ตอบ ${replyCounts[a.id]}`
@ -959,10 +958,10 @@ export default function AnnotationSidebar({
title={copyFailedId === a.id ? "คัดลอกไม่ได้ (เบราว์เซอร์บล็อกคลิปบอร์ด)" : "จัดการหมายเหตุนี้"} title={copyFailedId === a.id ? "คัดลอกไม่ได้ (เบราว์เซอร์บล็อกคลิปบอร์ด)" : "จัดการหมายเหตุนี้"}
className={`shrink-0 ${FOCUS_RING_CLS} ${ className={`shrink-0 ${FOCUS_RING_CLS} ${
copiedId === a.id copiedId === a.id
? "text-success-ink" ? "text-emerald-500"
: copyFailedId === a.id : copyFailedId === a.id
? "text-error-ink" ? "text-red-500"
: "text-ink-soft hover:text-ink group-hover:text-ink-soft" : "text-slate-500 hover:text-slate-600 group-hover:text-slate-600"
}`} }`}
onClick={(e) => { onClick={(e) => {
if (menuFor?.kind === "annotation" && menuFor.id === a.id) closeRowMenu(); if (menuFor?.kind === "annotation" && menuFor.id === a.id) closeRowMenu();
@ -991,19 +990,19 @@ export default function AnnotationSidebar({
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveNote(a); if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveNote(a);
}} }}
placeholder="โน้ต เช่น เหตุผลที่ทำรายการนี้…" placeholder="โน้ต เช่น เหตุผลที่ทำรายการนี้…"
className="w-full resize-none rounded-md border border-line p-1.5 text-[11px] focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary" className="w-full resize-none rounded border border-slate-300 p-1.5 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
/> />
<div className="mt-1 flex justify-end gap-1.5 text-[11px]"> <div className="mt-1 flex justify-end gap-1.5 text-[11px]">
<button <button
type="button" type="button"
className={`rounded-md px-2 py-0.5 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`} className={`rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => setNoteFor(null)} onClick={() => setNoteFor(null)}
> >
ยกเลิก ยกเลิก
</button> </button>
<button <button
type="button" type="button"
className={`rounded-md bg-primary px-2 py-0.5 font-medium text-white ${FOCUS_RING_CLS}`} className={`rounded bg-primary px-2 py-0.5 font-medium text-white ${FOCUS_RING_CLS}`}
onClick={() => saveNote(a)} onClick={() => saveNote(a)}
> >
บันทึก บันทึก
@ -1012,8 +1011,8 @@ export default function AnnotationSidebar({
</div> </div>
) : ( ) : (
note && ( note && (
<p className="mt-0.5 flex items-start gap-1 pl-6 text-[11px] text-ink-soft"> <p className="mt-0.5 flex items-start gap-1 pl-6 text-[11px] text-slate-500">
<StickyNote className="mt-0.5 h-3 w-3 shrink-0 text-warning-ink" /> <StickyNote className="mt-0.5 h-3 w-3 shrink-0 text-amber-400" />
<span className="line-clamp-2 whitespace-pre-wrap break-words">{note}</span> <span className="line-clamp-2 whitespace-pre-wrap break-words">{note}</span>
</p> </p>
) )
@ -1025,10 +1024,10 @@ export default function AnnotationSidebar({
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* F4: กรองแสดง annotation ตามผู้เขียน (viewer + sidebar พร้อมกัน) */} {/* F4: กรองแสดง annotation ตามผู้เขียน (viewer + sidebar พร้อมกัน) */}
<div className="shrink-0 border-b border-line p-2"> <div className="shrink-0 border-b border-slate-200 p-2">
<Select <Select
aria-label="เลือกเจ้าของหมายเหตุที่แสดง" aria-label="เลือกเจ้าของหมายเหตุที่แสดง"
className="w-full rounded-md border border-line bg-white px-2 py-1.5 text-xs text-ink-soft focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary" className="w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs text-slate-600 focus:outline-none focus:ring-2 focus:ring-slate-400"
value={userFilter} value={userFilter}
onChange={(e) => onUserFilterChange(e.target.value)} onChange={(e) => onUserFilterChange(e.target.value)}
> >
@ -1043,11 +1042,11 @@ export default function AnnotationSidebar({
</div> </div>
{/* แท็บหลัก — tablist/tab semantics ให้ screen reader ประกาศว่าเป็นแท็บ */} {/* แท็บหลัก — tablist/tab semantics ให้ screen reader ประกาศว่าเป็นแท็บ */}
<div role="tablist" aria-label="รายการในแผงข้าง" className="flex shrink-0 border-b border-line"> <div role="tablist" aria-label="รายการในแผงข้าง" className="flex shrink-0 border-b border-slate-200">
{( {(
[ [
["bookmarks", `Bookmark (${visibleBookmarks.length})`], ["bookmarks", `ที่คั่นหน้า (${bookmarks.length})`],
["annotations", `Annotation (${annotations.length})`], ["annotations", `หมายเหตุ (${annotations.length})`],
] as const ] as const
).map(([key, label]) => ( ).map(([key, label]) => (
<button <button
@ -1057,8 +1056,8 @@ export default function AnnotationSidebar({
aria-selected={tab === key} aria-selected={tab === key}
className={`flex-1 px-2 py-2 text-xs font-medium tabular-nums transition ${FOCUS_RING_CLS} ${ className={`flex-1 px-2 py-2 text-xs font-medium tabular-nums transition ${FOCUS_RING_CLS} ${
tab === key tab === key
? "border-b-2 border-primary text-ink" ? "border-b-2 border-primary text-slate-900"
: "text-ink-soft hover:text-ink" : "text-slate-500 hover:text-slate-600"
}`} }`}
onClick={() => setTab(key)} onClick={() => setTab(key)}
> >
@ -1070,25 +1069,25 @@ export default function AnnotationSidebar({
{/* N6: ค้นหา bookmark พร้อมเงื่อนไข — เฉพาะแท็บที่คั่นหน้า {/* N6: ค้นหา bookmark พร้อมเงื่อนไข — เฉพาะแท็บที่คั่นหน้า
(แยกจากช่อง F14 ที่ค้นหมายเหตุ ซึ่งอยู่เหนือแท็บ) */} (แยกจากช่อง F14 ที่ค้นหมายเหตุ ซึ่งอยู่เหนือแท็บ) */}
{tab === "bookmarks" && ( {tab === "bookmarks" && (
<div className="shrink-0 space-y-1.5 border-b border-line p-2"> <div className="shrink-0 space-y-1.5 border-b border-slate-200 p-2">
{/* แถวคำค้น — โครงเดียวกับ F14 ของแท็บหมายเหตุ: input เต็มกว้าง + ปุ่มเดียว {/* แถวคำค้น — โครงเดียวกับ F14 ของแท็บหมายเหตุ: input เต็มกว้าง + ปุ่มเดียว
(เดิมปุ่ม "เงื่อนไข"+badge แย่งแถวเดียวกันจน input เหลือพื้นที่ข้อความ (เดิมปุ่ม "เงื่อนไข"+badge แย่งแถวเดียวกันจน input เหลือพื้นที่ข้อความ
~80px ≈ 6–7 ตัวอักษรไทย พิมพ์คำค้นจริงไม่พอ) */} ~80px ≈ 6–7 ตัวอักษรไทย พิมพ์คำค้นจริงไม่พอ) */}
<form className="flex gap-1" onSubmit={(e) => void runBookmarkSearch(e)}> <form className="flex gap-1" onSubmit={(e) => void runBookmarkSearch(e)}>
<div className="relative min-w-0 flex-1"> <div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-soft" /> <Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
<input <input
value={bmQuery} value={bmQuery}
onChange={(e) => setBmQuery(e.target.value)} onChange={(e) => setBmQuery(e.target.value)}
onKeyDown={(e) => e.stopPropagation()} // ไม่ให้ปุ่มในช่องค้นหาชนคีย์ลัดของ viewer onKeyDown={(e) => e.stopPropagation()} // ไม่ให้ปุ่มในช่องค้นหาชนคีย์ลัดของ viewer
placeholder="ชื่อ หรือ โน้ต…" placeholder="ชื่อ หรือ โน้ต…"
className="w-full rounded-md border border-line py-1.5 pl-7 pr-2 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary" className="w-full rounded-md border border-slate-300 py-1.5 pl-7 pr-2 text-xs focus:outline-none focus:ring-2 focus:ring-slate-400"
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={activeConditionCount === 0 || bmSearching} disabled={activeConditionCount === 0 || bmSearching}
className={`shrink-0 rounded-md border border-line px-2 py-1.5 text-xs text-ink-soft hover:bg-ink/[0.05] disabled:opacity-40 ${FOCUS_RING_CLS}`} className={`shrink-0 rounded-md border border-slate-300 px-2 py-1.5 text-xs text-slate-600 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
> >
{bmSearching ? "…" : "หา"} {bmSearching ? "…" : "หา"}
</button> </button>
@ -1101,8 +1100,8 @@ export default function AnnotationSidebar({
type="button" type="button"
aria-expanded={bmShowConditions} aria-expanded={bmShowConditions}
onClick={() => setBmShowConditions((v) => !v)} onClick={() => setBmShowConditions((v) => !v)}
className={`flex min-w-0 flex-1 items-center gap-1.5 rounded px-1.5 py-1 text-[11px] hover:bg-ink/[0.05] ${FOCUS_RING_CLS} ${ className={`flex min-w-0 flex-1 items-center gap-1.5 rounded px-1.5 py-1 text-[11px] hover:bg-slate-100 ${FOCUS_RING_CLS} ${
hasBmConditionValues ? "text-primary" : "text-ink-soft" hasBmConditionValues ? "text-primary" : "text-slate-500"
}`} }`}
> >
<SlidersHorizontal className="h-3 w-3 shrink-0" /> <SlidersHorizontal className="h-3 w-3 shrink-0" />
@ -1115,14 +1114,22 @@ export default function AnnotationSidebar({
<ChevronRight className="h-3 w-3 shrink-0" /> <ChevronRight className="h-3 w-3 shrink-0" />
)} )}
</button> </button>
{/* ปุ่มส่งออก JSON (N7) ถูกถอด 2026-09-25 ตามคำขอผู้ใช้ — ระบบภายนอก {/* N7: ส่งออกรายการ bookmark เป็น JSON — ลิงก์ธรรมดา pattern Export CSV (F16)
ดึงข้อมูลผ่าน N10 API แทน (ปุ่มเดียวที่เหลือคือ toggle เงื่อนไขค้นหา) */} ส่งออกทุกรายการของไฟล์เสมอ ไม่ผูกกับเงื่อนไขค้นหาที่ตั้งไว้ */}
<a
href={`/api/bookmarks/export?fileId=${fileId}`}
title="ส่งออกทุกที่คั่นหน้าของไฟล์นี้เป็น JSON"
aria-label="ส่งออกทุกที่คั่นหน้าของไฟล์นี้เป็น JSON"
className={`coarse-36 shrink-0 rounded-md p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 ${FOCUS_RING_CLS}`}
>
<Braces className="h-3.5 w-3.5" />
</a>
</div> </div>
{bmShowConditions && ( {bmShowConditions && (
// จังหวะเดียวทุกแถว: label บนบรรทัด + คู่ input แบ่งความกว้าง grid 1fr–1fr // จังหวะเดียวทุกแถว: label บนบรรทัด + คู่ input แบ่งความกว้าง grid 1fr–1fr
// (เดิม label inline 2 แถว + label บน 1 แถว อ่านไม่เป็นระเบียบ ขอบ input // (เดิม label inline 2 แถว + label บน 1 แถว อ่านไม่เป็นระเบียบ ขอบ input
// ไม่ตรงแนวกัน และคู่ date ในแถว inline ล้น 288px จนต้องหนีขึ้นบรรทัดบน) // ไม่ตรงแนวกัน และคู่ date ในแถว inline ล้น 288px จนต้องหนีขึ้นบรรทัดบน)
<div className="space-y-1.5 text-[11px] text-ink-soft"> <div className="space-y-1.5 text-[11px] text-slate-600">
<div> <div>
<span className="block pb-0.5">หน้า</span> <span className="block pb-0.5">หน้า</span>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-1"> <div className="grid grid-cols-[1fr_auto_1fr] items-center gap-1">
@ -1137,9 +1144,9 @@ export default function AnnotationSidebar({
} }
onKeyDown={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}
placeholder="จาก" placeholder="จาก"
className="w-full min-w-0 rounded-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary" className="w-full min-w-0 rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
/> />
<span className="text-ink-faint">–</span> <span className="text-slate-400">–</span>
<input <input
type="number" type="number"
min={1} min={1}
@ -1151,7 +1158,7 @@ export default function AnnotationSidebar({
} }
onKeyDown={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}
placeholder="ถึง" placeholder="ถึง"
className="w-full min-w-0 rounded-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary" className="w-full min-w-0 rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
/> />
</div> </div>
</div> </div>
@ -1164,7 +1171,7 @@ export default function AnnotationSidebar({
setBmConditions({ ...bmConditions, userId: e.target.value }) setBmConditions({ ...bmConditions, userId: e.target.value })
} }
onKeyDown={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}
className="w-full rounded-md border border-line bg-white px-1.5 py-1 text-[11px] focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary" className="w-full rounded border border-slate-300 bg-white px-1.5 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
> >
<option value="">ทุกคน</option> <option value="">ทุกคน</option>
{bookmarkAuthors.map((u) => ( {bookmarkAuthors.map((u) => (
@ -1176,11 +1183,7 @@ export default function AnnotationSidebar({
</div> </div>
<div> <div>
<span className="block pb-0.5">บันทึกวันที่</span> <span className="block pb-0.5">บันทึกวันที่</span>
{/* เรียง 2 แถวเต็มกว้าง (ไม่ใช่คู่แนวนอน + "–" คั่น): คอลัมน์คู่แคบ <div className="grid grid-cols-[1fr_auto_1fr] items-center gap-1">
~122px ของ sidebar ใส่ค่า "23 ก.ย. 2026" ที่ 16px (rule จอสัมผัส
กัน iOS zoom-on-focus) + icon ปฏิทินไม่พอ ปีโดนตัด (code review
2026-09-23) — aria-label กำกับช่วงเริ่ม/สุดท้ายอยู่แล้ว */}
<div className="grid gap-1">
<input <input
type="date" type="date"
aria-label="วันที่เริ่ม" aria-label="วันที่เริ่ม"
@ -1189,8 +1192,9 @@ export default function AnnotationSidebar({
setBmConditions({ ...bmConditions, dateFrom: e.target.value }) setBmConditions({ ...bmConditions, dateFrom: e.target.value })
} }
onKeyDown={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}
className="w-full min-w-0 rounded-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary" className="w-full min-w-0 rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
/> />
<span className="text-slate-400">–</span>
<input <input
type="date" type="date"
aria-label="วันที่สุดท้าย" aria-label="วันที่สุดท้าย"
@ -1199,14 +1203,14 @@ export default function AnnotationSidebar({
setBmConditions({ ...bmConditions, dateTo: e.target.value }) setBmConditions({ ...bmConditions, dateTo: e.target.value })
} }
onKeyDown={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}
className="w-full min-w-0 rounded-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary" className="w-full min-w-0 rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
/> />
</div> </div>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
type="button" type="button"
className={`rounded-md px-1.5 py-1 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`} className={`rounded px-1.5 py-1 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => { onClick={() => {
// UX audit: ล้างให้สิ้นเชิง — คำค้น + เงื่อนไข + ผลที่แสดง พร้อมกัน // UX audit: ล้างให้สิ้นเชิง — คำค้น + เงื่อนไข + ผลที่แสดง พร้อมกัน
setBmConditions(EMPTY_BM_CONDITIONS); setBmConditions(EMPTY_BM_CONDITIONS);
@ -1222,12 +1226,12 @@ export default function AnnotationSidebar({
{/* error กับ hint เป็นคำเตือนคนละเรื่อง (ฟอร์มผิด vs ผลเป็นของเงื่อนไขเก่า) {/* error กับ hint เป็นคำเตือนคนละเรื่อง (ฟอร์มผิด vs ผลเป็นของเงื่อนไขเก่า)
— มี error แสดงเดี่ยว ไม่ซ้อนสองข้อความพร้อมกัน */} — มี error แสดงเดี่ยว ไม่ซ้อนสองข้อความพร้อมกัน */}
{bmSearchError ? ( {bmSearchError ? (
<p className="px-1 text-[11px] text-error-ink">{bmSearchError}</p> <p className="px-1 text-[11px] text-red-600">{bmSearchError}</p>
) : ( ) : (
bmResults !== null && bmResults !== null &&
bmSearchedSig !== null && bmSearchedSig !== null &&
bmSearchedSig !== JSON.stringify([bmQuery.trim(), bmConditions]) && ( bmSearchedSig !== JSON.stringify([bmQuery.trim(), bmConditions]) && (
<p className="px-1 text-[11px] text-warning-ink"> <p className="px-1 text-[11px] text-amber-600">
เงื่อนไขเปลี่ยนแล้ว กด &quot;หา&quot; เพื่อค้นใหม่ เงื่อนไขเปลี่ยนแล้ว กด &quot;หา&quot; เพื่อค้นใหม่
</p> </p>
) )
@ -1240,23 +1244,23 @@ export default function AnnotationSidebar({
เนื้อหาใน list ด้านล่าง คลิกแถว = กระโดดไปตำแหน่ง + เลือก */} เนื้อหาใน list ด้านล่าง คลิกแถว = กระโดดไปตำแหน่ง + เลือก */}
{tab === "annotations" && ( {tab === "annotations" && (
<form <form
className="flex shrink-0 gap-1 border-b border-line p-2" className="flex shrink-0 gap-1 border-b border-slate-200 p-2"
onSubmit={(e) => void runSearch(e)} onSubmit={(e) => void runSearch(e)}
> >
<div className="relative min-w-0 flex-1"> <div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-soft" /> <Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
<input <input
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.stopPropagation()} // ไม่ให้ปุ่มในช่องค้นหาชนคีย์ลัดของ viewer onKeyDown={(e) => e.stopPropagation()} // ไม่ให้ปุ่มในช่องค้นหาชนคีย์ลัดของ viewer
placeholder="ค้นหาในหมายเหตุ…" placeholder="ค้นหาในหมายเหตุ…"
className="w-full rounded-md border border-line py-1.5 pl-7 pr-2 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary" className="w-full rounded-md border border-slate-300 py-1.5 pl-7 pr-2 text-xs focus:outline-none focus:ring-2 focus:ring-slate-400"
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={!searchQuery.trim() || searching} disabled={!searchQuery.trim() || searching}
className={`shrink-0 rounded-md border border-line px-2 py-1.5 text-xs text-ink-soft hover:bg-ink/[0.05] disabled:opacity-40 ${FOCUS_RING_CLS}`} className={`shrink-0 rounded-md border border-slate-300 px-2 py-1.5 text-xs text-slate-600 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
> >
{searching ? "…" : "หา"} {searching ? "…" : "หา"}
</button> </button>
@ -1268,14 +1272,14 @@ export default function AnnotationSidebar({
bmResults !== null ? ( bmResults !== null ? (
<div> <div>
<div className="flex items-center justify-between px-1 pb-1"> <div className="flex items-center justify-between px-1 pb-1">
<p className="text-[11px] font-semibold tabular-nums text-ink-soft"> <p className="text-[11px] font-semibold tabular-nums text-slate-500">
ผลการค้นหา ({bmResults.length}) ผลการค้นหา ({bmResults.length})
</p> </p>
<button <button
type="button" type="button"
aria-label="ปิดผลการค้นหา" aria-label="ปิดผลการค้นหา"
title="กลับไปรายการที่คั่นหน้า" title="กลับไปรายการที่คั่นหน้า"
className={`text-ink-soft hover:text-ink ${FOCUS_RING_CLS}`} className={`text-slate-500 hover:text-slate-600 ${FOCUS_RING_CLS}`}
onClick={() => setBmResults(null)} onClick={() => setBmResults(null)}
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
@ -1285,14 +1289,14 @@ export default function AnnotationSidebar({
แชร์จอให้คนอื่นดู (แก้ฟอร์มหลังค้นบรรทัดนี้ไม่เปลี่ยน จนกด "หา" ใหม่) */} แชร์จอให้คนอื่นดู (แก้ฟอร์มหลังค้นบรรทัดนี้ไม่เปลี่ยน จนกด "หา" ใหม่) */}
{bmSearchedSummary && ( {bmSearchedSummary && (
<p <p
className="truncate px-1 pb-1 text-[11px] text-ink-soft" className="truncate px-1 pb-1 text-[11px] text-slate-500"
title={bmSearchedSummary} title={bmSearchedSummary}
> >
ค้นด้วย: {bmSearchedSummary} ค้นด้วย: {bmSearchedSummary}
</p> </p>
)} )}
{bmResults.length === 0 ? ( {bmResults.length === 0 ? (
<p className="px-1 pb-1 text-xs text-ink-soft">ไม่พบ</p> <p className="px-1 pb-1 text-xs text-slate-500">ไม่พบ</p>
) : ( ) : (
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{bmResults.map((b) => { {bmResults.map((b) => {
@ -1301,22 +1305,22 @@ export default function AnnotationSidebar({
<li key={b.id}> <li key={b.id}>
<button <button
type="button" type="button"
className={`flex w-full items-start gap-1.5 rounded px-1 py-1 text-left hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`} className={`flex w-full items-start gap-1.5 rounded px-1 py-1 text-left hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => onGoToBookmark(b)} onClick={() => onGoToBookmark(b)}
> >
<Bookmark className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning-ink" /> <Bookmark className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<span className="min-w-0"> <span className="min-w-0">
<span className="block truncate text-[13px] leading-snug">{b.title}</span> <span className="block truncate text-[13px] leading-snug">{b.title}</span>
<span className="block text-xs text-ink-soft"> <span className="block text-xs text-slate-500">
หน้า {b.pageIndex + 1} · {b.userName || "ไม่ทราบชื่อ"} หน้า {b.pageIndex + 1} · {b.userName || "ไม่ทราบชื่อ"}
</span> </span>
{parent && ( {parent && (
<span className="block truncate text-[11px] text-ink-soft"> <span className="block truncate text-[11px] text-slate-500">
ใน: {parent.title} ใน: {parent.title}
</span> </span>
)} )}
{b.note && ( {b.note && (
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-ink-soft"> <span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
{b.note} {b.note}
</span> </span>
)} )}
@ -1329,13 +1333,9 @@ export default function AnnotationSidebar({
)} )}
</div> </div>
) : bookmarks.length === 0 ? ( ) : bookmarks.length === 0 ? (
<p className="p-4 text-center text-xs text-ink-soft"> <p className="p-4 text-center text-xs text-slate-500">
ยังไม่มีที่คั่นหน้า — กดปุ่มคั่นหน้าบนแถบเครื่องมือเพื่อเพิ่ม ยังไม่มีที่คั่นหน้า — กดปุ่มคั่นหน้าบนแถบเครื่องมือเพื่อเพิ่ม
</p> </p>
) : visibleBookmarks.length === 0 ? (
<p className="p-4 text-center text-xs text-ink-soft">
ไม่มีที่คั่นหน้าของเจ้าของหมายเหตุที่เลือก
</p>
) : ( ) : (
<> <>
{/* สลับมุมมองแท็บนี้: ต้นไม้หัวข้อ ↔ root แยกตามหน้า */} {/* สลับมุมมองแท็บนี้: ต้นไม้หัวข้อ ↔ root แยกตามหน้า */}
@ -1363,34 +1363,34 @@ export default function AnnotationSidebar({
{!collapsed && ( {!collapsed && (
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{roots.map((b) => { {roots.map((b) => {
const childCount = visibleBookmarks.filter( const childCount = bookmarks.filter(
(x) => x.parentId === b.id, (x) => x.parentId === b.id,
).length; ).length;
return ( return (
<li key={b.id}> <li key={b.id}>
<button <button
type="button" type="button"
className={`flex w-full items-start gap-1.5 rounded py-1 pl-5 pr-1 text-left hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`} className="flex w-full items-start gap-1.5 rounded py-1 pl-5 pr-1 text-left hover:bg-slate-100"
onClick={() => onGoToBookmark(b)} onClick={() => onGoToBookmark(b)}
> >
<Bookmark className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning-ink" /> <Bookmark className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-[13px] leading-snug font-medium text-ink"> <span className="block truncate text-[13px] leading-snug font-medium text-slate-700">
{b.title} {b.title}
</span> </span>
{/* ผู้บันทึกเหมือนแถวผลการค้นหา — ไม่ใส่ "หน้า N" {/* ผู้บันทึกเหมือนแถวผลการค้นหา — ไม่ใส่ "หน้า N"
เพราะหัวกลุ่มของมุมมองนี้บอกหน้าอยู่แล้ว */} เพราะหัวกลุ่มของมุมมองนี้บอกหน้าอยู่แล้ว */}
<span className="block text-xs text-ink-soft"> <span className="block text-xs text-slate-500">
{b.userName || "ไม่ทราบชื่อ"} {b.userName || "ไม่ทราบชื่อ"}
</span> </span>
{b.note && ( {b.note && (
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-ink-soft"> <span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
{b.note} {b.note}
</span> </span>
)} )}
</span> </span>
{childCount > 0 && ( {childCount > 0 && (
<span className="ml-auto shrink-0 self-center rounded-full bg-ink/[0.05] px-1.5 text-[10px] tabular-nums leading-4 text-ink-soft"> <span className="ml-auto shrink-0 self-center rounded-full bg-slate-100 px-1.5 text-[10px] tabular-nums leading-4 text-slate-500">
{childCount} {childCount}
</span> </span>
)} )}
@ -1406,7 +1406,7 @@ export default function AnnotationSidebar({
) : ( ) : (
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{/* N2: root = หมุดหัวข้อใหญ่ เรียงตามลำดับหน้า (N5) — sub render ซ้อนใต้ผ่าน renderBookmarkNode */} {/* N2: root = หมุดหัวข้อใหญ่ เรียงตามลำดับหน้า (N5) — sub render ซ้อนใต้ผ่าน renderBookmarkNode */}
{visibleBookmarks {bookmarks
.filter((b) => !b.parentId) .filter((b) => !b.parentId)
.sort(byPageOrder) .sort(byPageOrder)
.map((b) => renderBookmarkNode(b, 1))} .map((b) => renderBookmarkNode(b, 1))}
@ -1417,29 +1417,29 @@ export default function AnnotationSidebar({
) : visibleSearchResults !== null ? ( ) : visibleSearchResults !== null ? (
<div> <div>
<div className="flex items-center justify-between px-1 pb-1"> <div className="flex items-center justify-between px-1 pb-1">
<p className="text-[11px] font-semibold tabular-nums text-ink-soft"> <p className="text-[11px] font-semibold tabular-nums text-slate-500">
ผลการค้นหา ({visibleSearchResults.length}) ผลการค้นหา ({visibleSearchResults.length})
</p> </p>
<button <button
type="button" type="button"
aria-label="ปิดผลการค้นหา" aria-label="ปิดผลการค้นหา"
title="กลับไปรายการหมายเหตุ" title="กลับไปรายการหมายเหตุ"
className={`text-ink-soft hover:text-ink ${FOCUS_RING_CLS}`} className={`text-slate-500 hover:text-slate-600 ${FOCUS_RING_CLS}`}
onClick={() => setSearchResults(null)} onClick={() => setSearchResults(null)}
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
{visibleSearchResults.length === 0 ? ( {visibleSearchResults.length === 0 ? (
<p className="px-1 pb-1 text-xs text-ink-soft">ไม่พบ</p> <p className="px-1 pb-1 text-xs text-slate-500">ไม่พบ</p>
) : ( ) : (
// reuse แถวเดิม — li มี data-ann-id ให้ effect scrollIntoView หาเจอ // reuse แถวเดิม — li มี data-ann-id ให้ effect scrollIntoView หาเจอ
<ul className="space-y-1">{visibleSearchResults.map(renderAnnotationRow)}</ul> <ul className="space-y-1">{visibleSearchResults.map(renderAnnotationRow)}</ul>
)} )}
</div> </div>
) : annotations.length === 0 ? ( ) : annotations.length === 0 ? (
<p className="p-4 text-center text-xs text-ink-soft"> <p className="p-4 text-center text-xs text-slate-500">
ยังไม่มีหมายเหตุ — คลุมข้อความ / วาดเส้น / คอมเมนต์บนหน้าเอกสาร ยังไม่มีหมายเหตุ — คลุมข้อความ / วาดเส้น / คลิกวางคอมเมนต์บนหน้าเอกสาร
<span className="mt-1 block text-[11px]"> <span className="mt-1 block text-[11px]">
คีย์ลัด: H มาร์ก · D วาด · C comment · B คั่นหน้า · Ctrl+Z ย้อน คีย์ลัด: H มาร์ก · D วาด · C comment · B คั่นหน้า · Ctrl+Z ย้อน
</span> </span>
@ -1478,7 +1478,7 @@ export default function AnnotationSidebar({
<> <>
{pendingItems.length > 0 && ( {pendingItems.length > 0 && (
<section> <section>
<p className="px-2 pb-1 pt-2 text-[11px] font-semibold text-ink-soft"> <p className="px-2 pb-1 pt-2 text-[11px] font-semibold text-slate-500">
ค้างดำเนินการ ค้างดำเนินการ
</p> </p>
<ul className="space-y-1">{pendingItems.map(renderAnnotationRow)}</ul> <ul className="space-y-1">{pendingItems.map(renderAnnotationRow)}</ul>
@ -1486,7 +1486,7 @@ export default function AnnotationSidebar({
)} )}
{resolvedItems.length > 0 && ( {resolvedItems.length > 0 && (
<section> <section>
<p className="px-2 pb-1 pt-3 text-[11px] font-semibold text-success-ink"> <p className="px-2 pb-1 pt-3 text-[11px] font-semibold text-emerald-500">
จัดการแล้ว จัดการแล้ว
</p> </p>
<ul className="space-y-1">{resolvedItems.map(renderAnnotationRow)}</ul> <ul className="space-y-1">{resolvedItems.map(renderAnnotationRow)}</ul>
@ -1508,81 +1508,62 @@ export default function AnnotationSidebar({
{/* F19/A7: dialog ประวัติ — รายการ version (เวลา + ชนิดการเปลี่ยนแปลง) + ปุ่มกู้คืน */} {/* F19/A7: dialog ประวัติ — รายการ version (เวลา + ชนิดการเปลี่ยนแปลง) + ปุ่มกู้คืน */}
{historyFor && ( {historyFor && (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4"
onClick={() => setHistoryFor(null)} onClick={() => setHistoryFor(null)}
> >
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="ประวัติการเปลี่ยนแปลง" aria-label="ประวัติการเปลี่ยนแปลง"
className="w-96 max-w-full rounded-lg border border-line bg-white p-4 shadow-xl" className="w-80 rounded-lg border border-slate-200 bg-white p-4 shadow-xl"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<p className="text-sm font-semibold">ประวัติการเปลี่ยนแปลง</p> <p className="text-sm font-semibold">ประวัติการเปลี่ยนแปลง</p>
<p className="mt-0.5 truncate text-[11px] text-ink-soft"> <p className="mt-0.5 truncate text-[11px] text-slate-500">
{annotationPreviewText(historyFor)} {annotationPreviewText(historyFor)}
</p> </p>
{historyError && ( {historyError && (
<p className="mt-2 rounded bg-error/10 px-2 py-1 text-[11px] text-error-ink"> <p className="mt-2 rounded bg-red-50 px-2 py-1 text-[11px] text-red-600">
{historyError} {historyError}
</p> </p>
)} )}
{versions === null && !historyError && ( {versions === null && !historyError && (
<p className="mt-3 text-xs text-ink-soft">กำลังโหลด…</p> <p className="mt-3 text-xs text-slate-500">กำลังโหลด…</p>
)} )}
{versions !== null && versions.length === 0 && ( {versions !== null && versions.length === 0 && (
<p className="mt-3 text-xs text-ink-soft"> <p className="mt-3 text-xs text-slate-500">
ยังไม่มีบันทึกการเปลี่ยนแปลง (เกิดขึ้นเมื่อแก้ไขหรือลบรายการนี้) ยังไม่มีบันทึกการเปลี่ยนแปลง (เกิดขึ้นเมื่อแก้ไขหรือลบรายการนี้)
</p> </p>
)} )}
{versions !== null && versions.length > 0 && ( {versions !== null && versions.length > 0 && (
<ul className="mt-3 max-h-[60vh] divide-y divide-line/60 overflow-y-auto"> <ul className="mt-3 max-h-56 divide-y divide-slate-100 overflow-y-auto">
{versions.map((v, i) => { {versions.map((v) => (
// สิ่งที่เปลี่ยนของรอบนี้ = snapshot ก่อนแก้ (v.data) เทียบ state <li key={v.id} className="flex items-center gap-2 py-2">
// ถัดไป: snapshot ของรอบแก้ที่ใหม่กว่าถัดไป (i-1) หรือค่าปัจจุบัน <span className="min-w-0 flex-1">
// ของรายการ (version ใหม่สุด) — แถว "ถูกลบ" เป็น snapshot สุดท้าย <span className="block text-xs font-medium">
// ก่อนลบ ไม่มี state ต่อจากนั้น จึงไม่ diff {v.kind === "delete" ? "ถูกลบ" : "ถูกแก้ไข"}
const changes =
v.kind === "update" && historyCurrent
? describeAnnotationChange(
v.type,
v.data,
i === 0 ? historyCurrent.data : versions[i - 1].data,
)
: [];
return (
<li key={v.id} className="flex items-start gap-2 py-2">
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium">
{v.kind === "delete" ? "ถูกลบ" : "ถูกแก้ไข"}
</span>
<span className="block text-[11px] text-ink-soft" suppressHydrationWarning>
{formatThaiDate(v.createdAt, true)} · โดย {v.userName}
</span>
{changes.map((c) => (
<span key={c} className="mt-0.5 block text-[11px] text-ink-soft">
{c}
</span>
))}
</span> </span>
<button <span className="block text-[11px] text-slate-500" suppressHydrationWarning>
type="button" {new Date(v.createdAt).toLocaleString("th-TH")}
disabled={restoringId !== null} </span>
className={`mt-0.5 shrink-0 rounded-md border border-line px-2 py-1 text-[11px] text-ink-soft hover:bg-ink/[0.05] disabled:opacity-40 ${FOCUS_RING_CLS}`} </span>
onClick={() => void handleRestore(v.id)} <button
> type="button"
{restoringId === v.id ? "กำลังกู้คืน…" : "กู้คืน"} disabled={restoringId !== null}
</button> className={`shrink-0 rounded border border-slate-300 px-2 py-1 text-[11px] text-slate-600 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
</li> onClick={() => void handleRestore(v.id)}
); >
})} {restoringId === v.id ? "กำลังกู้คืน…" : "กู้คืน"}
</button>
</li>
))}
</ul> </ul>
)} )}
<div className="mt-3 flex justify-end text-xs"> <div className="mt-3 flex justify-end text-xs">
<button <button
type="button" type="button"
autoFocus autoFocus
className={`rounded-md px-3 py-1.5 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`} className={`rounded px-3 py-1.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => setHistoryFor(null)} onClick={() => setHistoryFor(null)}
> >
ปิด ปิด
@ -1602,7 +1583,7 @@ export default function AnnotationSidebar({
data-tool-menu="" data-tool-menu=""
role="menu" role="menu"
aria-label={menuFor.kind === "annotation" ? "จัดการหมายเหตุ" : "จัดการหัวข้อ"} aria-label={menuFor.kind === "annotation" ? "จัดการหมายเหตุ" : "จัดการหัวข้อ"}
className="w-60 rounded-lg border border-line bg-white p-1.5 shadow-lg" className="w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
style={{ style={{
position: "fixed", position: "fixed",
left: menuPos.left, left: menuPos.left,
@ -1616,20 +1597,20 @@ export default function AnnotationSidebar({
> >
{menuConfirmDelete ? ( {menuConfirmDelete ? (
<div className="p-1"> <div className="p-1">
<p className="px-1 text-xs text-ink"> <p className="px-1 text-xs text-slate-700">
ลบ{menuFor.kind === "annotation" ? "รายการ" : "หัวข้อ"}นี้? ลบ{menuFor.kind === "annotation" ? "รายการ" : "หัวข้อ"}นี้?
</p> </p>
<div className="mt-1.5 flex justify-end gap-1.5"> <div className="mt-1.5 flex justify-end gap-1.5">
<button <button
type="button" type="button"
className={`rounded-md px-2 py-1 text-xs text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`} className={`rounded px-2 py-1 text-xs text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={closeRowMenu} onClick={closeRowMenu}
> >
ยกเลิก ยกเลิก
</button> </button>
<button <button
type="button" type="button"
className={`rounded-md border border-error px-2 py-1 text-xs font-medium text-error-ink hover:bg-error/10 ${FOCUS_RING_CLS}`} className={`rounded bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-700 ${FOCUS_RING_CLS}`}
onClick={() => { onClick={() => {
// อ่าน kind/id ก่อน close — setMenuFor(null) แล้วเงื่อนไขเดิมหาย // อ่าน kind/id ก่อน close — setMenuFor(null) แล้วเงื่อนไขเดิมหาย
if (menuFor.kind === "annotation") onDeleteAnnotation(menuFor.id); if (menuFor.kind === "annotation") onDeleteAnnotation(menuFor.id);
@ -1675,7 +1656,7 @@ export default function AnnotationSidebar({
)} )}
{menuAnn.userId === userId && ( {menuAnn.userId === userId && (
<> <>
<div className="my-1 border-t border-line/60" /> <div className="my-1 border-t border-slate-100" />
<MenuItem <MenuItem
danger danger
Icon={Trash2} Icon={Trash2}
@ -1710,7 +1691,7 @@ export default function AnnotationSidebar({
)} )}
{menuBm.userId === userId && ( {menuBm.userId === userId && (
<> <>
<div className="my-1 border-t border-line/60" /> <div className="my-1 border-t border-slate-100" />
<MenuItem <MenuItem
danger danger
Icon={Trash2} Icon={Trash2}

View file

@ -1,176 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { ChevronDown, FileStack } from "lucide-react";
import {
FacetFilterPanel,
createDefaultFacets,
type ExplorerItem,
type FacetFilterState,
} from "@/components/explorer/FacetFilterPanel";
import type { AnnotationType } from "@/db/schema";
import { SummaryList } from "./SummaryList";
// หน้าสรุปหมายเหตุ (F15/D2) redesign 2026-09-24 ตามคำขอผู้ใช้ — explorer 2 คอลัมน์
// แบบหน้ากลุ่ม N9 โดยมิติแรกของตัวกรองเป็น "หน้า" แทน "ไฟล์" (ไฟล์เดียวทั้งหน้า):
// ซ้าย = แผงตัวกรองกลาง FacetFilterPanel (หน้า/ชนิดรายการ/ชนิดของ Annotation/
// ผู้บันทึก) ขวา = SummaryList การ์ดรายหน้า — ข้อมูล query ฝั่ง server ส่งมา
// ครั้งเดียวแล้วกรองใน memory (read-only snapshot ไม่มี SSE เหมือนเดิม)
export function SummaryExplorer({
fileId,
items,
}: {
fileId: string;
items: ExplorerItem[];
}) {
const [filters, setFilters] = useState<FacetFilterState>(createDefaultFacets);
// facet count เป็น static จากข้อมูลทั้งไฟล์ (ไม่หดตามตัวกรองอื่น — เหตุผลเดียว
// กับหน้ากลุ่ม)
const counts = useMemo(() => {
let bm = 0;
let ann = 0;
const type: Partial<Record<AnnotationType, number>> = {};
const user: Record<string, number> = {};
for (const it of items) {
user[it.dto.userId] = (user[it.dto.userId] ?? 0) + 1;
if (it.kind === "bm") bm++;
else {
ann++;
type[it.dto.type] = (type[it.dto.type] ?? 0) + 1;
}
}
return { total: items.length, bm, ann, type, user };
}, [items]);
// รายชื่อผู้บันทึกที่มีรายการจริง เรียงชื่อไทย — แทนบล็อก "ผู้ร่วมทำ" แบบอ่านอย่าง
// เดียวของหน้าเดิม (กรองตามคนได้ + เห็น count) — localeCompare "th" รันทั้ง
// SSR/hydration หลังย้ายมา client: CLDR สมัยใหม่ของ Node/browser ใกล้กันพอ
// รับความเสี่ยง mismatch ต่ำไว้ (code review 2026-09-24 — ถ้าเจอ hydration
// warning จริง ให้ย้าย sort นี้ไปฝั่ง server แล้วส่งเป็น prop)
const users = useMemo(() => {
const labelById = new Map<string, string>();
for (const it of items) {
if (!labelById.has(it.dto.userId)) {
labelById.set(it.dto.userId, it.dto.userName || "ไม่ทราบชื่อ");
}
}
const out: { userId: string; label: string }[] = [];
labelById.forEach((label, userId) => out.push({ userId, label }));
return out.sort((a, b) => a.label.localeCompare(b.label, "th"));
}, [items]);
// มิติ "หน้า": เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (ทางเข้าหน้าแทนรายการตาม
// หน้าจุดไข่ปลาของหน้าเดิม — เห็น count ก่อนกระโดดดีกว่า) — ไอคอนเป็น spacer
// ให้เลขชิดซ้ายตรงแนวกลุ่มผู้บันทึก; หน้าเยอะเป็นสิบ ๆ รายการใช้ optionsScroll
// ให้ scroll ภายในกลุ่ม (max-h-64) กันกลุ่มอื่นถูกดันออกนอกจอ
const pageOptions = useMemo(() => {
const perPage = new Map<number, number>();
for (const it of items) {
perPage.set(it.pageIndex, (perPage.get(it.pageIndex) ?? 0) + 1);
}
return Array.from(perPage.entries())
.sort((a, b) => a[0] - b[0])
.map(([pi, n]) => ({
key: pi,
label: `หน้า ${pi + 1}`,
count: n,
icon: <span aria-hidden className="w-4 shrink-0" />,
}));
}, [items]);
const firstGroup = {
label: "หน้า",
parentIcon: (
<FileStack aria-hidden className="h-4 w-4 shrink-0 text-ink-soft" />
),
allLabel: "ทุกหน้า",
options: pageOptions,
optionsScroll: true,
};
// pure predicate — ห้าม sort ซ้ำ (server เรียงลำดับอ่านให้แล้ว)
const filtered = useMemo(
() =>
items.filter(
(it) =>
(filters.first === null || it.pageIndex === filters.first) &&
(filters.kinds.size === 0 || filters.kinds.has(it.kind)) &&
(it.kind === "ann"
? filters.types.size === 0 || filters.types.has(it.dto.type)
: true) &&
(filters.userId === null || it.dto.userId === filters.userId),
),
[items, filters],
);
// จำนวนตัวกรองที่ ≠ default — โชว์ที่ summary มือถือ (กัน "กรองเก่ากรองเงียบ")
// และเป็นเงื่อนไขปุ่ม "ล้างตัวกรอง"/ชุดพิมพ์ฉบับเต็ม
const activeCount =
(filters.first !== null ? 1 : 0) +
(filters.kinds.size > 0 ? 1 : 0) +
(filters.types.size > 0 ? 1 : 0) +
(filters.userId !== null ? 1 : 0);
const resetFilters = () => setFilters(createDefaultFacets());
return (
<div className="mt-6 lg:grid lg:grid-cols-[15rem_minmax(0,1fr)] lg:items-start lg:gap-8">
{/* มือถือ (< lg): ตัวกรองหุบ/กางเป็น <details> — named group `group/filters`
กัน `group-open:` match ancestor ซ้ำชื่อ (pattern ของหน้ากลุ่ม); ตอนพิมพ์
ซ่อนทั้งสอง shell — กระดาษได้ฉบับเต็มจาก SummaryList เสมอ */}
<details className="group/filters rounded-xl border border-line bg-white lg:hidden print:hidden">
<summary className="focus-ring flex cursor-pointer list-none items-center justify-between rounded-md px-4 py-3 text-sm font-semibold text-ink transition hover:text-primary [&::-webkit-details-marker]:hidden">
ตัวกรอง{activeCount > 0 ? ` (${activeCount})` : ""}
<ChevronDown
aria-hidden
className="h-4 w-4 shrink-0 text-ink-faint transition-transform group-open/filters:rotate-180 motion-reduce:transition-none"
/>
</summary>
<div className="max-h-[70dvh] overscroll-contain overflow-y-auto border-t border-line/70 px-2.5 pb-3 pt-1">
<FacetFilterPanel
state={filters}
onChange={setFilters}
firstGroup={firstGroup}
users={users}
counts={counts}
/>
</div>
</details>
{/* desktop: แผงตัวกรอง sticky — หน้าไม่มี navbar จึง top-6 พอ (เหมือนหน้ากลุ่ม) */}
<aside
aria-label="ตัวกรองรายการ"
className="hidden rounded-xl border border-line bg-white p-3 lg:sticky lg:top-6 lg:block lg:max-h-[calc(100dvh-3rem)] lg:scroll-pb-1 lg:scroll-pt-1 lg:overflow-y-auto print:hidden"
>
<h2 className="px-1.5 text-sm font-semibold text-ink">
ตัวกรอง{activeCount > 0 ? ` (${activeCount})` : ""}
</h2>
<div className="mt-3">
<FacetFilterPanel
state={filters}
onChange={setFilters}
firstGroup={firstGroup}
users={users}
counts={counts}
/>
</div>
</aside>
{/* div ไม่ใช่ section — กฎ print `main.summary-page section` (break-inside
avoid-page) มุ่งที่การ์ดรายหน้าข้างใน ห้ามครอบทั้ง list กลายเป็นก้อนเดียว */}
<div className="mt-5 lg:mt-0">
<SummaryList
fileId={fileId}
items={filtered}
allItems={items}
activeCount={activeCount}
onReset={resetFilters}
/>
</div>
</div>
);
}

View file

@ -1,334 +0,0 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import {
Bookmark,
ChevronRight,
ListFilter,
StickyNote,
} from "lucide-react";
import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon";
import type { ExplorerItem } from "@/components/explorer/FacetFilterPanel";
import type { AnnotationType } from "@/db/schema";
import {
TYPE_ORDER,
TYPE_SHORT,
annotationNote,
annotationPreviewText,
annotationTypeLabel,
} from "@/utils/annotation-display";
import { formatThaiDate } from "@/utils/date-format";
// คอลัมน์ content ของหน้าสรุป (redesign 2026-09-24) — คงหลัก "1 หน้า 1 การ์ด"
// ของหน้าเดิมแต่ผิวบางลง: หัวการ์ด sticky (เลขหน้า + บรรทัดสรุป + เปิดหน้านี้)
// แทนขอบ folio เต็ม ส่วนแถวรายการเป็น passive surface จุดคลิกเดียวคือปุ่ม
// "View" ท้ายแถวเหมือน GroupList ของหน้ากลุ่ม (คำขอผู้ใช้ 2026-09-24) — deep
// link ?ann= / ?page= เดิม; meta line ไม่มี segment ไฟล์ (ไฟล์เดียวทั้งหน้า)
/** บรรทัดสรุปของหน้า จากรายการที่มองเห็นจริงหลังกรอง (ย้ายจาก server 2026-09-24 —
* กรองเหลือแต่เส้นวาดของใครคนหนึ่ง บรรทัดสรุปต้องเล่าเรื่องนั้น ไม่ใช่ยอดดิบ):
* "ไฮไลต์ 1 · คอมเมนต์ 1 (จัดการแล้ว 1) · โดย ผู้พิพากษา ก., เลขานุการ ข."
* (หน้าที่เหลือแต่ที่คั่นหน้า = "โดย …" อย่างเดียว) */
function pageSummaryLine(items: ExplorerItem[]): string {
const typeCounts: Partial<Record<AnnotationType, number>> = {};
let resolvedCount = 0;
const participants = new Set<string>();
for (const it of items) {
if (it.kind === "ann") {
typeCounts[it.dto.type] = (typeCounts[it.dto.type] ?? 0) + 1;
if (it.dto.type === "COMMENT" && it.dto.resolved) resolvedCount += 1;
participants.add(it.dto.userName);
} else {
participants.add(it.dto.userName || "ไม่ทราบชื่อ");
}
}
const parts: string[] = [];
for (const t of TYPE_ORDER) {
const n = typeCounts[t];
if (!n) continue;
if (t === "COMMENT" && resolvedCount > 0)
parts.push(`คอมเมนต์ ${n} (จัดการแล้ว ${resolvedCount})`);
else parts.push(`${TYPE_SHORT[t]} ${n}`);
}
// localeCompare "th" ทั้ง SSR/hydration — รับความเสี่ยง mismatch ต่ำไว้เหมือน
// users ของ SummaryExplorer (code review 2026-09-24)
const who = Array.from(participants).sort((x, y) => x.localeCompare(y, "th"));
const shown = who.slice(0, 3).join(", ");
const whoLabel = who.length > 3 ? `${shown} และอีก ${who.length - 3} คน` : shown;
return parts.length > 0 ? `${parts.join(" · ")} · โดย ${whoLabel}` : `โดย ${whoLabel}`;
}
/** จุดคั่น "·" ของ meta line */
function Sep() {
return <span aria-hidden className="text-ink-faint">·</span>;
}
/** meta line ของแถวสรุป: บริบทกลาง ("ใน: …" ของ sub bookmark / ชื่อชนิดของ
* annotation) → ผู้บันทึก → วันที่ — ต่างจาก GroupList ตรงไม่มี segment ไฟล์
* (ไฟล์เดียว ชื่ออยู่แถบหัวของหน้าเองแล้ว) */
function RowMeta({
middle,
user,
date,
}: {
middle?: string;
user: string;
date: string;
}) {
return (
<span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-ink-soft">
{middle && (
<>
<span className="min-w-0 max-w-full truncate" title={middle}>
{middle}
</span>
<Sep />
</>
)}
<span className="min-w-0 max-w-full truncate">โดย {user}</span>
<Sep />
{/* formatThaiDate แปลงตาม timezone เคื่องที่ render — SSR/server กับ
hydration/ผู้ใช้อาจต่างกัน ใส่ suppressHydrationWarning ตาม convention
(เดียวกับ GroupList/AnnotationSidebar) */}
<span className="whitespace-nowrap" suppressHydrationWarning>
{date}
</span>
</span>
);
}
/** โน้ตคำอธิบาย (N1) / โน้ตย่อยของ bookmark (N2) — 2 บรรทัดพอ */
function RowNote({ text }: { text: string }) {
return (
<span className="mt-0.5 flex items-start gap-1 text-xs leading-relaxed text-ink-soft">
<StickyNote aria-hidden className="mt-0.5 h-3 w-3 shrink-0 text-warning-ink" />
<span className="line-clamp-2 whitespace-pre-wrap break-words">{text}</span>
</span>
);
}
/** การ์ดรายหน้าตามจำนวนที่ได้เห็น — anchorIds เฉพาะชุดบนจอ (ชุดพิมพ์ฉบับเต็ม
* ที่ render คู่กันตอนกรองอยู่ต้องไม่มี id ซ้ำ) */
function PageSections({
fileId,
items,
anchorIds,
}: {
fileId: string;
items: ExplorerItem[];
anchorIds: boolean;
}) {
// จัดกลุ่มตามหน้า — Map เรียงตาม insertion = หน้าน้อย→มาก (items เรียงจาก server)
const pages = useMemo(() => {
const byPage = new Map<number, ExplorerItem[]>();
for (const it of items) {
let arr = byPage.get(it.pageIndex);
if (!arr) {
arr = [];
byPage.set(it.pageIndex, arr);
}
arr.push(it);
}
return Array.from(byPage.entries());
}, [items]);
return (
<div className="flex flex-col gap-4">
{pages.map(([pi, pageItems]) => (
// ห้ามใส่ overflow-hidden — ฆ่า position:sticky ของหัวการ์ด (sticky ไม่
// ทำงานใน ancestor ที่ overflow:hidden; มุมโค้งไม่มีสีหลุดมุมเพราะหัวการ์ด
// ขาวบนการ์ดขาว) — id page-N คงจากหน้าเดิมให้ลิงก์เก่ายังใช้ได้
<section
key={pi}
id={anchorIds ? `page-${pi + 1}` : undefined}
aria-labelledby={anchorIds ? `page-${pi + 1}-title` : undefined}
className="scroll-mt-6 rounded-xl border border-line bg-white"
>
{/* หัวการ์ด sticky ระดับ viewport — ไต่ list ยาวแล้วยังรู้ว่าอยู่หน้าไหน
(รับช่วง orientation จากรายการตามหน้าจุดไข่ปลาที่ถอดออก; บนกระดาษ
กลับเป็นตำแหน่งปกติผ่านกฎ .page-head ใน globals.css) */}
<div className="page-head sticky top-0 z-10 flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-line bg-white px-3 py-2.5 sm:px-4">
<h2
id={anchorIds ? `page-${pi + 1}-title` : undefined}
className="flex items-baseline gap-1 tabular-nums"
>
<span className="text-[11px] font-medium text-ink-soft">หน้า</span>
{/* เลขหน้าเป็น ink ไม่ใช่ primary — Genesis "น้ำเงินเฉพาะของ
interactive" ความเด่นมาจาก font-display semibold */}
<span className="font-display text-lg font-semibold leading-none text-ink">
{pi + 1}
</span>
</h2>
<p className="min-w-0 flex-1 text-xs leading-relaxed text-ink-soft">
{pageSummaryLine(pageItems)}
</p>
<Link
href={`/note/annotate/${fileId}?page=${pi + 1}`}
className="focus-ring inline-flex shrink-0 items-center gap-0.5 text-xs text-primary hover:underline"
>
เปิดหน้านี้
<ChevronRight aria-hidden className="h-3 w-3" />
</Link>
</div>
<ul className="divide-y divide-line/70">
{pageItems.map((item) => {
const isBm = item.kind === "bm";
// ปลายทาง + คำบอกตำแหน่งต่อชนิดรายการ (title tooltip + aria-label
// ของปุ่ม View — ปุ่มชื่อเหมือนกันทั้ง list ต้องรู้จักแยก) ไม่ต่อ
// ชื่อไฟล์เหมือนหน้ากลุ่ม (ไฟล์เดียว ชื่ออยู่แถบหัวแล้ว)
const where = isBm
? `ไปที่หน้า ${item.pageIndex + 1}`
: `ดู${annotationTypeLabel(item.dto)}ที่หน้า ${item.pageIndex + 1}`;
const href = isBm
? `/note/annotate/${fileId}?page=${item.pageIndex + 1}`
: `/note/annotate/${fileId}?ann=${item.dto.id}`;
return (
// แถวเป็น passive surface — จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว
// (คำขอผู้ใช้ 2026-09-24 เหมือนหน้ากลุ่ม) จึงไม่มี hover bg ที่แถว
<li
key={item.dto.id}
className="flex items-start gap-2.5 px-3 py-3 sm:px-4"
>
{isBm ? (
<>
<Bookmark
aria-hidden
className="mt-1 h-4 w-4 shrink-0 text-warning-ink"
/>
<span className="min-w-0 flex-1">
<span className="block whitespace-pre-wrap break-words text-sm font-medium leading-snug text-ink">
{item.dto.title}
</span>
<RowMeta
middle={
item.parentTitle
? `ใน: ${item.parentTitle}`
: undefined
}
user={item.dto.userName || "ไม่ทราบชื่อ"}
date={formatThaiDate(item.dto.createdAt, true)}
/>
{item.dto.note && <RowNote text={item.dto.note} />}
</span>
</>
) : (
<>
<AnnotationTypeIcon
type={item.dto.type}
data={item.dto.data}
className="mt-1 h-4 w-4 shrink-0 text-ink-soft"
/>
<span className="min-w-0 flex-1">
<span className="block whitespace-pre-wrap break-words text-sm leading-snug text-ink">
{annotationPreviewText(item.dto)}
{item.dto.type === "COMMENT" && item.dto.resolved && (
<span className="ml-1.5 whitespace-nowrap rounded border border-success/40 bg-success/10 px-1.5 py-0.5 text-xs font-medium text-success-ink">
จัดการแล้ว
</span>
)}
</span>
<RowMeta
middle={annotationTypeLabel(item.dto)}
user={item.dto.userName}
date={formatThaiDate(item.dto.createdAt, true)}
/>
{annotationNote(item.dto) && (
<RowNote text={annotationNote(item.dto)} />
)}
</span>
</>
)}
{/* scroll-mt-12: โฟกัสผ่าน Tab แล้วปุ่มไม่จมใต้หัวการ์ด sticky
(~41px) — WCAG 2.4.11 Focus not obscured */}
<Link
href={href}
title={where}
aria-label={`View — ${where}`}
className="focus-ring mt-0.5 inline-flex h-8 shrink-0 scroll-mt-12 items-center rounded-md border border-line bg-white px-3 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-primary hover:text-primary"
>
View
</Link>
</li>
);
})}
</ul>
</section>
))}
</div>
);
}
export function SummaryList({
fileId,
items,
allItems,
activeCount,
onReset,
}: {
fileId: string;
/** ผ่านตัวกรองแล้ว (เรียงลำดับจาก server คงเดิม) */
items: ExplorerItem[];
/** ข้อมูลทั้งหมดก่อนกรอง — ใช้เป็นชุดพิมพ์ฉบับเต็มตอนกรองอยู่ */
allItems: ExplorerItem[];
activeCount: number;
onReset: () => void;
}) {
return (
<>
{/* จำนวนผลลัพธ์อยู่ต้นคอลัมน์ content (ที่เห็นผลของตัวกรอง) ไม่ใช่ในแผง —
aria-live ให้ screen reader ได้รับแจ้งผลกรองทันทีที่ toggle */}
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 px-1 pb-2.5 print:hidden">
<p className="text-sm text-ink-soft" aria-live="polite">
พบ{" "}
<span className="font-semibold tabular-nums text-ink">
{items.length}
</span>{" "}
รายการ
</p>
{activeCount > 0 && (
<button
type="button"
onClick={onReset}
className="focus-ring rounded-md px-1.5 text-sm text-primary transition hover:underline"
>
ล้างตัวกรอง
</button>
)}
</div>
{items.length > 0 && (
<div className={activeCount > 0 ? "print:hidden" : undefined}>
<PageSections fileId={fileId} items={items} anchorIds />
</div>
)}
{items.length === 0 && (
<div className="rounded-xl border border-dashed border-line bg-white/70 p-10 text-center print:hidden">
<ListFilter aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
<p className="mt-3 text-sm font-medium text-ink-soft">
ไม่มีรายการตรงตามตัวกรอง
</p>
<button
type="button"
onClick={onReset}
className="focus-ring mt-4 inline-flex rounded-md border border-line bg-white px-4 py-2 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
>
ล้างตัวกรองทั้งหมด
</button>
</div>
)}
{/* ตอนพิมพ์แสดงฉบับเต็มเสมอ — ตัวกรองที่ตั้งค้างไว้ต้องไม่ตัดรายการออกเงียบ ๆ
บนกระดาษ (หน้าสรุปมีโอกาสถูกพิมพ์แนบสำนวน ความครบถ้วนสำคัญกว่า subset —
ต้องการ subset มี export CSV อยู่แล้ว); ไม่กรอง = DOM ชุดเดียวไม่ซ้ำ */}
{activeCount > 0 && (
<div className="hidden print:block">
<PageSections fileId={fileId} items={allItems} anchorIds={false} />
</div>
)}
</>
);
}

View file

@ -1,91 +0,0 @@
'use client';
import { useState, ChangeEvent, useEffect } from 'react';
export interface DropdownData {
[key: string]: string[];
}
interface DependentDropdownProps {
data: DropdownData;
name1: string; // เพิ่มมาเพื่อใช้กับ Form Action
name2: string; // เพิ่มมาเพื่อใช้กับ Form Action
label1?: string;
label2?: string;
placeholder1?: string;
placeholder2?: string;
onSelectChange?: (mainValue: string, subValue: string) => void;
}
export default function DependentDropdown({
data,
name1,
name2,
label1 = 'เลือกตัวเลือกหลัก:',
label2 = 'เลือกตัวเลือกย่อย:',
placeholder1 = '-- กรุณาเลือก --',
placeholder2 = '-- กรุณาเลือกตัวเลือกหลักก่อน --',
onSelectChange,
}: DependentDropdownProps) {
const [mainSelection, setMainSelection] = useState<string>('');
const [subSelection, setSubSelection] = useState<string>('');
const handleMainChange = (e: ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
setMainSelection(value);
setSubSelection('');
};
const handleSubChange = (e: ChangeEvent<HTMLSelectElement>) => {
setSubSelection(e.target.value);
};
useEffect(() => {
if (onSelectChange) {
onSelectChange(mainSelection, subSelection);
}
}, [mainSelection, subSelection, onSelectChange]);
const subOptions = mainSelection ? data[mainSelection] : [];
return (
<div className="space-y-4 w-full">
{/* Dropdown ที่ 1 */}
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-ink">{label1}</label>
<select
name={name1} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้
value={mainSelection}
onChange={handleMainChange}
required
className="genesis-input w-full"
>
<option value="">{placeholder1}</option>
{Object.keys(data).map((key) => (
<option key={key} value={key}>{key}</option>
))}
</select>
</div>
{/* Dropdown ที่ 2 */}
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-ink">{label2}</label>
<select
name={name2} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้
value={subSelection}
onChange={handleSubChange}
disabled={!mainSelection}
required
className="genesis-input w-full disabled:bg-canvas disabled:text-ink-faint"
>
<option value="">
{mainSelection ? '-- กรุณาเลือก --' : placeholder2}
</option>
{subOptions.map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
</div>
</div>
);
}

View file

@ -1,166 +0,0 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Spinner } from "./Spinner";
/**
* FR-2 ขั้นตอนที่ 1 — dialog ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?" พร้อมชื่อ commit
* message ทางเลือก (FR-13) — ใช้ร่วมกับทั้ง Save & Version และ Start Versioning
* FR-18 — มี onConfirmClose = มีปุ่มที่สอง [บันทึกเวอร์ชันและปิดเอกสาร] เป็น primary ตัวเดียว
* (ปุ่มยืนยันหลักกลายเป็น secondary) ส่งเฉพาะ platform ที่ปิดเอกสารได้
* พอร์ตจาก ConfirmVersionDialog.tsx ต้นฉบับ — แทน Dialog ของ Fluent ด้วย portal + Genesis
* (pattern overlay เดียวกับ AnnotationSidebar)
*/
const DEFAULT_TITLE = "สร้าง version ใหม่?";
const DEFAULT_DESCRIPTION = "ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้";
const DEFAULT_CONFIRM_LABEL = "สร้าง version";
const BTN_SECONDARY =
"focus-ring rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50";
const BTN_PRIMARY =
"focus-ring rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50";
export function ConfirmVersionDialog({
open,
busy,
title,
description,
confirmLabel,
onConfirm,
onConfirmClose,
onDismiss,
}: {
open: boolean;
/** กำลังอยู่ระหว่าง flow หลังกดยืนยันแล้ว — ปุ่มทั้งหมด disable + แสดง spinner + ปิดไม่ได้ */
busy: boolean;
/** ปรับข้อความตอน reuse กับ flow อื่น (FR-13 — Start Versioning ใช้ dialog ตัวเดียวกัน) */
title?: string;
description?: ReactNode;
confirmLabel?: string;
/** ได้รับ commit message ที่ผู้ใช้พิมพ์ (FR-13) — ว่างได้ */
onConfirm: (message: string) => void;
/** FR-18 — submit ที่สอง "บันทึกเวอร์ชันและปิดเอกสาร" */
onConfirmClose?: (message: string) => void;
onDismiss: () => void;
}) {
const [message, setMessage] = useState("");
const dialogRef = useRef<HTMLDivElement>(null);
// component ถูก mount ค้างไว้ตลอด (TaskpaneApp render ไว้นอก phase branch) — ต้องเคลียร์ค่าตอนปิด
// ไม่ใช่ตอน unmount ไม่งั้นข้อความรอบก่อนหลุดมารอบถัดไป
useEffect(() => {
if (!open) {
setMessage("");
}
}, [open]);
// ESC = ยกเลิก (เฉพาะไม่กำลัง busy — ตอน busy ห้ามปิดได้ทุกทางตาม spec)
// Tab กักไว้ใน dialog — สิ่งที่ Fluent Dialog เคยทำให้อัตโนมัติ (WCAG 2.1.2/2.4.3)
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busy) onDismiss();
if (event.key === "Tab") {
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
"button:not([disabled]), textarea:not([disabled]), [href], input, select, [tabindex]:not([tabindex='-1'])",
);
const first = focusables?.item(0);
const last = focusables?.item((focusables?.length ?? 0) - 1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, busy, onDismiss]);
// โฟกัสเข้า dialog ตอนเปิด + คืน focus ให้ trigger เดิมตอนปิด (อีกส่วนที่ Fluent เคยทำให้)
useEffect(() => {
if (!open) return;
const previous = document.activeElement as HTMLElement | null;
dialogRef.current?.focus();
return () => {
previous?.focus();
};
}, [open]);
if (!open) return null;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4"
onClick={() => !busy && onDismiss()}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={title ?? DEFAULT_TITLE}
tabIndex={-1}
className="w-full max-w-sm rounded-xl border border-line bg-white p-4 shadow-lg focus:outline-none"
onClick={(event) => event.stopPropagation()}
>
<h2 className="font-display text-base font-semibold text-ink">{title ?? DEFAULT_TITLE}</h2>
<p className="mt-1 text-sm text-ink-soft">{description ?? DEFAULT_DESCRIPTION}</p>
<label className="mt-3 block text-sm font-medium text-ink" htmlFor="wordaddin-version-message">
คำอธิบาย version (ไม่บังคับ)
</label>
<textarea
id="wordaddin-version-message"
className="genesis-input mt-1 block w-full resize-y"
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="เช่น แก้ไขข้อ 3 สัญญาจ้าง"
maxLength={2048}
rows={3}
disabled={busy}
aria-label="คำอธิบาย version"
/>
{busy ? (
<div className="mt-3">
<Spinner label="กำลังดำเนินการ…" />
</div>
) : null}
{/* จอแคบ: 3 ปุ่มในบรรทัดเดียว wrap ไม่มีแบบแผน (ปุ่ม primary ตกบรรทัดเดี่ยวเป็นเศษ)
→ จัดเป็น 2 แถวโดยตั้งใจ — คู่ ยกเลิก/ยืนยัน บน + ปุ่ม primary เต็มความกว้างล่าง */}
<div className="mt-4 flex flex-col gap-2">
<div className="flex justify-end gap-2">
<button type="button" className={BTN_SECONDARY} disabled={busy} onClick={onDismiss}>
ยกเลิก
</button>
<button
type="button"
className={onConfirmClose ? BTN_SECONDARY : BTN_PRIMARY}
disabled={busy}
onClick={() => onConfirm(message)}
>
{confirmLabel ?? DEFAULT_CONFIRM_LABEL}
</button>
</div>
{onConfirmClose ? (
<button
type="button"
className={`${BTN_PRIMARY} w-full`}
disabled={busy}
onClick={() => onConfirmClose(message)}
>
บันทึกเวอร์ชันและปิดเอกสาร
</button>
) : null}
</div>
</div>
</div>,
document.body,
);
}
export default ConfirmVersionDialog;

View file

@ -1,45 +0,0 @@
import type { ReactNode } from "react";
import { CircleAlert, CircleCheck, TriangleAlert } from "lucide-react";
/**
* Genesis MessageBar ของ wordaddin — แทน MessageBar ของ Fluent UI v9 ต้นฉบับ
* intent error = ข้อความ/ไอคอนใช้เฉด -ink (contrast ≥5:1 ตาม DESIGN.md), พื้น tint จาง
* มี icon นำตามระดับ — แยกด้วยรูปทรง ไม่ใช่สีอย่างเดียว (WCAG 1.4.1) + role ให้ screen
* reader ประกาศ (error = alert, ที่เหลือ = status polite — WCAG 4.1.3) + w-full ให้ทุก
* แถบกว้างเท่ากันทั้งคอลัมน์
* prop action = ปุ่มเสริมในแถบ (เช่น "ลองใหม่" ของ bootError/history error)
*/
const ICONS = {
error: CircleAlert,
warning: TriangleAlert,
success: CircleCheck,
} as const;
export function MessageBar({
intent,
children,
action,
}: {
intent: "error" | "warning" | "success";
children: ReactNode;
action?: ReactNode;
}) {
const Icon = ICONS[intent];
const tone =
intent === "error"
? "border-error/30 bg-error/10 text-error-ink"
: intent === "warning"
? "border-warning/30 bg-warning/15 text-warning-ink"
: "border-success/30 bg-success/10 text-success-ink";
return (
<div
role={intent === "error" ? "alert" : "status"}
className={`flex w-full items-start gap-2 rounded-lg border px-3 py-2 text-sm ${tone}`}
>
<Icon className="mt-0.5 size-4 shrink-0" aria-hidden />
<div className="min-w-0 flex-1 break-words">{children}</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
);
}

View file

@ -1,65 +0,0 @@
import { CircleCheck, Save, X } from "lucide-react";
import { MessageBar } from "./MessageBar";
import { Spinner } from "./Spinner";
/**
* FR-17/FR-18 — ปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] + สถานะการบันทึก (ใช้ทุก phase ตาม spec v10)
* banner autoSaveError ย้ายไปอยู่เหนือ toggle แท็บของ TaskpaneApp (2026-10-20) — ให้เห็นทั้งสองแท็บ
* พอร์ตจาก SaveControls ใน App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — logic เดิม เปลี่ยนแค่ชั้นภาพ
*/
export function SaveControls({
disabled,
busy,
status,
error,
closeSupported,
onSave,
onSaveAndClose,
}: {
/** กำลังอยู่ใน flow อื่น (versioning) — ปิดปุ่มกันกดซ้อน */
disabled: boolean;
busy: boolean;
status: "saving" | "saved" | null;
error: string | null;
closeSupported: boolean;
onSave: () => void;
onSaveAndClose: () => void;
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
<button
type="button"
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50"
disabled={disabled || busy}
onClick={onSave}
>
<Save className="size-4" aria-hidden />
บันทึก
</button>
{closeSupported ? (
<button
type="button"
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50"
disabled={disabled || busy}
onClick={onSaveAndClose}
>
<X className="size-4" aria-hidden />
บันทึกและปิดเอกสาร
</button>
) : null}
</div>
{busy && status === "saving" ? <Spinner label="กำลังบันทึก…" /> : null}
{!busy && status === "saved" ? (
<p className="inline-flex items-center gap-1.5 text-sm text-ink-soft">
<CircleCheck className="size-4 text-success-ink" aria-hidden />
บันทึกแล้ว
</p>
) : null}
{error ? <MessageBar intent="error">{error}</MessageBar> : null}
</div>
);
}
export default SaveControls;

View file

@ -1,13 +0,0 @@
import { LoaderCircle } from "lucide-react";
/** Genesis Spinner — แทน Spinner ของ Fluent UI v9 ต้นฉบับ (ใช้ไอคอนหมุนของ lucide)
* role="status" ให้ progressMessage ที่เปลี่ยนตลอด flow ถูกประกาศแบบ polite (WCAG 4.1.3) */
export function Spinner({ label, className = "" }: { label?: string; className?: string }) {
return (
<span role="status" className={`inline-flex items-center gap-2 text-sm text-ink-soft ${className}`}>
<LoaderCircle className="size-4 animate-spin" aria-hidden />
{label ? <span>{label}</span> : null}
{!label ? <span className="sr-only">กำลังโหลด…</span> : null}
</span>
);
}

View file

@ -1,619 +0,0 @@
/* global Office */
"use client";
import { useCallback, useEffect, useState } from "react";
import { FileUp, GitCommitHorizontal } from "lucide-react";
import { autoSaveService } from "@/wordaddin/services/autoSaveService";
import * as authService from "@/wordaddin/services/authService";
import { AuthRequiredError } from "@/wordaddin/services/apiClient";
import * as documentService from "@/wordaddin/services/documentService";
import { getErrorMessage } from "@/wordaddin/services/errors";
import * as metadataService from "@/wordaddin/services/metadataService";
import { getDownloadLink, getVersions } from "@/wordaddin/services/versioningService";
import { saveAndVersion, startVersioning, VersionSavedButCloseFailedError } from "@/wordaddin/services/versionFlow";
import { VersionRecord } from "@/wordaddin/models/versionRecord";
import ConfirmVersionDialog from "./ConfirmVersionDialog";
import { MessageBar } from "./MessageBar";
import SaveControls from "./SaveControls";
import { Spinner } from "./Spinner";
import TemplateToolsPanel from "./TemplateToolsPanel";
import VersionHistoryPanel from "./VersionHistoryPanel";
/**
* Task pane หลัก — state machine:
* checking → (ไม่มี/ตรวจไม่ผ่าน ticket ในไฟล์) no-access
* → (ticket ผ่าน + ไม่ใช่ tracked document) untracked
* → (ticket ผ่าน + tracked document) tracking
* ไม่มี login ตั้งแต่ยกเลิก Keycloak (2026-10-27) — auth เข้าทางเดียวคือ ticket
* ของ e-Court: `detect()` อ่าน ticket_data + HmacSha256 จาก custom properties
* แล้วเรียก `authService.signInWithTicket` (ตรวจ HMAC → exp → session → ขอ token
* ตาม fid — PLAN-ADDIN-AUTH.md)
* พอร์ตจาก App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — เปลี่ยน: import path,
* JSX → Genesis/Tailwind, ถอด FluentProvider/makeStyles, ถอดหน้า login,
* เพิ่ม prop demo (โหมด ?addindemo ของ WordAddinGate — ตั้ง phase ตรง ไม่ detect จริง)
*
* กติกา: ทุก async ต้อง catch — AuthRequiredError → หน้า no-access ให้ขอไฟล์ใหม่จาก e-Court
* error อื่นแสดง MessageBar ไม่ปล่อย unhandled rejection
*
* FR-17 — auto-save (local เท่านั้น) เริ่มตอน mount ไม่ผูกกับ phase; FR-3 reminder
* กลายเป็น flag `uncommittedSinceVersion` ที่ periodic tick ของ autoSaveService ตั้งค่า
*/
type Phase = "checking" | "no-access" | "untracked" | "tracking";
/** แท็บของ taskpane — versioning คือทุกอย่างเดิม, template คือเครื่องมือจัดการ Content Controls
* (Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้ง untracked และ tracking) */
type Tab = "versioning" | "template";
/** ปุ่ม toggle แท็บ — กลุ่มปุ่ม + aria-pressed ตามแบบแผน FacetFilterPanel (ไม่ใช่ role="tablist"
* เพราะไม่ทำ arrow-key navigation ของ tablist จริง — ทำครึ่ง ๆ ผิด spec กว่าไม่ทำ)
* active เป็น tint น้ำเงินอ่อน ไม่ใช่พื้นทึบ เพื่อไม่แย่งน้ำหนักกับ CTA primary ตัวจริง (Save & Version) */
const tabButtonClass = (active: boolean) =>
`focus-ring flex-1 rounded-md px-2 py-1.5 text-sm transition disabled:pointer-events-none disabled:opacity-50 ${
active ? "bg-primary/[0.08] font-medium text-primary" : "text-ink-soft hover:text-primary"
}`;
const BTN_PRIMARY =
"focus-ring inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50";
/** demo mode — version ตัวอย่าง 3 รายการ (ใหม่ → เก่า) ให้ history panel มีของโชว์ทันที */
function demoVersions(now: number): VersionRecord[] {
return [
{
versionId: "demo-v3",
versionNumber: 3,
createdAt: new Date(now - 3_600_000).toISOString(),
createdByUserId: authService.getUsername() ?? "tester01",
contentHash: "demo",
message: "ปรับคำร้องตามคำแนะนำอัยการ",
hasPdf: true,
fields: null,
},
{
versionId: "demo-v2",
versionNumber: 2,
createdAt: new Date(now - 86_400_000).toISOString(),
createdByUserId: authService.getUsername() ?? "tester01",
contentHash: "demo",
message: null,
hasPdf: true,
fields: null,
},
{
versionId: "demo-v1",
versionNumber: 1,
createdAt: new Date(now - 172_800_000).toISOString(),
createdByUserId: authService.getUsername() ?? "tester01",
contentHash: "demo",
message: "สร้างเอกสารครั้งแรก",
hasPdf: false,
fields: null,
},
];
}
const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => {
const [phase, setPhase] = useState<Phase>(demo === "untracked" ? "untracked" : demo ? "tracking" : "checking");
const [activeTab, setActiveTab] = useState<Tab>("versioning");
/** operation ของแท็บ template กำลังเขียนเอกสารอยู่ (panel รายงานผ่าน onBusyChange) —
* ปิด toggle กันสลับแท็บกลาง operation แล้ว flow versioning มา stamp/capture ซ้อน */
const [templateBusy, setTemplateBusy] = useState(false);
const handleTemplateBusyChange = useCallback((busy: boolean) => setTemplateBusy(busy), []);
const [bootError, setBootError] = useState<string | null>(null);
const [username, setUsername] = useState<string | null>(demo ? (authService.getUsername() ?? null) : null);
const [documentId, setDocumentId] = useState<string | null>(demo ? "00000000-0000-0000-0000-00000000demo" : null);
/** FR-16 — template ต้นทางของไฟล์ร่าง (มีเฉพาะไฟล์ที่เว็บสร้างแบบ draft และยังไม่ได้ track) */
const [documentTemplateId, setDocumentTemplateId] = useState<string | null>(null);
const [versions, setVersions] = useState<VersionRecord[]>(demo ? demoVersions(Date.now()) : []);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
/** ข้อความบนหน้า no-access — ต่างกันตามสาเหตุ (ไม่มี token ในไฟล์ / token หมดอายุ) */
const [noAccessNotice, setNoAccessNotice] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
const [saving, setSaving] = useState(false);
const [progressMessage, setProgressMessage] = useState<string | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [startConfirmOpen, setStartConfirmOpen] = useState(false);
const [flowError, setFlowError] = useState<string | null>(null);
const [flowWarning, setFlowWarning] = useState<string | null>(null);
const [downloadingPdfVersionId, setDownloadingPdfVersionId] = useState<string | null>(null);
const [flowSuccess, setFlowSuccess] = useState<string | null>(null);
const [downloadingVersionId, setDownloadingVersionId] = useState<string | null>(null);
/** FR-18 — platform นี้ปิดเอกสารจาก add-in ได้ไหม (Word on the web ใช้ close() ไม่ได้ → ซ่อนปุ่มทุกแบบ)
* นอก Word (SSR/browser ธรรมดา) canCloseDocument catch คืน false เสมอ */
const [closeSupported] = useState(() => documentService.canCloseDocument());
/** FR-18 — สถานะของปุ่ม [บันทึก] / [บันทึกและปิดเอกสาร] (แยกจาก flow versioning) */
const [saveBusy, setSaveBusy] = useState(false);
const [saveStatus, setSaveStatus] = useState<"saving" | "saved" | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
/** FR-17 — banner เตือนเมื่อ auto-save ล้ม (ไม่บล็อก แต่ไม่ปล่อยเงียบ) */
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
/** FR-3 — เห็นการแก้ไขจริงหลัง version ล่าสุด: clean→dirty ของ periodic tick หรือ
* notifyDocumentChanged ของ service ที่แก้เอกสาร (แสดงเฉพาะตอน tracking — ไฟล์ที่เปิดมา
* dirty ตั้งแต่แรกไม่นับ เก็บไว้ที่ autoSaveService) */
const [hasUncommitted, setHasUncommitted] = useState(false);
/** AuthRequiredError (ticket หมดอายุ/token ขอไม่ได้ หลัง renew-retry ของ apiClient)
* → หน้า no-access — ticket ต่ออายุไม่ได้ ผู้ใช้ต้องขอไฟล์ใหม่จาก e-Court
* จึงรีเซ็ต state ทั้งชุดกันใช้ของเก่าต่อ */
const handleAuthRequired = useCallback((error: unknown): boolean => {
if (error instanceof AuthRequiredError) {
setPhase("no-access");
setActiveTab("versioning");
setDocumentId(null);
setDocumentTemplateId(null);
setVersions([]);
setNoAccessNotice("ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง");
return true;
}
return false;
}, []);
const refreshHistory = useCallback(
async (id: string) => {
setHistoryLoading(true);
setHistoryError(null);
try {
setVersions(await getVersions(id));
} catch (error) {
if (!handleAuthRequired(error)) {
setHistoryError(getErrorMessage(error, "โหลด version history ไม่สำเร็จ"));
}
} finally {
setHistoryLoading(false);
}
},
[handleAuthRequired],
);
/** ตรวจตอนเปิด task pane (FR-1): ticket ในไฟล์ → SavedByAddIn → history
* FR-16 — อ่าน properties ครั้งเดียวทั้งชุดแล้ว derive tracked เอง เพราะไฟล์ร่าง (untracked)
* ต้องได้อ่าน TemplateId ไปใช้ตอน Start Versioning ด้วย (ข้ามทั้งหมดในโหมด demo)
* auth ทางเข้าเดียว: ticket_data + HmacSha256 ที่ e-Court stamp มา — signInWithTicket
* ตรวจ HMAC → exp → session/localStorage แล้วขอ token ตาม fid ให้ครบก่อนไหลต่อ
* (นอก Word เช่น browser ธรรมดา → อ่านไม่ได้ = ไป no-access) */
const detect = useCallback(async () => {
setPhase("checking");
setBootError(null);
try {
let properties: metadataService.TrackedDocumentProperties | null = null;
try {
properties = await metadataService.readTrackedProperties();
} catch {
properties = null;
}
if (!properties) {
setNoAccessNotice("อ่านข้อมูลเอกสารไม่สำเร็จ — ลองปิดแล้วเปิด task pane อีกครั้ง");
setPhase("no-access");
return;
}
const signIn = await authService.signInWithTicket({
ticketData: properties.ticketData,
ticketHmac: properties.ticketHmac,
});
if (!signIn.ok) {
setNoAccessNotice(signIn.message);
setPhase("no-access");
return;
}
setUsername(authService.getUsername() ?? null);
const tracked = properties.savedByAddIn === metadataService.ADDIN_SIGNATURE_GUID;
const id = tracked ? properties.documentTrackingId : undefined;
if (!id) {
// ไม่เคย track / track แต่ไม่มี id (ไฟล์เสียหาย) — ไฟล์ร่างจาก template อยู่กลุ่มนี้ (FR-16)
setDocumentTemplateId(properties.templateId ?? null);
setPhase("untracked");
return;
}
setDocumentId(id);
setDocumentTemplateId(null);
setPhase("tracking");
await refreshHistory(id);
} catch (error) {
setBootError(getErrorMessage(error, "ตรวจสอบเอกสารไม่สำเร็จ"));
}
}, [refreshHistory]);
useEffect(() => {
if (demo) return; // ?addindemo — ตั้ง phase จาก prop แล้ว ไม่ตรวจไฟล์จริง
void detect();
}, [demo, detect]);
/** FR-17 — เริ่ม auto-save ตั้งแต่ mount ไม่ผูกกับ phase (ข้ามใน demo กัน error banner จาก Word ที่ไม่มี)
* ผูก listener ให้ React state: error → banner, แก้ไขจริง (clean→dirty / notifyDocumentChanged)
* → flag FR-3 */
useEffect(() => {
autoSaveService.onError = (error) =>
setAutoSaveError(getErrorMessage(error, "บันทึกอัตโนมัติไม่สำเร็จ — ลองกด \"บันทึก\" ด้วยตนเอง"));
autoSaveService.onSaved = () => setAutoSaveError(null);
autoSaveService.onStateChange = () => setHasUncommitted(autoSaveService.hasUncommittedSinceVersion);
if (!demo) {
autoSaveService.start();
}
return () => {
autoSaveService.stop();
autoSaveService.onError = undefined;
autoSaveService.onSaved = undefined;
autoSaveService.onStateChange = undefined;
};
}, [demo]);
/** FR-18 [บันทึก] — local save เดี่ยวผ่าน lock ของ autoSaveService (ไม่ stamp metadata อะไร) */
const handleSaveOnly = async () => {
setSaveBusy(true);
setSaveStatus("saving");
setSaveError(null);
setAutoSaveError(null);
try {
await autoSaveService.save();
setSaveStatus("saved");
} catch (error) {
setSaveStatus(null);
setSaveError(getErrorMessage(error, "บันทึกไม่สำเร็จ กรุณาลองอีกครั้ง"));
} finally {
setSaveBusy(false);
}
};
/** FR-18 [บันทึกและปิดเอกสาร] — ลำดับตาม spec: suspend → forceSave → close(Save) → resume
* save ล้ม = ไม่ปิด (AC-21) · ปิดสำเร็จแล้ว task pane จะปิดตามเอกสารเอง ไม่มี toast ท้าย */
const handleSaveAndClose = async () => {
setSaveBusy(true);
setSaveStatus("saving");
setSaveError(null);
setAutoSaveError(null);
try {
await autoSaveService.suspend();
try {
await autoSaveService.forceSave();
await documentService.closeDocument(documentService.DocumentCloseMode.Save);
} finally {
autoSaveService.resume();
}
} catch (error) {
setSaveStatus(null);
setSaveError(getErrorMessage(error, "บันทึก/ปิดเอกสารไม่สำเร็จ กรุณาลองอีกครั้ง"));
} finally {
setSaveBusy(false);
}
};
/** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) · message คือ commit message จาก dialog (FR-13)
* FR-16 — ไฟล์ร่าง: แนบ templateId ไปให้ backend ผูก Document กับ template ต้นทาง */
const handleStartVersioning = async (message: string) => {
setStarting(true);
setFlowError(null);
setFlowSuccess(null);
setFlowWarning(null);
try {
const result = await startVersioning(message, documentTemplateId ?? undefined, setProgressMessage);
setDocumentId(result.documentId);
setDocumentTemplateId(null);
setPhase("tracking");
setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`);
if (!result.hasPdf)
setFlowWarning(
"บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)",
);
await refreshHistory(result.documentId);
} catch (error) {
if (!handleAuthRequired(error)) {
setFlowError(getErrorMessage(error, "เริ่มติดตามเอกสารไม่สำเร็จ"));
}
} finally {
setProgressMessage(null);
setStarting(false);
}
};
/** Save & Version (FR-2) — ตามลำดับ spec: confirm → stamp → XML → save → file+fields → upload
* message คือ commit message จาก dialog (FR-13)
* FR-18 — closeAfter=true มาจาก submit ที่สองของ dialog: version สำเร็จ → ปิดเอกสารตาม */
const handleSaveAndVersion = async (message: string, closeAfter = false) => {
if (!documentId) {
return;
}
setSaving(true);
setFlowError(null);
setFlowSuccess(null);
setFlowWarning(null);
try {
const latestVersionNumber = versions[0]?.versionNumber ?? 0;
const result = await saveAndVersion(documentId, latestVersionNumber, message, setProgressMessage, closeAfter);
if (closeAfter) {
// ปิดเอกสารสำเร็จแล้ว — task pane จะปิดตามเอกสาร ไม่ต้อง refresh อะไรต่อ
return;
}
setFlowSuccess(`สร้าง version ${result.versionNumber} เรียบร้อย`);
if (!result.hasPdf)
setFlowWarning(
"บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)",
);
await refreshHistory(documentId);
} catch (error) {
if (error instanceof VersionSavedButCloseFailedError) {
// version ปลอดภัยบน backend แล้ว — เอกสารยังเปิด แจ้งเป็น warning ไม่ใช่ error ของ version
setFlowWarning(error.message);
await refreshHistory(documentId);
return;
}
if (!handleAuthRequired(error)) {
setFlowError(getErrorMessage(error, "สร้าง version ไม่สำเร็จ"));
}
} finally {
setProgressMessage(null);
setSaving(false);
}
};
/**
* FR-4 — ดาวน์โหลด version ที่เลือก: ขอ presigned URL อายุสั้นจาก backend แล้วเปิดใน
* เบราว์เซอร์ของเครื่อง (webview ของ Word — โดยเฉพาะ WKWebView บน Mac — ทิ้ง blob download
* แบบเงียบๆ ไฟล์ไม่เคยถูกเขียนที่ไหนเลย)
*/
const handleDownload = async (version: VersionRecord, format: "docx" | "pdf" = "docx") => {
if (!documentId) {
return;
}
const setDownloading = format === "pdf" ? setDownloadingPdfVersionId : setDownloadingVersionId;
setDownloading(version.versionId);
setFlowError(null);
try {
const link = await getDownloadLink(documentId, version.versionId, format);
if (typeof Office.context.ui.openBrowserWindow === "function") {
Office.context.ui.openBrowserWindow(link.url);
setFlowSuccess(
format === "pdf"
? `กำลังเปิด PDF ของ v${version.versionNumber} ในเบราว์เซอร์ของคุณ`
: `กำลังดาวน์โหลด v${version.versionNumber} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads`,
);
} else {
throw new Error("Word นี้ไม่รองรับการเปิดเบราว์เซอร์ กรุณาเปิดไฟล์จาก Web Portal");
}
} catch (error) {
if (!handleAuthRequired(error)) {
setFlowError(getErrorMessage(error, "ดาวน์โหลด version ไม่สำเร็จ"));
}
} finally {
setDownloading(null);
}
};
return (
<div className="flex min-h-screen flex-col gap-2 p-4">
<div className="flex items-center justify-between gap-2 border-b border-line pb-2">
<span className="font-display text-sm font-semibold leading-tight text-ink">e-Court Documents Versioning</span>
{phase !== "no-access" && phase !== "checking" && username ? (
/* ไม่มีปุ่มออกจากระบบ (ไม่มี login ให้ออก) — แสดงชื่อเจ้าของไฟล์จาก pass-through เฉย ๆ
จอแคบ: ตัดด้วย truncate + เต็ม ๆ ใน title ไม่กินพื้นที่แถบหัวจนตัวชื่อแอป wrap */
<span
className="shrink-0 max-w-40 truncate rounded-md bg-canvas px-2 py-1.5 text-xs text-ink-soft"
title={username}
>
{username}
</span>
) : null}
</div>
{/* FR-17 — banner auto-save error ยกไว้เหนือ toggle (เดิมซ่อนอยู่ใน SaveControls ของแท็บ
versioning): auto-save รันทุกแท็บ การเขียนค่าทดสอบของแท็บ template ก็ทำให้ dirty ได้
จึงต้องเห็น error แม้อยู่อีกแท็บ */}
{(phase === "untracked" || phase === "tracking") && autoSaveError ? (
<MessageBar intent="warning">{autoSaveError}</MessageBar>
) : null}
{/* toggle แท็บ — แสดงเฉพาะ phase ที่เข้าถึงเอกสารแล้ว (checking/no-access ยังไม่มีอะไรให้จัดการ)
ปิดทั้งสองปุ่มระหว่าง flow ที่เขียนเอกสาร: saving/starting ของ versioning (กัน progress หาย
กลาง flow — handler อยู่ที่ component นี้ flow ยังรันจนจบ แค่หน้าตา), saveBusy ของบันทึก
local/ปิดเอกสาร และ templateBusy ที่ panel รายงานผ่าน onBusyChange — กันเครื่องมือ template
แข่งแก้เอกสารกลาง stamp/capture ของ versionFlow หรือกลับกัน (panel unmount แล้ว promise
ของ service ก็ยังเขียนเอกสารต่อ ต้องปิดทางสลับตั้งแต่ต้น) */}
{(phase === "untracked" || phase === "tracking") && (
<div className="mt-2 flex gap-1 rounded-lg border border-line p-1" role="group" aria-label="ส่วนการทำงาน">
<button
type="button"
aria-pressed={activeTab === "versioning"}
className={tabButtonClass(activeTab === "versioning")}
disabled={saving || starting || saveBusy || templateBusy}
onClick={() => setActiveTab("versioning")}
>
Versioning
</button>
<button
type="button"
aria-pressed={activeTab === "template"}
className={tabButtonClass(activeTab === "template")}
disabled={saving || starting || saveBusy || templateBusy}
onClick={() => setActiveTab("template")}
>
เครื่องมือ Template
</button>
</div>
)}
{phase === "checking" ? (
<div className="flex flex-col items-center gap-4 pt-10">
<Spinner label="กำลังตรวจสอบเอกสาร…" />
{bootError ? (
<MessageBar
intent="error"
action={
<button
type="button"
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
onClick={() => void detect()}
>
ลองใหม่
</button>
}
>
{bootError}
</MessageBar>
) : null}
</div>
) : null}
{phase === "no-access" ? (
/* ไม่มี login ให้กด — บอกสาเหตุและจบที่นั้น (ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) */
<div className="flex flex-col gap-2 pt-4">
<p className="text-base text-ink">เอกสารนี้ยังใช้งาน add-in ไม่ได้</p>
<p className="text-sm text-ink-soft">
{noAccessNotice ?? "ไม่พบ ticket ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"}
</p>
</div>
) : null}
{phase === "untracked" && activeTab === "versioning" ? (
<div className="flex flex-col gap-2 pt-4">
<p className="text-base text-ink">เอกสารนี้ยังไม่ได้ติดตาม version</p>
<p className="text-sm text-ink-soft">
กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1
(ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)
</p>
{documentTemplateId ? (
<p className="text-sm text-ink-soft">
ไฟล์นี้เป็นไฟล์ร่างจาก template — กดเริ่มติดตามแล้วระบบจะผูกเอกสารกับ template ต้นทาง
และ sync ค่า fields ให้อัตโนมัติ
</p>
) : null}
<button
type="button"
className={`${BTN_PRIMARY} mt-2 w-full justify-center`}
disabled={starting}
onClick={() => setStartConfirmOpen(true)}
>
<FileUp className="size-4" aria-hidden />
{starting ? "กำลังเริ่มติดตาม…" : "Start Versioning This Document"}
</button>
<SaveControls
disabled={starting}
busy={saveBusy}
status={saveStatus}
error={saveError}
closeSupported={closeSupported}
onSave={() => void handleSaveOnly()}
onSaveAndClose={() => void handleSaveAndClose()}
/>
{starting && progressMessage ? <Spinner label={progressMessage} /> : null}
{flowError ? <MessageBar intent="error">{flowError}</MessageBar> : null}
</div>
) : null}
{phase === "tracking" && activeTab === "versioning" ? (
/* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (MessageBar/history list ไม่เต็มความกว้าง pane) */
<div className="flex flex-col gap-2 pt-4">
{/* กลุ่มข้อความสถานะ */}
{hasUncommitted ? (
<MessageBar intent="warning">
มีการแก้ไขที่ยังไม่ถูกบันทึกเป็นเวอร์ชันใหม่ — กด Save &amp; Version เพื่อบันทึกเป็น version ใหม่
</MessageBar>
) : null}
{flowWarning ? <MessageBar intent="warning">{flowWarning}</MessageBar> : null}
{flowSuccess ? <MessageBar intent="success">{flowSuccess}</MessageBar> : null}
{flowError ? <MessageBar intent="error">{flowError}</MessageBar> : null}
{/* กลุ่ม action บันทึก — ปุ่มหลักเต็มความกว้างแถบ (action เด่นที่สุดของทั้งแอป) + progress + local save
icon ใช้ commit ไม่ใช่ floppy — แยกจากปุ่ม "บันทึก" (local save) ที่ความหมายต่างกัน */}
<div className="flex flex-col gap-2 pt-2">
<button
type="button"
className={`${BTN_PRIMARY} w-full justify-center px-5 py-3`}
disabled={saving}
onClick={() => setConfirmOpen(true)}
>
<GitCommitHorizontal className="size-4" aria-hidden />
Save &amp; Version
</button>
{saving && progressMessage ? <Spinner label={progressMessage} /> : null}
<SaveControls
disabled={saving}
busy={saveBusy}
status={saveStatus}
error={saveError}
closeSupported={closeSupported}
onSave={() => void handleSaveOnly()}
onSaveAndClose={() => void handleSaveAndClose()}
/>
</div>
{/* ink-soft (ไม่ใช่ ink-faint 2.6:1) — tracking id คือ identifier ที่ผู้ใช้อาจต้องอ่าน/คัดลอกตอนแจ้งปัญหา */}
<p className="pt-2 text-xs text-ink-soft">tracking id: {documentId?.slice(0, 8)}…</p>
<VersionHistoryPanel
versions={versions}
loading={historyLoading}
error={historyError}
downloadingVersionId={downloadingVersionId}
downloadingPdfVersionId={downloadingPdfVersionId}
onDownloadPdf={(version) => void handleDownload(version, "pdf")}
onDownload={(version) => void handleDownload(version)}
onRetry={() => documentId && void refreshHistory(documentId)}
/>
</div>
) : null}
{/* แท็บเครื่องมือ Template — state ทั้งหมดอยู่ใน panel เอง (snapshot ชั่วคราวของเอกสาร
สลับแท็บแล้ว unmount = ทิ้งค่าทิ้งโดยตั้งใจ) */}
{(phase === "untracked" || phase === "tracking") && activeTab === "template" ? (
<TemplateToolsPanel onBusyChange={handleTemplateBusyChange} />
) : null}
{/* dialog ทั้งสองเป็นของ flow versioning — render เฉพาะแท็บ versioning (ตอน dialog เปิด
overlay fixed inset-0 + Tab trap อยู่แล้ว ปุ่ม toggle แตะไม่ได้ จึงไม่มี path ที่ dialog
เปิดค้างขณะสลับแท็บ) */}
{(phase === "untracked" || phase === "tracking") && activeTab === "versioning" ? (
<>
{/* กดยืนยันแล้วปิด dialog ทันที — progress/ผลลัพธ์แสดงที่ pane (dialog ค้าง = บังผลลัพธ์
+ เสี่ยงกดยืนยันซ้ำสร้าง version ซ้ำ; busy ใน dialog เหลือเป็น defense ชั่วขณะก่อน re-render) */}
<ConfirmVersionDialog
open={confirmOpen}
busy={saving}
onConfirm={(message) => {
setConfirmOpen(false);
void handleSaveAndVersion(message);
}}
{...(closeSupported
? {
onConfirmClose: (message: string) => {
setConfirmOpen(false);
void handleSaveAndVersion(message, true);
},
}
: {})}
onDismiss={() => setConfirmOpen(false)}
/>
{/* FR-13 — Start Versioning ใช้ dialog เดียวกัน: ยืนยัน + พิมพ์ commit message ของ v1 ก่อนเริ่ม track */}
<ConfirmVersionDialog
open={startConfirmOpen}
busy={starting}
title="เริ่มติดตามเอกสารนี้?"
description={
documentTemplateId
? "ระบบจะ stamp metadata ลงไฟล์ บันทึกไฟล์ปัจจุบันเป็น version 1 และผูกเอกสารกับ template ต้นทาง (ไฟล์ร่าง)"
: "ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้"
}
confirmLabel="เริ่มติดตาม (version 1)"
onConfirm={(message) => {
setStartConfirmOpen(false);
void handleStartVersioning(message);
}}
onDismiss={() => setStartConfirmOpen(false)}
/>
</>
) : null}
</div>
);
};
export default TaskpaneApp;

View file

@ -1,411 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { CornerDownLeft, Crosshair, FilePlus, LoaderCircle, PenLine, ScanSearch, WrapText } from "lucide-react";
import {
applyTestValues,
insertBreakAtSelection,
insertParagraphAtSelection,
listContentControls,
selectContentControl,
supportsAllContentControlTypes,
type ScannedField,
} from "@/wordaddin/services/templateService";
import { getErrorMessage } from "@/wordaddin/services/errors";
import { MessageBar } from "./MessageBar";
/**
* แท็บ "เครื่องมือ Template" (แท็บที่สองของ taskpane) — เครื่องมือช่วยทำ template
* สำหรับคนออกแบบเอกสาร ใช้ได้ทั้ง phase untracked และ tracking เพราะไม่แตะ backend/tracking:
* 1. สแกน Content Controls ทั้งเอกสาร + เตือน tag ว่าง/ซ้ำ + กระโดดไปเลือกช่อง
* 2. กรอกค่าทดสอบทุกช่องแล้วเขียนกลับเข้าเอกสารจริง (ดูผลจัดหน้า/ตัดบรรทัดทันที
* โดยไม่ต้อง render ผ่าน /api/template/render)
* 3. แทรกย่อหน้า/ตัวแบ่งบรรทัด/ตัวแบ่งหน้าที่ cursor ตอนจัดโครงสร้าง
*
* state ทั้งหมดถือภายใน — เป็น snapshot ชั่วคราวของเอกสาร: สลับแท็บแล้ว unmount = ทิ้ง
* ค่าทิ้งโดยตั้งใจ (เก็บไว้ก็ stale เสียเองเมื่อผู้ใช้แก้เอกสารระหว่างนั้น)
*/
const BTN_PRIMARY =
"focus-ring inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50";
const BTN_SECONDARY =
"focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50";
/** px-2 py-1.5 + text-xs ≈ สูง 30px — พ้นเกณฑ์ touch target 24px (WCAG 2.5.8) เหมือน VersionHistoryPanel */
const BTN_ACTION =
"focus-ring inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs text-ink-soft transition hover:bg-primary/[0.06] hover:text-primary disabled:pointer-events-none disabled:opacity-50";
/** ปุ่มแทรกองค์ประกอบ (ย่อหน้า/ตัวแบ่ง) — icon บน label ล่าง 3 ปุ่มต่อแถว */
const BTN_INSERT =
"focus-ring flex flex-col items-center gap-1 rounded-md border border-line bg-white px-1 py-2 text-xs text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50";
function TemplateToolsPanel({ onBusyChange }: { onBusyChange?: (busy: boolean) => void }) {
/** null = ยังไม่เคยสแกน (empty state ต่างจาก [] = สแกนแล้วไม่มีช่อง) */
const [fields, setFields] = useState<ScannedField[] | null>(null);
const [scanning, setScanning] = useState(false);
const [applying, setApplying] = useState(false);
const [insertingParagraph, setInsertingParagraph] = useState(false);
const [insertingBreak, setInsertingBreak] = useState<"line" | "page" | null>(null);
/** ค่าฟอร์มต่อ tag — key คือ tag ที่ trim แล้วไม่ว่างและไม่ซ้ำ */
const [values, setValues] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const [warning, setWarning] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
/** คำเตือนความสามารถของ host (WordApi <1.5 เห็นเฉพาะ rich text CC) — แยก state จาก warning
* ผลลัพธ์ของ operation เพราะเป็นข้อจำกัดของ host ตลอด session ห้ามโดน banner อื่นล้าง/ทับ */
const [capabilityWarning, setCapabilityWarning] = useState<string | null>(null);
const busy = scanning || applying || insertingParagraph || insertingBreak !== null;
/** รายงาน busy ให้ TaskpaneApp ปิด toggle แท็บระหว่าง operation ที่เขียนเอกสาร — กันสลับไปแท็บ
* Versioning กด Save & Version แล้ว stamp/capture ซ้อน operation ที่ยังรันอยู่ (panel unmount
* แล้ว promise ของ service ก็ยังเขียนเอกสารต่อ) · cleanup ส่ง false ตอน unmount กันปุ่มค้าง disabled */
useEffect(() => {
onBusyChange?.(busy);
return () => onBusyChange?.(false);
}, [busy, onBusyChange]);
/** tag ที่ไม่ว่างไม่ซ้ำ (เอาตัวแรกของ tag ซ้ำ) — เป็น key ของฟอร์มค่าทดสอบ */
const uniqueTags = useMemo(() => {
if (!fields) return [];
const seen = new Set<string>();
const tags: string[] = [];
for (const field of fields) {
const tag = field.tag.trim();
if (tag === "" || seen.has(tag)) continue;
seen.add(tag);
tags.push(tag);
}
return tags;
}, [fields]);
const emptyTagCount = useMemo(
() => (fields ? fields.filter((field) => field.tag.trim() === "").length : 0),
[fields],
);
/** tag ที่มีมากกว่า 1 ช่อง (เทียบหลัง trim) — render pipeline อ่านค่าเฉพาะช่องแรกของ tag จึงต้องเตือน */
const duplicateTags = useMemo(() => {
if (!fields) return [];
const counts = new Map<string, number>();
for (const field of fields) {
const tag = field.tag.trim();
if (tag === "") continue;
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
return Array.from(counts.entries())
.filter(([, count]) => count > 1)
.map(([tag]) => tag);
}, [fields]);
/** มีอย่างน้อยหนึ่งช่องที่กรอกค่า — กันกดเขียนตอนฟอร์มว่างทั้งหมด (service จะไม่แตะอะไรเลย
* แล้ว UI ไปตีความ appliedCount 0 ว่า "ช่องถูกลบไป" ซึ่งเข้าใจผิด) */
const hasFilledValue = useMemo(
() => uniqueTags.some((tag) => (values[tag] ?? "").trim() !== ""),
[uniqueTags, values],
);
/**
* สแกนเอกสาร + เติมค่าฟอร์ม
* - merge=false (กดปุ่มสแกนเอง): seed ฟอร์มใหม่ทั้งหมดจากค่าปัจจุบันของช่องแรกของแต่ละ tag
* พร้อมรีเซ็ต success/warning เดิม (ผลลัพธ์รอบก่อนไม่เกี่ยวกับ list รอบใหม่แล้ว)
* - merge=true (หลังเขียนค่า): คงค่าที่ผู้ใช้พิมพ์ค้างไว้ เติมเฉพาะ tag ใหม่ที่ยังไม่มีในฟอร์ม
* และตัด tag ที่หายไปจากเอกสารทิ้ง — ทั้งสองอยู่บนผลสแกนรอบใหม่เสมอ
* **ไม่แตะ success/warning** เพราะ handler ที่เรียกจัด banner ผลลัพธ์ของตัวเองอยู่
* (คำเตือนความสามารถของ host แยก state ต่างหาก จึงไม่ทับกัน)
*/
const runScan = async (merge: boolean) => {
setScanning(true);
setError(null);
if (!merge) {
setSuccess(null);
setWarning(null);
}
try {
const scanned = await listContentControls();
setFields(scanned);
// Word ต่ำกว่า WordApi 1.5 (เช่น Office 2019) มองเห็นเฉพาะ rich text CC — template ของศาล
// ส่วนใหญ่เป็น plain text CC; เป็นข้อจำกัดของ host ตลอด session จึงเช็คให้ทุกรอบสแกน
setCapabilityWarning(
supportsAllContentControlTypes()
? null
: "Word รุ่นนี้มองเห็นเฉพาะช่องแบบ rich text — ช่องแบบ plain text ต้องใช้ Word ที่รองรับ WordApi 1.5 (เช่น Microsoft 365) จึงแสดงและเขียนค่าได้",
);
setValues((previous) => {
// สร้างใหม่จาก tag ของผลสแกนรอบนี้เท่านั้น — merge เอาแค่ค่าเดิมของ tag ที่ยังอยู่
// (tag ที่ถูกลบจากเอกสารตัดทิ้ง ไม่ให้ค้างไปโดน missingTags รอบเขียนถัด ๆ ไป)
const next: Record<string, string> = {};
const seen = new Set<string>();
for (const field of scanned) {
const tag = field.tag.trim();
if (tag === "" || seen.has(tag)) continue;
seen.add(tag);
next[tag] = merge && tag in previous ? previous[tag] : field.text;
}
return next;
});
} catch (scanError) {
setError(getErrorMessage(scanError, "สแกนช่องในเอกสารไม่สำเร็จ"));
} finally {
setScanning(false);
}
};
const handleView = async (id: number) => {
setError(null);
try {
await selectContentControl(id);
} catch (viewError) {
setError(getErrorMessage(viewError, "เลือกช่องไม่สำเร็จ"));
}
};
/** แทรกย่อหน้าว่างที่ cursor — เห็นผลในเอกสารทันทีจึงไม่ต้องแจ้ง success รบกวน */
const handleInsertParagraph = async () => {
setInsertingParagraph(true);
setError(null);
try {
await insertParagraphAtSelection();
} catch (insertError) {
setError(getErrorMessage(insertError, "แทรกย่อหน้าไม่สำเร็จ"));
} finally {
setInsertingParagraph(false);
}
};
const handleInsertBreak = async (breakKind: "line" | "page") => {
setInsertingBreak(breakKind);
setError(null);
try {
await insertBreakAtSelection(breakKind);
} catch (insertError) {
setError(
getErrorMessage(insertError, breakKind === "page" ? "แทรกตัวแบ่งหน้าไม่สำเร็จ" : "แทรกตัวแบ่งบรรทัดไม่สำเร็จ"),
);
} finally {
setInsertingBreak(null);
}
};
const handleApply = async () => {
setApplying(true);
setError(null);
setSuccess(null);
setWarning(null);
try {
const result = await applyTestValues(values);
if (result.appliedCount === 0 && result.failures.length === 0) {
setWarning("ไม่พบช่องที่ตรงกับค่าที่กรอก (อาจถูกลบไปหลังสแกน) — กดสแกนใหม่");
} else {
if (result.appliedCount > 0) {
setSuccess(`เขียนค่าทดสอบลงแล้ว ${result.appliedCount} ช่อง`);
}
const problems: string[] = [];
if (result.missingTags.length > 0) {
problems.push(`ไม่พบช่อง tag: ${result.missingTags.join(", ")}`);
}
if (result.failures.length > 0) {
const first = result.failures[0];
problems.push(
`เขียนไม่สำเร็จ ${result.failures.length} tag (${result.failures
.map((failure) => failure.tag)
.join(", ")}) — ${first.reason}`,
);
}
if (problems.length > 0) {
setWarning(
`${problems.join(" · ")} — ถ้าเอกสารนี้ถูกล็อค (Review → Restrict Editing) ให้ปลดก่อนจึงเขียนค่าได้`,
);
}
}
// สแกนใหม่ให้ค่าย่อใน list ตรงเอกสารจริง — คงค่าฟอร์มไว้ให้แก้ต่อ
await runScan(true);
} catch (applyError) {
setError(getErrorMessage(applyError, "เขียนค่าลงเอกสารไม่สำเร็จ"));
} finally {
setApplying(false);
}
};
return (
/* ห้ามใส่ items-start — ลูกทุกตัวจะหดตามเนื้อหา (เหมือน branch tracking ของ TaskpaneApp) */
<div className="flex flex-col gap-2 pt-4">
{error ? <MessageBar intent="error">{error}</MessageBar> : null}
{warning ? <MessageBar intent="warning">{warning}</MessageBar> : null}
{success ? <MessageBar intent="success">{success}</MessageBar> : null}
{capabilityWarning ? <MessageBar intent="warning">{capabilityWarning}</MessageBar> : null}
{/* §1 ตรวจสอบ Content Controls */}
<div className="mt-2 flex items-baseline justify-between gap-2">
<h3 className="font-display text-sm font-semibold text-ink">ตรวจสอบ Content Controls</h3>
{fields ? <span className="text-xs text-ink-soft">{fields.length} ช่อง</span> : null}
</div>
<button
type="button"
className={`${BTN_SECONDARY} w-full justify-center`}
disabled={busy}
aria-busy={scanning}
onClick={() => void runScan(false)}
>
{scanning ? (
<LoaderCircle className="size-4 animate-spin" aria-hidden />
) : (
<ScanSearch className="size-4" aria-hidden />
)}
สแกนช่องในเอกสาร
</button>
{fields && (emptyTagCount > 0 || duplicateTags.length > 0) ? (
<MessageBar intent="warning">
{emptyTagCount > 0 ? `มี ${emptyTagCount} ช่องที่ยังไม่ได้ตั้ง tag` : ""}
{emptyTagCount > 0 && duplicateTags.length > 0 ? " · " : ""}
{duplicateTags.length > 0
? `tag ซ้ำ: ${duplicateTags.join(", ")} — ตอน render ระบบอ่านเฉพาะช่องแรกของ tag`
: ""}
</MessageBar>
) : null}
{!fields && !scanning ? (
<p className="text-sm text-ink-soft">กดสแกนเพื่อดูรายการช่องในเอกสาร</p>
) : null}
{fields && fields.length === 0 && !scanning ? (
<p className="text-sm text-ink-soft">ไม่พบ content control ในเอกสาร</p>
) : null}
{fields && fields.length > 0 ? (
<ol className="relative">
{fields.length > 1 ? <span className="absolute bottom-2 left-[3.5px] top-2 w-px bg-line" aria-hidden /> : null}
{fields.map((field, index) => (
<li className="relative pb-3 pl-4 last:pb-0" key={field.id}>
<span
className={`absolute left-0 top-[5px] size-2 rounded-full ${
field.tag.trim() === "" ? "border border-warning bg-canvas" : "bg-ink"
}`}
aria-hidden
/>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-xs text-ink-faint">{index + 1}.</span>
<span
className="break-all text-sm font-medium text-ink"
title={`tag: ${field.tag || "—"} · title: ${field.title || "—"} · type: ${field.type}`}
>
{field.tag.trim() === "" ? "(ไม่มี tag)" : field.tag.trim()}
</span>
<span className="rounded-full bg-ink/[0.06] px-1.5 py-px text-[11px] text-ink-soft">
{field.kind === "inline" ? "ในบรรทัด" : "บล็อก"}
</span>
<span className="ml-auto">
<button
type="button"
className={BTN_ACTION}
disabled={busy}
onClick={() => void handleView(field.id)}
>
<Crosshair className="size-3.5" aria-hidden />
ดูช่อง
</button>
</span>
</div>
<p className="mt-0.5 truncate text-xs text-ink-soft" title={field.text}>
{field.text.trim() === "" ? "(ว่าง)" : field.text}
</p>
</li>
))}
</ol>
) : null}
{/* §2 กรอกค่าทดสอบ */}
{uniqueTags.length > 0 ? (
<>
<h3 className="mt-4 font-display text-sm font-semibold text-ink">กรอกค่าทดสอบ</h3>
<p className="text-sm text-ink-soft">
แก้ค่าแล้วกดเขียนลงเอกสารเพื่อดูผลจัดหน้าจริง — ช่องที่เว้นว่างจะไม่ถูกแตะ
</p>
<div className="flex flex-col gap-2" aria-busy={applying}>
{uniqueTags.map((tag, index) => (
<div key={tag} className="flex flex-col gap-1">
<label className="text-sm font-medium text-ink break-all" htmlFor={`wordaddin-template-value-${index}`}>
{tag}
</label>
<input
id={`wordaddin-template-value-${index}`}
className="genesis-input block w-full"
value={values[tag] ?? ""}
onChange={(event) => setValues((previous) => ({ ...previous, [tag]: event.target.value }))}
disabled={busy}
/>
</div>
))}
</div>
<button
type="button"
className={`${BTN_PRIMARY} w-full justify-center`}
disabled={busy || !hasFilledValue}
onClick={() => void handleApply()}
>
<PenLine className="size-4" aria-hidden />
{applying ? "กำลังเขียนค่า…" : "เขียนค่าลงเอกสาร"}
</button>
</>
) : fields ? (
<p className="mt-4 text-sm text-ink-soft">ไม่มีช่องที่ตั้ง tag ไว้ — สแกนก่อนจึงจะกรอกค่าทดสอบได้</p>
) : (
<p className="mt-4 text-sm text-ink-soft">สแกนก่อนเพื่อกรอกค่าทดสอบ</p>
)}
{/* §3 ปุ่มแทรกองค์ประกอบหน้า — เสริมตอนจัดโครงสร้าง template (ตรวจด้วย formatting marks ตามปกติ:
Ctrl+Shift+8 — สลับผ่าน add-in ไม่ได้เพราะ Office.js ไม่มี API) */}
<h3 className="mt-4 font-display text-sm font-semibold text-ink">แทรกย่อหน้า / ตัวแบ่ง</h3>
<div className="grid grid-cols-3 gap-1">
<button
type="button"
className={BTN_INSERT}
disabled={busy}
aria-busy={insertingParagraph}
title="แทรกย่อหน้าว่างที่ตำแหน่ง cursor (เหมือนกด Enter)"
onClick={() => void handleInsertParagraph()}
>
{insertingParagraph ? (
<LoaderCircle className="size-4 animate-spin" aria-hidden />
) : (
<CornerDownLeft className="size-4" aria-hidden />
)}
ย่อหน้า
</button>
<button
type="button"
className={BTN_INSERT}
disabled={busy}
aria-busy={insertingBreak === "line"}
title="ขึ้นบรรทัดใหม่ในย่อหน้าเดิม (Shift+Enter)"
onClick={() => void handleInsertBreak("line")}
>
{insertingBreak === "line" ? (
<LoaderCircle className="size-4 animate-spin" aria-hidden />
) : (
<WrapText className="size-4" aria-hidden />
)}
แบ่งบรรทัด
</button>
<button
type="button"
className={BTN_INSERT}
disabled={busy}
aria-busy={insertingBreak === "page"}
title="ขึ้นหน้าใหม่ (Ctrl+Enter)"
onClick={() => void handleInsertBreak("page")}
>
{insertingBreak === "page" ? (
<LoaderCircle className="size-4 animate-spin" aria-hidden />
) : (
<FilePlus className="size-4" aria-hidden />
)}
แบ่งหน้า
</button>
</div>
</div>
);
}
export default TemplateToolsPanel;

View file

@ -1,149 +0,0 @@
import { Download, FileText, LoaderCircle } from "lucide-react";
import { VersionRecord } from "@/wordaddin/models/versionRecord";
import { MessageBar } from "./MessageBar";
import { Spinner } from "./Spinner";
/**
* FR-4 — แสดง version history (เลข version / เวลา / ผู้สร้าง) + ปุ่มดาวน์โหลด version นั้น
* ชั้นภาพ: timeline แนวตั้ง — จุดโหนดเท่าทึบคือเวอร์ชันล่าสุด จุดกลวงคือเก่ากว่า
* (เลข version เป็น static → ใช้ ink ตามกฎ "blue เฉพาะ interactive" ของ DESIGN.md)
*/
function formatTimestamp(iso: string): string {
const date = new Date(iso);
return Number.isNaN(date.getTime())
? iso
: date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
}
/** px-2 py-1.5 + text-xs ≈ สูง 30px — พ้นเกณฑ์ touch target 24px ของ WCAG 2.5.8 โดยยัง dense พอสำหรับ list */
const BTN_ACTION =
"focus-ring inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs text-ink-soft transition hover:bg-primary/[0.06] hover:text-primary disabled:pointer-events-none disabled:opacity-50";
export function VersionHistoryPanel({
versions,
loading,
error,
downloadingVersionId,
downloadingPdfVersionId,
onDownloadPdf,
onDownload,
onRetry,
}: {
versions: VersionRecord[];
loading: boolean;
error: string | null;
/** versionId ของ version ที่กำลังดาวน์โหลดอยู่ (แสดง spinner ที่ปุ่ม) */
downloadingVersionId: string | null;
downloadingPdfVersionId: string | null;
onDownloadPdf: (version: VersionRecord) => void;
onDownload: (version: VersionRecord) => void;
onRetry: () => void;
}) {
const anyDownloading = downloadingVersionId !== null || downloadingPdfVersionId !== null;
return (
<div className="flex flex-col gap-1.5">
<div className="mt-4 mb-2 flex items-baseline justify-between">
<h3 className="font-display text-sm font-semibold text-ink">Version history</h3>
{versions.length > 0 ? <span className="text-xs text-ink-soft">{versions.length} เวอร์ชัน</span> : null}
</div>
{loading ? <Spinner label="กำลังโหลด…" /> : null}
{error ? (
<MessageBar
intent="error"
action={
<button
type="button"
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
onClick={onRetry}
>
ลองใหม่
</button>
}
>
{error}
</MessageBar>
) : null}
{!loading && !error && versions.length === 0 ? (
<p className="text-sm text-ink-soft">ยังไม่มี version ในระบบ</p>
) : null}
{versions.length > 0 ? (
<ol className="relative">
{/* ราง timeline — วาดครั้งเดียวระดับรายการ (เวอร์ชันเดียวไม่ต้องมีเส้น) */}
{versions.length > 1 ? (
<span className="absolute bottom-2 left-[3.5px] top-2 w-px bg-line" aria-hidden />
) : null}
{versions.map((version, index) => {
const isLatest = index === 0;
return (
<li className="relative pb-3 pl-4 last:pb-0" key={version.versionId}>
<span
className={`absolute left-0 top-[5px] size-2 rounded-full ${
isLatest ? "bg-ink" : "border border-ink-faint bg-canvas"
}`}
aria-hidden
/>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-display text-sm font-semibold text-ink">v{version.versionNumber}</span>
{isLatest ? (
<span className="rounded-full bg-ink/[0.06] px-1.5 py-px text-[11px] text-ink-soft">ล่าสุด</span>
) : null}
<span className="ml-auto flex items-center gap-1">
<button
type="button"
className={BTN_ACTION}
disabled={anyDownloading}
aria-busy={downloadingVersionId === version.versionId}
onClick={() => onDownload(version)}
>
{/* คง label เดิม + สลับเป็น spinner — label ยาว "กำลังดาวน์โหลด…" ทำแถว wrap กระโดด */}
{downloadingVersionId === version.versionId ? (
<LoaderCircle className="size-3.5 animate-spin" aria-hidden />
) : (
<Download className="size-3.5" aria-hidden />
)}
ดาวน์โหลด
</button>
{version.hasPdf ? (
<button
type="button"
className={BTN_ACTION}
disabled={anyDownloading}
aria-busy={downloadingPdfVersionId === version.versionId}
onClick={() => onDownloadPdf(version)}
>
{downloadingPdfVersionId === version.versionId ? (
<LoaderCircle className="size-3.5 animate-spin" aria-hidden />
) : (
<FileText className="size-3.5" aria-hidden />
)}
PDF
</button>
) : null}
</span>
</div>
{version.message ? (
<p className="mt-0.5 whitespace-pre-wrap [overflow-wrap:anywhere] text-xs text-ink">
{version.message}
</p>
) : null}
<p className="mt-0.5 text-xs text-ink-soft">
{formatTimestamp(version.createdAt)} · โดย {version.createdByUserName ?? version.createdByUserId}
</p>
</li>
);
})}
</ol>
) : null}
</div>
);
}
export default VersionHistoryPanel;

View file

@ -1,131 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { withBasePath } from "@/utils/base-path";
import { signInForDemo } from "@/wordaddin/services/authService";
import { MessageBar } from "./MessageBar";
import { Spinner } from "./Spinner";
import TaskpaneApp from "./TaskpaneApp";
/**
* Gate ของ taskpane ใน App Router — ทำหน้าที่แทน Office.onReady ก่อน render ของ webpack
* ต้นฉบับ: Office.js โหลดใน <head> ของ taskpane.html แล้ว Office.onReady ก่อน createRoot
* (บริบท Next: ฉีด <script> self-host หลัง hydration แทน — ไฟล์ services ทั้งหมดไม่สัมผัส
* Office ที่ module top-level จึงปลอดภัย)
*
* โหมด ?addindemo=untracked|tracking (เฉพาะ dev) — ข้าม office.js ทั้งหมด + sign in mock
* ทันที เพื่อดู UI ทุก phase ใน browser ธรรมดา (mock versioningService ทำงานได้เอง
* แต่ flow ที่แตะ Word จริงจะจบที่ error ตาม expected — ดู docs/WORDADDIN-DEV.md)
*/
/** office.js self-host จาก postinstall scripts/fetch-office-js.mjs (intranet ไม่ใช้ CDN) */
const OFFICE_JS_SRC = withBasePath("/wordaddin/office-js/office.js");
/** Office.onReady callback ไม่ยิงภายใน 5 วิหลังฉีด script ก็ proceed (กัน webview แปลก ๆ — โหลดสำเร็จแต่ onReady เงียบ) */
const ONREADY_FALLBACK_MS = 5_000;
/**
* โหลด office.js ครั้งเดียวต่อ page เป็น singleton promise ระดับ module —
* ห้ามฉีด <script> ซ้ำ: React StrictMode รัน effect สองรอบใน dev และ guard
* `typeof Office !== "undefined"` ไม่ครอบช่วงที่ตัวแรกยังโหลดไม่เสร็จ
* (โหลดซ้ำ = evaluate ซ้ำ = "Cannot redefine property: context" เพราะ office.js
* define `Microsoft.Office.WebExtension.context` แบบ configurable:false)
* error → คืน promise ใหม่ครั้งหน้า (ลบ script เก่าทิ้งให้ inject ใหม่ได้)
*/
let officeReadyPromise: Promise<boolean> | null = null;
function ensureOfficeJs(): Promise<boolean> {
if (!officeReadyPromise) {
officeReadyPromise = new Promise<boolean>((resolve) => {
if (typeof Office !== "undefined") {
void Office.onReady(() => resolve(true));
return;
}
const script = document.createElement("script");
const fallbackTimer = setTimeout(() => resolve(true), ONREADY_FALLBACK_MS);
const onReady = () => {
// เคลียร์ timer เฉพาะเมื่อ callback ของ Office.onReady ยิงจริง — ถ้าเคลียร์ตั้งแต่ onload
// แล้ว onReady เงียบ fallback จะตายไปก่อนทำหน้าที่ (ค้าง loading ตลอดไป)
void Office.onReady(() => {
clearTimeout(fallbackTimer);
resolve(true);
});
};
script.onload = onReady;
script.onerror = () => {
clearTimeout(fallbackTimer);
officeReadyPromise = null;
script.remove();
resolve(false);
};
script.src = OFFICE_JS_SRC;
script.async = false; // ให้ script นี้ได้ทำงานตามลำดับก่อนโค้ดที่แตะ Office
script.id = "office-js";
document.head.appendChild(script);
});
}
return officeReadyPromise;
}
type GateStatus = "loading" | "ready" | "error";
export function WordAddinGate() {
const [status, setStatus] = useState<GateStatus>("loading");
const [demo, setDemo] = useState<"untracked" | "tracking" | null>(null);
useEffect(() => {
const demoParam = new URLSearchParams(window.location.search).get("addindemo");
if (process.env.NODE_ENV !== "production" && (demoParam === "untracked" || demoParam === "tracking")) {
signInForDemo();
setDemo(demoParam);
setStatus("ready");
return;
}
let cancelled = false;
void ensureOfficeJs()
.then((ok) => {
if (!cancelled) setStatus(ok ? "ready" : "error");
})
.catch(() => {
// Office.onReady throw sync ใน executor กลายเป็น rejection — ไป error screen แทนค้าง loading
if (!cancelled) setStatus("error");
});
return () => {
cancelled = true;
};
}, []);
if (status === "loading") {
return (
<div className="flex min-h-screen items-center justify-center">
<Spinner label="กำลังเริ่มต้น…" className="text-ink-soft" />
</div>
);
}
if (status === "error") {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<MessageBar
intent="error"
action={
<button
type="button"
className="focus-ring rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
onClick={() => window.location.reload()}
>
ลองใหม่
</button>
}
>
โหลด Office.js ไม่สำเร็จ — เช็คว่า dev server รันอยู่และไฟล์ public/wordaddin/office-js/office.js
ถูกสร้างแล้ว (npm install รัน postinstall scripts/fetch-office-js.mjs)
</MessageBar>
</div>
);
}
return <TaskpaneApp demo={demo ?? undefined} />;
}
export default WordAddinGate;

View file

@ -8,13 +8,9 @@ import {
pgTable, pgTable,
text, text,
timestamp, timestamp,
uniqueIndex,
uuid, uuid,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
// type-only — ไม่ลาก runtime ของ lib-tmpl มาที่ schema (ใช้เป็นชนิดของ fields jsonb)
import type { DocumentExtractSnapshot } from "@/lib/lib-tmpl";
// ─── Enum ──────────────────────────────────────────────────────────────────── // ─── Enum ────────────────────────────────────────────────────────────────────
export const annotationType = pgEnum("annotation_type", [ export const annotationType = pgEnum("annotation_type", [
@ -114,16 +110,7 @@ export type TextboxData = {
/** ตรายาง preset (F18/B5, D7) — scale เป็น % ของความกว้างหน้า (ความกว้างกรอบตรายาง) /** ตรายาง preset (F18/B5, D7) — scale เป็น % ของความกว้างหน้า (ความกว้างกรอบตรายาง)
* color เป็น optional — ไม่มี = STAMP_COLOR (แถวเดิมก่อนมีตัวเลือกสี) */ * color เป็น optional — ไม่มี = STAMP_COLOR (แถวเดิมก่อนมีตัวเลือกสี) */
/** rotation = มุมเอียงของตรายางเป็นองศา (−180..180, หมุนรอบ**กลางกรอบ** — ลบ = export type StampData = { label: string; position: Point; scale: number; color?: string; note?: string };
* ทวนเข็มนาฬิกา) ไม่มี field = ไม่เอียง (0°) — แถวเดิมไม่มี ใช้ร่วมกันได้ */
export type StampData = {
label: string;
position: Point;
scale: number;
color?: string;
note?: string;
rotation?: number;
};
/** เลือกตาม column `type` (discriminated โดย enum) — ห้าม loosen เป็น any */ /** เลือกตาม column `type` (discriminated โดย enum) — ห้าม loosen เป็น any */
export type AnnotationData = export type AnnotationData =
@ -137,28 +124,15 @@ export type AnnotationData =
// ─── Tables ────────────────────────────────────────────────────────────────── // ─── Tables ──────────────────────────────────────────────────────────────────
export const files = pgTable( export const files = pgTable("files", {
"files", id: uuid("id").primaryKey().defaultRandom(),
{ title: text("title").notNull(),
id: uuid("id").primaryKey().defaultRandom(), description: text("description"),
title: text("title").notNull(), pdfUrl: text("pdf_url").notNull(),
description: text("description"), createdAt: timestamp("created_at", { withTimezone: true })
pdfUrl: text("pdf_url").notNull(), .defaultNow()
/** N8: dedupe key เอกสารนำเข้าจากลิงก์ e-Court ("ecourt:<fileId>" / "url:<href>") — NULL = ไฟล์ seed/อัปโหลด */ .notNull(),
externalId: text("external_id"), });
/** N9: รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน เช่น คดีเดียวกัน (จากลิงก์ e-Court `?typeId=` หรือช่องกลุ่มของฟอร์มอัปโหลด N4 — NULL = ไม่มีกลุ่ม)
* ไม่มีคอลัมน์ชื่อกลุ่ม — แสดงผลด้วยรหัสกลุ่มโดยตรง (ถอด type_name ตามคำขอผู้ใช้ 2026-09-22) */
typeId: text("type_id"),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => [
index("files_type_id_idx").on(t.typeId),
// unique บน nullable ยอมหลาย NULL → row seed/อัปโหลดเดิม (external_id = NULL) ไม่ชนกัน
uniqueIndex("files_external_id_key").on(t.externalId),
],
);
export const annotations = pgTable( export const annotations = pgTable(
"annotations", "annotations",
@ -256,84 +230,12 @@ export const annotationVersions = pgTable(
(t) => [index("annotation_versions_file_idx").on(t.fileId)], (t) => [index("annotation_versions_file_idx").on(t.fileId)],
); );
// ─── e-Court Documents Versioning (docs/PLAN-DOCVER.md) ──────────────────────
/** เอกสารหนึ่งชิ้นต่อ docId ที่ผู้เรียกกำหนด — สร้าง implicitly ตอน upload version แรก
* (ไม่มี endpoint/หน้าจอบริหารแยก) · **ไม่เกี่ยวกับตาราง files ของแอป note** —
* ไม่มี FK ไม่มี field ร่วม ไม่มีหน้า note ใดอ่าน (ของกลุ่ม /api/documents ล้วน ๆ) */
export const documents = pgTable(
"documents",
{
id: uuid("id").primaryKey().defaultRandom(),
/** รหัสเอกสารจากผู้เรียก (add-in ใช้ GUID DocumentTrackingId / e-Court ใช้รหัสของตัวเอง)
* — unique ทั้งระบบ: เอกสารเดียวมี row เดียว (ห้ามซ้ำ) */
docId: text("doc_id").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => [uniqueIndex("documents_doc_id_key").on(t.docId)],
);
/** version ของเอกสาร — ไฟล์จริงอยู่ใน MinIO ตาม object key (docx + pdf ต่อ version)
* · pdf เป็น optional ตาม contract ของ add-in (best-effort — NULL = version นี้ไม่มี pdf)
* · object key เป็น uuid ล้วน `docver/<versionId>/document.{docx,pdf}` ไม่มี input ผู้ใช้ */
/** fields ของ version — **ก้อนเดียวกับ return ของ `/api/template/extract`** (ผ่าน
* `extractDocumentSnapshot` ของ lib/lib-tmpl.ts ตัวเดียวกัน): `{cc, bookmark, properties,
* verify}` แกะจากไฟล์ .docx ฝั่ง server ตอน upload (2026-10-30) · row เก่าก่อนวันนั้น
* เก็บ map ดิบ `{ [tag]: text }` / `{ [fieldKey]: {value, missing} }` — ฝั่งอ่านของ add-in
* (FieldsSnapshot + readSnapshotField) แยกรูปเอง จึงไม่ต้อง migrate ข้อมูลเก่า */
export type DocumentVersionFields = DocumentExtractSnapshot;
export const documentVersions = pgTable(
"document_versions",
{
id: uuid("id").primaryKey().defaultRandom(),
documentId: uuid("document_id")
.notNull()
.references(() => documents.id, { onDelete: "cascade" }),
versionNumber: integer("version_number").notNull(),
/** GUID ที่ client generate ก่อน upload — เก็บไว้เทียบ/สืบสวน ไม่ใช้เป็น identity
* (ไม่มี idempotency — retry ของผู้เรียกเกิด version ใหม่ ตามแผน §4) */
clientVersionId: text("client_version_id"),
/** SHA-256 hex ของไบต์ docx — คำนวณฝั่ง server เสมอ (NOT NULL เพราะ buffer ก่อน insert ทุกครั้ง) */
contentHash: text("content_hash").notNull(),
/** commit message (FR-13) — trim แล้วว่าง = NULL */
message: text("message"),
/** snapshot ตอน upload: ก้อน extract snapshot `{cc, bookmark, properties, verify}`
* เหมือน return ของ /api/template/extract (ดู DocumentVersionFields เหนือตาราง) */
fields: jsonb("fields").$type<DocumentVersionFields>(),
templateId: text("template_id"),
/** ชื่อไฟล์ดั้งเดิมที่ browser ส่ง (ผ่าน decodeUploadFilename + strip path แล้ว) —
* ใช้ตั้ง Content-Disposition ของ object ไม่ใช่ชื่อ object */
fileName: text("file_name"),
docxObjectKey: text("docx_object_key").notNull(),
pdfObjectKey: text("pdf_object_key"),
docxSize: integer("docx_size").notNull(),
pdfSize: integer("pdf_size"),
/** ผู้อัปโหลด — endpoint รับมาแบบ plain field เพราะระบบ stand-alone ไม่มี session */
userId: text("user_id").notNull(),
userName: text("user_name").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => [
// safety net ของการจ่ายเลข max+1 ภายใต้ FOR UPDATE (PLAN-DOCVER.md §3)
uniqueIndex("document_versions_document_number_key").on(t.documentId, t.versionNumber),
index("document_versions_document_created_idx").on(t.documentId, t.createdAt),
],
);
// ─── DTO (timestamp เป็น ISO string เสมอ — ห้ามส่ง Date ข้าม server→client) ── // ─── DTO (timestamp เป็น ISO string เสมอ — ห้ามส่ง Date ข้าม server→client) ──
export type AnnotationRow = typeof annotations.$inferSelect; export type AnnotationRow = typeof annotations.$inferSelect;
export type BookmarkRow = typeof bookmarks.$inferSelect; export type BookmarkRow = typeof bookmarks.$inferSelect;
export type ReplyRow = typeof annotationReplies.$inferSelect; export type ReplyRow = typeof annotationReplies.$inferSelect;
export type AnnotationVersionRow = typeof annotationVersions.$inferSelect; export type AnnotationVersionRow = typeof annotationVersions.$inferSelect;
export type DocumentRow = typeof documents.$inferSelect;
export type DocumentVersionRow = typeof documentVersions.$inferSelect;
export type AnnotationDTO = { export type AnnotationDTO = {
id: string; id: string;
@ -446,33 +348,3 @@ export function toVersionDTO(row: AnnotationVersionRow): AnnotationVersionDTO {
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
}; };
} }
/** item ของ GET /api/documents/{docId}/versions — surface ตรง VersionRecord ของ
* wordaddin/models/versionRecord.ts (คัดลอกมา define เอง — ห้าม import จาก wordaddin/
* ซึ่งเป็น client-only zone) + createdByUserName เป็นส่วนเสริม (field เพิ่มไม่ทำ
* UI ฝั่ง client พัง) · hasPdf derive จาก pdfObjectKey !== null ตัวเดียว (PLAN-DOCVER.md §1) */
export type DocumentVersionDTO = {
versionId: string;
versionNumber: number;
createdAt: string;
createdByUserId: string;
createdByUserName: string;
contentHash: string;
fields: DocumentVersionFields | null;
message: string | null;
hasPdf: boolean;
};
export function toDocumentVersionDTO(row: DocumentVersionRow): DocumentVersionDTO {
return {
versionId: row.id,
versionNumber: row.versionNumber,
createdAt: row.createdAt.toISOString(),
createdByUserId: row.userId,
createdByUserName: row.userName,
contentHash: row.contentHash,
fields: row.fields,
message: row.message,
hasPdf: row.pdfObjectKey !== null,
};
}

View file

@ -1,106 +0,0 @@
# Dev dependencies ของ judes-note — รันด้วย `docker compose up -d` (ทั้งคู่)
# หรือ `docker compose up -d minio` (ถ้าจะรัน DB แบบเดิมด้วย docker run)
#
# service `db` ใช้ชื่อ container/วอลุ่มเดียวกับคำสั่ง docker run เดิมใน README
# (วอลุ่ม pin ชื่อไม่มี prefix ของ compose ผ่าน `name:` ด้านล่าง) — ถ้ามี container
# judes-note-db เก่ารันอยู่ (สร้างด้วย docker run) ให้ `docker rm -f judes-note-db`
# ก่อน แล้ว compose จะต่อวอลุ่ม judes-note-db-data เดิมได้เลย ข้อมูลไม่หาย
services:
docedit:
build:
context: .
dockerfile: docker/web.Dockerfile
restart: unless-stopped
environment:
- SECRET=${SECRET}
- PRIVATE_KEY_HEX=${PRIVATE_KEY_HEX}
- PUBLIC_KEY_HEX=${PUBLIC_KEY_HEX}
- EXTERNAL_API_KEY=${EXTERNAL_API_KEY}
- DOCVER_API_KEY=${DOCVER_API_KEY}
- DOCVER_TOKEN_SECRET=${DOCVER_TOKEN_SECRET}
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/note
### สำหรับโค้ดตัวอย่างระบบนอกเรียกเข้ามาไม่ได้ใช้ในระบบนี้ ห้ามเอาไช้
- DOCEDIT_BASE_URL=http://docedit:3000/docedit
- TICKET_SEC=300
- DOCX_WORKERS=4
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
minio:
condition: service_healthy
db:
image: postgres:18-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-note}
PGDATA: /var/lib/postgresql/data/pgdata
ports:
- "54322:5432"
volumes:
- judes-note-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 10s
timeout: 5s
retries: 5
# Object storage ของกลุ่ม API เอกสาร versioning (docs/PLAN-DOCVER.md §6) —
# bucket docver ไม่ต้องสร้างเอง: app สร้างตอน first use ผ่าน ensureBucket
# (ใช้ registry ทางการของ MinIO ที่ quay.io + pin RELEASE — Docker Hub
# ของ minio/minio เจอ rate limit/เข้าไม่ได้บ่อย)
minio:
image: docker.io/pgsty/silo:RELEASE.2026-10-26T00-00-00Z
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
# MC_HOST_local: http://${MINIO_ROOT_USER}:${MINIO_ROOT_PASSWORD}@localhost:39000
MINIO_BROWSER_REDIRECT_URL: https://${APP_DOMAIN}/console/
ports:
- "${MINIO_PORT}:9000" # S3 API
- "9001:9001" # MinIO console
volumes:
- judes-note-minio-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 3s
retries: 10
# สร้าง bucket docx-versions + docx-templates แบบ private (anonymous = none)
# idempotent — รันซ้ำได้ด้วย --ignore-existing
minio-init:
# quay.io mirror — Docker Hub ทำ minio/mc เป็น private แล้ว (pull access denied)
image: cgr.dev/chainguard/minio-client:latest-dev
depends_on:
minio:
condition: service_healthy
restart: "no"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
entrypoint: >
/bin/sh -c "
set -e;
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD;
mc mb --ignore-existing local/docx-versions;
mc mb --ignore-existing local/docx-templates;
mc anonymous set none local/docx-versions;
mc anonymous set none local/docx-templates;
echo 'MinIO buckets ready: docx-versions, docx-templates (private)';
"
volumes:
# pin ชื่อจริงไม่มี prefix <project>_ ของ compose ให้ตรงชื่อวอลุ่มจาก docker run เดิม
judes-note-db-data:
name: judes-note-db-data
judes-note-minio-data:
name: judes-note-minio-data

Some files were not shown because too many files have changed in this diff Show more