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:
parent
79b38fa152
commit
a020e96bda
14 changed files with 2029 additions and 362 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,6 +147,15 @@
|
|||
|
||||
/* M6: ปุ่ม icon ใน sidebar (16/14px ติดกัน — จิ้มพลาดไปปุ่มข้าง ๆ ง่าย โดยเฉพาะ
|
||||
"ลบ" ชิด "ประวัติ") → อย่างน้อย 32px แบบไม่ต้องขยายตัว icon ที่มองเห็น */
|
||||
/* UX audit 2026-09-16: ขยายเป็นปุ่มทุกตัวใน sidebar — รวมปุ่มข้อความเล็ก
|
||||
(ยกเลิก/บันทึก/ล้างทั้งหมด/ViewSwitch/แท็บ) ที่เดิมหลุดจาก rule; แยกเป็น 2 rule
|
||||
เพราะ specificity ของ selector นี้ชนะ Tailwind class เดี่ยว — ใส่ display/
|
||||
align-items ใน rule กว้างจะขโมย items-start ของปุ่มแถวรายการ (ไอคอนลอยกลาง
|
||||
ข้อความ 2 บรรทัด) ตัวจัด center จึงคงไว้เฉพาะ button.shrink-0 (ไอคอนเดี่ยว)
|
||||
ส่วนแถวรายการเป็นปุ่มเต็มความกว้างสูงพออยู่แล้ว ไม่กระทบ */
|
||||
aside[data-sidebar] button {
|
||||
min-height: 2rem;
|
||||
}
|
||||
aside[data-sidebar] button.shrink-0 {
|
||||
min-height: 2rem;
|
||||
min-width: 2rem;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue