bookmark เรียงตามลำดับหน้า (N5) + ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ พร้อม bookmarks.user_name (N6) + UX audit sidebar 2026-09-16: เมนู ⋯ แทนไอคอนชุดติดกัน, มุมมองรอง ตามหน้า หัวกลุ่มหุบได้, focus ring, ปุ่ม sidebar จิ้มง่าย >=32px, Escape ปิด dialog ประวัติ + แผนใหม่ docs/PLAN-BOOKMARK.md + agent ui-ux-designer แทน ui-designer + skill frontend-design

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-16 13:24:33 +07:00
parent 79b38fa152
commit a020e96bda
14 changed files with 2029 additions and 362 deletions

View file

@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { and, asc, desc, eq, or, sql } from "drizzle-orm";
import { and, asc, desc, eq, gte, lte, or, sql } from "drizzle-orm";
import { publishAnnotateEvent } from "@/lib/annotate-events";
import { db } from "@/db";
@ -49,6 +49,8 @@ export type CreateBookmarkInput = {
fileId: string;
pageIndex: number;
userId: string;
/** N6: ชื่อผู้บันทึก — แสดงในผลค้นหา bookmark */
userName: string;
title: string;
/** % ของความสูงหน้า (0–100) */
yOffset: number;
@ -706,6 +708,7 @@ export async function createBookmark(input: CreateBookmarkInput): Promise<Bookma
const fileId = vUuid(raw?.fileId, "fileId");
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);
if (
typeof raw?.yOffset !== "number" ||
@ -739,7 +742,7 @@ export async function createBookmark(input: CreateBookmarkInput): Promise<Bookma
const [inserted] = await tx
.insert(bookmarks)
.values({ fileId, pageIndex, userId, title, yOffset, parentId, note })
.values({ fileId, pageIndex, userId, userName, title, yOffset, parentId, note })
.returning();
return inserted;
});
@ -990,3 +993,117 @@ export async function searchAnnotations(fileId: string, q: string): Promise<Anno
.orderBy(asc(annotations.createdAt));
return rows.map(toAnnotationDTO);
}
// ─── Search bookmarks (N6 — requirement 5.3.12) ─────────────────────────────
export type SearchBookmarksInput = {
fileId: string;
/** คำค้น — ILIKE บน title + note (ไม่ส่ง/ว่าง = ไม่จำกัด ถ้ามีเงื่อนไขอื่น) */
q?: string;
/** ช่วงหน้า 1-based ตรงที่ผู้ใช้เห็น ("หน้า N") — ส่งฝั่งเดียวได้ */
pageFrom?: number;
pageTo?: number;
/** userId ของผู้บันทึก */
userId?: string;
/** ช่วงวันที่ที่บันทึก — YYYY-MM-DD ตีความเป็นเขตเวลา +07:00 คงที่ (ไทยไม่มี DST) */
dateFrom?: string;
dateTo?: string;
};
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
/** หน้า (1-based) ของเงื่อนไขค้นหา — optional, integer ≥ 1 */
function vSearchPage(v: unknown, field: string): number | undefined {
if (v === undefined || v === null || v === "") return undefined;
if (typeof v !== "number" || !Number.isInteger(v) || v < 1) {
fieldError(field, "เป็นจำนวนเต็ม ≥ 1");
}
return v;
}
/** วันที่ (YYYY-MM-DD) ของเงื่อนไขค้นหา — optional, เช็ครูปแบบก่อน (แปลงเป็นเวลาทีหลัง swap) */
function vSearchDateString(v: unknown, field: string): string | undefined {
if (v === undefined || v === null || v === "") return undefined;
if (typeof v !== "string" || !DATE_ONLY_RE.test(v)) {
fieldError(field, "เป็นวันที่รูปแบบ YYYY-MM-DD");
}
return v;
}
/** แปลง YYYY-MM-DD → ขอบเขตเวลา ต้นวัน/ปลายวัน เขตเวลา +07:00 คงที่ (ไทยไม่มี DST) */
function dateBound(v: string, endOfDay: boolean, field: string): Date {
const d = new Date(`${v}T${endOfDay ? "23:59:59.999" : "00:00:00"}+07:00`);
if (Number.isNaN(d.getTime())) {
fieldError(field, "เป็นวันที่ที่มีอยู่จริง");
}
return d;
}
/**
* ค้นหา bookmark พร้อมเงื่อนไข — ทุกเงื่อนไข optional แต่ต้องมีอย่างน้อยหนึ่งอย่าง
* (คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ รวมกันด้วย AND); คู่ช่วงกลับด้าน (from > to)
* = swap เงียบ ๆ — read-only จึงไม่ publish event/ไม่ revalidatePath
*/
export async function searchBookmarks(input: SearchBookmarksInput): Promise<BookmarkDTO[]> {
const raw = input as unknown as Record<string, unknown> | null | undefined;
const fid = vUuid(raw?.fileId, "fileId");
const q = typeof raw?.q === "string" && raw.q.trim() !== ""
? vString(raw.q, "q", MAX_QUERY_CHARS)
: undefined;
const userId = typeof raw?.userId === "string" && raw.userId !== ""
? vString(raw.userId, "userId")
: undefined;
let pageFrom = vSearchPage(raw?.pageFrom, "pageFrom");
let pageTo = vSearchPage(raw?.pageTo, "pageTo");
if (pageFrom !== undefined && pageTo !== undefined && pageFrom > pageTo) {
[pageFrom, pageTo] = [pageTo, pageFrom];
}
// N6: swap กลับด้านต้องทำที่ "วัน" (string) ก่อนคำนวณขอบเขตเวลา — swap ที่ Date
// แล้วขอบเขตปลายวัน/ต้นวันจะติดกันเหลือ 1 ms (เช่น 16→15 กลายเป็น [15T23:59:59.999,
// 16T00:00]) จนช่วงกลับด้านได้ผล 0 รายการเสมอ
let fromDate = vSearchDateString(raw?.dateFrom, "dateFrom");
let toDate = vSearchDateString(raw?.dateTo, "dateTo");
if (fromDate !== undefined && toDate !== undefined && fromDate > toDate) {
[fromDate, toDate] = [toDate, fromDate];
}
const dateFrom = fromDate !== undefined ? dateBound(fromDate, false, "dateFrom") : undefined;
const dateTo = toDate !== undefined ? dateBound(toDate, true, "dateTo") : undefined;
if (
q === undefined &&
userId === undefined &&
pageFrom === undefined &&
pageTo === undefined &&
dateFrom === undefined &&
dateTo === undefined
) {
throw new Error("ต้องระบุเงื่อนไขอย่างน้อยหนึ่งอย่าง (คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่)");
}
const esc = q !== undefined ? q.replace(/[\\%_]/g, (c) => `\\${c}`) : "";
const pattern = `%${esc}%`;
const rows = await db
.select()
.from(bookmarks)
.where(
and(
eq(bookmarks.fileId, fid),
// note เป็น NULL ได้ — OR(title ILIKE, NULL) = ผลของ title ตาม SQL semantics (ถูกต้อง)
q !== undefined
? or(
sql`${bookmarks.title} ILIKE ${pattern}`,
sql`${bookmarks.note} ILIKE ${pattern}`,
)
: undefined,
userId !== undefined ? eq(bookmarks.userId, userId) : undefined,
pageFrom !== undefined ? gte(bookmarks.pageIndex, pageFrom - 1) : undefined,
pageTo !== undefined ? lte(bookmarks.pageIndex, pageTo - 1) : undefined,
dateFrom !== undefined ? gte(bookmarks.createdAt, dateFrom) : undefined,
dateTo !== undefined ? lte(bookmarks.createdAt, dateTo) : undefined,
),
)
// ลำดับเดียวกับที่ต้นไม้ bookmark แสดงบนจอ (N5)
.orderBy(asc(bookmarks.pageIndex), asc(bookmarks.yOffset), asc(bookmarks.createdAt));
return rows.map(toBookmarkDTO);
}