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:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -745,12 +745,18 @@
|
||||
},
|
||||
"customize": {
|
||||
"title": "تخصيص الجدول",
|
||||
"hint": "أظهر أو أخفِ الأعمدة، أعد ترتيبها، أو أعد التعيين الافتراضي.",
|
||||
"hint": "أظهر أو أخفِ الأعمدة. اسحب رؤوس الأعمدة لإعادة الترتيب.",
|
||||
"moveUp": "تحريك للأعلى",
|
||||
"moveDown": "تحريك للأسفل",
|
||||
"reset": "إعادة التعيين",
|
||||
"dragHint": "اسحب حد العمود لتغيير عرضه"
|
||||
},
|
||||
"highlight": {
|
||||
"title": "تمييز الاجتماع الحالي",
|
||||
"hint": "تظليل الاجتماع الذي يقع وقته الآن."
|
||||
},
|
||||
"dragRow": "اسحب لإعادة الترتيب",
|
||||
"editMeetingTitle": "تعديل عنوان الاجتماع",
|
||||
"rowColor": {
|
||||
"label": "لون الصف"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
Generated
+590
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user