@meistrari/minuta-nuxt 0.1.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,6 +66,93 @@ const minuta = await createMinuta({
66
66
  // navegue para a tela que renderiza <MinutaEmbed :minuta-id="minuta.id" />
67
67
  ```
68
68
 
69
+ Para peça de texto fixo com campos variáveis, sem agente, veja
70
+ [Templates e variáveis](#templates-e-variáveis).
71
+
72
+ ## Templates e variáveis
73
+
74
+ Nem toda peça precisa de agente. Quando o texto já está decidido e o que muda
75
+ são alguns campos — petições intermediárias, ofícios, requerimentos de rotina —
76
+ existe o caminho **template**: um documento com placeholders, escrito no mesmo
77
+ editor A4, que vira minuta pronta no mesmo request, sem passar por agente
78
+ nenhum.
79
+
80
+ **O CDB não guarda template.** Quem guarda é o app hospedeiro, que já tem
81
+ cadastro para isso. O que o embed entrega é o editor e o formato; o que o CDB
82
+ faz é trocar placeholder por valor.
83
+
84
+ ### Escrever o template
85
+
86
+ ```vue
87
+ <MinutaTemplateEditor
88
+ v-model="cadastro.templateHtml"
89
+ :variables="CATALOGO"
90
+ style="height: 100vh"
91
+ @update:used-variables="keys => cadastro.variaveis = keys"
92
+ />
93
+ ```
94
+
95
+ O catálogo é um array plano, definido pelo host — o CDB não sabe o que significa
96
+ nenhuma dessas chaves:
97
+
98
+ ```ts
99
+ const CATALOGO = [
100
+ { key: 'PROCESSO', label: 'Nº do Processo', description: 'De info_processos.processo', group: 'Processo' },
101
+ { key: 'NOME_REU', label: 'Nome do Réu', group: 'Réu' },
102
+ ]
103
+ ```
104
+
105
+ No documento, o advogado digita `/` (ou usa o botão `{ }` da barra), busca **pelo
106
+ rótulo** e insere. O placeholder é um nó atômico: seleciona inteiro, apaga
107
+ inteiro, e negrito/fonte aplicam nele por completo. Não dá para digitar uma
108
+ chave que não existe, e não dá para partir uma pela metade ao formatar —
109
+ que é o motivo de ser nó, e não `#NOME_VARIAVEL` no texto.
110
+
111
+ `update:used-variables` devolve as chaves usadas, na ordem do documento e sem
112
+ repetição. É com ela que o host sabe o que perguntar; ele nunca precisa ler o
113
+ HTML.
114
+
115
+ ### Gerar a peça
116
+
117
+ ```ts
118
+ const minuta = await createMinuta({
119
+ name: 'Requerimento — contrato 123',
120
+ templateHtml: cadastro.templateHtml,
121
+ variables: { PROCESSO: '1234567-89.2026.8.26.0100', NOME_REU: null },
122
+ })
123
+
124
+ minuta.status // 'READY' — sem agente, sem polling
125
+ minuta.missingVariables // ['NOME_REU']
126
+ ```
127
+
128
+ Só texto nos valores: número e data chegam já formatados pelo host, que é quem
129
+ sabe se `1234.5` é `R$ 1.234,50`. `null` significa "não resolveu".
130
+
131
+ **Variável sem valor não some.** Ela continua sendo um placeholder vivo dentro
132
+ do documento, no parágrafo em que está — quem abrir a minuta vê o buraco onde
133
+ ele fica, em vez de descobrir que falta algo por um botão desabilitado. O host
134
+ decide o que fazer com `missingVariables` (abrir pendência, bloquear o
135
+ protocolo, ou deixar preencher no editor mesmo).
136
+
137
+ `templateHtml` e `agentId` se excluem: mandar os dois é 400.
138
+
139
+ ### Props do `<MinutaTemplateEditor>`
140
+
141
+ | Prop | Default | Descrição |
142
+ |---|---|---|
143
+ | `modelValue` | `''` | HTML do template (`v-model`) |
144
+ | `variables` | `[]` | catálogo `{ key, label, description?, group? }` |
145
+ | `editable` | `true` | permite editar |
146
+ | `showToolbar` | `true` | barra com o resumo das variáveis usadas |
147
+ | `margins` | 2,5cm/3cm | margens da página em px @96dpi |
148
+ | `chrome` | `null` | cabeçalho/rodapé; `v-model:chrome` para persistir |
149
+
150
+ Eventos: `update:modelValue`, `update:usedVariables`, `update:chrome`,
151
+ `ready(editor)`. Slots: `toolbar-start`, `toolbar-end`.
152
+ `defineExpose`: `getHtml()`, `usedVariables`, `editor`.
153
+
154
+ Playground: `bun run dev` e abra `/?template`.
155
+
69
156
  ### Props do `<MinutaEmbed>`
70
157
 
71
158
  | Prop | Default | Descrição |
@@ -75,10 +162,20 @@ const minuta = await createMinuta({
75
162
  | `autoSave` | `true` | salva 2s após a última edição |
76
163
  | `showToolbar` | `true` | barra com nome, estado de save e export |
77
164
  | `margins` | 2,5cm/3cm | margens da página em px @96dpi |
165
+ | `renamable` | segue `editable` | nome editável na própria barra |
166
+ | `chatAgentId` | `null` | id de plataforma do agente assistente; sem ele, o chat não aparece |
78
167
 
79
168
  Eventos: `ready(editor)`, `saved(minuta)`, `error(message)`.
169
+ Slot: `toolbar-start` (o host injeta navegação própria, ex.: botão voltar).
80
170
  `defineExpose`: `saveNow()`, `exportDocx()`, `editor`.
81
171
 
172
+ **Dois agentes, dois lugares.** O que *escreve* a minuta vai no `agentId` da
173
+ chamada de criação acima — é decisão de quem cria, e cada app manda o seu (o
174
+ mesmo campo existe na Data API, para quem cria do servidor; a env
175
+ `MINUTA_AVULSA_AGENT` do CDB é só o default de quem não mandar nada). O que
176
+ *edita pelo chat* é prop do componente (`chatAgentId`), porque vive dentro da
177
+ tela de edição.
178
+
82
179
  A barra do embed inclui **Histórico**: versões são criadas automaticamente
83
180
  conforme o documento é editado (snapshot no auto-save, com espaçamento mínimo
84
181
  de 10 min) e podem ser restauradas — o estado atual vira um snapshot antes.
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meistrari/minuta-nuxt",
3
3
  "configKey": "minutaNuxt",
4
- "version": "0.1.0",
4
+ "version": "1.1.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "0.8.4",
7
7
  "unbuild": "2.0.0"
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { Editor } from '@tiptap/core'
3
- import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
3
+ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
4
4
  import type { MinutaAnalysis } from '../composables/useMinutaApi'
5
5
  import { annotationY, applyAnnotationDecorations, findTextRange, getAnnotationDecorationRanges, getUserEditRanges, locateAnnotationDetailed, scrollToAnnotation, setUserEditRanges, trackUserEdits } from '../composables/annotationAnchors'
6
6
 
@@ -15,6 +15,11 @@ const props = defineProps<{
15
15
  analysis: MinutaAnalysis
16
16
  /** container com scroll do documento (para o scroll suave ao clicar) */
17
17
  scrollContainer: HTMLElement | null
18
+ /**
19
+ * Modo lista: em tela estreita não há margem onde ancorar os cards, então
20
+ * eles viram uma coluna em fluxo normal (dentro de um painel lateral).
21
+ */
22
+ compact?: boolean
18
23
  }>()
19
24
 
20
25
  const emit = defineEmits<{
@@ -66,6 +71,9 @@ function metaFor(item: GutterItem) {
66
71
  }
67
72
 
68
73
  const items = ref<GutterItem[]>([])
74
+ /** itens sem `range`: a análise não trouxe âncora ou o trecho sumiu do texto */
75
+ const orphanCount = computed(() => items.value.filter(i => !i.range).length)
76
+ const noticeRef = ref<HTMLElement | null>(null)
69
77
  const activeId = ref<string | null>(null)
70
78
  const cardRefs = new Map<string, HTMLElement>()
71
79
 
@@ -84,17 +92,24 @@ function flatten(): GutterItem[] {
84
92
  manual: anno.manual,
85
93
  trechoOriginal: anno.trechoOriginal,
86
94
  })
95
+ /**
96
+ * `onde` é a âncora de reserva (seção da peça) quando não há `trecho`. Uma
97
+ * geração em produção escreveu `secao` no lugar de `onde` e derrubou até
98
+ * esse fallback — aceitar o sinônimo custa uma linha e salva a tela de
99
+ * uma análise fora do contrato.
100
+ */
101
+ const onde = (anno: { onde?: string, secao?: string }) => anno.onde ?? anno.secao
87
102
  analysis.teses?.forEach((tese, i) => {
88
- out.push({ id: `tese-${i}`, kind: 'tese', source: { key: 'teses', index: i }, title: tese.titulo, body: tese.fundamento, onde: tese.onde, ...anchor(tese), range: null, y: null, finalY: 0 })
103
+ out.push({ id: `tese-${i}`, kind: 'tese', source: { key: 'teses', index: i }, title: tese.titulo, body: tese.fundamento, onde: onde(tese), ...anchor(tese), range: null, y: null, finalY: 0 })
89
104
  })
90
105
  analysis.jurisprudencias?.forEach((juris, i) => {
91
- out.push({ id: `juris-${i}`, kind: 'jurisprudencia', source: { key: 'jurisprudencias', index: i }, title: juris.referencia, body: juris.tese, onde: juris.onde, ...anchor(juris), range: null, y: null, finalY: 0 })
106
+ out.push({ id: `juris-${i}`, kind: 'jurisprudencia', source: { key: 'jurisprudencias', index: i }, title: juris.referencia, body: juris.tese, onde: onde(juris), ...anchor(juris), range: null, y: null, finalY: 0 })
92
107
  })
93
108
  analysis.citacoes?.forEach((citacao, i) => {
94
- out.push({ id: `cit-${i}`, kind: 'citacao', source: { key: 'citacoes', index: i }, title: citacao.referencia, body: citacao.como, onde: citacao.onde, ...anchor(citacao), range: null, y: null, finalY: 0 })
109
+ out.push({ id: `cit-${i}`, kind: 'citacao', source: { key: 'citacoes', index: i }, title: citacao.referencia, body: citacao.como, onde: onde(citacao), ...anchor(citacao), range: null, y: null, finalY: 0 })
95
110
  })
96
111
  analysis.fatos?.forEach((fato, i) => {
97
- out.push({ id: `fato-${i}`, kind: 'fato', source: { key: 'fatos', index: i }, title: fato.dado, onde: fato.onde, documento: fato.documento, pagina: fato.pagina, ...anchor(fato), range: null, y: null, finalY: 0 })
112
+ out.push({ id: `fato-${i}`, kind: 'fato', source: { key: 'fatos', index: i }, title: fato.dado, onde: onde(fato), documento: fato.documento, pagina: fato.pagina, ...anchor(fato), range: null, y: null, finalY: 0 })
98
113
  })
99
114
  return out
100
115
  }
@@ -230,7 +245,9 @@ async function compute() {
230
245
 
231
246
  // passo 2: resolve sobreposição com as alturas reais renderizadas
232
247
  await nextTick()
233
- let cursor = 8
248
+ // os cards são absolutos; o aviso é o único elemento no fluxo, então a
249
+ // primeira posição livre começa abaixo dele (senão ele cobre o 1º card)
250
+ let cursor = 8 + (noticeRef.value?.offsetHeight ? noticeRef.value.offsetHeight + 8 : 0)
234
251
  for (const item of items.value) {
235
252
  const desired = item.y ?? cursor
236
253
  item.finalY = Math.max(desired, cursor)
@@ -259,9 +276,13 @@ function syncDecorations() {
259
276
  }
260
277
 
261
278
  function onCardClick(item: GutterItem) {
279
+ // card sem âncora não tem para onde levar: ativá-lo só apagaria o destaque
280
+ // do card que está ativo, e ele já se apresenta como inerte (cursor normal)
281
+ if (!item.range)
282
+ return
262
283
  activeId.value = item.id
263
284
  syncDecorations()
264
- if (item.range && props.editor)
285
+ if (props.editor)
265
286
  scrollToAnnotation(props.editor, item.range.from, props.scrollContainer)
266
287
  }
267
288
 
@@ -290,7 +311,7 @@ watch(() => props.editor, (editor, old) => {
290
311
  }, { immediate: true })
291
312
 
292
313
  onMounted(() => {
293
- const sheet = rootRef.value?.parentElement
314
+ const sheet = props.compact ? null : rootRef.value?.parentElement
294
315
  if (sheet) {
295
316
  // como o Google Docs: com os comentários abertos, a folha desloca pra
296
317
  // esquerda pra dar lugar à coluna (a classe vive enquanto o gutter vive)
@@ -315,7 +336,13 @@ onUnmounted(() => {
315
336
  </script>
316
337
 
317
338
  <template>
318
- <div ref="rootRef" class="minuta-gutter">
339
+ <div ref="rootRef" class="minuta-gutter" :class="{ 'minuta-gutter--compact': props.compact }">
340
+ <!-- análise sem âncora: sem `trecho` não há onde grudar o card; diz isso
341
+ em vez de empilhar tudo no topo com clique morto -->
342
+ <div v-if="items.length && orphanCount === items.length" ref="noticeRef" class="minuta-gutter-notice">
343
+ Esta análise veio sem os trechos de referência, então os itens não
344
+ estão ligados a pontos do texto.
345
+ </div>
319
346
  <div
320
347
  v-for="item in items"
321
348
  :key="item.id"
@@ -325,7 +352,8 @@ onUnmounted(() => {
325
352
  'minuta-gutter-card--active': activeId === item.id,
326
353
  'minuta-gutter-card--orphan': !item.range,
327
354
  }"
328
- :style="{ transform: `translateY(${item.finalY}px)` }"
355
+ :style="props.compact ? undefined : { transform: `translateY(${item.finalY}px)` }"
356
+ :title="item.range ? undefined : 'Sem trecho de referência: não dá para localizar no texto'"
329
357
  @click="onCardClick(item)"
330
358
  >
331
359
  <div class="minuta-gutter-card__kind">
@@ -361,7 +389,7 @@ onUnmounted(() => {
361
389
  </template>
362
390
 
363
391
  <style scoped>
364
- .minuta-gutter{height:100%;left:calc(100% + 16px);pointer-events:none;position:absolute;top:0;width:264px;z-index:20}.minuta-gutter-card{background:#fff;border:.5px solid #dee3e7;border-radius:10px;cursor:pointer;left:0;padding:8px 12px;pointer-events:auto;position:absolute;right:0;transition:border-color .15s ease,background .15s ease,transform .2s ease}.minuta-gutter-card:hover{border-color:#b6bcc6}.minuta-gutter-card--active{background:#fafbfc;border-color:#565d69}.minuta-gutter-card--orphan{border-style:dashed;opacity:.7}.minuta-gutter-card__source{align-items:baseline;border-top:.5px solid #eceef1;display:flex;gap:8px;justify-content:space-between;margin-top:5px;padding-top:5px}.minuta-gutter-card__source-text{color:#9ca3af;font-size:10px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.minuta-gutter-card__source-btn{background:none;border:none;color:#565d69;cursor:pointer;flex-shrink:0;font-size:10px;font-weight:500;padding:0}.minuta-gutter-card__source-btn:hover{color:#111827;text-decoration:underline}.minuta-gutter-card__badge{align-items:center;border-radius:999px;display:inline-flex;flex-shrink:0;font-size:9.5px;font-weight:650;letter-spacing:.05em;line-height:1.5;padding:1px 7px;text-transform:uppercase}.minuta-gutter-card__kind{align-items:center;display:flex;gap:6px;margin-bottom:4px;min-width:0}.minuta-gutter-card__onde{color:#b6bcc6;font-size:10px;font-weight:400;letter-spacing:normal;min-width:0;overflow:hidden;text-overflow:ellipsis;text-transform:none;white-space:nowrap}.minuta-gutter-card__title{color:#111827;font-size:12px;font-weight:590;line-height:1.35;margin:0}.minuta-gutter-card__body{color:#6b7280;display:-webkit-box;font-size:11px;-webkit-line-clamp:3;line-height:1.45;margin:3px 0 0;-webkit-box-orient:vertical;overflow:hidden}
392
+ .minuta-gutter{height:100%;left:calc(100% + 16px);pointer-events:none;position:absolute;top:0;width:264px;z-index:20}.minuta-gutter--compact{display:flex;flex-direction:column;gap:8px;position:static;width:100%}.minuta-gutter--compact .minuta-gutter-card{position:static;transform:none!important}.minuta-gutter-card{background:#fff;border:.5px solid #dee3e7;border-radius:10px;cursor:pointer;left:0;padding:8px 12px;pointer-events:auto;position:absolute;right:0;transition:border-color .15s ease,background .15s ease,transform .2s ease}.minuta-gutter-card:hover{border-color:#b6bcc6}.minuta-gutter-card--active{background:#fafbfc;border-color:#565d69}.minuta-gutter-card--orphan{border-style:dashed;cursor:default;opacity:.7}.minuta-gutter-notice{background:#fafbfc;border:.5px solid #e5e7eb;border-radius:8px;color:#6b7280;font-size:11.5px;line-height:1.45;margin-bottom:8px;padding:8px 10px;position:relative}.minuta-gutter-card__source{align-items:baseline;border-top:.5px solid #eceef1;display:flex;gap:8px;justify-content:space-between;margin-top:5px;padding-top:5px}.minuta-gutter-card__source-text{color:#9ca3af;font-size:10px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.minuta-gutter-card__source-btn{background:none;border:none;color:#565d69;cursor:pointer;flex-shrink:0;font-size:10px;font-weight:500;padding:0}.minuta-gutter-card__source-btn:hover{color:#111827;text-decoration:underline}.minuta-gutter-card__badge{align-items:center;border-radius:999px;display:inline-flex;flex-shrink:0;font-size:9.5px;font-weight:650;letter-spacing:.05em;line-height:1.5;padding:1px 7px;text-transform:uppercase}.minuta-gutter-card__kind{align-items:center;display:flex;gap:6px;margin-bottom:4px;min-width:0}.minuta-gutter-card__onde{color:#b6bcc6;font-size:10px;font-weight:400;letter-spacing:normal;min-width:0;overflow:hidden;text-overflow:ellipsis;text-transform:none;white-space:nowrap}.minuta-gutter-card__title{color:#111827;font-size:12px;font-weight:590;line-height:1.35;margin:0}.minuta-gutter-card__body{color:#6b7280;display:-webkit-box;font-size:11px;-webkit-line-clamp:3;line-height:1.45;margin:3px 0 0;-webkit-box-orient:vertical;overflow:hidden}
365
393
  </style>
366
394
 
367
395
  <style>
@@ -4,57 +4,98 @@ import type { MinutaFile } from '../composables/useMinutaApi'
4
4
  import { useMinutaApi } from '../composables/useMinutaApi'
5
5
 
6
6
  /**
7
- * Overlay de visualização dos documentos do caso (imagem/texto/iframe),
8
- * aberto pelos cards de fato extraído da margem ou pelo botão Documentos.
7
+ * Visualização dos documentos do caso, aberta pelos cards de fato extraído da
8
+ * margem ou pelo botão Documentos.
9
+ *
10
+ * O corpo é o `TelaPreview` do Tela Build — o mesmo visualizador que o resto
11
+ * do produto usa. Ele resolve o que a implementação anterior (um `<iframe>`
12
+ * apontando para uma `blob:` URL) não resolvia:
13
+ *
14
+ * - **destaque do trecho.** O card de fato manda `highlight`, e antes esse
15
+ * campo era recebido e descartado: o visualizador nativo do Chrome não tem
16
+ * API de destaque. O TelaPreview aceita `highlightText`/`highlightPage`.
17
+ * - zoom, navegação por página, modo arrasto e tela cheia, que o `<iframe>`
18
+ * delegava à barra cinza do browser.
19
+ * - `vault://` resolvido por `resolveFileUrl`, e o cartão de download para
20
+ * formato que o navegador não abre.
9
21
  */
10
22
 
11
23
  const props = defineProps<{
12
24
  files: MinutaFile[]
13
25
  initialUrl?: string | null
14
- /** página inicial (PDF em iframe via #page=N) */
26
+ /** página para onde rolar e destacar */
15
27
  initialPage?: number | null
28
+ /** trecho a destacar na página (vem do card de fato extraído) */
29
+ highlight?: string | null
16
30
  }>()
17
31
 
18
32
  const emit = defineEmits<{ close: [], error: [message: string] }>()
19
33
 
20
34
  const { getDownloadUrl } = useMinutaApi()
35
+ const { pdfLoader } = usePdfLoader()
21
36
 
22
37
  const selected = ref(props.initialUrl || props.files[0]?.url || '')
23
- const content = ref<{ src: string, kind: 'image' | 'text' | 'frame', text?: string } | null>(null)
24
- const loading = ref(false)
38
+ watch(() => props.initialUrl, (url) => {
39
+ if (url)
40
+ selected.value = url
41
+ })
25
42
 
26
- const currentName = computed(() => props.files.find(f => f.url === selected.value)?.name ?? '')
43
+ const EXT_MIME: Record<string, string> = {
44
+ pdf: 'application/pdf',
45
+ png: 'image/png',
46
+ jpg: 'image/jpeg',
47
+ jpeg: 'image/jpeg',
48
+ gif: 'image/gif',
49
+ webp: 'image/webp',
50
+ svg: 'image/svg+xml',
51
+ txt: 'text/plain',
52
+ md: 'text/markdown',
53
+ csv: 'text/csv',
54
+ json: 'application/json',
55
+ }
56
+
57
+ function mimeOf(name: string): string {
58
+ const ext = name.split('.').pop()?.toLowerCase() ?? ''
59
+ return EXT_MIME[ext] ?? 'application/octet-stream'
60
+ }
27
61
 
28
- async function load() {
29
- const file = props.files.find(f => f.url === selected.value)
62
+ /** o seletor do TelaPreview agrupa por "variável"; aqui só há uma categoria */
63
+ const fileOptions = computed(() => props.files.map(file => ({
64
+ variable: 'Documentos do caso',
65
+ file: file.name,
66
+ value: file.url,
67
+ mimeType: mimeOf(file.name),
68
+ })))
69
+
70
+ const currentFile = computed(() => {
71
+ const file = props.files.find(f => f.url === selected.value) ?? props.files[0]
30
72
  if (!file)
31
- return
32
- loading.value = true
33
- content.value = null
34
- try {
35
- const src = await getDownloadUrl(file.url)
36
- if (/\.(png|jpe?g|gif|webp|svg)$/i.test(file.name)) {
37
- content.value = { src, kind: 'image' }
38
- }
39
- else if (/\.(txt|md|json|csv|log)$/i.test(file.name)) {
40
- const text = await (await fetch(src)).text()
41
- content.value = { src, kind: 'text', text }
42
- }
43
- else {
44
- const isPdf = /\.pdf$/i.test(file.name)
45
- const page = props.initialPage && file.url === props.initialUrl ? `#page=${props.initialPage}` : ''
46
- content.value = { src: isPdf ? src + page : src, kind: 'frame' }
47
- }
73
+ return null
74
+ return {
75
+ id: file.url,
76
+ fileName: file.name,
77
+ fileUrl: file.url,
78
+ fileType: mimeOf(file.name),
48
79
  }
49
- catch {
50
- emit('error', 'Não consegui carregar o documento')
80
+ })
81
+
82
+ /** o destaque só vale para o documento que o card apontou */
83
+ const highlightForCurrent = computed(() => (
84
+ selected.value === props.initialUrl ? props.highlight ?? null : null
85
+ ))
86
+ const pageForCurrent = computed(() => (
87
+ selected.value === props.initialUrl ? props.initialPage ?? null : null
88
+ ))
89
+
90
+ async function resolveFileUrl(url: string): Promise<string> {
91
+ try {
92
+ return await getDownloadUrl(url)
51
93
  }
52
- finally {
53
- loading.value = false
94
+ catch (error: any) {
95
+ emit('error', error?.message || 'Não foi possível abrir o documento')
96
+ return ''
54
97
  }
55
98
  }
56
-
57
- watch(selected, load, { immediate: true })
58
99
  </script>
59
100
 
60
101
  <template>
@@ -62,23 +103,40 @@ watch(selected, load, { immediate: true })
62
103
  <div class="minuta-viewer__overlay" @click.self="emit('close')">
63
104
  <div class="minuta-viewer__modal">
64
105
  <div class="minuta-viewer__head">
65
- <select v-if="files.length > 1" v-model="selected">
66
- <option v-for="file in files" :key="file.url" :value="file.url">
67
- {{ file.name }}
68
- </option>
69
- </select>
70
- <span v-else>{{ currentName }}</span>
71
- <button type="button" @click="emit('close')">
72
- ×
73
- </button>
106
+ <span class="minuta-viewer__title">Documentos do caso</span>
107
+ <TelaButton variant="ghost" size="sm" icon="i-ph-x" @click="emit('close')" />
74
108
  </div>
75
109
  <div class="minuta-viewer__body">
76
- <p v-if="loading" class="minuta-viewer__loading">
77
- Carregando…
78
- </p>
79
- <img v-else-if="content?.kind === 'image'" :src="content.src">
80
- <pre v-else-if="content?.kind === 'text'">{{ content.text }}</pre>
81
- <iframe v-else-if="content" :src="content.src" />
110
+ <TelaPreview
111
+ class="h-full"
112
+ :model-value="selected"
113
+ :file-options="fileOptions"
114
+ :current-file="currentFile"
115
+ :resolve-file-url="resolveFileUrl"
116
+ :pdf-loader="pdfLoader"
117
+ :highlight-text="highlightForCurrent"
118
+ :highlight-page="pageForCurrent"
119
+ :labels="{ selectVariable: 'Selecionar documento', file: 'Documento' }"
120
+ :select-variable-labels="{
121
+ placeholder: 'Selecionar documento',
122
+ searchPlaceholder: 'Buscar documento...',
123
+ noVariablesFound: 'Nenhum documento encontrado',
124
+ variablesLabel: 'Categoria',
125
+ filesLabel: 'documentos',
126
+ }"
127
+ :content-labels="{
128
+ failedToLoadFile: 'Não foi possível carregar o documento',
129
+ page: 'Página',
130
+ download: 'Baixar',
131
+ unableToPreview: 'Este formato não abre no navegador',
132
+ zoomOut: 'Diminuir',
133
+ zoomIn: 'Aumentar',
134
+ handMode: 'Modo arrasto',
135
+ fullscreen: 'Tela cheia',
136
+ pageOf: 'de',
137
+ }"
138
+ @update:model-value="selected = $event"
139
+ />
82
140
  </div>
83
141
  </div>
84
142
  </div>
@@ -86,5 +144,5 @@ watch(selected, load, { immediate: true })
86
144
  </template>
87
145
 
88
146
  <style scoped>
89
- .minuta-viewer__overlay{align-items:center;background:rgba(0,0,0,.5);display:flex;inset:0;justify-content:center;padding:24px;position:fixed;z-index:100}.minuta-viewer__modal{background:#fff;border-radius:12px;box-shadow:0 20px 25px -5px rgba(0,0,0,.2);display:flex;flex-direction:column;height:100%;max-height:90vh;max-width:960px;overflow:hidden;width:100%}.minuta-viewer__head{align-items:center;border-bottom:1px solid #e5e7eb;color:#111827;display:flex;font-size:14px;font-weight:600;gap:12px;justify-content:space-between;padding:10px 16px}.minuta-viewer__head select{background:#fff;border:.5px solid #d4d9df;border-radius:6px;font-size:13px;max-width:70%;padding:4px 8px}.minuta-viewer__head button{background:none;border:none;color:#6b7280;cursor:pointer;font-size:18px;line-height:1}.minuta-viewer__body{background:#f9fafb;flex:1;min-height:0;overflow-y:auto}.minuta-viewer__loading{color:#6b7280;font-size:13px;padding:24px}.minuta-viewer__body img{height:100%;-o-object-fit:contain;object-fit:contain;width:100%}.minuta-viewer__body pre{color:#1f2937;font-size:13px;line-height:1.6;margin:0;padding:20px;white-space:pre-wrap}.minuta-viewer__body iframe{border:0;height:100%;width:100%}
147
+ .minuta-viewer__overlay{align-items:center;background:rgba(15,23,42,.45);display:flex;inset:0;justify-content:center;padding:24px;position:fixed;z-index:60}.minuta-viewer__modal{background:#fff;border-radius:12px;box-shadow:0 24px 60px rgba(15,23,42,.28);display:flex;flex-direction:column;height:min(88vh,100%);overflow:hidden;width:min(1100px,100%)}.minuta-viewer__head{align-items:center;border-bottom:1px solid rgb(0 0 0/8%);display:flex;gap:12px;justify-content:space-between;padding:10px 12px 10px 16px}.minuta-viewer__title{color:#171717;font-size:13px;font-weight:540}.minuta-viewer__body{background:#fff;flex:1;min-height:0}@media (max-width:640px){.minuta-viewer__overlay{padding:0}.minuta-viewer__modal{border-radius:0;height:100%;width:100%}}
90
148
  </style>