LayoutSistema.vue 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797
  1. <script setup>
  2. import {
  3. computed,
  4. onBeforeUnmount,
  5. onMounted,
  6. reactive,
  7. ref,
  8. watch,
  9. } from "vue";
  10. import { useRoute, useRouter } from "vue-router";
  11. import BotaoAlterarTema from "../components/BotaoAlterarTema.vue";
  12. import DocumentList from "../components/DocumentList.vue";
  13. import DocumentUpload from "../components/DocumentUpload.vue";
  14. import SearchBox from "../components/SearchBox.vue";
  15. import { listDocuments } from "../api/chat.js";
  16. import { useChat } from "../composables/useChat.js";
  17. import { useSearch } from "../composables/useSearch.js";
  18. import { useAuth } from "../composables/useAuth.js";
  19. const router = useRouter();
  20. const route = useRoute();
  21. const {
  22. conversations,
  23. activeConversationId,
  24. conversationsLoading,
  25. setActiveConversation,
  26. deleteConversation,
  27. renameConversation,
  28. newConversation,
  29. } = useChat();
  30. const { user, logout } = useAuth();
  31. const {
  32. results: searchResults,
  33. loading: searchLoading,
  34. error: searchError,
  35. run: runSearch,
  36. } = useSearch();
  37. const docs = reactive({
  38. items: [],
  39. loading: false,
  40. error: "",
  41. });
  42. const logoutLoading = ref(false);
  43. const hoveredConversaId = ref(null);
  44. const editingConversaId = ref(null);
  45. const editingTitulo = ref("");
  46. const sidebarRecolhida = ref(false);
  47. const painelAberto = ref("");
  48. const menuUsuarioAberto = ref(false);
  49. const menuUsuarioRef = ref(null);
  50. const rotaAtiva = computed(() => String(route.name ?? ""));
  51. const usuarioNome = computed(() => user.value?.Nome || user.value?.Login || "Usuario");
  52. const usuarioSetor = computed(() => user.value?.Setor || user.value?.Nivel || "");
  53. const isNivel1 = computed(() => String(user.value?.Nivel) === "1");
  54. const layoutStyle = computed(() => ({
  55. "--layout-sidebar-width": sidebarRecolhida.value ? "84px" : "280px",
  56. }));
  57. const classeItemSidebarBase = computed(() =>
  58. sidebarRecolhida.value
  59. ? "w-full flex items-center justify-center gap-0 rounded-xl p-2.5 text-sm font-medium transition-colors !bg-none !bg-transparent !border-0 !transform-none hover:!transform-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
  60. : "w-full flex items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm font-medium transition-colors !bg-none !bg-transparent !border-0 !transform-none hover:!transform-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
  61. );
  62. const classeItemSidebarAtivo =
  63. "text-gray-900 !bg-gray-200/70 hover:!bg-gray-300/70 dark:text-white dark:!bg-white/10 dark:hover:!bg-white/10";
  64. const classeItemSidebarInativo =
  65. "text-gray-600 hover:text-gray-900 hover:!bg-gray-300/70 dark:text-white/70 dark:hover:text-white dark:hover:!bg-white/10";
  66. const classeIconAtivo = "text-gray-900 dark:text-white";
  67. const classeIconInativo = "text-gray-500 dark:text-white/60";
  68. const classeRotulo = computed(() =>
  69. sidebarRecolhida.value ? "sr-only" : "min-w-0 truncate",
  70. );
  71. function classeItemSidebar(ativo) {
  72. return [
  73. classeItemSidebarBase.value,
  74. ativo ? classeItemSidebarAtivo : classeItemSidebarInativo,
  75. ];
  76. }
  77. function classeIcon(ativo) {
  78. return ["h-5 w-5 shrink-0", ativo ? classeIconAtivo : classeIconInativo];
  79. }
  80. const AVATAR_CORES = [
  81. "bg-blue-500", "bg-violet-500", "bg-emerald-500",
  82. "bg-amber-500", "bg-rose-500", "bg-cyan-500", "bg-indigo-500"
  83. ];
  84. const avatarIniciais = computed(() => {
  85. const nome = usuarioNome.value.trim();
  86. const partes = nome.split(/\s+/);
  87. if (partes.length >= 2) return (partes[0][0] + partes[1][0]).toUpperCase();
  88. return nome.slice(0, 2).toUpperCase();
  89. });
  90. const avatarCor = computed(() => {
  91. let hash = 0;
  92. for (const c of usuarioNome.value) hash = (hash * 31 + c.charCodeAt(0)) & 0xffffffff;
  93. return AVATAR_CORES[Math.abs(hash) % AVATAR_CORES.length];
  94. });
  95. const tituloPagina = computed(() => {
  96. const nome = rotaAtiva.value;
  97. if (nome === "home") return "Nova conversa";
  98. if (nome === "conversas-pesquisar") return "Pesquisar conversas";
  99. if (nome === "conversas-historico") return "Histórico";
  100. if (nome === "configuracoes") return "Configurações";
  101. if (nome === "usuarios") return "Usuários";
  102. return "Oráculo";
  103. });
  104. const conversacoesAgrupadas = computed(() => {
  105. const hoje = new Date().setHours(0, 0, 0, 0);
  106. const ontem = hoje - 86400000;
  107. const semana = hoje - 6 * 86400000;
  108. const grupos = [
  109. { label: "Hoje", items: [] },
  110. { label: "Ontem", items: [] },
  111. { label: "Últimos 7 dias",items: [] },
  112. { label: "Mais antigas", items: [] },
  113. ];
  114. for (const c of conversations.value) {
  115. const t = c.updatedAt ?? 0;
  116. if (t >= hoje) grupos[0].items.push(c);
  117. else if (t >= ontem) grupos[1].items.push(c);
  118. else if (t >= semana) grupos[2].items.push(c);
  119. else grupos[3].items.push(c);
  120. }
  121. return grupos.filter((g) => g.items.length > 0);
  122. });
  123. const tituloModal = computed(() => {
  124. if (painelAberto.value === "update") return "Carregar documento";
  125. if (painelAberto.value === "documentos") return "Documentos";
  126. return "";
  127. });
  128. function irPara(nome) {
  129. router.push({ name: nome });
  130. }
  131. function alternarPainel(nome) {
  132. const n = String(nome ?? "");
  133. painelAberto.value = painelAberto.value === n ? "" : n;
  134. }
  135. function fecharModal() {
  136. painelAberto.value = "";
  137. }
  138. function alternarMenuUsuario() {
  139. menuUsuarioAberto.value = !menuUsuarioAberto.value;
  140. }
  141. function fecharMenuUsuario() {
  142. menuUsuarioAberto.value = false;
  143. }
  144. async function novaConversa() {
  145. newConversation();
  146. await router.push({ name: "home" });
  147. }
  148. async function abrirConversa(id) {
  149. await setActiveConversation(id);
  150. await router.push({ name: "home" });
  151. }
  152. function iniciarEdicao(conversa) {
  153. editingConversaId.value = conversa.id;
  154. editingTitulo.value = conversa.title;
  155. }
  156. async function confirmarEdicao(id) {
  157. if (editingTitulo.value.trim()) await renameConversation(id, editingTitulo.value);
  158. editingConversaId.value = null;
  159. editingTitulo.value = "";
  160. }
  161. function cancelarEdicao() {
  162. editingConversaId.value = null;
  163. editingTitulo.value = "";
  164. }
  165. async function encerrarSessao() {
  166. if (logoutLoading.value) return;
  167. logoutLoading.value = true;
  168. try {
  169. await logout();
  170. await router.replace({ name: "login" });
  171. } finally {
  172. logoutLoading.value = false;
  173. }
  174. }
  175. async function loadDocs() {
  176. docs.loading = true;
  177. docs.error = "";
  178. try {
  179. const data = await listDocuments({ limit: 50, offset: 0 });
  180. docs.items = data.items ?? [];
  181. } catch (e) {
  182. docs.error = e?.message || "erro";
  183. docs.items = [];
  184. } finally {
  185. docs.loading = false;
  186. }
  187. }
  188. async function onIngested() {
  189. await loadDocs();
  190. }
  191. onMounted(async () => {
  192. await loadDocs();
  193. });
  194. onMounted(() => {
  195. const v = window.localStorage.getItem("layout.sidebarRecolhida");
  196. if (v != null) sidebarRecolhida.value = v === "1" || v === "true";
  197. });
  198. watch(
  199. () => sidebarRecolhida.value,
  200. (v) => {
  201. window.localStorage.setItem("layout.sidebarRecolhida", v ? "1" : "0");
  202. },
  203. );
  204. function onKeyDown(e) {
  205. if (e?.key !== "Escape") return;
  206. if (menuUsuarioAberto.value) fecharMenuUsuario();
  207. else if (painelAberto.value) fecharModal();
  208. }
  209. function onDocumentClick(e) {
  210. if (!menuUsuarioAberto.value) return;
  211. if (!menuUsuarioRef.value?.contains(e.target)) fecharMenuUsuario();
  212. }
  213. onMounted(() => {
  214. window.addEventListener("keydown", onKeyDown);
  215. document.addEventListener("click", onDocumentClick);
  216. });
  217. onBeforeUnmount(() => {
  218. window.removeEventListener("keydown", onKeyDown);
  219. document.removeEventListener("click", onDocumentClick);
  220. });
  221. let overflowAntes = "";
  222. watch(
  223. () => painelAberto.value,
  224. (v) => {
  225. const aberto = Boolean(v);
  226. if (aberto) {
  227. overflowAntes = document.body.style.overflow || "";
  228. document.body.style.overflow = "hidden";
  229. } else {
  230. document.body.style.overflow = overflowAntes;
  231. }
  232. },
  233. );
  234. </script>
  235. <template>
  236. <div class="h-screen overflow-hidden bg-background text-foreground">
  237. <div
  238. class="[--layout-header-height:5rem] relative grid h-full min-h-0 grid-cols-[var(--layout-sidebar-width)_1fr] grid-rows-[var(--layout-header-height)_1fr] overflow-hidden transition-[grid-template-columns] duration-200 ease-in-out max-[980px]:grid-cols-1 max-[980px]:grid-rows-[var(--layout-header-height)_1fr_auto]"
  239. :style="layoutStyle"
  240. >
  241. <div
  242. aria-hidden="true"
  243. class="pointer-events-none absolute inset-0 z-0 bg-[radial-gradient(900px_520px_at_20%_10%,rgba(37,99,235,0.12),transparent_60%),radial-gradient(700px_520px_at_85%_35%,rgba(162,102,255,0.10),transparent_60%)] dark:hidden"
  244. />
  245. <div
  246. aria-hidden="true"
  247. class="pointer-events-none absolute inset-0 z-0 hidden bg-[radial-gradient(900px_520px_at_20%_10%,rgba(91,140,255,0.18),transparent_60%),radial-gradient(700px_520px_at_85%_35%,rgba(162,102,255,0.14),transparent_60%)] dark:block"
  248. />
  249. <header
  250. class="col-span-full row-start-1 relative z-30 grid grid-cols-[var(--layout-sidebar-width)_1fr] items-center gap-[18px] border-b border-gray-200 bg-primary px-[18px] py-[14px] text-primary-foreground dark:border-gray-700 dark:bg-primary max-[980px]:flex"
  251. >
  252. <div class="min-w-0 overflow-hidden">
  253. <div
  254. class="truncate whitespace-nowrap text-[16px] font-extrabold tracking-[0.14em]"
  255. >
  256. ORÁCULO
  257. </div>
  258. <div
  259. v-if="!sidebarRecolhida"
  260. class="mt-1.5 text-xs text-primary-foreground/80"
  261. >
  262. Assistente interno com base de conhecimento
  263. </div>
  264. </div>
  265. <div
  266. class="min-w-0 flex items-center justify-between gap-4 max-[980px]:flex-1"
  267. >
  268. <div class="min-w-0">
  269. <div class="truncate text-base font-medium">{{ tituloPagina }}</div>
  270. </div>
  271. <div class="flex items-center gap-2">
  272. <BotaoAlterarTema />
  273. <div ref="menuUsuarioRef" class="relative">
  274. <button
  275. type="button"
  276. class="flex items-center gap-2.5 rounded-xl px-1.5 py-1.5 !text-primary-foreground !border-0 !bg-none !bg-transparent !transform-none motion-safe:transition-colors hover:!bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background md:pr-2.5"
  277. :aria-expanded="menuUsuarioAberto"
  278. aria-haspopup="menu"
  279. aria-label="Menu do usuário"
  280. @click="alternarMenuUsuario"
  281. >
  282. <div
  283. class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white ring-1 ring-white/20"
  284. :class="avatarCor"
  285. >
  286. {{ avatarIniciais }}
  287. </div>
  288. <div class="hidden min-w-0 text-left md:block">
  289. <div class="truncate text-sm font-semibold leading-tight">{{ usuarioNome }}</div>
  290. <div
  291. v-if="usuarioSetor"
  292. class="truncate text-xs text-primary-foreground/70 leading-tight"
  293. >
  294. {{ usuarioSetor }}
  295. </div>
  296. </div>
  297. <svg
  298. class="hidden h-4 w-4 shrink-0 text-primary-foreground/70 transition-transform md:block"
  299. :class="menuUsuarioAberto ? 'rotate-180' : ''"
  300. viewBox="0 0 24 24"
  301. fill="none"
  302. stroke="currentColor"
  303. stroke-width="1.8"
  304. >
  305. <path
  306. stroke-linecap="round"
  307. stroke-linejoin="round"
  308. d="m6 9 6 6 6-6"
  309. />
  310. </svg>
  311. </button>
  312. <transition
  313. enter-active-class="transition duration-150 ease-out"
  314. enter-from-class="opacity-0 -translate-y-1 scale-95"
  315. enter-to-class="opacity-100 translate-y-0 scale-100"
  316. leave-active-class="transition duration-100 ease-in"
  317. leave-from-class="opacity-100 translate-y-0 scale-100"
  318. leave-to-class="opacity-0 -translate-y-1 scale-95"
  319. >
  320. <div
  321. v-if="menuUsuarioAberto"
  322. role="menu"
  323. class="absolute right-0 top-full z-20 mt-2 w-60 origin-top-right overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg"
  324. >
  325. <div class="flex items-center gap-3 border-b border-border px-3 py-3">
  326. <div
  327. class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
  328. :class="avatarCor"
  329. >
  330. {{ avatarIniciais }}
  331. </div>
  332. <div class="min-w-0">
  333. <div class="truncate text-sm font-semibold leading-tight">{{ usuarioNome }}</div>
  334. <div
  335. v-if="usuarioSetor"
  336. class="truncate text-xs text-foreground/60 leading-tight"
  337. >
  338. {{ usuarioSetor }}
  339. </div>
  340. </div>
  341. </div>
  342. <div class="p-1.5">
  343. <button
  344. type="button"
  345. role="menuitem"
  346. class="flex w-full items-center gap-2.5 rounded-lg !border-0 !bg-none !bg-transparent px-2.5 py-2 text-left text-sm font-medium !text-foreground !transform-none motion-safe:transition-colors hover:!bg-black/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:!bg-white/10"
  347. @click="fecharMenuUsuario(); irPara('configuracoes')"
  348. >
  349. <svg
  350. class="h-5 w-5 shrink-0 text-foreground/60"
  351. viewBox="0 0 24 24"
  352. fill="none"
  353. stroke="currentColor"
  354. stroke-width="1.8"
  355. >
  356. <path
  357. stroke-linecap="round"
  358. stroke-linejoin="round"
  359. d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 0 1 1.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.894.149c-.424.07-.764.383-.929.78-.165.398-.143.854.107 1.204l.527.738c.32.447.27 1.06-.12 1.45l-.774.773a1.125 1.125 0 0 1-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.398.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.27-1.45-.12l-.773-.774a1.125 1.125 0 0 1-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.108-1.204l-.526-.738a1.125 1.125 0 0 1 .12-1.45l.773-.773a1.125 1.125 0 0 1 1.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894Z"
  360. />
  361. <path
  362. stroke-linecap="round"
  363. stroke-linejoin="round"
  364. d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
  365. />
  366. </svg>
  367. <span>Configurações</span>
  368. </button>
  369. <button
  370. type="button"
  371. role="menuitem"
  372. class="flex w-full items-center gap-2.5 rounded-lg !border-0 !bg-none !bg-transparent px-2.5 py-2 text-left text-sm font-medium !text-red-600 !transform-none motion-safe:transition-colors hover:!bg-red-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 dark:!text-red-400"
  373. :disabled="logoutLoading"
  374. @click="encerrarSessao"
  375. >
  376. <svg
  377. class="h-5 w-5 shrink-0"
  378. viewBox="0 0 24 24"
  379. fill="none"
  380. stroke="currentColor"
  381. stroke-width="1.8"
  382. >
  383. <path
  384. stroke-linecap="round"
  385. stroke-linejoin="round"
  386. d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9"
  387. />
  388. </svg>
  389. <span>{{ logoutLoading ? "Saindo..." : "Sair" }}</span>
  390. </button>
  391. </div>
  392. </div>
  393. </transition>
  394. </div>
  395. </div>
  396. </div>
  397. </header>
  398. <aside
  399. class="col-start-1 row-start-2 relative z-10 flex min-h-0 min-w-0 flex-col gap-3 overflow-hidden border-r border-gray-200 bg-transparent p-2 dark:border-gray-700 max-[980px]:row-start-3 max-[980px]:border-r-0 max-[980px]:border-t"
  400. >
  401. <div
  402. class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[28px] border border-gray-200 bg-white/70 p-3 shadow-sm backdrop-blur-md dark:border-white/10 dark:bg-[#101827] dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)]"
  403. >
  404. <div
  405. :class="
  406. sidebarRecolhida
  407. ? 'flex items-center justify-center px-1 pb-1'
  408. : 'flex items-center justify-end px-1 pb-1'
  409. "
  410. >
  411. <button
  412. type="button"
  413. class="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-gray-200 bg-white/60 text-gray-600 transition-colors hover:bg-white/80 hover:text-gray-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background dark:border-white/10 dark:bg-white/5 dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
  414. :aria-label="
  415. sidebarRecolhida ? 'Expandir sidebar' : 'Recolher sidebar'
  416. "
  417. :title="sidebarRecolhida ? 'Expandir sidebar' : 'Recolher sidebar'"
  418. @click="sidebarRecolhida = !sidebarRecolhida"
  419. >
  420. <svg
  421. v-if="sidebarRecolhida"
  422. class="h-5 w-5"
  423. viewBox="0 0 24 24"
  424. fill="none"
  425. stroke="currentColor"
  426. stroke-width="1.8"
  427. >
  428. <path
  429. stroke-linecap="round"
  430. stroke-linejoin="round"
  431. d="m9 6 6 6-6 6"
  432. />
  433. </svg>
  434. <svg
  435. v-else
  436. class="h-5 w-5"
  437. viewBox="0 0 24 24"
  438. fill="none"
  439. stroke="currentColor"
  440. stroke-width="1.8"
  441. >
  442. <path
  443. stroke-linecap="round"
  444. stroke-linejoin="round"
  445. d="m15 6-6 6 6 6"
  446. />
  447. </svg>
  448. </button>
  449. </div>
  450. <!-- Seção: Chat -->
  451. <div
  452. v-if="!sidebarRecolhida"
  453. class="px-2 pt-1 pb-0.5 text-[10px] font-semibold uppercase tracking-widest text-gray-400 dark:text-white/30 select-none"
  454. >
  455. Chat
  456. </div>
  457. <nav class="grid gap-1">
  458. <button
  459. type="button"
  460. :class="classeItemSidebar(rotaAtiva === 'home' && !activeConversationId)"
  461. :title="sidebarRecolhida ? 'Nova conversa' : ''"
  462. @click="novaConversa"
  463. >
  464. <svg
  465. :class="classeIcon(rotaAtiva === 'home' && !activeConversationId)"
  466. viewBox="0 0 24 24"
  467. fill="none"
  468. stroke="currentColor"
  469. stroke-width="1.8"
  470. >
  471. <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
  472. </svg>
  473. <span :class="classeRotulo">Nova conversa</span>
  474. </button>
  475. </nav>
  476. <!-- Lista de conversas recentes (apenas sidebar expandida) -->
  477. <div
  478. v-if="!sidebarRecolhida"
  479. class="mt-0.5 -mx-0.5 px-0.5"
  480. >
  481. <div v-if="conversationsLoading" class="px-3 py-2 text-xs text-gray-400 dark:text-white/30">
  482. Carregando...
  483. </div>
  484. <template v-for="grupo in conversacoesAgrupadas" :key="grupo.label">
  485. <div class="px-3 pt-2 pb-0.5 text-[10px] font-medium text-gray-400 dark:text-white/30 select-none">
  486. {{ grupo.label }}
  487. </div>
  488. <div class="grid gap-1">
  489. <button
  490. v-for="c in grupo.items"
  491. :key="c.id"
  492. type="button"
  493. class="relative w-full flex items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors overflow-hidden !bg-none !border-0 !transform-none hover:!transform-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
  494. :class="String(activeConversationId) === c.id
  495. ? 'text-gray-900 bg-gray-200/70 dark:text-white dark:bg-white/10'
  496. : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100/80 dark:text-white/70 dark:hover:text-white dark:hover:bg-white/10'"
  497. @mouseenter="hoveredConversaId = c.id"
  498. @mouseleave="hoveredConversaId = null"
  499. @click="editingConversaId !== c.id && abrirConversa(c.id)"
  500. >
  501. <template v-if="editingConversaId === c.id">
  502. <input
  503. type="text"
  504. v-model="editingTitulo"
  505. class="flex-1 min-w-0 bg-transparent text-sm outline-none border-b border-gray-400 dark:border-white/40 text-gray-900 dark:text-white"
  506. @keydown.enter.prevent="confirmarEdicao(c.id)"
  507. @keydown.escape.prevent="cancelarEdicao"
  508. @click.stop
  509. autofocus
  510. />
  511. <div class="flex items-center gap-0.5 shrink-0" @click.stop>
  512. <button
  513. type="button"
  514. class="rounded p-0.5 text-gray-500 hover:text-gray-900 dark:text-white/50 dark:hover:text-white transition-colors"
  515. title="Confirmar"
  516. @click.stop="confirmarEdicao(c.id)"
  517. >
  518. <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
  519. <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
  520. </svg>
  521. </button>
  522. <button
  523. type="button"
  524. class="rounded p-0.5 text-gray-500 hover:text-gray-900 dark:text-white/50 dark:hover:text-white transition-colors"
  525. title="Cancelar"
  526. @click.stop="cancelarEdicao"
  527. >
  528. <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
  529. <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
  530. </svg>
  531. </button>
  532. </div>
  533. </template>
  534. <template v-else>
  535. <span class="flex-1 min-w-0 truncate leading-snug">{{ c.title }}</span>
  536. <div
  537. v-show="hoveredConversaId === c.id"
  538. class="absolute right-0 top-0 h-full flex items-center gap-0.5 pr-2 pl-16"
  539. :class="String(activeConversationId) === c.id
  540. ? 'bg-gradient-to-l from-gray-200 from-60% dark:from-[#131f30] to-transparent'
  541. : 'bg-gradient-to-l from-gray-100 from-60% dark:from-[#101827] to-transparent'"
  542. @click.stop
  543. >
  544. <button
  545. type="button"
  546. class="rounded p-0.5 text-gray-400 hover:text-gray-700 dark:text-white/40 dark:hover:text-white transition-colors"
  547. title="Renomear"
  548. @click.stop="iniciarEdicao(c)"
  549. >
  550. <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  551. <path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125" />
  552. </svg>
  553. </button>
  554. <button
  555. type="button"
  556. class="rounded p-0.5 text-gray-400 hover:text-red-600 dark:text-white/40 dark:hover:text-red-400 transition-colors"
  557. title="Excluir"
  558. @click.stop="deleteConversation(c.id)"
  559. >
  560. <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  561. <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
  562. </svg>
  563. </button>
  564. </div>
  565. </template>
  566. </button>
  567. </div>
  568. </template>
  569. <div
  570. v-if="!conversationsLoading && conversacoesAgrupadas.length === 0"
  571. class="px-3 py-2 text-xs text-gray-400 dark:text-white/30 italic"
  572. >
  573. Nenhuma conversa ainda
  574. </div>
  575. </div>
  576. <!-- Seções inferiores: Biblioteca + Sistema -->
  577. <div class="mt-auto flex-shrink-0 grid gap-1 pt-2">
  578. <div class="my-1 h-px bg-black/10 dark:bg-white/10" />
  579. <template v-if="!isNivel1">
  580. <div
  581. v-if="!sidebarRecolhida"
  582. class="px-2 pb-0.5 text-[10px] font-semibold uppercase tracking-widest text-gray-400 dark:text-white/30 select-none"
  583. >
  584. Biblioteca
  585. </div>
  586. <button
  587. type="button"
  588. :class="classeItemSidebar(painelAberto === 'update')"
  589. :title="sidebarRecolhida ? 'Carregar documento' : ''"
  590. @click="alternarPainel('update')"
  591. >
  592. <svg
  593. :class="classeIcon(painelAberto === 'update')"
  594. viewBox="0 0 24 24"
  595. fill="none"
  596. stroke="currentColor"
  597. stroke-width="1.8"
  598. >
  599. <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v12" />
  600. <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 7.5 12 3l4.5 4.5" />
  601. <path stroke-linecap="round" stroke-linejoin="round" d="M4 21h16" />
  602. </svg>
  603. <span :class="classeRotulo">Carregar documento</span>
  604. </button>
  605. <button
  606. type="button"
  607. :class="classeItemSidebar(painelAberto === 'documentos')"
  608. :title="sidebarRecolhida ? 'Documentos' : ''"
  609. @click="alternarPainel('documentos')"
  610. >
  611. <svg
  612. :class="classeIcon(painelAberto === 'documentos')"
  613. viewBox="0 0 24 24"
  614. fill="none"
  615. stroke="currentColor"
  616. stroke-width="1.8"
  617. >
  618. <path stroke-linecap="round" stroke-linejoin="round" d="M7 3h7l3 3v15a.75.75 0 0 1-.75.75H7.75A.75.75 0 0 1 7 21V3Z" />
  619. <path stroke-linecap="round" stroke-linejoin="round" d="M14 3v4h4" />
  620. <path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6M9 15.5h6" />
  621. </svg>
  622. <span :class="classeRotulo">Documentos</span>
  623. </button>
  624. <div class="my-1 h-px bg-black/10 dark:bg-white/10" />
  625. </template>
  626. <div
  627. v-if="!sidebarRecolhida"
  628. class="px-2 pb-0.5 text-[10px] font-semibold uppercase tracking-widest text-gray-400 dark:text-white/30 select-none"
  629. >
  630. Sistema
  631. </div>
  632. <button
  633. type="button"
  634. :class="classeItemSidebar(rotaAtiva === 'configuracoes')"
  635. :title="sidebarRecolhida ? 'Configurações' : ''"
  636. @click="irPara('configuracoes')"
  637. >
  638. <svg
  639. :class="classeIcon(rotaAtiva === 'configuracoes')"
  640. viewBox="0 0 24 24"
  641. fill="none"
  642. stroke="currentColor"
  643. stroke-width="1.8"
  644. >
  645. <path stroke-linecap="round" stroke-linejoin="round" d="M4 21v-7" />
  646. <path stroke-linecap="round" stroke-linejoin="round" d="M4 10V3" />
  647. <path stroke-linecap="round" stroke-linejoin="round" d="M12 21v-9" />
  648. <path stroke-linecap="round" stroke-linejoin="round" d="M12 8V3" />
  649. <path stroke-linecap="round" stroke-linejoin="round" d="M20 21v-5" />
  650. <path stroke-linecap="round" stroke-linejoin="round" d="M20 12V3" />
  651. <path stroke-linecap="round" stroke-linejoin="round" d="M1 14h6" />
  652. <path stroke-linecap="round" stroke-linejoin="round" d="M9 8h6" />
  653. <path stroke-linecap="round" stroke-linejoin="round" d="M17 16h6" />
  654. </svg>
  655. <span :class="classeRotulo">Configurações</span>
  656. </button>
  657. </div>
  658. </div>
  659. </aside>
  660. <Teleport to="body">
  661. <Transition name="modal">
  662. <div v-if="painelAberto" class="fixed inset-0 z-[9999]">
  663. <div
  664. class="modal-backdrop absolute inset-0 bg-black/40 dark:bg-black/60"
  665. @click="fecharModal"
  666. />
  667. <div class="absolute inset-0 flex items-center justify-center p-4">
  668. <div
  669. class="modal-panel w-full max-w-[980px] overflow-hidden rounded-2xl border border-gray-200 bg-background text-foreground shadow-xl dark:border-gray-700"
  670. @click.stop
  671. >
  672. <div
  673. class="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4 dark:border-gray-700"
  674. >
  675. <div
  676. class="text-base font-semibold text-gray-900 dark:text-gray-100"
  677. >
  678. {{ tituloModal }}
  679. </div>
  680. <button
  681. type="button"
  682. class="rounded-lg border border-transparent p-1.5 text-gray-500 transition-colors hover:border-gray-200 hover:bg-gray-100 dark:text-gray-400 dark:hover:border-gray-700 dark:hover:bg-white/5"
  683. @click="fecharModal"
  684. >
  685. <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  686. <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
  687. </svg>
  688. </button>
  689. </div>
  690. <div class="max-h-[calc(100vh-8rem)] overflow-auto p-5">
  691. <DocumentUpload
  692. v-if="painelAberto === 'update'"
  693. @ingested="onIngested"
  694. />
  695. <template v-else-if="painelAberto === 'documentos'">
  696. <SearchBox
  697. :results="searchResults"
  698. :loading="searchLoading"
  699. :error="searchError"
  700. @search="runSearch"
  701. />
  702. <div class="h-4" />
  703. <DocumentList
  704. :items="docs.items"
  705. :loading="docs.loading"
  706. :error="docs.error"
  707. @refresh="loadDocs"
  708. />
  709. </template>
  710. </div>
  711. </div>
  712. </div>
  713. </div>
  714. </Transition>
  715. </Teleport>
  716. <main
  717. class="col-start-2 row-start-2 relative z-10 flex min-h-0 min-w-0 flex-col overflow-hidden p-0 max-[980px]:col-start-1 max-[980px]:row-start-2"
  718. >
  719. <div class="flex-1 min-h-0 overflow-auto p-4 md:p-6">
  720. <slot />
  721. </div>
  722. </main>
  723. </div>
  724. </div>
  725. </template>
  726. <style scoped>
  727. .modal-enter-active,
  728. .modal-leave-active {
  729. transition: opacity 0.2s ease;
  730. }
  731. .modal-enter-from,
  732. .modal-leave-to {
  733. opacity: 0;
  734. }
  735. .modal-enter-active .modal-panel,
  736. .modal-leave-active .modal-panel {
  737. transition: transform 0.2s ease, opacity 0.2s ease;
  738. }
  739. .modal-enter-from .modal-panel,
  740. .modal-leave-to .modal-panel {
  741. transform: scale(0.96) translateY(8px);
  742. opacity: 0;
  743. }
  744. </style>