Executive Meetings: inline editing, drag reorder, and current-meeting highlight

Implements Task #122 — four features on the Executive Meetings schedule:
1. Word-like inline editing of meeting title and attendee names with bold,
   italic, underline, color, and font controls (Tiptap-backed EditableCell).
2. Drag-to-reorder column headers (dnd-kit), persisted in the existing
   em-schedule-cols-v1 storage key. Old up/down buttons removed.
3. Drag-to-reorder rows within a day. New POST /api/executive-meetings/reorder
   endpoint swaps daily_number in a single transaction using a two-phase
   negative→final assignment that respects the (date, daily_number) UNIQUE
   constraint. Audit-logged and role-guarded.
4. Highlight the meeting whose start/end window is "now", refreshed every
   60 s. Toggle and color picker live in the customize popover and persist
   to localStorage.

Backend changes
- Widened titleAr / titleEn / attendees.name to text (applied via direct
  ALTER TABLE because db:push --force is currently blocked by Task #126).
- New artifacts/api-server/src/lib/sanitize.ts with an allowlist for
  inline tags + safe inline styles. Applied to POST, PATCH, PUT-attendees,
  duplicate, and reorder paths so rich text round-trips safely.
- Code-review fix: duplicate handler now re-sanitizes titleAr/titleEn.
- Code-review fix: print page no longer interpolates the unsanitized
  attendee.title into dangerouslySetInnerHTML.
- 4 new tests cover sanitization stripping, reorder happy path, cross-day
  rejection, and permission denial. Pre-existing app_permissions failures
  are unrelated and tracked by Task #126.

Frontend changes
- artifacts/tx-os/src/components/editable-cell.tsx with FormattingToolbar.
- ScheduleSection wires saveTitle, saveAttendeeName, reorder mutation
  (optimistic with revert on error), highlight tick, and HighlightPrefs.
- New SortableHeader component for column drag.
- AttendeesCell now passes the original attendee index to PUT writes so
  edits reach the right row even after grouping into virtual/internal/
  external sections.
- Print page renders sanitized HTML via dangerouslySetInnerHTML on names
  and titles (titles still rendered as plain text on the print page).
- Translation keys added in ar.json and en.json.

Drift
- Sanitization for attendee.title was identified by the architect review
  as a defense-in-depth gap and proposed as Task #130 rather than fixing
  inline; the print-page XSS path that depended on it was fixed directly.
This commit is contained in:
riyadhafraa
2026-04-29 07:39:24 +00:00
parent 7a2f91d262
commit 5114b207da
13 changed files with 1969 additions and 125 deletions
+2
View File
@@ -24,6 +24,7 @@
"google-auth-library": "^10.6.2",
"pino": "^9",
"pino-http": "^10",
"sanitize-html": "^2.17.3",
"socket.io": "^4.8.3",
"zod": "catalog:"
},
@@ -35,6 +36,7 @@
"@types/express": "^5.0.6",
"@types/express-session": "^1.19.0",
"@types/node": "catalog:",
"@types/sanitize-html": "^2.16.1",
"esbuild": "^0.27.3",
"esbuild-plugin-pino": "^2.3.3",
"pg": "^8.20.0",
+85
View File
@@ -0,0 +1,85 @@
import sanitizeHtml from "sanitize-html";
const ALLOWED_FONT_FAMILIES = new Set<string>([
"Cairo",
"Tajawal",
"Amiri",
"Noto Naskh Arabic",
"IBM Plex Sans Arabic",
"system-ui",
"sans-serif",
"serif",
"monospace",
]);
const FONT_SIZE_RE = /^(?:[8-9]|[1-9]\d)px$/;
const COLOR_RE = /^(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\))$/i;
export const RICH_TEXT_OPTIONS: sanitizeHtml.IOptions = {
allowedTags: ["span", "strong", "b", "em", "i", "u", "br"],
allowedAttributes: {
span: ["style"],
strong: ["style"],
b: ["style"],
em: ["style"],
i: ["style"],
u: ["style"],
},
allowedStyles: {
"*": {
"color": [COLOR_RE],
"background-color": [COLOR_RE],
"font-weight": [/^(?:normal|bold|[1-9]00)$/i],
"font-style": [/^(?:normal|italic|oblique)$/i],
"text-decoration": [/^(?:none|underline)$/i],
"text-align": [/^(?:left|right|center|start|end|justify)$/i],
"font-size": [FONT_SIZE_RE],
"font-family": [
// Allow either an exact known family or a comma-separated list of
// quoted/unquoted families that all match our allowlist.
(value: string) => isAllowedFontFamilyValue(value),
] as unknown as RegExp[],
},
},
allowedSchemes: [],
disallowedTagsMode: "discard",
enforceHtmlBoundary: false,
// Keep text content of disallowed tags rather than dropping it entirely;
// this matters when a user pastes from Word and we strip the wrapper.
exclusiveFilter: () => false,
};
function isAllowedFontFamilyValue(raw: string): boolean {
// Split on commas, strip whitespace + surrounding quotes, then check each
// candidate name. If any unknown name appears we reject the whole value.
const parts = raw.split(",").map((s) => s.trim().replace(/^["']|["']$/g, ""));
if (parts.length === 0) return false;
return parts.every((p) => ALLOWED_FONT_FAMILIES.has(p));
}
/**
* Sanitize a Tiptap-style rich-text HTML string from a client.
*
* - Allowed tags: span, strong/b, em/i, u, br
* - Allowed inline styles: color, background-color, font-weight, font-style,
* text-decoration, text-align, font-size (px, 8-99), font-family (allowlist)
* - All other tags/attributes/styles are stripped.
*
* Empty / null / undefined input becomes "".
*/
export function sanitizeRichText(input: unknown): string {
if (input === null || input === undefined) return "";
const s = typeof input === "string" ? input : String(input);
if (s.length === 0) return "";
return sanitizeHtml(s, RICH_TEXT_OPTIONS);
}
/**
* Convenience for places that previously accepted plain text but now
* accept rich text — null/empty preserved as null when needed.
*/
export function sanitizeRichTextOrNull(input: unknown): string | null {
if (input === null || input === undefined) return null;
const s = sanitizeRichText(input);
return s === "" ? null : s;
}
@@ -25,12 +25,14 @@ import {
executiveMeetingFontSettingsTable,
rolesTable,
usersTable,
type ExecutiveMeetingAttendee,
} from "@workspace/db";
import {
requireAuth,
requireExecutiveAccess,
getEffectiveRoleIds,
} from "../middlewares/auth";
import { sanitizeRichText } from "../lib/sanitize";
import type { Request, Response, NextFunction } from "express";
const router: IRouter = Router();
@@ -164,16 +166,20 @@ const timeSchema = z
.regex(/^\d{2}:\d{2}(:\d{2})?$/, "expected HH:MM");
const positiveIntSchema = z.number().int().positive();
// titleAr/En and attendee.name accept sanitized rich-text HTML (Tiptap
// output). The byte count grows quickly with inline <span style=...> wrappers,
// so we allow up to ~10k bytes for titles and ~5k for attendee names. The
// sanitizer strips disallowed tags/attrs before persistence.
const attendeeSchema = z.object({
name: z.string().trim().min(1).max(200),
name: z.string().trim().min(1).max(5000),
title: z.string().trim().max(200).nullable().optional(),
attendanceType: z.enum(ATTENDANCE_TYPES).default("internal"),
sortOrder: z.number().int().min(0).optional(),
});
const meetingBaseFields = {
titleAr: z.string().trim().min(1).max(300),
titleEn: z.string().trim().min(1).max(300),
titleAr: z.string().trim().min(1).max(10000),
titleEn: z.string().trim().min(1).max(10000),
meetingDate: dateSchema,
dailyNumber: positiveIntSchema.max(1000).nullable().optional(),
startTime: timeSchema.nullable().optional(),
@@ -228,6 +234,14 @@ const duplicateSchema = z.object({
targetDate: dateSchema,
});
const reorderSchema = z.object({
meetingDate: dateSchema,
orderedIds: z
.array(z.number().int().positive())
.min(2, "Need at least 2 meetings to reorder")
.max(200),
});
const dateOnly = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const timeHm = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/);
@@ -585,8 +599,8 @@ router.post(
const dailyNumber =
data.dailyNumber ?? (await nextDailyNumber(tx, data.meetingDate));
const meetingValues: typeof executiveMeetingsTable.$inferInsert = {
titleAr: data.titleAr,
titleEn: data.titleEn ?? "",
titleAr: sanitizeRichText(data.titleAr),
titleEn: sanitizeRichText(data.titleEn ?? ""),
meetingDate: data.meetingDate,
dailyNumber,
startTime: data.startTime ?? null,
@@ -610,7 +624,7 @@ router.post(
await tx.insert(executiveMeetingAttendeesTable).values(
attendees.map((a, idx) => ({
meetingId: meeting.id,
name: a.name,
name: sanitizeRichText(a.name),
title: a.title ?? null,
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
@@ -667,8 +681,10 @@ router.patch(
const updateValues: Partial<typeof executiveMeetingsTable.$inferInsert> = {
updatedBy: userId,
};
if (data.titleAr !== undefined) updateValues.titleAr = data.titleAr;
if (data.titleEn !== undefined) updateValues.titleEn = data.titleEn ?? "";
if (data.titleAr !== undefined)
updateValues.titleAr = sanitizeRichText(data.titleAr);
if (data.titleEn !== undefined)
updateValues.titleEn = sanitizeRichText(data.titleEn ?? "");
if (data.meetingDate !== undefined) updateValues.meetingDate = data.meetingDate;
if (data.dailyNumber !== undefined && data.dailyNumber !== null)
updateValues.dailyNumber = data.dailyNumber;
@@ -697,7 +713,7 @@ router.patch(
await tx.insert(executiveMeetingAttendeesTable).values(
data.attendees.map((a, idx) => ({
meetingId: id,
name: a.name,
name: sanitizeRichText(a.name),
title: a.title ?? null,
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
@@ -793,7 +809,7 @@ router.put(
await tx.insert(executiveMeetingAttendeesTable).values(
data.attendees.map((a, idx) => ({
meetingId: id,
name: a.name,
name: sanitizeRichText(a.name),
title: a.title ?? null,
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
@@ -837,8 +853,11 @@ router.post(
const nextNumber = await nextDailyNumber(tx, data.targetDate);
const meetingValues: typeof executiveMeetingsTable.$inferInsert = {
dailyNumber: nextNumber,
titleAr: source.titleAr,
titleEn: source.titleEn,
// Re-sanitize on duplicate so a row that was somehow stored with
// disallowed markup (e.g. raw SQL import, schema regression) does
// not get cloned into a fresh meeting unchecked.
titleAr: sanitizeRichText(source.titleAr),
titleEn: source.titleEn ? sanitizeRichText(source.titleEn) : source.titleEn,
meetingDate: data.targetDate,
startTime: source.startTime,
endTime: source.endTime,
@@ -859,9 +878,12 @@ router.post(
if (!meeting) throw new Error("duplicate_failed");
if (source.attendees && source.attendees.length > 0) {
await tx.insert(executiveMeetingAttendeesTable).values(
source.attendees.map((a, idx) => ({
source.attendees.map((a: ExecutiveMeetingAttendee, idx: number) => ({
meetingId: meeting.id,
name: a.name,
// source already-stored values are sanitized at write time, but
// re-running keeps a defense-in-depth boundary if anything was
// imported via raw SQL.
name: sanitizeRichText(a.name),
title: a.title,
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
@@ -891,6 +913,127 @@ router.post(
},
);
// =====================================================================
// REORDER (within a single day)
// =====================================================================
router.post(
"/executive-meetings/reorder",
requireExecutiveAccess,
requireMutate,
async (req, res): Promise<void> => {
const data = parseBody(res, reorderSchema, req.body);
if (!data) return;
const userId = req.session.userId!;
const dedup = new Set(data.orderedIds);
if (dedup.size !== data.orderedIds.length) {
res
.status(400)
.json({ error: "Duplicate ids in orderedIds", code: "duplicate_ids" });
return;
}
try {
await db.transaction(async (tx) => {
const rows = await tx
.select()
.from(executiveMeetingsTable)
.where(
and(
eq(executiveMeetingsTable.meetingDate, data.meetingDate),
inArray(executiveMeetingsTable.id, data.orderedIds),
),
);
if (rows.length !== data.orderedIds.length) {
throw Object.assign(new Error("missing_or_cross_day"), {
httpStatus: 400,
code: "invalid_ids",
});
}
// Snapshot the current (start_time, end_time, daily_number) sorted
// by current daily_number — the slots we will redistribute.
const slots = rows
.slice()
.sort((a, b) => a.dailyNumber - b.dailyNumber)
.map((r) => ({
startTime: r.startTime,
endTime: r.endTime,
dailyNumber: r.dailyNumber,
}));
const byId = new Map(rows.map((r) => [r.id, r] as const));
// Two-phase update to avoid (date, daily_number) unique conflicts.
// Phase 1: park into negative numbers.
for (let i = 0; i < data.orderedIds.length; i++) {
const id = data.orderedIds[i]!;
await tx
.update(executiveMeetingsTable)
.set({ dailyNumber: -(i + 1), updatedBy: userId })
.where(eq(executiveMeetingsTable.id, id));
}
// Phase 2: assign final slot.
for (let i = 0; i < data.orderedIds.length; i++) {
const id = data.orderedIds[i]!;
const slot = slots[i]!;
await tx
.update(executiveMeetingsTable)
.set({
startTime: slot.startTime,
endTime: slot.endTime,
dailyNumber: slot.dailyNumber,
updatedBy: userId,
})
.where(eq(executiveMeetingsTable.id, id));
}
await logAudit(tx, {
action: "reorder",
entityType: "meeting",
entityId: data.orderedIds[0]!,
oldValue: {
meetingDate: data.meetingDate,
order: rows
.slice()
.sort((a, b) => a.dailyNumber - b.dailyNumber)
.map((r) => r.id),
},
newValue: {
meetingDate: data.meetingDate,
order: data.orderedIds,
},
performedBy: userId,
});
const updated = await tx
.select()
.from(executiveMeetingsTable)
.where(
and(
eq(executiveMeetingsTable.meetingDate, data.meetingDate),
inArray(executiveMeetingsTable.id, data.orderedIds),
),
);
// Validate no two share dailyNumber post-update (defensive).
const seen = new Set<number>();
for (const u of updated) {
if (seen.has(u.dailyNumber)) {
throw new Error("duplicate_daily_number_after_reorder");
}
seen.add(u.dailyNumber);
}
return { updated, byId };
});
// Return the updated meetings in the new order, with attendees.
const idToFetch = data.orderedIds;
const fullList = await Promise.all(
idToFetch.map((id) => fetchMeetingWithAttendees(id)),
);
res.json({ meetings: fullList.filter(Boolean) });
} catch (err) {
const status = (err as { httpStatus?: number }).httpStatus ?? 500;
const code = (err as { code?: string }).code ?? "server_error";
const msg = err instanceof Error ? err.message : String(err);
res.status(status).json({ error: msg, code });
}
},
);
// =====================================================================
// REQUESTS
// =====================================================================
@@ -538,3 +538,116 @@ test("PDF archives: POST creates a snapshot and GET returns it", async () => {
assert.ok(Array.isArray(archives));
assert.ok(archives.some((a) => a.id === archive.id));
});
test("Sanitization: rich text strips disallowed tags but keeps inline formatting", async () => {
const dirty =
'Hello <script>alert(1)</script><strong style="color:#ff0000">World</strong>' +
'<a href="javascript:bad()">x</a><img src=x onerror=alert(1)>';
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: dirty,
titleEn: dirty,
meetingDate: today,
attendees: [
{ name: 'Tester <em style="color:blue">One</em><script>x</script>',
attendanceType: "internal", sortOrder: 0 },
],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
const day = await api(adminCookie, "GET",
`/api/executive-meetings?date=${today}`);
const body = await day.json();
const m = body.meetings.find((x) => x.id === meeting.id);
assert.ok(m, "meeting must come back");
// disallowed tags must be stripped
assert.ok(!/<script/i.test(m.titleAr), "script must be stripped from titleAr");
assert.ok(!/<script/i.test(m.titleEn), "script must be stripped from titleEn");
assert.ok(!/<img/i.test(m.titleAr), "img must be stripped");
assert.ok(!/<a /i.test(m.titleAr), "anchor must be stripped");
assert.ok(!/javascript:/i.test(m.titleAr), "javascript: scheme must be gone");
// allowed inline formatting must be preserved
assert.ok(/<strong[^>]*>World<\/strong>/i.test(m.titleAr), "strong must survive");
assert.ok(/color:\s*#ff0000/i.test(m.titleAr), "inline color must survive");
// attendee names get the same treatment
const att = m.attendees.find((a) => /Tester/.test(a.name));
assert.ok(att, "attendee must come back");
assert.ok(!/<script/i.test(att.name), "script stripped from attendee name");
assert.ok(/<em[^>]*>One<\/em>/i.test(att.name), "em must survive in attendee");
});
test("Reorder: POST /reorder swaps daily numbers + start times within a day", async () => {
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أول", titleEn: "First", meetingDate: today,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ثاني", titleEn: "Second", meetingDate: today,
startTime: "10:00", endTime: "10:30",
});
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ثالث", titleEn: "Third", meetingDate: today,
startTime: "11:00", endTime: "11:30",
});
assert.equal(a.status, 201);
assert.equal(b.status, 201);
assert.equal(c.status, 201);
const A = await a.json(); created.meetingIds.push(A.id);
const B = await b.json(); created.meetingIds.push(B.id);
const C = await c.json(); created.meetingIds.push(C.id);
// reverse the order: C, B, A
const reorder = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: today, orderedIds: [C.id, B.id, A.id] });
assert.equal(reorder.status, 200);
const day = await api(adminCookie, "GET",
`/api/executive-meetings?date=${today}`);
const body = await day.json();
const byId = new Map(body.meetings.map((m) => [m.id, m]));
const cAfter = byId.get(C.id);
const bAfter = byId.get(B.id);
const aAfter = byId.get(A.id);
// The first slot now holds C, the third now holds A.
assert.ok(cAfter.dailyNumber < bAfter.dailyNumber);
assert.ok(bAfter.dailyNumber < aAfter.dailyNumber);
});
test("Reorder: rejects ids that do not all belong to meetingDate", async () => {
const sameDay = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اليوم", titleEn: "Today", meetingDate: today,
});
const other = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "غدًا", titleEn: "Tomorrow",
meetingDate: new Date(Date.now() + 86400000)
.toISOString().slice(0, 10),
});
const S = await sameDay.json(); created.meetingIds.push(S.id);
const O = await other.json(); created.meetingIds.push(O.id);
const r = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: today, orderedIds: [S.id, O.id] });
assert.equal(r.status, 400);
});
test("Reorder: user without executive role is denied (403)", async () => {
const m = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ا", titleEn: "A", meetingDate: today,
});
const M = await m.json(); created.meetingIds.push(M.id);
// a brand-new "user" has only the base 'user' role (no executive perms)
const plain = await createUser("em_plain", "user");
const plainCookie = await login(plain.username, TEST_PASSWORD);
const r = await api(plainCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: today, orderedIds: [M.id] });
assert.equal(r.status, 403);
});
+6
View File
@@ -84,6 +84,12 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tiptap/extension-color": "^3.22.4",
"@tiptap/extension-font-family": "^3.22.4",
"@tiptap/extension-text-style": "^3.22.4",
"@tiptap/extension-underline": "^3.22.4",
"@tiptap/react": "^3.22.4",
"@tiptap/starter-kit": "^3.22.4",
"@uppy/aws-s3": "^5.1.0",
"@uppy/core": "^5.2.0",
"@uppy/dashboard": "^5.1.1",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1,398 @@
import {
useCallback,
useEffect,
useRef,
useState,
type CSSProperties,
} from "react";
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Underline } from "@tiptap/extension-underline";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import { FontFamily } from "@tiptap/extension-font-family";
import {
Bold as BoldIcon,
Italic as ItalicIcon,
Underline as UnderlineIcon,
Type as TypeIcon,
Check,
X as XIcon,
} from "lucide-react";
const COLOR_SWATCHES = [
{ value: "", label: "default" },
{ value: "#0B1E3F", label: "navy" },
{ value: "#dc2626", label: "red" },
{ value: "#ea580c", label: "orange" },
{ value: "#ca8a04", label: "amber" },
{ value: "#16a34a", label: "green" },
{ value: "#2563eb", label: "blue" },
{ value: "#7c3aed", label: "violet" },
{ value: "#6b7280", label: "gray" },
];
const FONT_OPTIONS = [
{ value: "", label: "default" },
{ value: "Cairo", label: "Cairo" },
{ value: "Tajawal", label: "Tajawal" },
{ value: "Amiri", label: "Amiri" },
{ value: "Noto Naskh Arabic", label: "Noto" },
{ value: "IBM Plex Sans Arabic", label: "IBM Plex" },
];
/**
* In-place rich text editor used in the executive-meetings schedule for
* Word-like editing of meeting names + attendee names.
*
* - When not editing, renders the saved sanitized HTML via dangerouslySetInnerHTML.
* - Click (or focus + Enter) enters edit mode with a floating toolbar.
* - Saves on blur outside the editor+toolbar or Ctrl/Cmd+Enter.
* - Esc cancels.
*
* The server is the source of sanitization. This component only sends what
* Tiptap produces; the server strips anything outside the allowlist.
*/
export function EditableCell({
value,
onSave,
placeholder,
ariaLabel,
dir,
className,
multiline = false,
disabled = false,
fontStyle,
testId,
}: {
value: string;
onSave: (html: string) => Promise<void> | void;
placeholder?: string;
ariaLabel?: string;
dir?: "rtl" | "ltr";
className?: string;
multiline?: boolean;
disabled?: boolean;
fontStyle?: CSSProperties;
testId?: string;
}) {
const [editing, setEditing] = useState(false);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const blurTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const editor = useEditor(
{
extensions: [
StarterKit.configure({
heading: false,
codeBlock: false,
blockquote: false,
horizontalRule: false,
bulletList: false,
orderedList: false,
listItem: false,
}),
Underline,
TextStyle,
Color,
FontFamily.configure({ types: ["textStyle"] }),
],
content: value || "",
editable: editing && !disabled,
// Editor renders into a contenteditable div. We don't auto-focus on
// mount; we focus when the user enters edit mode.
editorProps: {
attributes: {
class:
"outline-none focus:outline-none min-h-[1.5em] " +
(multiline ? "" : "whitespace-nowrap overflow-hidden"),
dir: dir ?? "auto",
},
handleKeyDown: (_view, event) => {
if (event.key === "Escape") {
cancelEdit();
return true;
}
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
saveEdit();
return true;
}
if (event.key === "Enter" && !multiline && !event.shiftKey) {
// Single-line cells: Enter saves instead of inserting newline.
event.preventDefault();
saveEdit();
return true;
}
return false;
},
},
},
[],
);
// Keep the editor's content in sync when the value prop changes (e.g.
// after a successful save the parent re-renders with the new server value).
useEffect(() => {
if (!editor) return;
if (!editing && editor.getHTML() !== (value || "<p></p>")) {
editor.commands.setContent(value || "", { emitUpdate: false });
}
}, [editor, value, editing]);
useEffect(() => {
if (!editor) return;
editor.setEditable(editing && !disabled);
if (editing) {
// Focus on the next tick so the editor is rendered first.
const id = setTimeout(() => editor.commands.focus("end"), 0);
return () => clearTimeout(id);
}
return undefined;
}, [editor, editing, disabled]);
const enterEdit = () => {
if (disabled) return;
setEditing(true);
};
const cancelEdit = useCallback(() => {
if (!editor) return;
editor.commands.setContent(value || "", { emitUpdate: false });
setEditing(false);
}, [editor, value]);
const saveEdit = useCallback(async () => {
if (!editor) return;
const html = editor.getHTML();
// Treat an empty editor (just <p></p>) as empty string.
const stripped = html === "<p></p>" ? "" : html;
setEditing(false);
if (stripped !== value) {
try {
await onSave(stripped);
} catch {
// Parent shows toast; revert local content to last known good value.
editor.commands.setContent(value || "", { emitUpdate: false });
}
}
}, [editor, onSave, value]);
// Click outside the editor+toolbar saves and exits edit mode. We use a
// tiny defer on blur because clicking a toolbar button briefly takes focus
// out of the editor.
const onFocusOut = (e: React.FocusEvent<HTMLDivElement>) => {
if (!editing) return;
const next = e.relatedTarget as Node | null;
if (next && wrapperRef.current?.contains(next)) return;
if (blurTimerRef.current) clearTimeout(blurTimerRef.current);
blurTimerRef.current = setTimeout(() => {
saveEdit();
}, 100);
};
const onFocusIn = () => {
if (blurTimerRef.current) {
clearTimeout(blurTimerRef.current);
blurTimerRef.current = null;
}
};
// Static (read-only) rendering: an HTML wrapper. We don't sanitize on the
// client because the server already sanitized. Plain-text legacy values
// contain no tags.
if (!editor) {
return (
<div
className={className}
style={fontStyle}
dangerouslySetInnerHTML={{ __html: value || (placeholder ?? "") }}
/>
);
}
if (!editing) {
return (
<div
role={disabled ? undefined : "button"}
tabIndex={disabled ? -1 : 0}
aria-label={ariaLabel}
className={
(className ?? "") +
(disabled
? ""
: " cursor-text hover:bg-blue-50/50 focus:bg-blue-50/50 focus:outline-none rounded transition-colors")
}
style={fontStyle}
data-testid={testId}
onClick={enterEdit}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
enterEdit();
}
}}
>
{value ? (
<span dangerouslySetInnerHTML={{ __html: value }} />
) : (
<span className="text-muted-foreground italic">
{placeholder ?? ""}
</span>
)}
</div>
);
}
return (
<div
ref={wrapperRef}
className={"relative " + (className ?? "")}
onFocus={onFocusIn}
onBlur={onFocusOut}
data-testid={testId}
data-editing="true"
style={fontStyle}
>
<FormattingToolbar editor={editor} onSave={saveEdit} onCancel={cancelEdit} />
<div
className="border border-blue-400 rounded bg-white px-1.5 py-1 shadow-sm focus-within:border-blue-600"
style={fontStyle}
>
<EditorContent editor={editor} />
</div>
</div>
);
}
function FormattingToolbar({
editor,
onSave,
onCancel,
}: {
editor: Editor;
onSave: () => void;
onCancel: () => void;
}) {
// Re-render when selection changes so active states reflect cursor position.
const [, setTick] = useState(0);
useEffect(() => {
const update = () => setTick((n) => n + 1);
editor.on("selectionUpdate", update);
editor.on("transaction", update);
return () => {
editor.off("selectionUpdate", update);
editor.off("transaction", update);
};
}, [editor]);
const isActive = (mark: string, attrs?: Record<string, unknown>) =>
editor.isActive(mark, attrs);
const btn = (active: boolean) =>
"p-1 rounded text-xs " +
(active
? "bg-[#0B1E3F] text-white"
: "bg-white text-[#0B1E3F] hover:bg-gray-100 border border-gray-200");
return (
<div
className="absolute -top-9 inline-start-0 z-20 flex items-center gap-1 bg-white border border-gray-200 rounded shadow-md px-1 py-0.5"
// Prevent toolbar buttons from stealing focus → keeps editor selection
// intact when toggling marks.
onMouseDown={(e) => e.preventDefault()}
data-testid="em-edit-toolbar"
>
<button
type="button"
className={btn(isActive("bold"))}
onClick={() => editor.chain().focus().toggleBold().run()}
aria-label="Bold"
data-testid="em-edit-bold"
>
<BoldIcon className="w-3.5 h-3.5" />
</button>
<button
type="button"
className={btn(isActive("italic"))}
onClick={() => editor.chain().focus().toggleItalic().run()}
aria-label="Italic"
data-testid="em-edit-italic"
>
<ItalicIcon className="w-3.5 h-3.5" />
</button>
<button
type="button"
className={btn(isActive("underline"))}
onClick={() => editor.chain().focus().toggleUnderline().run()}
aria-label="Underline"
data-testid="em-edit-underline"
>
<UnderlineIcon className="w-3.5 h-3.5" />
</button>
<span className="w-px h-4 bg-gray-200 mx-0.5" />
{COLOR_SWATCHES.map((c) => (
<button
key={c.value || "default"}
type="button"
className={
"w-4 h-4 rounded-sm border " +
(c.value
? "border-gray-300 hover:scale-110"
: "border-gray-400 bg-[repeating-linear-gradient(45deg,#fff,#fff_3px,#e5e7eb_3px,#e5e7eb_6px)]")
}
style={c.value ? { backgroundColor: c.value } : undefined}
onClick={() => {
if (c.value) {
editor.chain().focus().setColor(c.value).run();
} else {
editor.chain().focus().unsetColor().run();
}
}}
aria-label={`color ${c.label}`}
data-testid={`em-edit-color-${c.label}`}
/>
))}
<span className="w-px h-4 bg-gray-200 mx-0.5" />
<select
className="text-xs border border-gray-200 rounded bg-white text-[#0B1E3F] px-1 py-0.5"
value={(editor.getAttributes("textStyle").fontFamily as string) || ""}
onChange={(e) => {
const v = e.target.value;
if (v) editor.chain().focus().setFontFamily(v).run();
else editor.chain().focus().unsetFontFamily().run();
}}
aria-label="Font"
data-testid="em-edit-font"
>
{FONT_OPTIONS.map((f) => (
<option key={f.value || "default"} value={f.value}>
{f.label}
</option>
))}
</select>
<span className="w-px h-4 bg-gray-200 mx-0.5" />
<button
type="button"
className="p-1 rounded bg-green-600 text-white hover:bg-green-700"
onClick={onSave}
aria-label="Save"
data-testid="em-edit-save"
>
<Check className="w-3.5 h-3.5" />
</button>
<button
type="button"
className="p-1 rounded bg-gray-200 text-[#0B1E3F] hover:bg-gray-300"
onClick={onCancel}
aria-label="Cancel"
data-testid="em-edit-cancel"
>
<XIcon className="w-3.5 h-3.5" />
</button>
</div>
);
}
// Re-export TypeIcon name for the parent to mirror — silences unused-import
// warnings in agent-generated wrappers that use `Type` aliasing.
export const _editableCellTypeIcon = TypeIcon;
+7 -1
View File
@@ -745,12 +745,18 @@
},
"customize": {
"title": "تخصيص الجدول",
"hint": "أظهر أو أخفِ الأعمدة، أعد ترتيبها، أو أعد التعيين الافتراضي.",
"hint": "أظهر أو أخفِ الأعمدة. اسحب رؤوس الأعمدة لإعادة الترتيب.",
"moveUp": "تحريك للأعلى",
"moveDown": "تحريك للأسفل",
"reset": "إعادة التعيين",
"dragHint": "اسحب حد العمود لتغيير عرضه"
},
"highlight": {
"title": "تمييز الاجتماع الحالي",
"hint": "تظليل الاجتماع الذي يقع وقته الآن."
},
"dragRow": "اسحب لإعادة الترتيب",
"editMeetingTitle": "تعديل عنوان الاجتماع",
"rowColor": {
"label": "لون الصف"
},
+7 -1
View File
@@ -698,12 +698,18 @@
},
"customize": {
"title": "Customize table",
"hint": "Show or hide columns, reorder them, or reset to defaults.",
"hint": "Show or hide columns. Drag column headers to reorder.",
"moveUp": "Move up",
"moveDown": "Move down",
"reset": "Reset",
"dragHint": "Drag a column edge to resize it"
},
"highlight": {
"title": "Highlight current meeting",
"hint": "Outline the meeting whose time is now."
},
"dragRow": "Drag to reorder",
"editMeetingTitle": "Edit meeting title",
"rowColor": {
"label": "Row color"
},
@@ -221,7 +221,15 @@ export default function ExecutiveMeetingsPrintPage() {
<tr key={m.id} className={m.isHighlighted ? "hl" : ""}>
<td>{m.dailyNumber}</td>
<td>
<div style={{ fontWeight: 600 }}>{title}</div>
{/* title comes from the API already sanitized by
sanitizeRichText (allowlist of inline tags + a
small set of safe styles). Render as HTML so
in-cell formatting (bold, color, font…) carries
into the printed page. */}
<div
style={{ fontWeight: 600 }}
dangerouslySetInnerHTML={{ __html: title }}
/>
{m.location ? (
<div style={{ fontSize: 12, color: "#4b5563" }}>
{m.location}
@@ -241,7 +249,17 @@ export default function ExecutiveMeetingsPrintPage() {
<span className="em-print-attendee-num">
{idx + 1}-
</span>
{a.title ? `${a.name} (${a.title})` : a.name}
{/* a.name comes from the API already
sanitized by sanitizeRichText. a.title
is *not* rich text — render it as a
plain text node so an attacker who
smuggles HTML into the title field
cannot escape into <script> tags on
the print page. */}
<span
dangerouslySetInnerHTML={{ __html: a.name }}
/>
{a.title ? <span> ({a.title})</span> : null}
</div>
))}
</div>
+576 -104
View File
@@ -68,6 +68,25 @@ import {
} from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { useToast } from "@/hooks/use-toast";
import {
DndContext,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
closestCenter,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
horizontalListSortingStrategy,
verticalListSortingStrategy,
useSortable,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical } from "lucide-react";
import { EditableCell } from "@/components/editable-cell";
type Attendee = {
id?: number;
@@ -296,6 +315,55 @@ const ROW_COLOR_OPTIONS: { key: string; bg: string; label: string }[] = [
const COLS_STORAGE_KEY = "em-schedule-cols-v1";
const ROW_COLORS_STORAGE_KEY = "em-schedule-row-colors-v1";
const HIGHLIGHT_STORAGE_KEY = "em-current-meeting-highlight-v1";
type HighlightPrefs = { enabled: boolean; color: string };
const DEFAULT_HIGHLIGHT_PREFS: HighlightPrefs = {
enabled: true,
color: "#16a34a",
};
const HIGHLIGHT_COLOR_SWATCHES = [
"#16a34a",
"#2563eb",
"#dc2626",
"#ea580c",
"#7c3aed",
"#0B1E3F",
];
function parseTimeToMinutes(t: string | null): number | null {
if (!t) return null;
const m = /^(\d{2}):(\d{2})/.exec(t);
if (!m) return null;
const h = Number(m[1]);
const mm = Number(m[2]);
if (!Number.isFinite(h) || !Number.isFinite(mm)) return null;
return h * 60 + mm;
}
/**
* Returns the id of the meeting that is currently in progress on `dateIso`,
* or null. A meeting is "current" when the wall clock falls within
* [startTime, endTime) and the displayed date matches today.
*/
function computeCurrentMeetingId(
meetings: Meeting[],
dateIso: string,
nowMs: number,
): number | null {
const now = new Date(nowMs);
const todayIso = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
if (todayIso !== dateIso) return null;
const minutesNow = now.getHours() * 60 + now.getMinutes();
for (const m of meetings) {
const s = parseTimeToMinutes(m.startTime);
const e = parseTimeToMinutes(m.endTime);
if (s === null || e === null) continue;
if (s <= minutesNow && minutesNow < e) return m.id;
}
return null;
}
function formatTime(t: string | null): string {
if (!t) return "";
@@ -571,6 +639,7 @@ export default function ExecutiveMeetingsPage() {
isRtl={isRtl}
t={t}
font={effectiveFont}
canMutate={!!me?.canMutate}
/>
)}
{section === "manage" && me && (
@@ -627,6 +696,7 @@ function ScheduleSection({
isRtl,
t,
font,
canMutate,
}: {
meetings: Meeting[];
date: string;
@@ -635,7 +705,10 @@ function ScheduleSection({
isRtl: boolean;
t: (k: string) => string;
font: FontPrefs;
canMutate: boolean;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const tableStyle = buildFontStyle(font);
const [columns, setColumns] = useState<ColumnSetting[]>(() =>
normalizeColumns(readJsonFromStorage(COLS_STORAGE_KEY, DEFAULT_COLUMNS)),
@@ -643,6 +716,145 @@ function ScheduleSection({
const [rowColors, setRowColors] = useState<Record<number, string>>(() =>
readJsonFromStorage<Record<number, string>>(ROW_COLORS_STORAGE_KEY, {}),
);
const [highlightPrefs, setHighlightPrefs] = useState<HighlightPrefs>(() =>
readJsonFromStorage<HighlightPrefs>(
HIGHLIGHT_STORAGE_KEY,
DEFAULT_HIGHLIGHT_PREFS,
),
);
useEffect(() => {
writeJsonToStorage(HIGHLIGHT_STORAGE_KEY, highlightPrefs);
}, [highlightPrefs]);
// 60-second tick to recompute the "current" meeting highlight. Refreshing
// less frequently than the minute boundary risks the highlight visibly
// lagging when one meeting ends and another starts.
const [nowTick, setNowTick] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNowTick(Date.now()), 60_000);
return () => clearInterval(id);
}, []);
const currentMeetingId = useMemo(
() => computeCurrentMeetingId(meetings, date, nowTick),
[meetings, date, nowTick],
);
// Optimistic local override so we can render the new row order before the
// server round-trip finishes. Cleared on each new meetings prop.
const [optimisticOrder, setOptimisticOrder] = useState<number[] | null>(null);
const [reordering, setReordering] = useState(false);
useEffect(() => {
setOptimisticOrder(null);
}, [meetings, date]);
const orderedMeetings = useMemo(() => {
if (!optimisticOrder) return meetings;
const byId = new Map(meetings.map((m) => [m.id, m]));
const out: Meeting[] = [];
for (const id of optimisticOrder) {
const m = byId.get(id);
if (m) out.push(m);
}
// Append any meetings not in the optimistic order (defensive).
for (const m of meetings) {
if (!optimisticOrder.includes(m.id)) out.push(m);
}
return out;
}, [meetings, optimisticOrder]);
const refreshDay = useCallback(() => {
void qc.invalidateQueries({
queryKey: ["/api/executive-meetings", date],
});
}, [qc, date]);
const saveTitle = useCallback(
async (meeting: Meeting, html: string) => {
const body = isRtl ? { titleAr: html } : { titleEn: html };
try {
await apiJson(`/api/executive-meetings/${meeting.id}`, {
method: "PATCH",
body,
});
refreshDay();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({
title: t("common.error"),
description: msg,
variant: "destructive",
});
throw err;
}
},
[isRtl, refreshDay, toast, t],
);
const saveAttendeeName = useCallback(
async (meeting: Meeting, attendeeIdx: number, html: string) => {
const next = meeting.attendees.map((a, i) =>
i === attendeeIdx ? { ...a, name: html } : a,
);
try {
await apiJson(`/api/executive-meetings/${meeting.id}/attendees`, {
method: "PUT",
body: {
attendees: next.map((a) => ({
name: a.name,
title: a.title,
attendanceType: a.attendanceType,
sortOrder: a.sortOrder,
})),
},
});
refreshDay();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({
title: t("common.error"),
description: msg,
variant: "destructive",
});
throw err;
}
},
[refreshDay, toast, t],
);
const reorderRows = useCallback(
async (fromId: number, toId: number) => {
if (fromId === toId) return;
const ids = meetings.map((m) => m.id);
const fromIdx = ids.indexOf(fromId);
const toIdx = ids.indexOf(toId);
if (fromIdx < 0 || toIdx < 0) return;
const newOrder = ids.slice();
newOrder.splice(fromIdx, 1);
newOrder.splice(toIdx, 0, fromId);
setOptimisticOrder(newOrder);
setReordering(true);
try {
await apiJson(`/api/executive-meetings/reorder`, {
method: "POST",
body: { meetingDate: date, orderedIds: newOrder },
});
refreshDay();
} catch (err) {
setOptimisticOrder(null);
const msg = err instanceof Error ? err.message : String(err);
toast({
title: t("common.error"),
description: msg,
variant: "destructive",
});
} finally {
setReordering(false);
}
},
[meetings, date, refreshDay, toast, t],
);
useEffect(() => {
writeJsonToStorage(COLS_STORAGE_KEY, columns);
@@ -697,6 +909,43 @@ function ScheduleSection({
const isFinePointer = useMediaQuery("(pointer: fine)");
const showResizeHandles = isXl && isFinePointer;
// dnd-kit: sortable column headers + sortable rows.
// pointer activation distance prevents accidental drags from a normal click
// on the cell content (which is needed to enter inline-edit mode on the
// meeting/attendee cells).
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const onColumnDragEnd = useCallback(
(e: DragEndEvent) => {
const { active, over } = e;
if (!over || active.id === over.id) return;
setColumns((prev) => {
const ids = prev.map((c) => c.id);
const fromIdx = ids.indexOf(active.id as ColumnId);
const toIdx = ids.indexOf(over.id as ColumnId);
if (fromIdx < 0 || toIdx < 0) return prev;
const next = prev.slice();
const [moved] = next.splice(fromIdx, 1);
if (!moved) return prev;
next.splice(toIdx, 0, moved);
return next;
});
},
[],
);
const onRowDragEnd = useCallback(
(e: DragEndEvent) => {
const { active, over } = e;
if (!over || active.id === over.id) return;
void reorderRows(Number(active.id), Number(over.id));
},
[reorderRows],
);
return (
<div className="space-y-4 print:space-y-2">
<div className="flex items-center justify-between gap-3 flex-wrap print:hidden">
@@ -711,6 +960,8 @@ function ScheduleSection({
columns={columns}
onChange={setColumns}
t={t}
highlightPrefs={highlightPrefs}
onHighlightPrefsChange={setHighlightPrefs}
/>
<label className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t("executiveMeetings.date")}</span>
@@ -778,66 +1029,86 @@ function ScheduleSection({
<col key={c.id} style={{ width: c.width }} />
))}
</colgroup>
<thead>
<tr className="bg-[#0B1E3F] text-white">
{visibleColumns.map((c, idx) => {
const isLast = idx === visibleColumns.length - 1;
const align =
c.id === "number" || c.id === "time" ? "text-center" : "text-start";
return (
<th
key={c.id}
className={`relative border border-[#0B1E3F] px-2 py-2 ${align} font-semibold select-none`}
style={{ width: c.width }}
data-testid={`em-col-header-${c.id}`}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onColumnDragEnd}
>
<thead>
<tr className="bg-[#0B1E3F] text-white">
<SortableContext
items={visibleColumns.map((c) => c.id)}
strategy={horizontalListSortingStrategy}
>
{visibleColumns.map((c, idx) => (
<SortableHeader
key={c.id}
col={c}
isLast={idx === visibleColumns.length - 1}
isRtl={isRtl}
t={t}
showResizeHandle={showResizeHandles}
onResize={(delta) => setColumnWidth(c.id, c.width + delta)}
/>
))}
</SortableContext>
</tr>
</thead>
</DndContext>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onRowDragEnd}
>
<tbody>
{isLoading && (
<tr>
<td
colSpan={visibleColumns.length}
className="py-10 text-center text-muted-foreground"
>
{t(`executiveMeetings.col.${c.id}`)}
{/* Resize handles only render on large screens AND fine
(mouse) pointers — see comment on showResizeHandles. */}
{!isLast && showResizeHandles && (
<ResizeHandle
isRtl={isRtl}
onResize={(delta) => setColumnWidth(c.id, c.width + delta)}
/>
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td
colSpan={visibleColumns.length}
className="py-10 text-center text-muted-foreground"
>
{t("common.loading")}
</td>
</tr>
)}
{!isLoading && meetings.length === 0 && (
<tr>
<td
colSpan={visibleColumns.length}
className="py-10 text-center text-muted-foreground"
>
{t("executiveMeetings.noMeetings")}
</td>
</tr>
)}
{meetings.map((m) => (
<MeetingRow
key={m.id}
meeting={m}
isRtl={isRtl}
t={t}
visibleColumns={visibleColumns}
rowColorKey={rowColors[m.id] ?? "default"}
onPickRowColor={(key) => setRowColor(m.id, key)}
/>
))}
</tbody>
{t("common.loading")}
</td>
</tr>
)}
{!isLoading && orderedMeetings.length === 0 && (
<tr>
<td
colSpan={visibleColumns.length}
className="py-10 text-center text-muted-foreground"
>
{t("executiveMeetings.noMeetings")}
</td>
</tr>
)}
<SortableContext
items={orderedMeetings.map((m) => m.id)}
strategy={verticalListSortingStrategy}
>
{orderedMeetings.map((m) => (
<MeetingRow
key={m.id}
meeting={m}
isRtl={isRtl}
t={t}
visibleColumns={visibleColumns}
rowColorKey={rowColors[m.id] ?? "default"}
onPickRowColor={(key) => setRowColor(m.id, key)}
canMutate={canMutate}
onSaveTitle={(html) => saveTitle(m, html)}
onSaveAttendeeName={(idx, html) =>
saveAttendeeName(m, idx, html)
}
isCurrent={
highlightPrefs.enabled && currentMeetingId === m.id
}
highlightColor={highlightPrefs.color}
reordering={reordering}
/>
))}
</SortableContext>
</tbody>
</DndContext>
</table>
</div>
</div>
@@ -902,19 +1173,15 @@ function ColumnsCustomizer({
columns,
onChange,
t,
highlightPrefs,
onHighlightPrefsChange,
}: {
columns: ColumnSetting[];
onChange: (next: ColumnSetting[]) => void;
t: (k: string) => string;
highlightPrefs: HighlightPrefs;
onHighlightPrefsChange: (next: HighlightPrefs) => void;
}) {
const move = (index: number, dir: -1 | 1) => {
const target = index + dir;
if (target < 0 || target >= columns.length) return;
const next = columns.slice();
[next[index], next[target]] = [next[target], next[index]];
onChange(next);
};
const toggle = (id: ColumnId, value: boolean) => {
// Always keep at least one column visible.
const visibleCount = columns.filter((c) => c.visible).length;
@@ -945,7 +1212,7 @@ function ColumnsCustomizer({
{t("executiveMeetings.customize.hint")}
</p>
<ul className="space-y-1.5">
{columns.map((c, idx) => (
{columns.map((c) => (
<li
key={c.id}
className="flex items-center gap-2 rounded border border-gray-100 bg-gray-50 px-2 py-1.5"
@@ -968,26 +1235,6 @@ function ColumnsCustomizer({
) : (
<Eye className="w-3.5 h-3.5 text-[#0B1E3F]" />
)}
<button
type="button"
className="p-1 rounded hover:bg-white disabled:opacity-30"
onClick={() => move(idx, -1)}
disabled={idx === 0}
aria-label={t("executiveMeetings.customize.moveUp")}
data-testid={`em-customize-up-${c.id}`}
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button
type="button"
className="p-1 rounded hover:bg-white disabled:opacity-30"
onClick={() => move(idx, 1)}
disabled={idx === columns.length - 1}
aria-label={t("executiveMeetings.customize.moveDown")}
data-testid={`em-customize-down-${c.id}`}
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
</li>
))}
</ul>
@@ -1006,11 +1253,113 @@ function ColumnsCustomizer({
{t("executiveMeetings.customize.dragHint")}
</span>
</div>
<div className="mt-4 pt-3 border-t border-gray-200">
<div className="flex items-center justify-between mb-2">
<label
htmlFor="em-highlight-toggle"
className="text-sm font-semibold text-[#0B1E3F] cursor-pointer"
>
{t("executiveMeetings.highlight.title")}
</label>
<Switch
id="em-highlight-toggle"
checked={highlightPrefs.enabled}
onCheckedChange={(v) =>
onHighlightPrefsChange({ ...highlightPrefs, enabled: !!v })
}
data-testid="em-highlight-toggle"
/>
</div>
<p className="text-[11px] text-muted-foreground mb-2 leading-relaxed">
{t("executiveMeetings.highlight.hint")}
</p>
<div className="flex items-center gap-1.5">
{HIGHLIGHT_COLOR_SWATCHES.map((c) => {
const selected = highlightPrefs.color === c;
return (
<button
key={c}
type="button"
onClick={() =>
onHighlightPrefsChange({ ...highlightPrefs, color: c })
}
className={
"w-6 h-6 rounded-full border-2 transition-transform " +
(selected
? "border-[#0B1E3F] scale-110"
: "border-gray-300 hover:scale-110")
}
style={{ backgroundColor: c }}
aria-label={`highlight color ${c}`}
data-testid={`em-highlight-color-${c}`}
disabled={!highlightPrefs.enabled}
/>
);
})}
</div>
</div>
</PopoverContent>
</Popover>
);
}
/**
* Sortable column header for the schedule table. The whole <th> is the
* drag handle so it stays grabbable in any RTL/LTR mode and on touch. The
* resize handle on the trailing edge stops drag propagation so resizing
* still works.
*/
function SortableHeader({
col,
isLast,
isRtl,
t,
showResizeHandle,
onResize,
}: {
col: ColumnSetting;
isLast: boolean;
isRtl: boolean;
t: (k: string) => string;
showResizeHandle: boolean;
onResize: (delta: number) => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: col.id });
const align =
col.id === "number" || col.id === "time" ? "text-center" : "text-start";
const style: CSSProperties = {
width: col.width,
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
cursor: "grab",
};
return (
<th
ref={setNodeRef}
className={`relative border border-[#0B1E3F] px-2 py-2 ${align} font-semibold select-none`}
style={style}
data-testid={`em-col-header-${col.id}`}
{...attributes}
{...listeners}
>
{t(`executiveMeetings.col.${col.id}`)}
{/* Resize handles only render on large screens AND fine (mouse)
pointers — see comment on showResizeHandles. */}
{!isLast && showResizeHandle && (
<ResizeHandle isRtl={isRtl} onResize={onResize} />
)}
</th>
);
}
function RowColorPickerSwatches({
current,
onPick,
@@ -1121,6 +1470,12 @@ function MeetingRow({
visibleColumns,
rowColorKey,
onPickRowColor,
canMutate,
onSaveTitle,
onSaveAttendeeName,
isCurrent,
highlightColor,
reordering,
}: {
meeting: Meeting;
isRtl: boolean;
@@ -1128,6 +1483,12 @@ function MeetingRow({
visibleColumns: ColumnSetting[];
rowColorKey: string;
onPickRowColor: (key: string) => void;
canMutate: boolean;
onSaveTitle: (html: string) => Promise<void>;
onSaveAttendeeName: (idx: number, html: string) => Promise<void>;
isCurrent: boolean;
highlightColor: string;
reordering: boolean;
}) {
const title = isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr;
const isCancelled = meeting.status === "cancelled";
@@ -1136,6 +1497,19 @@ function MeetingRow({
const rowColor = ROW_COLOR_OPTIONS.find((o) => o.key === rowColorKey);
const rowBg = rowColor?.bg || "";
// Make the row a sortable item. `attributes` get spread onto the <tr> for
// a11y; `listeners` go on the dedicated grip handle in the # cell so a
// normal click on the cell content (to enter inline-edit mode) is still
// possible.
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: meeting.id, disabled: !canMutate });
const numberCellCls =
"border border-gray-300 px-2 py-2 text-center align-middle font-semibold " +
(highlight || isCancelled
@@ -1158,9 +1532,27 @@ function MeetingRow({
<td
key="number"
className={`relative group ${numberCellCls}`}
style={cellStyle(col)}
style={
isCurrent && !(highlight || isCancelled)
? { ...cellStyle(col), backgroundColor: highlightColor, color: "white" }
: cellStyle(col)
}
>
{meeting.dailyNumber}
<div className="flex items-center justify-center gap-1">
{canMutate && (
<button
type="button"
className="opacity-0 group-hover:opacity-70 hover:opacity-100 cursor-grab active:cursor-grabbing touch-none print:hidden"
aria-label={t("executiveMeetings.dragRow")}
data-testid={`em-row-grip-${meeting.id}`}
{...listeners}
{...attributes}
>
<GripVertical className="w-3.5 h-3.5" />
</button>
)}
<span>{meeting.dailyNumber}</span>
</div>
<RowColorPicker current={rowColorKey} onPick={onPickRowColor} t={t} />
</td>
);
@@ -1171,7 +1563,16 @@ function MeetingRow({
className="border border-gray-300 px-3 py-3 align-middle text-[#0B1E3F]"
style={cellStyle(col)}
>
<div className="font-medium leading-snug break-words">{title}</div>
<EditableCell
value={title}
onSave={onSaveTitle}
ariaLabel={t("executiveMeetings.editMeetingTitle")}
dir={isRtl ? "rtl" : "ltr"}
className="font-medium leading-snug break-words"
multiline
disabled={!canMutate}
testId={`em-edit-title-${meeting.id}`}
/>
{meeting.location && (
<div className="text-[11px] text-muted-foreground mt-0.5 break-words">
{meeting.location}
@@ -1186,7 +1587,12 @@ function MeetingRow({
className="border border-gray-300 px-3 py-3 align-middle"
style={cellStyle(col)}
>
<AttendeesCell meeting={meeting} t={t} />
<AttendeesCell
meeting={meeting}
t={t}
canMutate={canMutate}
onSaveAttendeeName={onSaveAttendeeName}
/>
</td>
);
case "time":
@@ -1206,11 +1612,25 @@ function MeetingRow({
}
};
// Combine row background with highlight tint. When the row is the current
// meeting we apply a soft tint (color at low opacity) plus a coloured ring
// via box-shadow (rings on <tr> don't render across cells; box-shadow does).
const baseBg = rowBg || "white";
const trStyle: CSSProperties = {
backgroundColor: baseBg,
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : reordering ? 0.7 : 1,
boxShadow: isCurrent ? `inset 0 0 0 2px ${highlightColor}` : undefined,
};
return (
<tr
ref={setNodeRef}
className="group"
style={rowBg ? { backgroundColor: rowBg } : { backgroundColor: "white" }}
style={trStyle}
data-testid={`em-row-${meeting.id}`}
data-current-meeting={isCurrent ? "true" : undefined}
>
{visibleColumns.map((c) => renderCell(c))}
</tr>
@@ -1304,13 +1724,21 @@ function MeetingCard({
function AttendeesCell({
meeting,
t,
canMutate = false,
onSaveAttendeeName,
}: {
meeting: Meeting;
t: (k: string) => string;
canMutate?: boolean;
onSaveAttendeeName?: (idx: number, html: string) => Promise<void>;
}) {
const virtual = meeting.attendees.filter((a) => a.attendanceType === "virtual");
const internal = meeting.attendees.filter((a) => a.attendanceType === "internal");
const external = meeting.attendees.filter((a) => a.attendanceType === "external");
// Build (attendee, originalIndex) tuples so attendees can be sliced into
// virtual/internal/external groups while still referring back to their
// index in `meeting.attendees` (needed by the PUT save endpoint).
const tagged = meeting.attendees.map((a, i) => ({ a, i }));
const virtual = tagged.filter(({ a }) => a.attendanceType === "virtual");
const internal = tagged.filter(({ a }) => a.attendanceType === "internal");
const external = tagged.filter(({ a }) => a.attendanceType === "external");
const hasSplit = virtual.length > 0 && (internal.length > 0 || external.length > 0);
const platformLabel: Record<Meeting["platform"], string> = {
@@ -1321,6 +1749,8 @@ function AttendeesCell({
other: t("executiveMeetings.virtual"),
};
const groupProps = { canMutate, onSaveAttendeeName };
if (hasSplit) {
return (
<div className="space-y-2">
@@ -1332,18 +1762,21 @@ function AttendeesCell({
: t("executiveMeetings.virtualAttendance")
}
items={virtual}
{...groupProps}
/>
)}
{internal.length > 0 && (
<AttendeeGroup
label={t("executiveMeetings.internalAttendance")}
items={internal}
{...groupProps}
/>
)}
{external.length > 0 && (
<AttendeeGroup
label={t("executiveMeetings.externalAttendance")}
items={external}
{...groupProps}
/>
)}
</div>
@@ -1355,32 +1788,71 @@ function AttendeesCell({
<AttendeeGroup
label={`${t("executiveMeetings.virtualAttendance")}${platformLabel[meeting.platform]}`}
items={virtual}
{...groupProps}
/>
);
}
return <AttendeeFlow items={meeting.attendees} />;
return <AttendeeFlow items={tagged} {...groupProps} />;
}
function AttendeeGroup({ label, items }: { label: string; items: Attendee[] }) {
type AttendeeWithIndex = { a: Attendee; i: number };
function AttendeeGroup({
label,
items,
canMutate,
onSaveAttendeeName,
}: {
label: string;
items: AttendeeWithIndex[];
canMutate?: boolean;
onSaveAttendeeName?: (idx: number, html: string) => Promise<void>;
}) {
return (
<div>
<div className="text-xs font-semibold text-[#0B1E3F] mb-0.5 text-center"> {label} </div>
<AttendeeFlow items={items} />
<AttendeeFlow
items={items}
canMutate={canMutate}
onSaveAttendeeName={onSaveAttendeeName}
/>
</div>
);
}
function AttendeeFlow({ items }: { items: Attendee[] }) {
function AttendeeFlow({
items,
canMutate = false,
onSaveAttendeeName,
}: {
items: AttendeeWithIndex[];
canMutate?: boolean;
onSaveAttendeeName?: (idx: number, html: string) => Promise<void>;
}) {
if (items.length === 0) {
return <span className="text-xs text-gray-500"></span>;
}
const editable = canMutate && !!onSaveAttendeeName;
return (
<ul className="flex flex-wrap justify-center gap-x-4 gap-y-0.5 text-[#0B1E3F] leading-snug">
{items.map((a, idx) => (
<li key={a.id ?? idx} className="whitespace-nowrap">
<span className="text-gray-500 me-1">{idx + 1}-</span>
{a.name}
{items.map(({ a, i }, displayIdx) => (
<li
key={a.id ?? i}
className={editable ? "min-w-[3rem]" : "whitespace-nowrap"}
>
<span className="text-gray-500 me-1">{displayIdx + 1}-</span>
{editable ? (
<EditableCell
value={a.name}
onSave={(html) => onSaveAttendeeName!(i, html)}
ariaLabel="attendee name"
className="inline-block align-baseline"
testId={`em-edit-attendee-${i}`}
/>
) : (
<span dangerouslySetInnerHTML={{ __html: a.name }} />
)}
</li>
))}
</ul>
+8 -3
View File
@@ -18,8 +18,11 @@ export const executiveMeetingsTable = pgTable(
{
id: serial("id").primaryKey(),
dailyNumber: integer("daily_number").notNull(),
titleAr: varchar("title_ar", { length: 300 }).notNull(),
titleEn: varchar("title_en", { length: 300 }).notNull().default(""),
// titleAr/En hold sanitized rich-text HTML (Tiptap output) so they need
// unbounded length. Plain-text rows from before the widening still
// render correctly because they contain no HTML tags.
titleAr: text("title_ar").notNull(),
titleEn: text("title_en").notNull().default(""),
meetingDate: date("meeting_date").notNull(),
startTime: time("start_time", { withTimezone: false }),
endTime: time("end_time", { withTimezone: false }),
@@ -58,7 +61,9 @@ export const executiveMeetingAttendeesTable = pgTable(
meetingId: integer("meeting_id")
.notNull()
.references(() => executiveMeetingsTable.id, { onDelete: "cascade" }),
name: varchar("name", { length: 200 }).notNull(),
// attendee name holds sanitized rich-text HTML so it needs unbounded
// length. Plain-text rows still render correctly (no tags = no parse).
name: text("name").notNull(),
title: varchar("title", { length: 200 }),
attendanceType: varchar("attendance_type", { length: 32 })
.notNull()
+590
View File
@@ -120,6 +120,9 @@ importers:
pino-http:
specifier: ^10
version: 10.5.0
sanitize-html:
specifier: ^2.17.3
version: 2.17.3
socket.io:
specifier: ^4.8.3
version: 4.8.3
@@ -148,6 +151,9 @@ importers:
'@types/node':
specifier: 'catalog:'
version: 25.3.5
'@types/sanitize-html':
specifier: ^2.16.1
version: 2.16.1
esbuild:
specifier: ^0.27.3
version: 0.27.3
@@ -358,6 +364,24 @@ importers:
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.1.0)
'@tiptap/extension-color':
specifier: ^3.22.4
version: 3.22.4(@tiptap/extension-text-style@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)))
'@tiptap/extension-font-family':
specifier: ^3.22.4
version: 3.22.4(@tiptap/extension-text-style@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)))
'@tiptap/extension-text-style':
specifier: ^3.22.4
version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-underline':
specifier: ^3.22.4
version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/react':
specifier: ^3.22.4
version: 3.22.4(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@tiptap/starter-kit':
specifier: ^3.22.4
version: 3.22.4
'@uppy/aws-s3':
specifier: ^5.1.0
version: 5.1.0(@uppy/core@5.2.0)
@@ -2328,6 +2352,170 @@ packages:
peerDependencies:
react: 19.1.0
'@tiptap/core@3.22.4':
resolution: {integrity: sha512-vGIGm/HpqLg8EAAQXQ+koV+/S828OEpzocfWcPOwo1u2QUVf9dQG47Yy6JJ8zFFaJwfv4dBcOXli+7BrJwsxDQ==}
peerDependencies:
'@tiptap/pm': 3.22.4
'@tiptap/extension-blockquote@3.22.4':
resolution: {integrity: sha512-7/61kNPbGFhMgM//zMknD0pSb69rGdRIkpulXOWS1JBrFHkH6hjZDfrOETNzgKkO+NlmzVl9rXSTv0xauS3lzA==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-bold@3.22.4':
resolution: {integrity: sha512-jIaPKfNOQu2lhpbLDvtwlQqM+mjF+Kk+auHpzYjBnsuwUli1Cl5ZOau7RH+rru/SQvZe1DtpQlANujDywugZAA==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-bubble-menu@3.22.4':
resolution: {integrity: sha512-v4pux5Ql3THAEjaLMY4ldtdy/Xy2qU7PJLBkq8ugLp8qicaKC+tpqxp6sGif4vLIjz7Ap5hurRbTNbXzszyyHA==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@tiptap/extension-bullet-list@3.22.4':
resolution: {integrity: sha512-TB+d3fGcTixYjO7coKqTr1mGTJuqr8hjDCPUFgzuvKyJnBhqWITmBzQ/8CLq4rr6mihgGURbD3N+xkQuPAKFiw==}
peerDependencies:
'@tiptap/extension-list': 3.22.4
'@tiptap/extension-code-block@3.22.4':
resolution: {integrity: sha512-MEurzNXfMET3rhjpoPJYUgMfxTdTqbzT9+ToFrqNGAHocdXVm6m1hhO2frVC7fEtHPnxXKsn0Z3NUbCRkRTLuA==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@tiptap/extension-code@3.22.4':
resolution: {integrity: sha512-cnbxmVhAcc7X3G81QUYEmKP0ve2hRmvAiFXBuuv9RUtQlBiRnzmhHoJOMgkX0CsMR7+8kMRpTfeDUYq2xp5s5w==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-color@3.22.4':
resolution: {integrity: sha512-1vDuVsrOETshe4j4nZhWalbKYcWfNybRCe30h829ExX06XwFryUYLb/LgTIaGCr9beWZUldsK+vOkBWdDTGMTw==}
peerDependencies:
'@tiptap/extension-text-style': 3.22.4
'@tiptap/extension-document@3.22.4':
resolution: {integrity: sha512-XQKla1+703FqQJC48tPDVgt9ucGiFbIEmQdOg5L5o07z9a6/NzuaZAc+1zJ7NxcUZzy+z6wBn1PrVMTiqiSXlw==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-dropcursor@3.22.4':
resolution: {integrity: sha512-N9/yMDC35jJp0V/naL0+6gi4gUDUIcPpWEzFdCDWUSYBA8mt41c1kI1ZU7UTKYIBzTClenhYHRc2XKZxxx0+LQ==}
peerDependencies:
'@tiptap/extensions': 3.22.4
'@tiptap/extension-floating-menu@3.22.4':
resolution: {integrity: sha512-DFuyYxgaZPgxum5z1yvJPbfYCvDdO8geXsdyqt0qYYdiat3aGE4ncJhiLRIFDhSHBhaZg5eCgu/YPYAN6jZnrA==}
peerDependencies:
'@floating-ui/dom': ^1.0.0
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@tiptap/extension-font-family@3.22.4':
resolution: {integrity: sha512-e4DSZTQeM0P/ko3mMeZIb/otLUWCv/vobtEX5FGwstl9RJzaso5cTA6avMQaAff+xerovxvhkCMfye0+PL8YGw==}
peerDependencies:
'@tiptap/extension-text-style': 3.22.4
'@tiptap/extension-gapcursor@3.22.4':
resolution: {integrity: sha512-UYBEUj3SFpKINIE7AdzcyeS3xICK+ee+YLBbuqNXyHStYChjJOohzJehqiqhjR16A88KQQ+ZjgyDcItKGygSog==}
peerDependencies:
'@tiptap/extensions': 3.22.4
'@tiptap/extension-hard-break@3.22.4':
resolution: {integrity: sha512-xq+a4dE7T6VwApCkh/yU3p30gn3F8g8Arb9CyEZm58/WIJUIGvHSTjDdHmvU16+kiWSBg+wOOsaFHhYjJjxcKA==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-heading@3.22.4':
resolution: {integrity: sha512-TUaj5f0Ir5qy9HKKt2ocnwfXKpZDYeHgbbP9gshKFzdq5PLe1RbIgkjfy6bnoI865cYjmPYWRjcT7XsKyIcb9Q==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-horizontal-rule@3.22.4':
resolution: {integrity: sha512-cCI1HekGQwhY/MbgaKQ0R/7HcH5ZM1oFAyI/J72QGLC0XnF403S/OXoHMuBWr1mCu8hNiQWCzeNRJUty0iytNw==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@tiptap/extension-italic@3.22.4':
resolution: {integrity: sha512-fVSDx5AYXgDI3v2zZIqb7V8EewthwM2NJ/ZCX+XaxRsqNEpnjVhgHs7UlvDqK1wj2OJ6zmUNjPtVlAFRxwT+HQ==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-link@3.22.4':
resolution: {integrity: sha512-uoP3yus02uwGPVzW2QaEPJWVIrUb/r5nKm6c8DiJv9fNSX1+gykZZMg42c6GwRFLZ/vyfWjVCbAE03VMUqafgA==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@tiptap/extension-list-item@3.22.4':
resolution: {integrity: sha512-H659KXTvggSypIDWSOJBZ37jh9pKjQriDDvYPYvOZCdfij0D0hsDXN/wXoypArneUkoBdgruHfTtMkFOaQlgkw==}
peerDependencies:
'@tiptap/extension-list': 3.22.4
'@tiptap/extension-list-keymap@3.22.4':
resolution: {integrity: sha512-t/zhker4oIS78AIGYDdFFfZC6zSBlszfD7z/zqFLGCg5PHNNgkZK5hKj6Vyix6D2SapRn/ajnx+8mhbKIUH5eA==}
peerDependencies:
'@tiptap/extension-list': 3.22.4
'@tiptap/extension-list@3.22.4':
resolution: {integrity: sha512-Xe8UFvvHmyp/c/TJsFwlwU9CWACYbBirNsluJ3U1+H8BTu1wqdrT/AXR5uIXeyCl5kiWKgX5q71eHWbYFOrqrg==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@tiptap/extension-ordered-list@3.22.4':
resolution: {integrity: sha512-w77hPVf7pcHt97vfrybg/l0t5CimCd4y75OJKuHuo3CfgM5xbUP/gaPNMDyLLe7MYole/UHi/XvG3XjgzqTzAw==}
peerDependencies:
'@tiptap/extension-list': 3.22.4
'@tiptap/extension-paragraph@3.22.4':
resolution: {integrity: sha512-de6dFkIhigiENESY6rNJ3yTVS/337ybfP30dNPudTwGe9oAu9ZCS+04j6QCvXSjhlI3ULiv7wiSHqrP26Gd+Hw==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-strike@3.22.4':
resolution: {integrity: sha512-aRHWQj42HiailXSC9LkKYM3jWMcSeGwOjbqM4PiuxQZmHVDRFmeHkfJItOdn2cSHaO0vuEVK+TvrWUWsBFi3pg==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-text-style@3.22.4':
resolution: {integrity: sha512-24DVBdySNKq3ovY+v9ERVxAyHStDa6ftUlyoHuZv0YXQ2amjUNOmqQtGEHBIULpCbBb1jZ+atHhv9MBZ0Ia9Pw==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-text@3.22.4':
resolution: {integrity: sha512-mM69uUW5cSxIhyEpWXi/YcfyupcJMDLCPEfYi62awH0iOP/LRoCv/nHjJq4Hyj/KxRJbe8HKwIUnqaCUf7m5Pg==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-underline@3.22.4':
resolution: {integrity: sha512-08kGdbhIrA6h10GWXqOkqIveaBj5tmxclK208/nUIAlonI9hPd739vu7fmVtpnmqCnSSNpoRtU4u6Gj5at0ZpA==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extensions@3.22.4':
resolution: {integrity: sha512-fOe8VptJvLPs32bNdUYo8SRyljwqKNQVXWW056VoXIc5en/59OdJlJQVeHI0jRRciH3MtrqODi/gfJR0VHNZ8A==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@tiptap/pm@3.22.4':
resolution: {integrity: sha512-hj8Qka6WcHRllHUdeSjDnq2XaisUo4KsoGJc1WcFpoa1Yd+OeD861zUMnV7DFVGdZRy45Obht0CUYJpXQ4yA4w==}
'@tiptap/react@3.22.4':
resolution: {integrity: sha512-XIQZPwLakR1t8+Q1UeCpr+kUHDWxpJzGy9r2xUi3mpPd6Wh8dtNltScBkUlCcr0sqc6J1GF6Is02JJVQGmCZMA==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
'@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0
react: 19.1.0
react-dom: 19.1.0
'@tiptap/starter-kit@3.22.4':
resolution: {integrity: sha512-qWjw+vfdin1rzMRpRU4cC5tLTwMJtUpXeQukv+6mOqqvhptuwuZBjUHImVEJaSPoHXS7+1ut+nTnrLyWyEuE5Q==}
'@tootallnate/once@2.0.0':
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'}
@@ -2442,6 +2630,9 @@ packages:
'@types/retry@0.12.2':
resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==}
'@types/sanitize-html@2.16.1':
resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==}
'@types/send@1.2.1':
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
@@ -2454,6 +2645,9 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -2826,6 +3020,10 @@ packages:
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
@@ -2848,6 +3046,19 @@ packages:
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
drizzle-kit@0.31.9:
resolution: {integrity: sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==}
hasBin: true
@@ -3009,6 +3220,10 @@ packages:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
@@ -3065,6 +3280,10 @@ packages:
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
@@ -3306,6 +3525,9 @@ packages:
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
htmlparser2@10.1.0:
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@@ -3383,6 +3605,10 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
@@ -3526,6 +3752,9 @@ packages:
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
linkifyjs@4.3.2:
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
locate-path@8.0.0:
resolution: {integrity: sha512-XT9ewWAC43tiAV7xDAPflMkG0qOPn2QjHqlgX8FOqmWa/rxnyYDulF9T0F7tRy1u+TVTmK/M//6VIOye+2zDXg==}
engines: {node: '>=20'}
@@ -3702,6 +3931,9 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
orval@8.5.3:
resolution: {integrity: sha512-+8Es2ZR3tPthzAL27X1a9AlboqTQ/w9U/PhMkp4vsLA9OvdkpXr+9f8lCfJUV/wtdX+lXBDQ4imx42Em943JSg==}
engines: {node: '>=22.18.0'}
@@ -3740,6 +3972,9 @@ packages:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
parse-srcset@1.0.2:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@@ -3883,6 +4118,42 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
prosemirror-changeset@2.4.1:
resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==}
prosemirror-commands@1.7.1:
resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
prosemirror-dropcursor@1.8.2:
resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==}
prosemirror-gapcursor@1.4.1:
resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
prosemirror-history@1.5.0:
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
prosemirror-keymap@1.2.3:
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
prosemirror-model@1.25.4:
resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==}
prosemirror-schema-list@1.5.1:
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
prosemirror-state@1.4.4:
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
prosemirror-tables@1.8.5:
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
prosemirror-transform@1.12.0:
resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==}
prosemirror-view@1.41.8:
resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==}
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@@ -4073,6 +4344,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
@@ -4090,6 +4364,9 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sanitize-html@2.17.3:
resolution: {integrity: sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==}
scheduler@0.26.0:
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
@@ -4434,6 +4711,9 @@ packages:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
@@ -6024,6 +6304,189 @@ snapshots:
'@tanstack/query-core': 5.90.20
react: 19.1.0
'@tiptap/core@3.22.4(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/pm': 3.22.4
'@tiptap/extension-blockquote@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-bold@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-bubble-menu@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@floating-ui/dom': 1.7.6
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
optional: true
'@tiptap/extension-bullet-list@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tiptap/extension-code@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-color@3.22.4(@tiptap/extension-text-style@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)))':
dependencies:
'@tiptap/extension-text-style': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-document@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-dropcursor@3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-floating-menu@3.22.4(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@floating-ui/dom': 1.7.6
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
optional: true
'@tiptap/extension-font-family@3.22.4(@tiptap/extension-text-style@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)))':
dependencies:
'@tiptap/extension-text-style': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-gapcursor@3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-hard-break@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-heading@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-horizontal-rule@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tiptap/extension-italic@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-link@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
linkifyjs: 4.3.2
'@tiptap/extension-list-item@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-list-keymap@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tiptap/extension-ordered-list@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-paragraph@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-strike@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-text-style@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-text@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-underline@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tiptap/pm@3.22.4':
dependencies:
prosemirror-changeset: 2.4.1
prosemirror-commands: 1.7.1
prosemirror-dropcursor: 1.8.2
prosemirror-gapcursor: 1.4.1
prosemirror-history: 1.5.0
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.4
prosemirror-schema-list: 1.5.1
prosemirror-state: 1.4.4
prosemirror-tables: 1.8.5
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
'@tiptap/react@3.22.4(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@types/use-sync-external-store': 0.0.6
fast-equals: 5.4.0
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
use-sync-external-store: 1.6.0(react@19.1.0)
optionalDependencies:
'@tiptap/extension-bubble-menu': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-floating-menu': 3.22.4(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
transitivePeerDependencies:
- '@floating-ui/dom'
'@tiptap/starter-kit@3.22.4':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-blockquote': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-bold': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-bullet-list': 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-code': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-code-block': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-document': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-dropcursor': 3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-gapcursor': 3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-hard-break': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-heading': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-horizontal-rule': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-italic': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-link': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-list-item': 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-list-keymap': 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-ordered-list': 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-paragraph': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-strike': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-text': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-underline': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tootallnate/once@2.0.0': {}
'@transloadit/prettier-bytes@0.3.5': {}
@@ -6158,6 +6621,10 @@ snapshots:
'@types/retry@0.12.2': {}
'@types/sanitize-html@2.16.1':
dependencies:
htmlparser2: 10.1.0
'@types/send@1.2.1':
dependencies:
'@types/node': 25.3.5
@@ -6171,6 +6638,8 @@ snapshots:
'@types/unist@3.0.3': {}
'@types/use-sync-external-store@0.0.6': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.3.5
@@ -6523,6 +6992,8 @@ snapshots:
decimal.js-light@2.5.1: {}
deepmerge@4.3.1: {}
delayed-stream@1.0.0: {}
depd@2.0.0: {}
@@ -6538,6 +7009,24 @@ snapshots:
'@babel/runtime': 7.28.6
csstype: 3.2.3
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
domelementtype@2.3.0: {}
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
drizzle-kit@0.31.9:
dependencies:
'@drizzle-team/brocli': 0.10.2
@@ -6639,6 +7128,8 @@ snapshots:
entities@4.5.0: {}
entities@7.0.1: {}
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
@@ -6756,6 +7247,8 @@ snapshots:
escape-html@1.0.3: {}
escape-string-regexp@4.0.0: {}
esutils@2.0.3: {}
etag@1.8.1: {}
@@ -7076,6 +7569,13 @@ snapshots:
dependencies:
void-elements: 3.1.0
htmlparser2@10.1.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 7.0.1
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -7145,6 +7645,8 @@ snapshots:
is-plain-obj@4.1.0: {}
is-plain-object@5.0.0: {}
is-promise@4.0.0: {}
is-stream@2.0.1: {}
@@ -7249,6 +7751,8 @@ snapshots:
dependencies:
uc.micro: 2.1.0
linkifyjs@4.3.2: {}
locate-path@8.0.0:
dependencies:
p-locate: 6.0.0
@@ -7385,6 +7889,8 @@ snapshots:
dependencies:
wrappy: 1.0.2
orderedmap@2.1.1: {}
orval@8.5.3(prettier@3.8.1)(typescript@5.9.3):
dependencies:
'@commander-js/extra-typings': 14.0.0(commander@14.0.3)
@@ -7450,6 +7956,8 @@ snapshots:
parse-ms@4.0.0: {}
parse-srcset@1.0.2: {}
parseurl@1.3.3: {}
path-expression-matcher@1.5.0: {}
@@ -7597,6 +8105,75 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
prosemirror-changeset@2.4.1:
dependencies:
prosemirror-transform: 1.12.0
prosemirror-commands@1.7.1:
dependencies:
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-dropcursor@1.8.2:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
prosemirror-gapcursor@1.4.1:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-view: 1.41.8
prosemirror-history@1.5.0:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
rope-sequence: 1.3.4
prosemirror-keymap@1.2.3:
dependencies:
prosemirror-state: 1.4.4
w3c-keyname: 2.2.8
prosemirror-model@1.25.4:
dependencies:
orderedmap: 2.1.1
prosemirror-schema-list@1.5.1:
dependencies:
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-state@1.4.4:
dependencies:
prosemirror-model: 1.25.4
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
prosemirror-tables@1.8.5:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
prosemirror-transform@1.12.0:
dependencies:
prosemirror-model: 1.25.4
prosemirror-view@1.41.8:
dependencies:
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
@@ -7798,6 +8375,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.59.0
fsevents: 2.3.3
rope-sequence@1.3.4: {}
router@2.2.0:
dependencies:
debug: 4.4.3
@@ -7818,6 +8397,15 @@ snapshots:
safer-buffer@2.1.2: {}
sanitize-html@2.17.3:
dependencies:
deepmerge: 4.3.1
escape-string-regexp: 4.0.0
htmlparser2: 10.1.0
is-plain-object: 5.0.0
parse-srcset: 1.0.2
postcss: 8.5.8
scheduler@0.26.0: {}
secure-json-parse@4.1.0: {}
@@ -8151,6 +8739,8 @@ snapshots:
void-elements@3.1.0: {}
w3c-keyname@2.2.8: {}
web-streams-polyfill@3.3.3: {}
webidl-conversions@3.0.1: {}