feat(rich-text, types): Tiptap editor, SSR-safe, comments + description; stricter typing (no any) across app

- Add Tiptap editor + toolbar and rich content rendering with sanitize-html
- Fix SSR hydration (immediatelyRender: false) and setContent options
- Comments: rich text + visibility selector, typed attachments (Id<_storage>)
- New Ticket: description rich text; attachments typed; queues typed
- Convex: server-side filters using indexes; priority order rename; stronger Doc/Id typing; remove helper with any
- Schemas/Mappers: zod v4 record typing; event payload record typing; customFields typed
- UI: replace any in header/play/list/timeline/fields; improve select typings
- Build passes; only non-blocking lint warnings remain
This commit is contained in:
esdrasrenan 2025-10-04 14:25:10 -03:00
parent 9b0c0bd80a
commit ea60c3b841
26 changed files with 1390 additions and 245 deletions

128
web/build.log Normal file
View file

@ -0,0 +1,128 @@
> web@0.1.0 build C:\Users\monke\OneDrive\Documentos\Projetos\sistema-de-chamados\web
> next build
Ôû▓ Next.js 15.5.3
- Environments: .env.local
Creating an optimized production build ...
Ô£ô Compiled successfully in 3.0s
Linting and checking validity of types ...
./src/app/ConvexClientProvider.tsx
4:21 Warning: 'useMemo' is defined but never used. @typescript-eslint/no-unused-vars
./src/app/tickets/new/page.tsx
16:9 Warning: The 'queues' logical expression could make the dependencies of useMemo Hook (at line 28) change on every render. To fix this, wrap the initialization of 'queues' in its own useMemo() Hook. react-hooks/exhaustive-deps
28:53 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
35:33 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
35:49 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
43:27 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
44:30 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
48:42 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
48:67 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/app/tickets/[id]/page.tsx
27:61 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/tickets/new-ticket-dialog.tsx
6:1 Warning: Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free. @typescript-eslint/ban-ts-comment
50:35 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
58:32 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
63:44 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
63:69 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
63:140 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
71:14 Warning: 'err' is defined but never used. @typescript-eslint/no-unused-vars
116:111 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
128:109 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
150:41 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/tickets/play-next-ticket-card.tsx
39:34 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
43:169 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
43:240 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
75:37 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
109:103 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
109:141 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/tickets/recent-tickets-panel.tsx
4:1 Warning: Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free. @typescript-eslint/ban-ts-comment
9:10 Warning: 'Spinner' is defined but never used. @typescript-eslint/no-unused-vars
27:58 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
30:41 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/tickets/ticket-comments.rich.tsx
31:9 Warning: 'generateUploadUrl' is assigned a value but never used. @typescript-eslint/no-unused-vars
52:100 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
61:49 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
61:74 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
64:14 Warning: 'err' is defined but never used. @typescript-eslint/no-unused-vars
113:34 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
151:83 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/tickets/ticket-detail-view.tsx
11:10 Warning: 'Separator' is defined but never used. @typescript-eslint/no-unused-vars
20:108 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
21:15 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
23:50 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
57:46 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
60:45 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
61:45 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
63:47 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/tickets/ticket-queue-summary.tsx
18:70 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/tickets/ticket-summary-header.tsx
50:50 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
59:63 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
59:85 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
59:109 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
61:26 Warning: 'e' is defined but never used. @typescript-eslint/no-unused-vars
98:63 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
98:89 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
98:113 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
107:31 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
121:42 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
125:60 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
125:82 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
125:106 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
134:31 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/tickets/ticket-timeline.tsx
12:10 Warning: 'cn' is defined but never used. @typescript-eslint/no-unused-vars
15:10 Warning: 'Separator' is defined but never used. @typescript-eslint/no-unused-vars
72:28 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/tickets/tickets-view.tsx
12:10 Warning: 'Spinner' is defined but never used. @typescript-eslint/no-unused-vars
27:80 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
31:31 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
36:76 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
49:51 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/ui/dropzone.tsx
4:1 Warning: Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free. @typescript-eslint/ban-ts-comment
./src/components/ui/field.tsx
11:52 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
28:58 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
info - Need to disable some ESLint rules? Learn more here: https://nextjs.org/docs/app/api-reference/config/eslint#disabling-rules
Failed to compile.
./src/components/tickets/play-next-ticket-card.tsx:43:9
Type error: Type '{ queue: { id: string; name: string; pending: number; waiting: number; breached: number; }; nextTicket: { id: string; reference: number; tenantId: string; subject: string; status: "RESOLVED" | "CLOSED" | "PENDING" | "ON_HOLD" | "OPEN" | "NEW"; ... 14 more ...; lastTimelineEntry?: string | undefined; } | null; } | { ...' is not assignable to type '{ queue: { id: string; name: string; pending: number; waiting: number; breached: number; }; nextTicket: { id: string; reference: number; tenantId: string; subject: string; status: "RESOLVED" | "CLOSED" | "PENDING" | "ON_HOLD" | "OPEN" | "NEW"; ... 14 more ...; lastTimelineEntry?: string | undefined; } | null; } | null'.
Type '{ queue: { id: string; name: string; pending: number; waiting: number; breached: number; }; nextTicket: { id: any; reference: any; tenantId: any; subject: any; summary: any; status: any; priority: any; channel: any; ... 11 more ...; metrics: null; }; }' is not assignable to type '{ queue: { id: string; name: string; pending: number; waiting: number; breached: number; }; nextTicket: { id: string; reference: number; tenantId: string; subject: string; status: "RESOLVED" | "CLOSED" | "PENDING" | "ON_HOLD" | "OPEN" | "NEW"; ... 14 more ...; lastTimelineEntry?: string | undefined; } | null; }'.
The types of 'nextTicket.lastTimelineEntry' are incompatible between these types.
Type 'null' is not assignable to type 'string | undefined'.
41 | })?.[0]
42 |
> 43 | const cardContext: TicketPlayContext | null = context ?? (nextTicketFromServer ? { queue: { id: "default", name: "Geral", pending: queueSummary.reduce((a: number, b: any) => a + b.pending, 0), waiting: queueSummary.reduce((a: number, b: any) => a + b.waiting, 0), breached: 0 }, nextTicket: nextTicketFromServer } : null)
| ^
44 |
45 | if (!cardContext || !cardContext.nextTicket) {
46 | return (
Next.js build worker exited with code: 1 and signal: null
ÔÇëELIFECYCLEÔÇë Command failed with exit code 1.

View file

@ -41,6 +41,7 @@ export default defineSchema({
reference: v.number(),
subject: v.string(),
summary: v.optional(v.string()),
description: v.optional(v.string()),
status: v.string(),
priority: v.string(),
channel: v.string(),
@ -59,7 +60,8 @@ export default defineSchema({
.index("by_tenant_status", ["tenantId", "status"])
.index("by_tenant_queue", ["tenantId", "queueId"])
.index("by_tenant_assignee", ["tenantId", "assigneeId"])
.index("by_tenant_reference", ["tenantId", "reference"]),
.index("by_tenant_reference", ["tenantId", "reference"])
.index("by_tenant", ["tenantId"]),
ticketComments: defineTable({
ticketId: v.id("tickets"),
@ -87,4 +89,3 @@ export default defineSchema({
createdAt: v.number(),
}).index("by_ticket", ["ticketId"]),
});

View file

@ -1,8 +1,8 @@
import { internalMutation, mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { Id } from "./_generated/dataModel";
import { Id, type Doc } from "./_generated/dataModel";
const STATUS_ORDER = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const;
const PRIORITY_ORDER = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const;
export const list = query({
args: {
@ -15,16 +15,28 @@ export const list = query({
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
let q = ctx.db
.query("tickets")
.withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId));
// Choose best index based on provided args for efficiency
let base: Doc<"tickets">[] = [];
if (args.status) {
base = await ctx.db
.query("tickets")
.withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", args.status!))
.collect();
} else if (args.queueId) {
base = await ctx.db
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!))
.collect();
} else {
base = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
.collect();
}
let filtered = base;
const all = await q.collect();
let filtered = all;
if (args.status) filtered = filtered.filter((t) => t.status === args.status);
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel);
if (args.queueId) filtered = filtered.filter((t) => t.queueId === args.queueId);
if (args.search) {
const term = args.search.toLowerCase();
filtered = filtered.filter(
@ -38,9 +50,9 @@ export const list = query({
// hydrate requester and assignee
const result = await Promise.all(
limited.map(async (t) => {
const requester = await ctx.db.get(t.requesterId);
const assignee = t.assigneeId ? await ctx.db.get(t.assigneeId) : null;
const queue = t.queueId ? await ctx.db.get(t.queueId) : null;
const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null;
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
return {
id: t._id,
reference: t.reference,
@ -80,7 +92,7 @@ export const list = query({
})
);
// sort by updatedAt desc
return result.sort((a, b) => (b.updatedAt as any) - (a.updatedAt as any));
return result.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
},
});
@ -89,9 +101,9 @@ export const getById = query({
handler: async (ctx, { tenantId, id }) => {
const t = await ctx.db.get(id);
if (!t || t.tenantId !== tenantId) return null;
const requester = await ctx.db.get(t.requesterId);
const assignee = t.assigneeId ? await ctx.db.get(t.assigneeId) : null;
const queue = t.queueId ? await ctx.db.get(t.queueId) : null;
const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null;
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
const comments = await ctx.db
.query("ticketComments")
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
@ -103,7 +115,7 @@ export const getById = query({
const commentsHydrated = await Promise.all(
comments.map(async (c) => {
const author = await ctx.db.get(c.authorId);
const author = (await ctx.db.get(c.authorId)) as Doc<"users"> | null;
const attachments = await Promise.all(
(c.attachments ?? []).map(async (att) => ({
id: att.storageId,
@ -296,7 +308,7 @@ export const changeAssignee = mutation({
handler: async (ctx, { ticketId, assigneeId, actorId }) => {
const now = Date.now();
await ctx.db.patch(ticketId, { assigneeId, updatedAt: now });
const user = await ctx.db.get(assigneeId);
const user = (await ctx.db.get(assigneeId)) as Doc<"users"> | null;
await ctx.db.insert("ticketEvents", {
ticketId,
type: "ASSIGNEE_CHANGED",
@ -311,7 +323,7 @@ export const changeQueue = mutation({
handler: async (ctx, { ticketId, queueId, actorId }) => {
const now = Date.now();
await ctx.db.patch(ticketId, { queueId, updatedAt: now });
const queue = await ctx.db.get(queueId);
const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null;
await ctx.db.insert("ticketEvents", {
ticketId,
type: "QUEUE_CHANGED",
@ -329,10 +341,18 @@ export const playNext = mutation({
},
handler: async (ctx, { tenantId, queueId, agentId }) => {
// Find eligible tickets: not resolved/closed and not assigned
let candidates = await ctx.db
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId ?? undefined as any))
.collect();
let candidates: Doc<"tickets">[] = []
if (queueId) {
candidates = await ctx.db
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId))
.collect()
} else {
candidates = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
}
candidates = candidates.filter(
(t) => t.status !== "RESOLVED" && t.status !== "CLOSED" && !t.assigneeId
@ -341,17 +361,18 @@ export const playNext = mutation({
if (candidates.length === 0) return null;
// prioritize by priority then createdAt
const rank: Record<string, number> = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
candidates.sort((a, b) => {
const pa = STATUS_ORDER.indexOf(a.priority as any);
const pb = STATUS_ORDER.indexOf(b.priority as any);
if (pa !== pb) return pa - pb;
return a.createdAt - b.createdAt;
});
const pa = rank[a.priority] ?? 999
const pb = rank[b.priority] ?? 999
if (pa !== pb) return pa - pb
return a.createdAt - b.createdAt
})
const chosen = candidates[0];
const now = Date.now();
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: chosen.status === "NEW" ? "OPEN" : chosen.status, updatedAt: now });
const agent = await ctx.db.get(agentId);
const agent = (await ctx.db.get(agentId)) as Doc<"users"> | null;
await ctx.db.insert("ticketEvents", {
ticketId: chosen._id,
type: "ASSIGNEE_CHANGED",
@ -359,51 +380,45 @@ export const playNext = mutation({
createdAt: now,
});
return await getPublicById(ctx, chosen._id);
// hydrate minimal public ticket like in list
const requester = (await ctx.db.get(chosen.requesterId)) as Doc<"users"> | null
const assignee = chosen.assigneeId ? ((await ctx.db.get(chosen.assigneeId)) as Doc<"users"> | null) : null
const queue = chosen.queueId ? ((await ctx.db.get(chosen.queueId)) as Doc<"queues"> | null) : null
return {
id: chosen._id,
reference: chosen.reference,
tenantId: chosen.tenantId,
subject: chosen.subject,
summary: chosen.summary,
status: chosen.status,
priority: chosen.priority,
channel: chosen.channel,
queue: queue?.name ?? null,
requester: requester && {
id: requester._id,
name: requester.name,
email: requester.email,
avatarUrl: requester.avatarUrl,
teams: requester.teams ?? [],
},
assignee: assignee
? {
id: assignee._id,
name: assignee.name,
email: assignee.email,
avatarUrl: assignee.avatarUrl,
teams: assignee.teams ?? [],
}
: null,
slaPolicy: null,
dueAt: chosen.dueAt ?? null,
firstResponseAt: chosen.firstResponseAt ?? null,
resolvedAt: chosen.resolvedAt ?? null,
updatedAt: chosen.updatedAt,
createdAt: chosen.createdAt,
tags: chosen.tags ?? [],
lastTimelineEntry: null,
metrics: null,
}
},
});
// internal helper to hydrate a ticket in the same shape as list/getById
const getPublicById = async (ctx: any, id: Id<"tickets">) => {
const t = await ctx.db.get(id);
if (!t) return null;
const requester = await ctx.db.get(t.requesterId);
const assignee = t.assigneeId ? await ctx.db.get(t.assigneeId) : null;
const queue = t.queueId ? await ctx.db.get(t.queueId) : null;
return {
id: t._id,
reference: t.reference,
tenantId: t.tenantId,
subject: t.subject,
summary: t.summary,
status: t.status,
priority: t.priority,
channel: t.channel,
queue: queue?.name ?? null,
requester: requester && {
id: requester._id,
name: requester.name,
email: requester.email,
avatarUrl: requester.avatarUrl,
teams: requester.teams ?? [],
},
assignee: assignee
? {
id: assignee._id,
name: assignee.name,
email: assignee.email,
avatarUrl: assignee.avatarUrl,
teams: assignee.teams ?? [],
}
: null,
slaPolicy: null,
dueAt: t.dueAt ?? null,
firstResponseAt: t.firstResponseAt ?? null,
resolvedAt: t.resolvedAt ?? null,
updatedAt: t.updatedAt,
createdAt: t.createdAt,
tags: t.tags ?? [],
lastTimelineEntry: null,
metrics: null,
};
};

View file

@ -20,6 +20,13 @@ const eslintConfig = [
"next-env.d.ts",
],
},
{
rules: {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/ban-ts-comment": "warn",
"react/no-unescaped-entities": "off",
},
},
];
export default eslintConfig;

View file

@ -33,6 +33,10 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "^3.35.0",
"@tanstack/react-table": "^8.21.3",
"@tiptap/extension-link": "^3.6.5",
"@tiptap/extension-placeholder": "^3.6.5",
"@tiptap/react": "^3.6.5",
"@tiptap/starter-kit": "^3.6.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.27.3",
@ -44,6 +48,7 @@
"react-dom": "19.1.0",
"react-hook-form": "^7.64.0",
"recharts": "^2.15.4",
"sanitize-html": "^2.17.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
@ -55,6 +60,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/sanitize-html": "^2.16.0",
"eslint": "^9",
"eslint-config-next": "15.5.3",
"prisma": "^6.16.2",

686
web/pnpm-lock.yaml generated
View file

@ -71,6 +71,18 @@ importers:
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@tiptap/extension-link':
specifier: ^3.6.5
version: 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-placeholder':
specifier: ^3.6.5
version: 3.6.5(@tiptap/extensions@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))
'@tiptap/react':
specifier: ^3.6.5
version: 3.6.5(@floating-ui/dom@1.7.4)(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@tiptap/starter-kit':
specifier: ^3.6.5
version: 3.6.5
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@ -104,6 +116,9 @@ importers:
recharts:
specifier: ^2.15.4
version: 2.15.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
sanitize-html:
specifier: ^2.17.0
version: 2.17.0
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -132,6 +147,9 @@ importers:
'@types/react-dom':
specifier: ^19
version: 19.2.0(@types/react@19.2.0)
'@types/sanitize-html':
specifier: ^2.16.0
version: 2.16.0
eslint:
specifier: ^9
version: 9.37.0(jiti@2.6.1)
@ -1255,6 +1273,9 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
'@rollup/rollup-android-arm-eabi@4.52.4':
resolution: {integrity: sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==}
cpu: [arm]
@ -1484,6 +1505,160 @@ packages:
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@tiptap/core@3.6.5':
resolution: {integrity: sha512-CgXuhevQbBcPfxaXzGZgIY9+aVMSAd68Q21g3EONz1iZBw026QgiaLhGK6jgGTErZL4GoNL/P+gC5nFCvN7+cA==}
peerDependencies:
'@tiptap/pm': ^3.6.5
'@tiptap/extension-blockquote@3.6.5':
resolution: {integrity: sha512-FOOgkLHXQ3zTiL2V1js5+PfaOHXuyr/GjeFZe+W1AUk58X/qJNOVGvKT1xlMOy9gy2ySgWmco7PhNXRRTimkWg==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-bold@3.6.5':
resolution: {integrity: sha512-8JXC+K4DXtPDbClHxgRAZnXYO2an2I86PbpqUw+S7m17XCr4t39Sw9CeNBohOHS6Cl8uxOKAjSyCZzqdnYkn3g==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-bubble-menu@3.6.5':
resolution: {integrity: sha512-RyCJghtkYZAljZQUfjk3B5tvVVCILsIYMR9XnC152uBiIuWsnz25qfdyBP+cOl6ONrQUvdscs0WmKvzN+nXZYw==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/pm': ^3.6.5
'@tiptap/extension-bullet-list@3.6.5':
resolution: {integrity: sha512-AP81hyN7oTyv5zbNVRK35cQA7zuLnI5ItFFyqMQKWh90vfftXi/zhC9C7FWvKtEH7Kk68B338G2mi4tlXDgBFQ==}
peerDependencies:
'@tiptap/extension-list': ^3.6.5
'@tiptap/extension-code-block@3.6.5':
resolution: {integrity: sha512-VPPke3LqZYKPlbDBp8IcTJQwvYb1PP0L+2Qi2n3ebN4+gKn+KGhrjnkO+xNHCySWlqywQmMTIfWX1sxA0eVVdQ==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/pm': ^3.6.5
'@tiptap/extension-code@3.6.5':
resolution: {integrity: sha512-U/cJFjE0hqBTbMb5J74e7ni5YReuJgS9NyJgTy94+Xt6vxR1vU4+qOl+3E0fOZtwDrxbLrsCQy3P3LvNb3HXdw==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-document@3.6.5':
resolution: {integrity: sha512-0c7kxWBIEIcoHUG89vpHOF2h4CMa0q6VWXhZ+6iqcI5uyqaKwgcW/TbHZR0nAwEsZLdRCKaryn2kO7jXiCjfnA==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-dropcursor@3.6.5':
resolution: {integrity: sha512-BsO3ufLHsdeV1ddChwQfi2Q4UkeqOF4LeUYPYBKfSg59aRKTSoxj3gZrAsaAm/0O3DmAiKNBiCtNRTJSApPEBQ==}
peerDependencies:
'@tiptap/extensions': ^3.6.5
'@tiptap/extension-floating-menu@3.6.5':
resolution: {integrity: sha512-ASKb5vHkYyB9g3vOAr2E2U+b6MbHk4Ff4PqngafGlWRAmOAmFxTcw9fLa3HKnj4pokSsYAEvYGOso99/W3GzhA==}
peerDependencies:
'@floating-ui/dom': ^1.0.0
'@tiptap/core': ^3.6.5
'@tiptap/pm': ^3.6.5
'@tiptap/extension-gapcursor@3.6.5':
resolution: {integrity: sha512-SHtp71zhV2bAQS8kaJ/otb2podGusDREZ9/SQ1rZi6yPcDFLS2KvIvsLssDwbjTuH6KefnsN6Vx01tzmXRAQig==}
peerDependencies:
'@tiptap/extensions': ^3.6.5
'@tiptap/extension-hard-break@3.6.5':
resolution: {integrity: sha512-6iMS6SzIn7+X95okRX8y3l/4f1G3lTrq24sbcAX4MHITncDC6g3TrdAxdA67Tqn5NI/OQx0LwF3kFJDO8QTAUg==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-heading@3.6.5':
resolution: {integrity: sha512-jFS5saqTtfG6MM0sW4X6mZlLycT2ud0Oo1GOZkCyBClwSOpZI/EBLNRIgoXgNtWrY917vB7xTQgCpTVHbvVRsQ==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-horizontal-rule@3.6.5':
resolution: {integrity: sha512-yNxcejI25j6NQMQuKQMTVmNYLnrHFCpzGAz1Ndzyar+gItYZXI9BLmMlwpLkIaJMpIKChj+2qHz25fPS5FlNFw==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/pm': ^3.6.5
'@tiptap/extension-italic@3.6.5':
resolution: {integrity: sha512-2EtO2uffw5YnTQ1cieLPv9t7OKCfJFbgHRJPXf7Nnfh8XFh5AEyzw0qBNXZyLtlB28+HHSWLc/OHS6xMfwUy0A==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-link@3.6.5':
resolution: {integrity: sha512-VLCDNwxLC1IPnWT3HLLJUg1Hflf8A2jfs7aNF4vyMTWmKnrk1zmN+VyXQTAkrqr27qE5FnmLhHOYF3SNolNucw==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/pm': ^3.6.5
'@tiptap/extension-list-item@3.6.5':
resolution: {integrity: sha512-A5JKf2dNG6IRrHmkaqroq/VcD5SnXYXgpQpsF7HrPGIzUSIjvjQu088980NQPHyMuTanDMml+nZgd8RzHhRISA==}
peerDependencies:
'@tiptap/extension-list': ^3.6.5
'@tiptap/extension-list-keymap@3.6.5':
resolution: {integrity: sha512-OHGGTJMdUOBincMgYGEN4WzHrTB/GFeCxLDJraDknPx4VJVa3UVZS8F8xd5cb2WnACEF33Ud/0yK3aN6kHrbtQ==}
peerDependencies:
'@tiptap/extension-list': ^3.6.5
'@tiptap/extension-list@3.6.5':
resolution: {integrity: sha512-2S6wNeaGvvYzJygBhHRLP0YubJAzY00WxQSO3NvHFeLFRFvilCnmh0JGMAqsNU+Owpz0iVrWY0YZskN5gPeR9w==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/pm': ^3.6.5
'@tiptap/extension-ordered-list@3.6.5':
resolution: {integrity: sha512-RiBl0Dkw8QtzS7OqUGm84BOyemw/N+hf8DYWsIqVysMRQAGBGhuklbw+DGpCL0nMHW4lh7WtvfKcb0yxLmhbbA==}
peerDependencies:
'@tiptap/extension-list': ^3.6.5
'@tiptap/extension-paragraph@3.6.5':
resolution: {integrity: sha512-AfuaBu+DKrRPspaLsXgo17dhuneISS6QsZTIzPeX21jFJcq3TjtD8wSzS4yRgzAQCEbupkI7t4JbtgxAIBNQHA==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-placeholder@3.6.5':
resolution: {integrity: sha512-9CLixogEb/4UkEyuDr4JdOlLvphcOVfZMdNMKmUVQdqo4MuZCdTDyK5ypfTPQJl8aUo0oCiEhqE0bQerYlueJQ==}
peerDependencies:
'@tiptap/extensions': ^3.6.5
'@tiptap/extension-strike@3.6.5':
resolution: {integrity: sha512-QR7CUmRJ7fJkHtxqKajKIaX/B4xpKFOsAOJHbnqZ8wzOtnEL5IlsmoUnbKBoVn0+2R2YKKvMK3lepGtAcVCfIQ==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-text@3.6.5':
resolution: {integrity: sha512-PVZDWUa25xPzmEN6WWA103yvYJn+NBvWb7WrQwWu9LkKUgd98ZgV3yFaEem/Ybugl/NDPV7q8GGaH+2wEg/VeA==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-underline@3.6.5':
resolution: {integrity: sha512-Ul1mO0H1e2vfvN5g48X/YQ8w1xFTpLqce+GUhi0OmXaZnVOTIMtLuN/zAAPjD+uw+79JVGjYa53lbo1dyhOfAw==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extensions@3.6.5':
resolution: {integrity: sha512-7aadEaRjSbFAIp3WGYR1LXrvtVprmBNxw3FakEUMJ+XKmGNErDJgDMZh+siAYw5MWwCCGa5kKu8Qi/i+DU+ILg==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/pm': ^3.6.5
'@tiptap/pm@3.6.5':
resolution: {integrity: sha512-S+j6MPgUXRIQd5/mdaLjaJnOt4ptFwjqGjGMUfBbf9a3uKpXUXaCCzfuC6ZikwaUtoVh4KN9BU3HCYDtgtENPA==}
'@tiptap/react@3.6.5':
resolution: {integrity: sha512-kum9fYzY6qmHuabcXDUTX2sVLdtJtZS0kN91mwD29Ue8HUkjVvEX92PwV2HtgNw3WFMaVxgm/dtm3XPTAlUEwg==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/pm': ^3.6.5
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
'@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
'@tiptap/starter-kit@3.6.5':
resolution: {integrity: sha512-LNAJQstB/VazmMlRbUyu3rCNVQ9af25Ywkn3Uyuwt3Ks9ZlliIm/x/zertdXTY2adoig+b36zT5Xcx1O4IdJ3A==}
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@ -1523,6 +1698,15 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
'@types/node@20.19.19':
resolution: {integrity: sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==}
@ -1534,6 +1718,12 @@ packages:
'@types/react@19.2.0':
resolution: {integrity: sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==}
'@types/sanitize-html@2.16.0':
resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@typescript-eslint/eslint-plugin@8.45.0':
resolution: {integrity: sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -1910,6 +2100,9 @@ packages:
react:
optional: true
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@ -2010,6 +2203,10 @@ packages:
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
engines: {node: '>=16.0.0'}
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
define-data-property@1.1.4:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'}
@ -2038,6 +2235,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==}
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
@ -2060,6 +2270,10 @@ packages:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
es-abstract@1.24.0:
resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==}
engines: {node: '>= 0.4'}
@ -2393,6 +2607,9 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@ -2484,6 +2701,10 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@ -2640,6 +2861,12 @@ packages:
resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
engines: {node: '>= 12.0.0'}
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@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@ -2665,10 +2892,17 @@ packages:
magic-string@0.30.19:
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@ -2785,6 +3019,9 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
own-keys@1.0.1:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
@ -2801,6 +3038,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse-srcset@1.0.2:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@ -2873,6 +3113,68 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
prosemirror-changeset@2.3.1:
resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==}
prosemirror-collab@1.3.1:
resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==}
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.3.2:
resolution: {integrity: sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==}
prosemirror-history@1.4.1:
resolution: {integrity: sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==}
prosemirror-inputrules@1.5.0:
resolution: {integrity: sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA==}
prosemirror-keymap@1.2.3:
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
prosemirror-markdown@1.13.2:
resolution: {integrity: sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==}
prosemirror-menu@1.2.5:
resolution: {integrity: sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==}
prosemirror-model@1.25.3:
resolution: {integrity: sha512-dY2HdaNXlARknJbrManZ1WyUtos+AP97AmvqdOQtWtrrC5g4mohVX5DTi9rXNFSk09eczLq9GuNTtq3EfMeMGA==}
prosemirror-schema-basic@1.2.4:
resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==}
prosemirror-schema-list@1.5.1:
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
prosemirror-state@1.4.3:
resolution: {integrity: sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==}
prosemirror-tables@1.8.1:
resolution: {integrity: sha512-DAgDoUYHCcc6tOGpLVPSU1k84kCUWTWnfWX3UDy2Delv4ryH0KqTD6RBI6k4yi9j9I8gl3j8MkPpRD/vWPZbug==}
prosemirror-trailing-node@3.0.0:
resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==}
peerDependencies:
prosemirror-model: ^1.22.1
prosemirror-state: ^1.4.2
prosemirror-view: ^1.33.8
prosemirror-transform@1.10.4:
resolution: {integrity: sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==}
prosemirror-view@1.41.2:
resolution: {integrity: sha512-PGS/jETmh+Qjmre/6vcG7SNHAKiGc4vKOJmHMPRmvcUl7ISuVtrtHmH06UDUwaim4NDJfZfVMl7U7JkMMETa6g==}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@ -2996,6 +3298,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@ -3011,6 +3316,9 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
sanitize-html@2.17.0:
resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==}
scheduler@0.26.0:
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
@ -3227,6 +3535,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
@ -3335,6 +3646,9 @@ packages:
jsdom:
optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@ -4297,6 +4611,8 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
'@remirror/core-constants@3.0.0': {}
'@rollup/rollup-android-arm-eabi@4.52.4':
optional: true
@ -4460,6 +4776,187 @@ snapshots:
'@tanstack/table-core@8.21.3': {}
'@tiptap/core@3.6.5(@tiptap/pm@3.6.5)':
dependencies:
'@tiptap/pm': 3.6.5
'@tiptap/extension-blockquote@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-bold@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-bubble-menu@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)':
dependencies:
'@floating-ui/dom': 1.7.4
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/pm': 3.6.5
optional: true
'@tiptap/extension-bullet-list@3.6.5(@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/extension-list': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-code-block@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/pm': 3.6.5
'@tiptap/extension-code@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-document@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-dropcursor@3.6.5(@tiptap/extensions@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/extensions': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-floating-menu@3.6.5(@floating-ui/dom@1.7.4)(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)':
dependencies:
'@floating-ui/dom': 1.7.4
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/pm': 3.6.5
optional: true
'@tiptap/extension-gapcursor@3.6.5(@tiptap/extensions@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/extensions': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-hard-break@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-heading@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-horizontal-rule@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/pm': 3.6.5
'@tiptap/extension-italic@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-link@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/pm': 3.6.5
linkifyjs: 4.3.2
'@tiptap/extension-list-item@3.6.5(@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/extension-list': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-list-keymap@3.6.5(@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/extension-list': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/pm': 3.6.5
'@tiptap/extension-ordered-list@3.6.5(@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/extension-list': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-paragraph@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-placeholder@3.6.5(@tiptap/extensions@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/extensions': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-strike@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-text@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-underline@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extensions@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/pm': 3.6.5
'@tiptap/pm@3.6.5':
dependencies:
prosemirror-changeset: 2.3.1
prosemirror-collab: 1.3.1
prosemirror-commands: 1.7.1
prosemirror-dropcursor: 1.8.2
prosemirror-gapcursor: 1.3.2
prosemirror-history: 1.4.1
prosemirror-inputrules: 1.5.0
prosemirror-keymap: 1.2.3
prosemirror-markdown: 1.13.2
prosemirror-menu: 1.2.5
prosemirror-model: 1.25.3
prosemirror-schema-basic: 1.2.4
prosemirror-schema-list: 1.5.1
prosemirror-state: 1.4.3
prosemirror-tables: 1.8.1
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.2)
prosemirror-transform: 1.10.4
prosemirror-view: 1.41.2
'@tiptap/react@3.6.5(@floating-ui/dom@1.7.4)(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/pm': 3.6.5
'@types/react': 19.2.0
'@types/react-dom': 19.2.0(@types/react@19.2.0)
'@types/use-sync-external-store': 0.0.6
fast-deep-equal: 3.1.3
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.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-floating-menu': 3.6.5(@floating-ui/dom@1.7.4)(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
transitivePeerDependencies:
- '@floating-ui/dom'
'@tiptap/starter-kit@3.6.5':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-blockquote': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-bold': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-bullet-list': 3.6.5(@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))
'@tiptap/extension-code': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-code-block': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-document': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-dropcursor': 3.6.5(@tiptap/extensions@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))
'@tiptap/extension-gapcursor': 3.6.5(@tiptap/extensions@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))
'@tiptap/extension-hard-break': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-heading': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-horizontal-rule': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-italic': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-link': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-list': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-list-item': 3.6.5(@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))
'@tiptap/extension-list-keymap': 3.6.5(@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))
'@tiptap/extension-ordered-list': 3.6.5(@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))
'@tiptap/extension-paragraph': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-strike': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-text': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-underline': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extensions': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/pm': 3.6.5
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
@ -4495,6 +4992,15 @@ snapshots:
'@types/json5@0.0.29': {}
'@types/linkify-it@5.0.0': {}
'@types/markdown-it@14.1.2':
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
'@types/mdurl@2.0.0': {}
'@types/node@20.19.19':
dependencies:
undici-types: 6.21.0
@ -4507,6 +5013,12 @@ snapshots:
dependencies:
csstype: 3.1.3
'@types/sanitize-html@2.16.0':
dependencies:
htmlparser2: 8.0.2
'@types/use-sync-external-store@0.0.6': {}
'@typescript-eslint/eslint-plugin@8.45.0(@typescript-eslint/parser@8.45.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.1
@ -4911,6 +5423,8 @@ snapshots:
optionalDependencies:
react: 19.1.0
crelt@1.0.6: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@ -4995,6 +5509,8 @@ snapshots:
deepmerge-ts@7.1.5: {}
deepmerge@4.3.1: {}
define-data-property@1.1.4:
dependencies:
es-define-property: 1.0.1
@ -5024,6 +5540,24 @@ snapshots:
'@babel/runtime': 7.28.4
csstype: 3.1.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
dotenv@16.6.1: {}
dunder-proto@1.0.1:
@ -5046,6 +5580,8 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.0
entities@4.5.0: {}
es-abstract@1.24.0:
dependencies:
array-buffer-byte-length: 1.0.2
@ -5572,6 +6108,13 @@ snapshots:
dependencies:
function-bind: 1.1.2
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 4.5.0
ignore@5.3.2: {}
ignore@7.0.5: {}
@ -5664,6 +6207,8 @@ snapshots:
is-number@7.0.0: {}
is-plain-object@5.0.0: {}
is-regex@1.2.1:
dependencies:
call-bound: 1.0.4
@ -5803,6 +6348,12 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.1
lightningcss-win32-x64-msvc: 1.30.1
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
linkifyjs@4.3.2: {}
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@ -5825,8 +6376,19 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1
entities: 4.5.0
linkify-it: 5.0.0
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
math-intrinsics@1.1.0: {}
mdurl@2.0.0: {}
merge2@1.4.1: {}
micromatch@4.0.8:
@ -5949,6 +6511,8 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
orderedmap@2.1.1: {}
own-keys@1.0.1:
dependencies:
get-intrinsic: 1.3.0
@ -5967,6 +6531,8 @@ snapshots:
dependencies:
callsites: 3.1.0
parse-srcset@1.0.2: {}
path-exists@4.0.0: {}
path-key@3.1.1: {}
@ -6026,6 +6592,111 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
prosemirror-changeset@2.3.1:
dependencies:
prosemirror-transform: 1.10.4
prosemirror-collab@1.3.1:
dependencies:
prosemirror-state: 1.4.3
prosemirror-commands@1.7.1:
dependencies:
prosemirror-model: 1.25.3
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
prosemirror-dropcursor@1.8.2:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
prosemirror-view: 1.41.2
prosemirror-gapcursor@1.3.2:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.3
prosemirror-state: 1.4.3
prosemirror-view: 1.41.2
prosemirror-history@1.4.1:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
prosemirror-view: 1.41.2
rope-sequence: 1.3.4
prosemirror-inputrules@1.5.0:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
prosemirror-keymap@1.2.3:
dependencies:
prosemirror-state: 1.4.3
w3c-keyname: 2.2.8
prosemirror-markdown@1.13.2:
dependencies:
'@types/markdown-it': 14.1.2
markdown-it: 14.1.0
prosemirror-model: 1.25.3
prosemirror-menu@1.2.5:
dependencies:
crelt: 1.0.6
prosemirror-commands: 1.7.1
prosemirror-history: 1.4.1
prosemirror-state: 1.4.3
prosemirror-model@1.25.3:
dependencies:
orderedmap: 2.1.1
prosemirror-schema-basic@1.2.4:
dependencies:
prosemirror-model: 1.25.3
prosemirror-schema-list@1.5.1:
dependencies:
prosemirror-model: 1.25.3
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
prosemirror-state@1.4.3:
dependencies:
prosemirror-model: 1.25.3
prosemirror-transform: 1.10.4
prosemirror-view: 1.41.2
prosemirror-tables@1.8.1:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.3
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
prosemirror-view: 1.41.2
prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.2):
dependencies:
'@remirror/core-constants': 3.0.0
escape-string-regexp: 4.0.0
prosemirror-model: 1.25.3
prosemirror-state: 1.4.3
prosemirror-view: 1.41.2
prosemirror-transform@1.10.4:
dependencies:
prosemirror-model: 1.25.3
prosemirror-view@1.41.2:
dependencies:
prosemirror-model: 1.25.3
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
punycode.js@2.3.1: {}
punycode@2.3.1: {}
pure-rand@6.1.0: {}
@ -6181,6 +6852,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.52.4
fsevents: 2.3.3
rope-sequence@1.3.4: {}
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@ -6204,6 +6877,15 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
sanitize-html@2.17.0:
dependencies:
deepmerge: 4.3.1
escape-string-regexp: 4.0.0
htmlparser2: 8.0.2
is-plain-object: 5.0.0
parse-srcset: 1.0.2
postcss: 8.5.6
scheduler@0.26.0: {}
semver@6.3.1: {}
@ -6472,6 +7154,8 @@ snapshots:
typescript@5.9.3: {}
uc.micro@2.1.0: {}
unbox-primitive@1.1.0:
dependencies:
call-bound: 1.0.4
@ -6617,6 +7301,8 @@ snapshots:
- supports-color
- terser
w3c-keyname@2.2.8: {}
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0

View file

@ -120,3 +120,21 @@
@apply bg-background text-foreground font-sans antialiased;
}
}
@layer components {
/* Tipografia básica para conteúdos rich text (Tiptap) */
.rich-text {
@apply text-foreground;
}
.rich-text p { @apply my-2; }
.rich-text a { @apply text-primary underline; }
.rich-text ul { @apply my-2 list-disc ps-5; }
.rich-text ol { @apply my-2 list-decimal ps-5; }
.rich-text li { @apply my-1; }
.rich-text blockquote { @apply my-3 border-l-2 border-muted-foreground/30 ps-3 text-muted-foreground; }
.rich-text h1 { @apply text-xl font-semibold my-3; }
.rich-text h2 { @apply text-lg font-semibold my-3; }
.rich-text h3 { @apply text-base font-semibold my-2; }
.rich-text code { @apply rounded bg-muted px-1 py-0.5 text-xs; }
.rich-text pre { @apply my-3 overflow-x-auto rounded bg-muted p-3 text-xs; }
}

View file

@ -3,6 +3,7 @@ import { SiteHeader } from "@/components/site-header"
import { TicketDetailView } from "@/components/tickets/ticket-detail-view"
import { TicketDetailStatic } from "@/components/tickets/ticket-detail-static"
import { getTicketById } from "@/lib/mocks/tickets"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
type TicketDetailPageProps = {
params: Promise<{ id: string }>
@ -24,7 +25,7 @@ export default async function TicketDetailPage({ params }: TicketDetailPageProps
/>
}
>
{isMock && mock ? <TicketDetailStatic ticket={mock as any} /> : <TicketDetailView id={id} />}
{isMock && mock ? <TicketDetailStatic ticket={mock as TicketWithDetails} /> : <TicketDetailView id={id} />}
</AppShell>
)
}

View file

@ -1,6 +1,8 @@
"use client";
import { useMemo, useState } from "react";
import type { Id } from "@/convex/_generated/dataModel";
import type { TicketQueueSummary } from "@/lib/schemas/ticket";
import { useRouter } from "next/navigation";
import { useMutation, useQuery } from "convex/react";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@ -8,12 +10,14 @@ import { useMutation, useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { useAuth } from "@/lib/auth-client";
import { RichTextEditor } from "@/components/ui/rich-text-editor";
export default function NewTicketPage() {
const router = useRouter();
const { userId } = useAuth();
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? [];
const queues = (useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined) ?? [];
const create = useMutation(api.tickets.create);
const addComment = useMutation(api.tickets.addComment);
const ensureDefaults = useMutation(api.bootstrap.ensureDefaults);
const [subject, setSubject] = useState("");
@ -21,25 +25,30 @@ export default function NewTicketPage() {
const [priority, setPriority] = useState("MEDIUM");
const [channel, setChannel] = useState("MANUAL");
const [queueName, setQueueName] = useState<string | null>(null);
const [description, setDescription] = useState("");
const queueOptions = useMemo(() => queues.map((q: any) => q.name), [queues]);
const queueOptions = useMemo(() => queues.map((q) => q.name), [queues]);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!userId) return;
if (queues.length === 0) await ensureDefaults({ tenantId: DEFAULT_TENANT_ID });
// Encontrar a fila pelo nome (simples)
const selQueue = (queues as any[]).find((q: any) => q.name === queueName);
const queueId = selQueue ? selQueue.id : undefined;
const selQueue = queues.find((q) => q.name === queueName);
const queueId = selQueue ? (selQueue.id as Id<"queues">) : undefined;
const id = await create({
tenantId: DEFAULT_TENANT_ID,
subject,
summary,
priority,
channel,
queueId: queueId as any,
requesterId: userId as any,
queueId,
requesterId: userId as Id<"users">,
});
const hasDescription = description.replace(/<[^>]*>/g, "").trim().length > 0
if (hasDescription) {
await addComment({ ticketId: id as Id<"tickets">, authorId: userId as Id<"users">, visibility: "PUBLIC", body: description, attachments: [] })
}
router.replace(`/tickets/${id}`);
}
@ -55,6 +64,10 @@ export default function NewTicketPage() {
<label className="text-sm">Resumo</label>
<textarea className="w-full rounded-md border bg-background px-3 py-2" value={summary} onChange={(e) => setSummary(e.target.value)} rows={3} />
</div>
<div className="space-y-2">
<label className="text-sm">Descrição</label>
<RichTextEditor value={description} onChange={setDescription} placeholder="Detalhe o problema, passos para reproduzir, links, etc." />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-sm">Prioridade</label>

View file

@ -83,7 +83,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarHeader className="gap-3">
<VersionSwitcher
label="Release"
versions={navigation.versions}
versions={[...navigation.versions]}
defaultVersion={navigation.versions[0]}
/>
<SearchForm placeholder="Buscar tickets, macros ou artigos" />

View file

@ -2,6 +2,8 @@
import { z } from "zod"
import { useState } from "react"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketQueueSummary } from "@/lib/schemas/ticket"
import { useMutation, useQuery } from "convex/react"
// @ts-ignore
import { api } from "@/convex/_generated/api"
@ -17,10 +19,12 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { Spinner } from "@/components/ui/spinner"
import { Dropzone } from "@/components/ui/dropzone"
import { RichTextEditor } from "@/components/ui/rich-text-editor"
const schema = z.object({
subject: z.string().min(3, "Informe um assunto"),
summary: z.string().optional(),
description: z.string().optional(),
priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"),
channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"),
queueName: z.string().nullable().optional(),
@ -31,11 +35,11 @@ export function NewTicketDialog() {
const [loading, setLoading] = useState(false)
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: { subject: "", summary: "", priority: "MEDIUM", channel: "MANUAL", queueName: null },
defaultValues: { subject: "", summary: "", description: "", priority: "MEDIUM", channel: "MANUAL", queueName: null },
mode: "onTouched",
})
const { userId } = useAuth()
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? []
const queues = (useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined) ?? []
const create = useMutation(api.tickets.create)
const addComment = useMutation(api.tickets.addComment)
const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
@ -45,18 +49,26 @@ export function NewTicketDialog() {
setLoading(true)
toast.loading("Criando ticket…", { id: "new-ticket" })
try {
const sel = queues.find((q: any) => q.name === values.queueName)
const sel = queues.find((q) => q.name === values.queueName)
const id = await create({
tenantId: DEFAULT_TENANT_ID,
subject: values.subject,
summary: values.summary,
priority: values.priority,
channel: values.channel,
queueId: sel?.id,
requesterId: userId as any,
queueId: sel?.id as Id<"queues"> | undefined,
requesterId: userId as Id<"users">,
})
if (attachments.length > 0 || (values.summary && values.summary.trim().length > 0)) {
await addComment({ ticketId: id as any, authorId: userId as any, visibility: "PUBLIC", body: values.summary || "", attachments })
const hasDescription = (values.description ?? "").replace(/<[^>]*>/g, "").trim().length > 0
const bodyHtml = hasDescription ? (values.description as string) : (values.summary || "")
if (attachments.length > 0 || bodyHtml.trim().length > 0) {
const typedAttachments = attachments.map((a) => ({
storageId: a.storageId as unknown as Id<"_storage">,
name: a.name,
size: a.size,
type: a.type,
}))
await addComment({ ticketId: id as Id<"tickets">, authorId: userId as Id<"users">, visibility: "PUBLIC", body: bodyHtml, attachments: typedAttachments })
}
toast.success("Ticket criado!", { id: "new-ticket" })
setOpen(false)
@ -93,6 +105,14 @@ export function NewTicketDialog() {
<FieldLabel htmlFor="summary">Resumo</FieldLabel>
<textarea id="summary" className="w-full rounded-md border bg-background p-2 text-sm" rows={3} {...form.register("summary")} />
</Field>
<Field>
<FieldLabel>Descrição</FieldLabel>
<RichTextEditor
value={form.watch("description") || ""}
onChange={(html) => form.setValue("description", html)}
placeholder="Detalhe o problema, passos para reproduzir, links, etc."
/>
</Field>
<Field>
<FieldLabel>Anexos</FieldLabel>
<Dropzone onUploaded={(files) => setAttachments((prev) => [...prev, ...files])} />
@ -101,7 +121,7 @@ export function NewTicketDialog() {
<div className="grid gap-3 sm:grid-cols-3">
<Field>
<FieldLabel>Prioridade</FieldLabel>
<Select value={form.watch("priority")} onValueChange={(v) => form.setValue("priority", v as any)}>
<Select value={form.watch("priority")} onValueChange={(v) => form.setValue("priority", v as z.infer<typeof schema>["priority"])}>
<SelectTrigger><SelectValue placeholder="Prioridade" /></SelectTrigger>
<SelectContent>
<SelectItem value="LOW">Baixa</SelectItem>
@ -113,7 +133,7 @@ export function NewTicketDialog() {
</Field>
<Field>
<FieldLabel>Canal</FieldLabel>
<Select value={form.watch("channel")} onValueChange={(v) => form.setValue("channel", v as any)}>
<Select value={form.watch("channel")} onValueChange={(v) => form.setValue("channel", v as z.infer<typeof schema>["channel"])}>
<SelectTrigger><SelectValue placeholder="Canal" /></SelectTrigger>
<SelectContent>
<SelectItem value="EMAIL">E-mail</SelectItem>
@ -135,7 +155,7 @@ export function NewTicketDialog() {
<SelectTrigger><SelectValue placeholder="Sem fila" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Sem fila</SelectItem>
{queues.map((q: any) => (
{queues.map((q) => (
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
))}
</SelectContent>

View file

@ -1,6 +1,7 @@
"use client"
import Link from "next/link"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react"
@ -9,7 +10,9 @@ import { useMutation, useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
import type { TicketPlayContext } from "@/lib/schemas/ticket"
import type { TicketPlayContext, TicketQueueSummary } from "@/lib/schemas/ticket"
import type { Id } from "@/convex/_generated/dataModel"
import { mapTicketFromServer } from "@/lib/mappers/ticket"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@ -17,6 +20,7 @@ import { Separator } from "@/components/ui/separator"
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
import { Spinner } from "@/components/ui/spinner"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
interface PlayNextTicketCardProps {
context?: TicketPlayContext
@ -25,19 +29,21 @@ interface PlayNextTicketCardProps {
export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
const router = useRouter()
const { userId } = useAuth()
const queueSummary = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? []
const queueSummary = (useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined) ?? []
const playNext = useMutation(api.tickets.playNext)
const [selectedQueueId, setSelectedQueueId] = useState<string | undefined>(undefined)
const nextTicketFromServer = useQuery(api.tickets.list, {
tenantId: DEFAULT_TENANT_ID,
status: undefined,
priority: undefined,
channel: undefined,
queueId: undefined,
queueId: (selectedQueueId as Id<"queues">) || undefined,
limit: 1,
})?.[0]
const nextTicketUi = nextTicketFromServer ? mapTicketFromServer(nextTicketFromServer as unknown) : null
const cardContext: TicketPlayContext | null = context ?? (nextTicketFromServer ? { queue: { id: "default", name: "Geral", pending: queueSummary.reduce((a: number, b: any) => a + b.pending, 0), waiting: queueSummary.reduce((a: number, b: any) => a + b.waiting, 0), breached: 0 }, nextTicket: nextTicketFromServer } : null)
const cardContext: TicketPlayContext | null = context ?? (nextTicketUi ? { queue: { id: "default", name: "Geral", pending: queueSummary.reduce((a, b) => a + b.pending, 0), waiting: queueSummary.reduce((a, b) => a + b.waiting, 0), breached: 0 }, nextTicket: nextTicketUi } : null)
if (!cardContext || !cardContext.nextTicket) {
return (
@ -63,6 +69,18 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
<TicketPriorityPill priority={ticket.priority} />
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex items-center justify-end gap-2">
<span className="text-sm text-muted-foreground">Fila:</span>
<Select value={selectedQueueId ?? "ALL"} onValueChange={(v) => setSelectedQueueId(v === "ALL" ? undefined : v)}>
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Todas" /></SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Todas</SelectItem>
{queueSummary.map((q) => (
<SelectItem key={q.id} value={q.id}>{q.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<h2 className="text-xl font-medium text-foreground">{ticket.subject}</h2>
<p className="text-sm text-muted-foreground">{ticket.summary}</p>
@ -91,7 +109,7 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
className="gap-2"
onClick={async () => {
if (!userId) return
const chosen = await playNext({ tenantId: DEFAULT_TENANT_ID, queueId: undefined, agentId: userId as any })
const chosen = await playNext({ tenantId: DEFAULT_TENANT_ID, queueId: (selectedQueueId as Id<"queues">) || undefined, agentId: userId as Id<"users"> })
if (chosen?.id) router.push(`/tickets/${chosen.id}`)
}}
>

View file

@ -7,6 +7,7 @@ import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { mapTicketsFromServerList } from "@/lib/mappers/ticket";
import { TicketsTable } from "@/components/tickets/tickets-table";
import { Spinner } from "@/components/ui/spinner";
import type { Ticket } from "@/lib/schemas/ticket";
export function RecentTicketsPanel() {
const ticketsRaw = useQuery(api.tickets.list, { tenantId: DEFAULT_TENANT_ID, limit: 10 });
@ -24,10 +25,10 @@ export function RecentTicketsPanel() {
</div>
);
}
const tickets = mapTicketsFromServerList(ticketsRaw as any[]);
const tickets = mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]);
return (
<div className="rounded-xl border bg-card">
<TicketsTable tickets={tickets as any} />
<TicketsTable tickets={tickets} />
</div>
);
}

View file

@ -1,6 +1,6 @@
"use client"
import { ticketStatusSchema } from "@/lib/schemas/ticket"
import { ticketStatusSchema, type TicketStatus } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
const statusConfig = {
@ -10,11 +10,9 @@ const statusConfig = {
ON_HOLD: { label: "Em espera", className: "bg-purple-100 text-purple-700 border-transparent" },
RESOLVED: { label: "Resolvido", className: "bg-emerald-100 text-emerald-700 border-transparent" },
CLOSED: { label: "Fechado", className: "bg-slate-100 text-slate-700 border-transparent" },
} satisfies Record<(typeof ticketStatusSchema)["_type"], { label: string; className: string }>
} satisfies Record<TicketStatus, { label: string; className: string }>
type TicketStatusBadgeProps = {
status: (typeof ticketStatusSchema)["_type"]
}
type TicketStatusBadgeProps = { status: TicketStatus }
export function TicketStatusBadge({ status }: TicketStatusBadgeProps) {
const config = statusConfig[status]

View file

@ -4,12 +4,13 @@ import { useMemo, useState } from "react"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage } from "@tabler/icons-react"
import { Download, ImageIcon, FileIcon } from "lucide-react"
import { Download, FileIcon } from "lucide-react"
import { useAction, useMutation } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
@ -17,7 +18,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
import { Dropzone } from "@/components/ui/dropzone"
import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
import { Dialog, DialogContent } from "@/components/ui/dialog"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
interface TicketCommentsProps {
ticket: TicketWithDetails
@ -31,6 +34,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
const [preview, setPreview] = useState<string | null>(null)
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id"|"author"|"visibility"|"body"|"attachments"|"createdAt"|"updatedAt">[]>([])
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC")
const commentsAll = useMemo(() => {
return [...pending, ...ticket.comments]
@ -43,19 +47,25 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const now = new Date()
const optimistic = {
id: `temp-${now.getTime()}`,
author: ticket.requester, // placeholder; poderia buscar o próprio usuário se necessário
visibility: "PUBLIC" as const,
body,
attachments: attachments.map((a) => ({ id: a.storageId, name: a.name, url: a.previewUrl } as any)),
author: ticket.requester,
visibility,
body: sanitizeEditorHtml(body),
attachments: attachments.map((a) => ({ id: a.storageId, name: a.name, url: a.previewUrl })),
createdAt: now,
updatedAt: now,
}
setPending((p) => [optimistic, ...p])
setBody("")
setAttachmentsToSend([])
toast.loading("Enviando comentário", { id: "comment" })
toast.loading("Enviando comentário.", { id: "comment" })
try {
await addComment({ ticketId: ticket.id as any, authorId: userId as any, visibility: "PUBLIC", body: optimistic.body, attachments })
const typedAttachments = attachments.map((a) => ({
storageId: a.storageId as unknown as Id<"_storage">,
name: a.name,
size: a.size,
type: a.type,
}))
await addComment({ ticketId: ticket.id as Id<"tickets">, authorId: userId as Id<"users">, visibility, body: optimistic.body, attachments: typedAttachments })
setPending([])
toast.success("Comentário enviado!", { id: "comment" })
} catch (err) {
@ -74,7 +84,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
<CardContent className="space-y-6 px-4 pb-6">
{commentsAll.length === 0 ? (
<p className="text-sm text-muted-foreground">
Ainda sem comentarios. Que tal registrar o proximo passo?
Ainda sem comentários. Que tal registrar o próximo passo?
</p>
) : (
commentsAll.map((comment) => {
@ -101,20 +111,19 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
</span>
</div>
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground break-words whitespace-pre-wrap">
{comment.body}
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground break-words">
<RichTextContent html={comment.body} />
</div>
{comment.attachments?.length ? (
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
{comment.attachments.map((a) => {
const att = a as any
{comment.attachments.map((att) => {
const isImg = (att?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
if (isImg && att.url) {
return (
<button
key={att.id}
type="button"
onClick={() => setPreview(att.url)}
onClick={() => setPreview(att.url || null)}
className="group overflow-hidden rounded-lg border bg-card p-0.5 hover:shadow"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
@ -140,15 +149,19 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
})
)}
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
<textarea
className="w-full rounded-md border bg-background p-3 text-sm"
placeholder="Escreva um comentario..."
rows={3}
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<RichTextEditor value={body} onChange={setBody} placeholder="Escreva um comentário..." />
<Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} />
<div className="flex items-center justify-end">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
Visibilidade:
<Select value={visibility} onValueChange={(v) => setVisibility(v as "PUBLIC" | "INTERNAL")}>
<SelectTrigger className="h-8 w-[140px]"><SelectValue placeholder="Visibilidade" /></SelectTrigger>
<SelectContent>
<SelectItem value="PUBLIC">Pública</SelectItem>
<SelectItem value="INTERNAL">Interna</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="submit" size="sm">Enviar</Button>
</div>
</form>

View file

@ -6,21 +6,23 @@ import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
import type { Id } from "@/convex/_generated/dataModel";
import type { TicketWithDetails } from "@/lib/schemas/ticket";
import { getTicketById } from "@/lib/mocks/tickets";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { TicketComments } from "@/components/tickets/ticket-comments";
import { TicketComments } from "@/components/tickets/ticket-comments.rich";
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
export function TicketDetailView({ id }: { id: string }) {
const isMockId = id.startsWith("ticket-");
const t = useQuery(api.tickets.getById, isMockId ? undefined : ({ tenantId: DEFAULT_TENANT_ID, id: id as any }));
let ticket: any | null = null;
const t = useQuery(api.tickets.getById, isMockId ? "skip" : ({ tenantId: DEFAULT_TENANT_ID, id: id as Id<"tickets"> }));
let ticket: TicketWithDetails | null = null;
if (t) {
ticket = mapTicketWithDetailsFromServer(t as any);
ticket = mapTicketWithDetailsFromServer(t as unknown);
} else if (isMockId) {
ticket = getTicketById(id) ?? null;
}
@ -54,13 +56,13 @@ export function TicketDetailView({ id }: { id: string }) {
);
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketSummaryHeader ticket={ticket as any} />
<TicketSummaryHeader ticket={ticket} />
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6">
<TicketComments ticket={ticket as any} />
<TicketTimeline ticket={ticket as any} />
<TicketComments ticket={ticket} />
<TicketTimeline ticket={ticket} />
</div>
<TicketDetailsPanel ticket={ticket as any} />
<TicketDetailsPanel ticket={ticket} />
</div>
</div>
);

View file

@ -15,7 +15,7 @@ interface TicketQueueSummaryProps {
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
const fromServer = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID })
const data: TicketQueueSummary[] = (queues ?? fromServer ?? []) as any
const data: TicketQueueSummary[] = (queues ?? (fromServer as TicketQueueSummary[] | undefined) ?? [])
if (!queues && fromServer === undefined) {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">

View file

@ -11,7 +11,8 @@ import { toast } from "sonner"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import type { Doc, Id } from "@/convex/_generated/dataModel"
import type { TicketWithDetails, TicketQueueSummary, TicketStatus } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
@ -27,9 +28,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const updateStatus = useMutation(api.tickets.updateStatus)
const changeAssignee = useMutation(api.tickets.changeAssignee)
const changeQueue = useMutation(api.tickets.changeQueue)
const agents = useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) ?? []
const queues = useQuery(api.queues.summary, { tenantId: ticket.tenantId }) ?? []
const [status, setStatus] = useState(ticket.status)
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
const queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? []
const [status, setStatus] = useState<TicketStatus>(ticket.status)
const statusPt: Record<string, string> = {
NEW: "Novo",
OPEN: "Aberto",
@ -47,16 +48,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
#{ticket.reference}
</Badge>
<TicketPriorityPill priority={ticket.priority} />
<TicketStatusBadge status={status as any} />
<TicketStatusBadge status={status} />
<Select
value={status}
onValueChange={async (value) => {
const prev = status
setStatus(value) // otimista
setStatus(value as import("@/lib/schemas/ticket").TicketStatus) // otimista
if (!userId) return
toast.loading("Atualizando status…", { id: "status" })
try {
await updateStatus({ ticketId: ticket.id as any, status: value as any, actorId: userId as any })
await updateStatus({ ticketId: ticket.id as Id<"tickets">, status: value, actorId: userId as Id<"users"> })
toast.success(`Status alterado para ${statusPt[value]}.`, { id: "status" })
} catch (e) {
setStatus(prev)
@ -95,7 +96,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
if (!userId) return
toast.loading("Atribuindo responsável…", { id: "assignee" })
try {
await changeAssignee({ ticketId: ticket.id as any, assigneeId: value as any, actorId: userId as any })
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: userId as Id<"users"> })
toast.success("Responsável atualizado!", { id: "assignee" })
} catch {
toast.error("Não foi possível atribuir.", { id: "assignee" })
@ -104,7 +105,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
>
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectContent>
{agents.map((a: any) => (
{agents.map((a) => (
<SelectItem key={a._id} value={a._id}>{a.name}</SelectItem>
))}
</SelectContent>
@ -118,11 +119,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
value={ticket.queue ?? ""}
onValueChange={async (value) => {
if (!userId) return
const q = queues.find((qq: any) => qq.name === value)
const q = queues.find((qq) => qq.name === value)
if (!q) return
toast.loading("Atualizando fila…", { id: "queue" })
try {
await changeQueue({ ticketId: ticket.id as any, queueId: q.id as any, actorId: userId as any })
await changeQueue({ ticketId: ticket.id as Id<"tickets">, queueId: q.id as Id<"queues">, actorId: userId as Id<"users"> })
toast.success("Fila atualizada!", { id: "queue" })
} catch {
toast.error("Não foi possível atualizar a fila.", { id: "queue" })
@ -131,7 +132,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
>
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectContent>
{queues.map((q: any) => (
{queues.map((q) => (
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
))}
</SelectContent>

View file

@ -56,12 +56,12 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
{entry.payload?.actorName ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Avatar className="size-5">
<AvatarImage src={entry.payload.actorAvatar} alt={entry.payload.actorName} />
<AvatarImage src={entry.payload?.actorAvatar as string | undefined} alt={String(entry.payload?.actorName ?? "")} />
<AvatarFallback>
{entry.payload.actorName.split(' ').slice(0,2).map((p:string)=>p[0]).join('').toUpperCase()}
{String(entry.payload?.actorName ?? '').split(' ').slice(0,2).map((p:string)=>p[0]).join('').toUpperCase()}
</AvatarFallback>
</Avatar>
por {entry.payload.actorName}
por {String(entry.payload?.actorName ?? '')}
</span>
) : null}
<span className="text-xs text-muted-foreground">
@ -69,7 +69,7 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
</span>
</div>
{(() => {
const p: any = entry.payload || {}
const p = (entry.payload || {}) as { toLabel?: string; to?: string; assigneeName?: string; assigneeId?: string; queueName?: string; queueId?: string; requesterName?: string; authorName?: string; authorId?: string }
let message: string | null = null
if (entry.type === "STATUS_CHANGED" && (p.toLabel || p.to)) message = `Status alterado para ${p.toLabel || p.to}`
if (entry.type === "ASSIGNEE_CHANGED" && (p.assigneeName || p.assigneeId)) message = `Responsável alterado${p.assigneeName ? ` para ${p.assigneeName}` : ""}`

View file

@ -7,6 +7,7 @@ import { useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import type { Ticket, TicketQueueSummary } from "@/lib/schemas/ticket"
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
import { TicketsTable } from "@/components/tickets/tickets-table"
import { Spinner } from "@/components/ui/spinner"
@ -14,7 +15,7 @@ import { Spinner } from "@/components/ui/spinner"
export function TicketsView() {
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID })
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined
const ticketsRaw = useQuery(api.tickets.list, {
tenantId: DEFAULT_TENANT_ID,
status: filters.status ?? undefined,
@ -24,16 +25,16 @@ export function TicketsView() {
search: filters.search || undefined,
})
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as any[]), [ticketsRaw])
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]), [ticketsRaw])
const filteredTickets = useMemo(() => {
if (!filters.queue) return tickets
return tickets.filter((t: any) => t.queue === filters.queue)
return tickets.filter((t: Ticket) => t.queue === filters.queue)
}, [tickets, filters.queue])
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketsFilters onChange={setFilters} queues={(queues ?? []).map((q: any) => q.name)} />
<TicketsFilters onChange={setFilters} queues={(queues ?? []).map((q) => q.name)} />
{ticketsRaw === undefined ? (
<div className="rounded-xl border bg-card p-4">
<div className="grid gap-3">
@ -46,7 +47,7 @@ export function TicketsView() {
</div>
</div>
) : (
<TicketsTable tickets={filteredTickets as any} />
<TicketsTable tickets={filteredTickets} />
)}
</div>
)

View file

@ -7,39 +7,38 @@ const FieldSet = ({ className, ...props }: React.ComponentProps<"fieldset">) =>
<fieldset role="group" className={cn("grid gap-3", className)} {...props} />
)
const FieldLegend = ({ className, ...props }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) => {
const { variant = "legend", ...rest } = props as any
return (
<legend
className={cn(
variant === "label" ? "text-sm font-medium" : "text-sm font-semibold",
"text-foreground", className
)}
{...rest}
/>
)
}
const FieldLegend = (
{ className, variant = "legend", ...props }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }
) => (
<legend
className={cn(
variant === "label" ? "text-sm font-medium" : "text-sm font-semibold",
"text-foreground",
className
)}
{...props}
/>
)
const FieldGroup = ({ className, ...props }: React.ComponentProps<"div">) => (
<div className={cn("@container/field-group grid gap-4", className)} {...props} />
)
const Field = ({ className, ...props }: React.ComponentProps<"div"> & { orientation?: "vertical" | "horizontal" | "responsive" }) => {
const { orientation = "vertical", ...rest } = props as any
return (
<div
data-orientation={orientation}
className={cn(
"flex gap-2",
orientation === "vertical" && "flex-col",
orientation === "horizontal" && "items-center",
orientation === "responsive" && "@[480px]/field-group:flex-row @[480px]/field-group:items-center flex-col",
className
)}
{...rest}
/>
)
}
const Field = (
{ className, orientation = "vertical", ...props }: React.ComponentProps<"div"> & { orientation?: "vertical" | "horizontal" | "responsive" }
) => (
<div
data-orientation={orientation}
className={cn(
"flex gap-2",
orientation === "vertical" && "flex-col",
orientation === "horizontal" && "items-center",
orientation === "responsive" && "@[480px]/field-group:flex-row @[480px]/field-group:items-center flex-col",
className
)}
{...props}
/>
)
const FieldContent = ({ className, ...props }: React.ComponentProps<"div">) => (
<div className={cn("flex flex-col", className)} {...props} />
@ -78,4 +77,3 @@ const FieldSeparator = ({ className, ...props }: React.ComponentProps<"div">) =>
)
export { FieldSet, FieldLegend, FieldGroup, Field, FieldContent, FieldLabel, FieldTitle, FieldDescription, FieldError, FieldSeparator }

View file

@ -0,0 +1,218 @@
"use client"
import { useEffect } from "react"
import { useEditor, EditorContent } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import Link from "@tiptap/extension-link"
import Placeholder from "@tiptap/extension-placeholder"
import { cn } from "@/lib/utils"
import sanitize from "sanitize-html"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import {
Bold,
Italic,
Strikethrough,
List,
ListOrdered,
Quote,
Undo,
Redo,
Link as LinkIcon,
} from "lucide-react"
type RichTextEditorProps = {
value?: string
onChange?: (html: string) => void
className?: string
placeholder?: string
disabled?: boolean
minHeight?: number
}
export function RichTextEditor({
value,
onChange,
className,
placeholder = "Escreva aqui...",
disabled,
minHeight = 120,
}: RichTextEditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
bulletList: { keepMarks: true },
orderedList: { keepMarks: true },
}),
Link.configure({
openOnClick: true,
autolink: true,
protocols: ["http", "https", "mailto"],
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
}),
Placeholder.configure({ placeholder }),
],
editorProps: {
attributes: {
class:
"prose prose-sm max-w-none focus:outline-none text-foreground",
},
},
content: value || "",
onUpdate({ editor }) {
onChange?.(editor.getHTML())
},
editable: !disabled,
// Avoid SSR hydration mismatches per Tiptap recommendation
immediatelyRender: false,
})
// Keep external value in sync when it changes
useEffect(() => {
if (!editor) return
const current = editor.getHTML()
if ((value ?? "") !== current) {
editor.commands.setContent(value || "", { emitUpdate: false })
}
}, [value, editor])
if (!editor) return null
return (
<div className={cn("rounded-md border bg-background", className)}>
<div className="flex flex-wrap items-center gap-1 border-b px-2 py-1">
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive("bold")}
ariaLabel="Negrito"
>
<Bold className="size-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive("italic")}
ariaLabel="Itálico"
>
<Italic className="size-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
active={editor.isActive("strike")}
ariaLabel="Tachado"
>
<Strikethrough className="size-4" />
</ToolbarButton>
<Separator orientation="vertical" className="mx-1 h-5" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive("bulletList")}
ariaLabel="Lista"
>
<List className="size-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive("orderedList")}
ariaLabel="Lista ordenada"
>
<ListOrdered className="size-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive("blockquote")}
ariaLabel="Citação"
>
<Quote className="size-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => {
const prev = editor.getAttributes("link").href as string | undefined
const url = window.prompt("URL do link:", prev || "https://")
if (url === null) return
if (url === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run()
return
}
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run()
}}
active={editor.isActive("link")}
ariaLabel="Inserir link"
>
<LinkIcon className="size-4" />
</ToolbarButton>
<div className="ms-auto flex items-center gap-1">
<ToolbarButton onClick={() => editor.chain().focus().undo().run()} ariaLabel="Desfazer">
<Undo className="size-4" />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().redo().run()} ariaLabel="Refazer">
<Redo className="size-4" />
</ToolbarButton>
</div>
</div>
<div style={{ minHeight }} className="rich-text p-3">
<EditorContent editor={editor} />
</div>
</div>
)
}
function ToolbarButton({
onClick,
active,
ariaLabel,
children,
}: {
onClick: () => void
active?: boolean
ariaLabel?: string
children: React.ReactNode
}) {
return (
<Button
type="button"
variant={active ? "default" : "ghost"}
size="icon"
className="h-7 w-7"
onClick={onClick}
aria-label={ariaLabel}
>
{children}
</Button>
)
}
// Utilitário simples para renderização segura do HTML do editor.
// Remove tags <script>/<style> e atributos on*.
export function sanitizeEditorHtml(html: string): string {
try {
return sanitize(html || "", {
allowedTags: [
"p","br","a","strong","em","u","s","blockquote","ul","ol","li","code","pre","span","h1","h2","h3"
],
allowedAttributes: {
a: ["href","name","target","rel"],
span: ["style"],
code: ["class"],
pre: ["class"],
},
allowedSchemes: ["http","https","mailto"],
// prevent target=_self phishing
transformTags: {
a: sanitize.simpleTransform("a", { rel: "noopener noreferrer", target: "_blank" }, true),
},
// disallow inline event handlers
allowVulnerableTags: false,
})
} catch {
return ""
}
}
export function RichTextContent({ html, className }: { html: string; className?: string }) {
return (
<div
className={cn("rich-text text-sm leading-relaxed", className)}
dangerouslySetInnerHTML={{ __html: sanitizeEditorHtml(html) }}
/>
)
}

View file

@ -1,6 +1,7 @@
"use client";
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
import type { Doc } from "@/convex/_generated/dataModel";
import { useMutation } from "convex/react";
// Lazy import to avoid build errors before convex is generated
@ -32,10 +33,8 @@ export function AuthProvider({ demoUser, tenantId, children }: { demoUser: DemoU
if (!process.env.NEXT_PUBLIC_CONVEX_URL) return; // allow dev without backend
if (!localDemoUser) return;
try {
const user = await ensureUser({ tenantId, name: localDemoUser.name, email: localDemoUser.email, avatarUrl: localDemoUser.avatarUrl });
// Convex returns a full document
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setUserId((user as any)?._id ?? null);
const user = (await ensureUser({ tenantId, name: localDemoUser.name, email: localDemoUser.email, avatarUrl: localDemoUser.avatarUrl })) as Doc<"users"> | null;
setUserId(user?._id ?? null);
} catch (e) {
console.error("Failed to ensure user:", e);
}

View file

@ -60,7 +60,7 @@ const serverEventSchema = z.object({
const serverTicketWithDetailsSchema = serverTicketSchema.extend({
description: z.string().optional().nullable(),
customFields: z.record(z.any()).default({}).optional(),
customFields: z.record(z.string(), z.any()).optional(),
timeline: z.array(serverEventSchema),
comments: z.array(serverCommentSchema),
});
@ -76,7 +76,6 @@ export function mapTicketFromServer(input: unknown) {
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null,
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null,
};
// Já validamos o formato recebido (serverTicketSchema). Retornamos no shape da UI.
return ui as unknown as z.infer<typeof ticketSchema>;
}
@ -88,6 +87,7 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
const s = serverTicketWithDetailsSchema.parse(input);
const ui = {
...s,
customFields: (s.customFields ?? {}) as Record<string, unknown>,
lastTimelineEntry: s.lastTimelineEntry ?? undefined,
updatedAt: new Date(s.updatedAt),
createdAt: new Date(s.createdAt),

View file

@ -59,7 +59,7 @@ export type TicketComment = z.infer<typeof ticketCommentSchema>
export const ticketEventSchema = z.object({
id: z.string(),
type: z.string(),
payload: z.record(z.any()).optional(),
payload: z.record(z.string(), z.any()).optional(),
createdAt: z.coerce.date(),
})
export type TicketEvent = z.infer<typeof ticketEventSchema>
@ -102,7 +102,7 @@ export type Ticket = z.infer<typeof ticketSchema>
export const ticketWithDetailsSchema = ticketSchema.extend({
description: z.string().optional(),
customFields: z.record(z.any()).default({}),
customFields: z.record(z.string(), z.any()).optional(),
timeline: z.array(ticketEventSchema),
comments: z.array(ticketCommentSchema),
})

View file

@ -19,7 +19,8 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@/convex/*": ["./convex/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],