GabrielRamison il y a 3 mois
Parent
commit
19014a22d0

+ 3 - 3
dist/index.html

@@ -5,9 +5,9 @@
     <meta http-equiv="X-UA-Compatible" content="IE=edge" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
     <link rel="icon" type="image/x-icon" href="/favicon.ico?v=2" />
-    <title>ORACULO</title>
-    <script type="module" crossorigin src="/assets/index-DJWTF99T.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DBhEvp2j.css">
+    <title>ORÁCULO</title>
+    <script type="module" crossorigin src="/assets/index-DRIqSdBF.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-B1uWbpcA.css">
   </head>
   <body class="bg-background">
     <noscript>

+ 1 - 1
index.html

@@ -5,7 +5,7 @@
     <meta http-equiv="X-UA-Compatible" content="IE=edge" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
     <link rel="icon" type="image/x-icon" href="/favicon.ico?v=2" />
-    <title>ORACULO</title>
+    <title>ORÁCULO</title>
   </head>
   <body class="bg-background">
     <noscript>

+ 57 - 0
src/api/auth.js

@@ -0,0 +1,57 @@
+import { apiBaseUrl } from "./client.js";
+
+async function parseResponse(res) {
+  const contentType = res.headers.get("content-type") ?? "";
+  if (contentType.includes("application/json")) {
+    return res.json().catch(() => ({}));
+  }
+
+  const text = await res.text().catch(() => "");
+  return text ? { message: text } : {};
+}
+
+function buildErrorMessage(payload, status) {
+  if (payload?.msg) return payload.msg;
+  if (payload?.error) return payload.error;
+  if (payload?.message) return payload.message;
+  return `http_error:${status}`;
+}
+
+export async function loginRequest({ login, senha, rememberMe = false }) {
+  const res = await fetch(`${apiBaseUrl}/api/auth/login`, {
+    method: "POST",
+    headers: {
+      "content-type": "application/json"
+    },
+    body: JSON.stringify({
+      login,
+      senha,
+      RemenberMe: rememberMe
+    })
+  });
+
+  const payload = await parseResponse(res);
+
+  if (!res.ok || payload?.status === false) {
+    throw new Error(buildErrorMessage(payload, res.status));
+  }
+
+  return payload;
+}
+
+export async function logoutRequest() {
+  const res = await fetch(`${apiBaseUrl}/api/auth/logout`, {
+    method: "POST",
+    headers: {
+      "content-type": "application/json"
+    }
+  });
+
+  const payload = await parseResponse(res);
+
+  if (!res.ok || payload?.status === false) {
+    throw new Error(buildErrorMessage(payload, res.status));
+  }
+
+  return payload;
+}

+ 12 - 5
src/components/ChatWindow.vue

@@ -2,12 +2,12 @@
   <div class="flex h-full min-h-[520px] w-full flex-col gap-4">
     <div
       ref="chatEl"
-      class="flex-1 overflow-auto rounded-2xl border border-border bg-black/10 p-4 dark:bg-black/20"
+      class="min-h-0 flex-1 overflow-auto rounded-2xl border border-gray-200 bg-background/60 p-4 dark:border-gray-700 dark:bg-black/20"
     >
       <div v-if="!messages.length" class="grid h-full place-items-center">
         <div class="text-center">
           <div class="text-xl font-semibold tracking-tight">Nova conversa</div>
-          <div class="mt-2 text-sm text-muted-foreground">Digite sua pergunta abaixo para começar.</div>
+          <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Digite sua pergunta abaixo para começar.</div>
         </div>
       </div>
 
@@ -18,7 +18,7 @@
             :class="
               m.role === 'user'
                 ? 'border-primary/25 bg-primary/10 text-foreground'
-                : 'border-border bg-white/30 text-foreground dark:bg-white/10'
+                : 'border-gray-200 bg-white/30 text-foreground dark:border-gray-700 dark:bg-white/10'
             "
           >
             {{ m.content }}
@@ -28,11 +28,18 @@
     </div>
 
     <div class="grid gap-3 md:grid-cols-[1fr_140px] md:items-start">
-      <textarea v-model="draft" rows="2" placeholder="Digite sua pergunta..." @keydown.enter.exact.prevent="onSend" />
+      <textarea
+        v-model="draft"
+        rows="2"
+        placeholder="Digite sua pergunta..."
+        class="resize-none"
+        style="resize: none"
+        @keydown.enter.exact.prevent="onSend"
+      />
       <button :disabled="loading || !draft.trim()" @click="onSend">Enviar</button>
     </div>
 
-    <div v-if="error" class="text-sm text-muted-foreground">Erro: {{ error }}</div>
+    <div v-if="error" class="text-sm text-gray-500 dark:text-gray-400">Erro: {{ error }}</div>
   </div>
 </template>
 

+ 24 - 41
src/components/DocumentList.vue

@@ -1,18 +1,31 @@
 <template>
-  <div class="panel">
-    <h2 class="title">Documentos</h2>
-    <div class="row">
-      <button :disabled="loading" @click="$emit('refresh')">Atualizar</button>
-      <div class="muted">{{ loading ? "Carregando..." : `${items.length} itens` }}</div>
+  <section class="rounded-2xl border border-gray-200 bg-gray-100/50 p-4 shadow-sm backdrop-blur-md dark:border-gray-700 dark:bg-secondary/50">
+    <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Documentos</h2>
+
+    <div class="mt-3 flex items-center justify-between gap-3">
+      <button
+        class="rounded-lg border border-primary bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:border-[var(--primary-600)] hover:bg-[var(--primary-600)] disabled:cursor-not-allowed disabled:opacity-50"
+        :disabled="loading"
+        @click="$emit('refresh')"
+      >
+        Atualizar
+      </button>
+      <div class="text-xs text-gray-500 dark:text-gray-400">{{ loading ? "Carregando..." : `${items.length} itens` }}</div>
     </div>
-    <div v-if="error" class="muted" style="margin-top: 10px">Erro: {{ error }}</div>
-    <div class="list">
-      <div v-for="it in items" :key="String(it.id)" class="item">
-        <div class="muted">{{ it.source || "sem fonte" }} · chunk {{ it.chunkIndex ?? "-" }}</div>
-        <div class="text">{{ it.text }}</div>
+
+    <div v-if="error" class="mt-2.5 text-sm text-gray-500 dark:text-gray-400">Erro: {{ error }}</div>
+
+    <div class="mt-3 grid max-h-[520px] gap-2 overflow-auto">
+      <div
+        v-for="it in items"
+        :key="String(it.id)"
+        class="rounded-xl border border-gray-200 bg-background/60 p-3 dark:border-gray-700 dark:bg-background/10"
+      >
+        <div class="text-xs text-gray-500 dark:text-gray-400">{{ it.source || "sem fonte" }} · chunk {{ it.chunkIndex ?? "-" }}</div>
+        <div class="mt-1 whitespace-pre-wrap text-sm leading-snug text-gray-900 dark:text-gray-100">{{ it.text }}</div>
       </div>
     </div>
-  </div>
+  </section>
 </template>
 
 <script setup>
@@ -23,33 +36,3 @@ defineProps({
 });
 defineEmits(["refresh"]);
 </script>
-
-<style scoped>
-.row {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 10px;
-}
-
-.list {
-  margin-top: 12px;
-  display: grid;
-  gap: 10px;
-  max-height: 520px;
-  overflow: auto;
-}
-
-.item {
-  border: 1px solid var(--border);
-  border-radius: 12px;
-  padding: 10px;
-  background: var(--surface-2);
-}
-
-.text {
-  margin-top: 6px;
-  white-space: pre-wrap;
-  line-height: 1.35;
-}
-</style>

+ 31 - 22
src/components/DocumentUpload.vue

@@ -1,25 +1,3 @@
-<template>
-  <div class="panel">
-    <h2 class="title">Ingestão</h2>
-    <div class="muted" style="margin-bottom: 10px">
-      Envie .txt, .pdf, .docx ou imagens (png/jpg) com manuais/prints para alimentar a base.
-    </div>
-    <input
-      type="file"
-      accept=".txt,.pdf,.docx,.png,.jpg,.jpeg,.webp,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,image/*,text/plain"
-      @change="onFile"
-    />
-    <div style="height: 10px" />
-    <textarea v-model="manualText" rows="6" placeholder="Ou cole um texto aqui para ingestão..." />
-    <div style="height: 10px" />
-    <button :disabled="loading || (!manualText.trim() && !file)" @click="onIngest">
-      Ingerir
-    </button>
-    <div v-if="status" class="muted" style="margin-top: 10px">{{ status }}</div>
-    <div v-if="error" class="muted" style="margin-top: 10px">Erro: {{ error }}</div>
-  </div>
-</template>
-
 <script setup>
 import { ref } from "vue";
 
@@ -59,3 +37,34 @@ async function onIngest() {
   }
 }
 </script>
+
+
+<template>
+  <section class="rounded-2xl border border-gray-300 bg-gray-100 p-4 shadow-sm backdrop-blur-md dark:border-gray-700 dark:bg-secondary/50">
+    <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Update</h2>
+    <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
+      Envie .txt, .pdf, .docx ou imagens (png/jpg) com manuais/prints para alimentar a base.
+    </div>
+    <input
+      class="mt-3"
+      type="file"
+      accept=".txt,.pdf,.docx,.png,.jpg,.jpeg,.webp,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,image/*,text/plain"
+      @change="onFile"
+    />
+    <textarea
+      v-model="manualText"
+      class="mt-3"
+      rows="6"
+      placeholder="Ou cole um texto aqui para ingestão..."
+    />
+    <button
+      class="mt-3 rounded-lg border border-primary bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:border-[var(--primary-600)] hover:bg-[var(--primary-600)] disabled:cursor-not-allowed disabled:opacity-50"
+      :disabled="loading || (!manualText.trim() && !file)"
+      @click="onIngest"
+    >
+      Ingerir
+    </button>
+    <div v-if="status" class="mt-2.5 text-sm text-gray-500 dark:text-gray-400">{{ status }}</div>
+    <div v-if="error" class="mt-2.5 text-sm text-gray-500 dark:text-gray-400">Erro: {{ error }}</div>
+  </section>
+</template>

+ 23 - 45
src/components/SearchBox.vue

@@ -1,21 +1,32 @@
 <template>
-  <div class="panel">
-    <h2 class="title">Busca</h2>
-    <div class="row">
+  <section class="rounded-2xl border border-gray-200 bg-gray-100/50 p-4 shadow-sm backdrop-blur-md dark:border-gray-700 dark:bg-secondary/50">
+    <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Busca</h2>
+    <div class="mt-3 grid grid-cols-1 gap-2 min-[520px]:grid-cols-[1fr_120px]">
       <input v-model="query" placeholder="Buscar na base..." @keydown.enter="onSearch" />
-      <button :disabled="loading || !query.trim()" @click="onSearch">Buscar</button>
+      <button
+        class="w-full rounded-lg border border-primary bg-primary px-3 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:border-[var(--primary-600)] hover:bg-[var(--primary-600)] disabled:cursor-not-allowed disabled:opacity-50"
+        :disabled="loading || !query.trim()"
+        @click="onSearch"
+      >
+        Buscar
+      </button>
     </div>
-    <div v-if="error" class="muted" style="margin-top: 10px">Erro: {{ error }}</div>
-    <div v-if="results.length" class="results">
-      <div v-for="r in results" :key="String(r.id)" class="hit">
-        <div class="hitHeader">
-          <div class="muted">score: {{ r.score?.toFixed?.(3) ?? r.score }}</div>
-          <div class="muted">{{ r.source || "sem fonte" }}</div>
+    <div v-if="error" class="mt-2.5 text-sm text-gray-500 dark:text-gray-400">Erro: {{ error }}</div>
+
+    <div v-if="results.length" class="mt-3 grid gap-2">
+      <div
+        v-for="r in results"
+        :key="String(r.id)"
+        class="rounded-xl border border-gray-200 bg-background/60 p-3 dark:border-gray-700 dark:bg-background/10"
+      >
+        <div class="flex items-center justify-between gap-3">
+          <div class="text-xs text-gray-500 dark:text-gray-400">score: {{ r.score?.toFixed?.(3) ?? r.score }}</div>
+          <div class="truncate text-xs text-gray-500 dark:text-gray-400">{{ r.source || "sem fonte" }}</div>
         </div>
-        <div class="hitText">{{ r.text }}</div>
+        <div class="mt-2 whitespace-pre-wrap text-sm leading-snug text-gray-900 dark:text-gray-100">{{ r.text }}</div>
       </div>
     </div>
-  </div>
+  </section>
 </template>
 
 <script setup>
@@ -33,36 +44,3 @@ function onSearch() {
   emit("search", query.value);
 }
 </script>
-
-<style scoped>
-.row {
-  display: grid;
-  grid-template-columns: 1fr 120px;
-  gap: 10px;
-}
-
-.results {
-  margin-top: 12px;
-  display: grid;
-  gap: 10px;
-}
-
-.hit {
-  border: 1px solid var(--border);
-  border-radius: 12px;
-  padding: 10px;
-  background: var(--surface-2);
-}
-
-.hitHeader {
-  display: flex;
-  justify-content: space-between;
-  gap: 10px;
-  margin-bottom: 8px;
-}
-
-.hitText {
-  white-space: pre-wrap;
-  line-height: 1.35;
-}
-</style>

+ 80 - 0
src/composables/useAuth.js

@@ -0,0 +1,80 @@
+import { computed, readonly, ref } from "vue";
+import { loginRequest, logoutRequest } from "../api/auth.js";
+
+const LOCAL_STORAGE_KEY = "auth.session";
+const SESSION_STORAGE_KEY = "auth.session.temp";
+
+function getStorage(kind) {
+  if (typeof window === "undefined") return null;
+  return kind === "local" ? window.localStorage : window.sessionStorage;
+}
+
+function loadStoredSession() {
+  for (const kind of ["local", "session"]) {
+    try {
+      const storage = getStorage(kind);
+      const raw = storage?.getItem(kind === "local" ? LOCAL_STORAGE_KEY : SESSION_STORAGE_KEY);
+      if (!raw) continue;
+      const parsed = JSON.parse(raw);
+      if (parsed?.usuario) return parsed;
+    } catch {}
+  }
+
+  return null;
+}
+
+function persistSession(session) {
+  try {
+    getStorage("local")?.removeItem(LOCAL_STORAGE_KEY);
+    getStorage("session")?.removeItem(SESSION_STORAGE_KEY);
+
+    if (session?.rememberMe) {
+      getStorage("local")?.setItem(LOCAL_STORAGE_KEY, JSON.stringify(session));
+      return;
+    }
+
+    if (session) {
+      getStorage("session")?.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
+    }
+  } catch {}
+}
+
+const session = ref(loadStoredSession());
+const user = computed(() => session.value?.usuario ?? null);
+const isAuthenticated = computed(() => Boolean(user.value?.Id));
+
+async function login({ login, senha, rememberMe = false }) {
+  const payload = await loginRequest({ login, senha, rememberMe });
+  session.value = {
+    usuario: payload.usuario,
+    rememberMe,
+    savedAt: new Date().toISOString()
+  };
+  persistSession(session.value);
+  return payload;
+}
+
+async function logout() {
+  try {
+    await logoutRequest();
+  } finally {
+    session.value = null;
+    persistSession(null);
+  }
+}
+
+function clearSession() {
+  session.value = null;
+  persistSession(null);
+}
+
+export function useAuth() {
+  return {
+    session: readonly(session),
+    user,
+    isAuthenticated,
+    login,
+    logout,
+    clearSession
+  };
+}

+ 25 - 9
src/composables/useChat.js

@@ -83,6 +83,13 @@ export function useChat() {
     } catch {}
   }
 
+  function clearActiveConversationId() {
+    if (typeof window === "undefined") return;
+    try {
+      window.localStorage.removeItem(STORAGE_ACTIVE_ID);
+    } catch {}
+  }
+
   function loadMessagesForConversation(id) {
     if (typeof window === "undefined") return defaultMessages;
     const key = messagesKey(id);
@@ -160,15 +167,10 @@ export function useChat() {
   }
 
   function newConversation() {
-    const id = genId();
-    const now = Date.now();
-    conversations.value = [{ id, title: "Nova conversa", createdAt: now, updatedAt: now, messageCount: defaultMessages.length }, ...conversations.value];
-    saveConversations(conversations.value);
-    activeConversationId.value = id;
-    saveActiveConversationId(id);
+    activeConversationId.value = "";
+    clearActiveConversationId();
     messages.value = [...defaultMessages];
-    saveMessagesForConversation(id, messages.value);
-    return id;
+    return "";
   }
 
   function deleteConversation(id) {
@@ -211,7 +213,9 @@ export function useChat() {
       return;
     }
 
-    newConversation();
+    activeConversationId.value = "";
+    clearActiveConversationId();
+    messages.value = [...defaultMessages];
   }
 
   ensureInitialState();
@@ -232,7 +236,15 @@ export function useChat() {
     const trimmed = String(content ?? "").trim();
     if (!trimmed) return;
 
+    if (!String(activeConversationId.value ?? "").trim()) {
+      activeConversationId.value = genId();
+      ensureConversationExists(activeConversationId.value);
+    }
+
     messages.value.push({ role: "user", content: trimmed });
+    saveActiveConversationId(activeConversationId.value);
+    saveMessagesForConversation(activeConversationId.value, messages.value);
+    upsertConversationMetaFromMessages(activeConversationId.value, messages.value);
     loading.value = true;
 
     try {
@@ -242,9 +254,13 @@ export function useChat() {
         content: data.answer || "(sem resposta)",
         sources: data.sources ?? []
       });
+      saveMessagesForConversation(activeConversationId.value, messages.value);
+      upsertConversationMetaFromMessages(activeConversationId.value, messages.value);
     } catch (e) {
       error.value = e?.message || "erro";
       messages.value.push({ role: "assistant", content: "Não consegui responder no momento." });
+      saveMessagesForConversation(activeConversationId.value, messages.value);
+      upsertConversationMetaFromMessages(activeConversationId.value, messages.value);
     } finally {
       loading.value = false;
     }

+ 398 - 79
src/layout/LayoutSistema.vue

@@ -1,5 +1,12 @@
 <script setup>
-import { computed, onMounted, reactive, ref } from "vue";
+import {
+  computed,
+  onBeforeUnmount,
+  onMounted,
+  reactive,
+  ref,
+  watch,
+} from "vue";
 import { useRoute, useRouter } from "vue-router";
 import BotaoAlterarTema from "../components/BotaoAlterarTema.vue";
 import DocumentList from "../components/DocumentList.vue";
@@ -8,21 +15,60 @@ import SearchBox from "../components/SearchBox.vue";
 import { ingestDocuments, ingestFile, listDocuments } from "../api/chat.js";
 import { useChat } from "../composables/useChat.js";
 import { useSearch } from "../composables/useSearch.js";
+import { useAuth } from "../composables/useAuth.js";
 
 const router = useRouter();
 const route = useRoute();
 const { newConversation } = useChat();
+const { user, logout } = useAuth();
 
-const { results: searchResults, loading: searchLoading, error: searchError, run: runSearch } = useSearch();
+const {
+  results: searchResults,
+  loading: searchLoading,
+  error: searchError,
+  run: runSearch,
+} = useSearch();
 
 const docs = reactive({
   items: [],
   loading: false,
-  error: ""
+  error: "",
 });
+const logoutLoading = ref(false);
 
+const sidebarRecolhida = ref(false);
 const painelAberto = ref("");
 const rotaAtiva = computed(() => String(route.name ?? ""));
+const usuarioNome = computed(() => user.value?.Nome || user.value?.Login || "Usuario");
+const usuarioSetor = computed(() => user.value?.Setor || user.value?.Nivel || "");
+const layoutStyle = computed(() => ({
+  "--layout-sidebar-width": sidebarRecolhida.value ? "84px" : "280px",
+}));
+const classeItemSidebarBase = computed(() =>
+  sidebarRecolhida.value
+    ? "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"
+    : "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",
+);
+const classeItemSidebarAtivo =
+  "text-gray-900 !bg-gray-200/70 hover:!bg-gray-300/70 dark:text-white dark:!bg-white/10 dark:hover:!bg-white/10";
+const classeItemSidebarInativo =
+  "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";
+const classeIconAtivo = "text-gray-900 dark:text-white";
+const classeIconInativo = "text-gray-500 dark:text-white/60";
+const classeRotulo = computed(() =>
+  sidebarRecolhida.value ? "sr-only" : "min-w-0 truncate",
+);
+
+function classeItemSidebar(ativo) {
+  return [
+    classeItemSidebarBase.value,
+    ativo ? classeItemSidebarAtivo : classeItemSidebarInativo,
+  ];
+}
+
+function classeIcon(ativo) {
+  return ["h-5 w-5 shrink-0", ativo ? classeIconAtivo : classeIconInativo];
+}
 const tituloPagina = computed(() => {
   const nome = rotaAtiva.value;
   if (nome === "home") return "Nova conversa";
@@ -31,6 +77,12 @@ const tituloPagina = computed(() => {
   return "Oráculo";
 });
 
+const tituloModal = computed(() => {
+  if (painelAberto.value === "update") return "Update";
+  if (painelAberto.value === "documentos") return "Documentos";
+  return "";
+});
+
 function irPara(nome) {
   router.push({ name: nome });
 }
@@ -40,11 +92,28 @@ function alternarPainel(nome) {
   painelAberto.value = painelAberto.value === n ? "" : n;
 }
 
+function fecharModal() {
+  painelAberto.value = "";
+}
+
 async function novaConversa() {
   newConversation();
   await router.push({ name: "home" });
 }
 
+async function encerrarSessao() {
+  if (logoutLoading.value) return;
+
+  logoutLoading.value = true;
+
+  try {
+    await logout();
+    await router.replace({ name: "login" });
+  } finally {
+    logoutLoading.value = false;
+  }
+}
+
 async function loadDocs() {
   docs.loading = true;
   docs.error = "";
@@ -75,123 +144,373 @@ async function onIngested({ text, source, file } = {}) {
 onMounted(async () => {
   await loadDocs();
 });
+
+onMounted(() => {
+  const v = window.localStorage.getItem("layout.sidebarRecolhida");
+  if (v != null) sidebarRecolhida.value = v === "1" || v === "true";
+});
+
+watch(
+  () => sidebarRecolhida.value,
+  (v) => {
+    window.localStorage.setItem("layout.sidebarRecolhida", v ? "1" : "0");
+  },
+);
+
+function onKeyDown(e) {
+  if (!painelAberto.value) return;
+  if (e?.key === "Escape") fecharModal();
+}
+
+onMounted(() => {
+  window.addEventListener("keydown", onKeyDown);
+});
+
+onBeforeUnmount(() => {
+  window.removeEventListener("keydown", onKeyDown);
+});
+
+let overflowAntes = "";
+watch(
+  () => painelAberto.value,
+  (v) => {
+    const aberto = Boolean(v);
+    if (aberto) {
+      overflowAntes = document.body.style.overflow || "";
+      document.body.style.overflow = "hidden";
+    } else {
+      document.body.style.overflow = overflowAntes;
+    }
+  },
+);
 </script>
 
 <template>
-  <div class="app">
-    <div class="appShell">
-      <aside class="sidebar">
-        <div class="sidebarHeader">
-          <div class="brandTitle">ORÁCULO</div>
-          <div class="brandSub">Assistente interno com base de conhecimento</div>
+  <div class="h-screen overflow-hidden bg-background text-foreground">
+    <div
+      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]"
+      :style="layoutStyle"
+    >
+      <div
+        aria-hidden="true"
+        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"
+      />
+      <div
+        aria-hidden="true"
+        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"
+      />
+
+      <header
+        class="col-span-full row-start-1 relative z-10 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"
+      >
+        <div class="min-w-0 overflow-hidden">
+          <div
+            class="truncate whitespace-nowrap text-[16px] font-extrabold tracking-[0.14em]"
+          >
+            ORÁCULO
+          </div>
+          <div
+            v-if="!sidebarRecolhida"
+            class="mt-1.5 text-xs text-primary-foreground/80"
+          >
+            Assistente interno com base de conhecimento
+          </div>
+        </div>
+
+        <div
+          class="min-w-0 flex items-center justify-between gap-4 max-[980px]:flex-1"
+        >
+          <div class="min-w-0">
+            <div class="truncate text-base font-medium">{{ tituloPagina }}</div>
+          </div>
+          <div class="flex items-center gap-3">
+            <div class="hidden text-right md:block">
+              <div class="text-sm font-semibold leading-tight">
+                {{ usuarioNome }}
+              </div>
+             
+            </div>
+            <BotaoAlterarTema />
+            <button
+              type="button"
+              class="rounded-lg !border-primary !bg-none !bg-primary px-3 py-2 text-sm font-medium !text-primary-foreground !transform-none transition-colors hover:!transform-none hover:!border-[var(--primary-600)] hover:!bg-[var(--primary-600)]"
+              :disabled="logoutLoading"
+              @click="encerrarSessao"
+            >
+              {{ logoutLoading ? "Saindo..." : "Sair" }}
+            </button>
+          </div>
         </div>
+      </header>
 
-        <div class="sidebarScroll">
-          <div class="panel sidebarNav">
+      <aside
+        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-0 dark:border-gray-700 max-[980px]:row-start-3 max-[980px]:border-r-0 max-[980px]:border-t"
+      >
+        <div
+          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-gradient-to-b dark:from-[#101827] dark:to-[#070b16] dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)]"
+        >
+          <div
+            :class="
+              sidebarRecolhida
+                ? 'flex items-center justify-center px-1 pb-1'
+                : 'flex items-center justify-end px-1 pb-1'
+            "
+          >
             <button
               type="button"
-              class="sidebarNavButton"
-              :class="{ active: rotaAtiva === 'home' }"
+              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"
+              :aria-label="
+                sidebarRecolhida ? 'Expandir sidebar' : 'Recolher sidebar'
+              "
+              :title="sidebarRecolhida ? 'Expandir sidebar' : 'Recolher sidebar'"
+              @click="sidebarRecolhida = !sidebarRecolhida"
+            >
+              <svg
+                v-if="sidebarRecolhida"
+                class="h-5 w-5"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+                stroke-width="1.8"
+              >
+                <path
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  d="m9 6 6 6-6 6"
+                />
+              </svg>
+              <svg
+                v-else
+                class="h-5 w-5"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+                stroke-width="1.8"
+              >
+                <path
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  d="m15 6-6 6 6 6"
+                />
+              </svg>
+            </button>
+          </div>
+          <nav class="grid gap-1.5">
+            <button
+              type="button"
+              :class="classeItemSidebar(rotaAtiva === 'home')"
+              :title="sidebarRecolhida ? 'Nova conversa' : ''"
               @click="novaConversa"
             >
-              Nova conversa
+              <svg
+                :class="classeIcon(rotaAtiva === 'home')"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+                stroke-width="1.8"
+              >
+                <path
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  d="M3 10.5 12 3l9 7.5V21a.75.75 0 0 1-.75.75H3.75A.75.75 0 0 1 3 21v-10.5Z"
+                />
+                <path
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  d="M9 21v-7.5a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 .75.75V21"
+                />
+              </svg>
+              <span :class="classeRotulo">Nova conversa</span>
             </button>
+
             <button
               type="button"
-              class="sidebarNavButton"
-              :class="{ active: rotaAtiva === 'conversas-pesquisar' }"
+              :class="classeItemSidebar(rotaAtiva === 'conversas-pesquisar')"
+              :title="sidebarRecolhida ? 'Pesquisar conversas' : ''"
               @click="irPara('conversas-pesquisar')"
             >
-              Pesquisar conversas
+              <svg
+                :class="classeIcon(rotaAtiva === 'conversas-pesquisar')"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+                stroke-width="1.8"
+              >
+                <path
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  d="M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15Z"
+                />
+                <path
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  d="M16.5 16.5 21 21"
+                />
+              </svg>
+              <span :class="classeRotulo">Pesquisar conversas</span>
             </button>
+
             <button
               type="button"
-              class="sidebarNavButton"
-              :class="{ active: rotaAtiva === 'conversas-historico' }"
+              :class="classeItemSidebar(rotaAtiva === 'conversas-historico')"
+              :title="sidebarRecolhida ? 'Histórico' : ''"
               @click="irPara('conversas-historico')"
             >
-              Histórico
+              <svg
+                :class="classeIcon(rotaAtiva === 'conversas-historico')"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+                stroke-width="1.8"
+              >
+                <path
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  d="M12 6v6l4 2"
+                />
+                <path
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  d="M21 12a9 9 0 1 1-3.2-6.9"
+                />
+              </svg>
+              <span :class="classeRotulo">Histórico</span>
             </button>
+          </nav>
 
-            <div class="sidebarNavDivider" />
+          <div class="mt-auto grid gap-1.5 pt-3">
+            <div class="my-1 h-px bg-black/10 dark:bg-white/10" />
 
             <button
               type="button"
-              class="sidebarNavButton"
-              :class="{ active: painelAberto === 'ingestao' }"
-              @click="alternarPainel('ingestao')"
+              :class="classeItemSidebar(painelAberto === 'update')"
+              :title="sidebarRecolhida ? 'Update' : ''"
+              @click="alternarPainel('update')"
             >
-              Ingestão
+              <svg
+                :class="classeIcon(painelAberto === 'update')"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+                stroke-width="1.8"
+              >
+                <path
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  d="M12 3v12"
+                />
+                <path
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  d="M7.5 7.5 12 3l4.5 4.5"
+                />
+                <path
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  d="M4 21h16"
+                />
+              </svg>
+              <span :class="classeRotulo">Update</span>
             </button>
+
             <button
               type="button"
-              class="sidebarNavButton"
-              :class="{ active: painelAberto === 'documentos' }"
+              :class="classeItemSidebar(painelAberto === 'documentos')"
+              :title="sidebarRecolhida ? 'Documentos' : ''"
               @click="alternarPainel('documentos')"
             >
-              Documentos
+              <svg
+                :class="classeIcon(painelAberto === 'documentos')"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+                stroke-width="1.8"
+              >
+                <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"
+                />
+                <path
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  d="M14 3v4h4"
+                />
+                <path
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  d="M9 12h6M9 15.5h6"
+                />
+              </svg>
+              <span :class="classeRotulo">Documentos</span>
             </button>
           </div>
-
-          <DocumentUpload v-if="painelAberto === 'ingestao'" @ingested="onIngested" />
-          <template v-if="painelAberto === 'documentos'">
-            <SearchBox :results="searchResults" :loading="searchLoading" :error="searchError" @search="runSearch" />
-            <DocumentList :items="docs.items" :loading="docs.loading" :error="docs.error" @refresh="loadDocs" />
-          </template>
         </div>
 
-        <div class="sidebarFooter muted">
-          Backend: http://localhost:3001 · Qdrant: http://localhost:6333 · Ollama: http://localhost:11434
-        </div>
+        
       </aside>
 
-      <main class="main !flex !flex-col !p-0 !overflow-hidden">
-        <header
-          class="sticky top-0 z-30 h-16 flex items-center justify-between gap-4 px-4 md:px-6 bg-primary text-primary-foreground border-b border-border backdrop-blur-sm"
-        >
-          <div class="min-w-0">
-            <div class="text-base font-medium truncate">{{ tituloPagina }}</div>
+      <Teleport to="body">
+        <div v-if="painelAberto" class="fixed inset-0 z-[9999]">
+          <div
+            class="absolute inset-0 bg-black/40 dark:bg-black/60"
+            @click="fecharModal"
+          />
+          <div class="absolute inset-0 flex items-center justify-center p-4">
+            <div
+              class="w-full max-w-[980px] overflow-hidden rounded-2xl border border-gray-200 bg-background text-foreground shadow-lg dark:border-gray-700"
+              @click.stop
+            >
+              <div
+                class="flex items-center justify-between gap-3 border-b border-gray-300 px-4 py-3 dark:border-gray-700"
+              >
+                <div
+                  class="text-sm font-semibold text-gray-900 dark:text-gray-100"
+                >
+                  {{ tituloModal }}
+                </div>
+                <button
+                  type="button"
+                  class="rounded-lg border border-primary bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:border-[var(--primary-600)] hover:bg-[var(--primary-600)]"
+                  @click="fecharModal"
+                >
+                  Fechar
+                </button>
+              </div>
+
+              <div class="max-h-[calc(100vh-8rem)] overflow-auto p-4">
+                <DocumentUpload
+                  v-if="painelAberto === 'update'"
+                  @ingested="onIngested"
+                />
+                <template v-else-if="painelAberto === 'documentos'">
+                  <SearchBox
+                    :results="searchResults"
+                    :loading="searchLoading"
+                    :error="searchError"
+                    @search="runSearch"
+                  />
+                  <div class="h-3" />
+                  <DocumentList
+                    :items="docs.items"
+                    :loading="docs.loading"
+                    :error="docs.error"
+                    @refresh="loadDocs"
+                  />
+                </template>
+              </div>
+            </div>
           </div>
-          <BotaoAlterarTema />
-        </header>
+        </div>
+      </Teleport>
 
-        <div class="flex-1 overflow-auto p-4 md:p-6">
+      <main
+        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"
+      >
+        <div class="flex-1 min-h-0 overflow-auto bg-black/10 p-4 md:p-6">
           <slot />
         </div>
       </main>
     </div>
   </div>
 </template>
-
-<style scoped>
-.sidebarNav {
-  display: grid;
-  align-content: end;
-  gap: 6px;
-  padding: 6px;
-}
-
-.sidebarNavButton {
-  width: 100%;
-  height: 95%;
-  text-align: center;
-  background: var(--primary);
-  color: var(--primary-foreground);
-  border-color: var(--primary);
-}
-
-.sidebarNavButton:hover:not(:disabled) {
-  background: var(--primary-600);
-  border-color: var(--primary-600);
-}
-
-.sidebarNavButton.active {
-  border-color: var(--ring);
-}
-
-.sidebarNavDivider {
-  height: 1px;
-  background: var(--border);
-  margin: 4px 0;
-}
-</style>

+ 23 - 3
src/router/index.js

@@ -2,13 +2,33 @@ import { createRouter, createWebHistory } from "vue-router";
 import PaginaInicialView from "../views/pagina-inicial/PaginaInicialView.vue";
 import ConversasHistoricoView from "../views/conversas/ConversasHistoricoView.vue";
 import ConversasPesquisarView from "../views/conversas/ConversasPesquisarView.vue";
+import LoginView from "../views/auth/LoginView.vue";
+import { useAuth } from "../composables/useAuth.js";
 
 export const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
   routes: [
-    { path: "/", name: "home", component: PaginaInicialView },
-    { path: "/conversas/historico", name: "conversas-historico", component: ConversasHistoricoView },
-    { path: "/conversas/pesquisar", name: "conversas-pesquisar", component: ConversasPesquisarView },
+    { path: "/login", name: "login", component: LoginView, meta: { guestOnly: true } },
+    { path: "/", name: "home", component: PaginaInicialView, meta: { requiresAuth: true } },
+    { path: "/conversas/historico", name: "conversas-historico", component: ConversasHistoricoView, meta: { requiresAuth: true } },
+    { path: "/conversas/pesquisar", name: "conversas-pesquisar", component: ConversasPesquisarView, meta: { requiresAuth: true } },
     { path: "/:pathMatch(.*)*", redirect: "/" }
   ]
 });
+
+router.beforeEach((to) => {
+  const { isAuthenticated } = useAuth();
+
+  if (to.meta?.guestOnly && isAuthenticated.value) {
+    return { name: "home" };
+  }
+
+  if (to.meta?.requiresAuth && !isAuthenticated.value) {
+    return {
+      name: "login",
+      query: to.fullPath && to.fullPath !== "/" ? { redirect: to.fullPath } : {}
+    };
+  }
+
+  return true;
+});

+ 4 - 1
src/styles/tailwind.css

@@ -235,7 +235,10 @@ body {
 
 @layer base {
   * {
-    @apply border-border outline-ring/50;
+    @apply border-gray-200 outline-ring/50;
+  }
+  .dark * {
+    @apply border-gray-700;
   }
   body {
     @apply bg-background text-foreground;

+ 120 - 0
src/views/auth/LoginView.vue

@@ -0,0 +1,120 @@
+<script setup>
+import { computed, ref } from "vue";
+import { useRoute, useRouter } from "vue-router";
+import BotaoAlterarTema from "../../components/BotaoAlterarTema.vue";
+import { useAuth } from "../../composables/useAuth.js";
+
+const router = useRouter();
+const route = useRoute();
+const { login } = useAuth();
+
+const form = ref({
+  login: "",
+  senha: "",
+  rememberMe: false
+});
+const loading = ref(false);
+const error = ref("");
+
+const canSubmit = computed(() => {
+  return Boolean(form.value.login.trim() && form.value.senha);
+});
+
+async function onSubmit() {
+  if (!canSubmit.value || loading.value) return;
+
+  loading.value = true;
+  error.value = "";
+
+  try {
+    await login(form.value);
+    const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/";
+    await router.replace(redirect);
+  } catch (err) {
+    error.value = err?.message || "Falha ao realizar login.";
+  } finally {
+    loading.value = false;
+  }
+}
+</script>
+
+<template>
+  <div class="relative min-h-screen overflow-hidden bg-background text-foreground">
+    <div
+      aria-hidden="true"
+      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"
+    />
+    <div
+      aria-hidden="true"
+      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"
+    />
+
+    <header class="relative z-10 flex items-center justify-between px-6 py-5">
+      <div>
+        <div class="text-[16px] font-extrabold tracking-[0.14em]">ORACULO</div>
+        <div class="mt-1 text-sm text-gray-500 dark:text-gray-400">
+          Acesso ao assistente interno
+        </div>
+      </div>
+
+      <BotaoAlterarTema />
+    </header>
+
+    <main class="relative z-10 flex min-h-[calc(100vh-88px)] items-center justify-center px-4 pb-8">
+      <div
+        class="w-full max-w-md rounded-[28px] border border-gray-200 bg-white/80 p-6 shadow-sm backdrop-blur-md dark:border-white/10 dark:bg-gradient-to-b dark:from-[#101827] dark:to-[#070b16] dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)]"
+      >
+        <div class="text-2xl font-extrabold tracking-tight">Entrar</div>
+        <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
+          Informe seu usuario ou email e sua senha para acessar o sistema.
+        </div>
+
+        <form class="mt-6 grid gap-4" @submit.prevent="onSubmit">
+          <label class="grid gap-1.5">
+            <span class="text-sm font-medium">Usuario ou email</span>
+            <input
+              v-model="form.login"
+              type="text"
+              autocomplete="username"
+              placeholder="Digite seu login"
+            />
+          </label>
+
+          <label class="grid gap-1.5">
+            <span class="text-sm font-medium">Senha</span>
+            <input
+              v-model="form.senha"
+              type="password"
+              autocomplete="current-password"
+              placeholder="Digite sua senha"
+            />
+          </label>
+
+          <label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
+            <input
+              v-model="form.rememberMe"
+              type="checkbox"
+              class="!h-4 !w-4 rounded border-gray-300 !p-0"
+            />
+            <span>Manter conectado neste navegador</span>
+          </label>
+
+          <div
+            v-if="error"
+            class="rounded-xl border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-200"
+          >
+            {{ error }}
+          </div>
+
+          <button
+            type="submit"
+            class="mt-2 inline-flex items-center justify-center rounded-xl border border-primary bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground transition-colors hover:border-[var(--primary-600)] hover:bg-[var(--primary-600)]"
+            :disabled="!canSubmit || loading"
+          >
+            {{ loading ? "Entrando..." : "Entrar" }}
+          </button>
+        </form>
+      </div>
+    </main>
+  </div>
+</template>

+ 28 - 42
src/views/conversas/ConversasHistoricoView.vue

@@ -31,51 +31,37 @@ function excluirConversa(id) {
 
 <template>
   <LayoutSistema>
-    <div class="panel">
-      <h2 class="title">Histórico</h2>
-      <div class="muted" style="margin-bottom: 10px">{{ items.length }} conversas</div>
+    <section class="rounded-2xl border border-gray-200 bg-gray-100/50 p-4 shadow-sm backdrop-blur-md dark:border-gray-700 dark:bg-secondary/50">
+      <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Histórico</h2>
+      <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ items.length }} conversas</div>
 
-      <div class="historyList">
-        <div v-for="c in items" :key="c.id" class="historyItem" :class="{ active: c.id === activeConversationId }">
-          <button class="historyOpen" type="button" @click="abrirConversa(c.id)">
-            <div class="historyTitle">{{ c.title || "Nova conversa" }}</div>
-            <div class="muted">{{ formatarData(c.updatedAt) }} · {{ c.messageCount ?? 0 }} msgs</div>
+      <div class="mt-3 grid gap-2">
+        <div v-for="c in items" :key="c.id" class="grid grid-cols-[1fr_auto] items-stretch gap-2">
+          <button
+            type="button"
+            class="rounded-xl border bg-background/60 p-3 text-left transition-colors dark:bg-background/10"
+            :class="
+              c.id === activeConversationId
+                ? 'border-gray-500 dark:border-gray-400'
+                : 'border-gray-200 hover:border-gray-500 dark:border-gray-700 dark:hover:border-gray-400'
+            "
+            @click="abrirConversa(c.id)"
+          >
+            <div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ c.title || 'Nova conversa' }}</div>
+            <div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
+              {{ formatarData(c.updatedAt) }} · {{ c.messageCount ?? 0 }} msgs
+            </div>
+          </button>
+
+          <button
+            type="button"
+            class="rounded-xl border border-gray-200 bg-background/60 px-3 py-2 text-sm text-gray-500 transition-colors hover:border-gray-500 dark:border-gray-700 dark:bg-background/10 dark:text-gray-400 dark:hover:border-gray-400"
+            @click="excluirConversa(c.id)"
+          >
+            Excluir
           </button>
-          <button class="historyDelete" type="button" @click="excluirConversa(c.id)">Excluir</button>
         </div>
       </div>
-    </div>
+    </section>
   </LayoutSistema>
 </template>
-
-<style scoped>
-.historyList {
-  display: grid;
-  gap: 10px;
-}
-
-.historyItem {
-  display: grid;
-  grid-template-columns: 1fr auto;
-  gap: 10px;
-  align-items: stretch;
-}
-
-.historyItem.active .historyOpen {
-  border-color: var(--ring);
-}
-
-.historyOpen {
-  text-align: left;
-}
-
-.historyTitle {
-  font-weight: 650;
-  margin-bottom: 6px;
-}
-
-.historyDelete {
-  white-space: nowrap;
-}
-</style>
-

+ 16 - 31
src/views/conversas/ConversasPesquisarView.vue

@@ -23,9 +23,10 @@ function abrirConversa(id) {
 
 <template>
   <LayoutSistema>
-    <div class="panel">
-      <h2 class="title">Pesquisar conversas</h2>
-      <div class="row">
+    <section class="rounded-2xl border border-gray-200 bg-gray-100/50 p-4 shadow-sm backdrop-blur-md dark:border-gray-700 dark:bg-secondary/50">
+      <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Pesquisar conversas</h2>
+
+      <div class="mt-3 grid grid-cols-1 gap-2 min-[520px]:grid-cols-[1fr_120px]">
         <input
           v-model="draftQuery"
           placeholder="Buscar por título ou conteúdo..."
@@ -34,38 +35,22 @@ function abrirConversa(id) {
         <button type="button" :disabled="!draftQuery.trim()" @click="buscar">Buscar</button>
       </div>
 
-      <div class="muted" style="margin-top: 10px">
+      <div class="mt-2.5 text-sm text-gray-500 dark:text-gray-400">
         {{ query.trim() ? `${results.length} resultados` : "Digite para filtrar o histórico." }}
       </div>
 
-      <div class="results" style="margin-top: 12px">
-        <button v-for="c in results" :key="c.id" class="resultItem" type="button" @click="abrirConversa(c.id)">
-          <div class="resultTitle">{{ c.title || "Nova conversa" }}</div>
-          <div class="muted">{{ c.messageCount ?? 0 }} msgs</div>
+      <div class="mt-3 grid gap-2">
+        <button
+          v-for="c in results"
+          :key="c.id"
+          type="button"
+          class="rounded-xl border border-gray-200 bg-background/60 p-3 text-left transition-colors hover:border-gray-500 dark:border-gray-700 dark:bg-background/10 dark:hover:border-gray-400"
+          @click="abrirConversa(c.id)"
+        >
+          <div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ c.title || "Nova conversa" }}</div>
+          <div class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ c.messageCount ?? 0 }} msgs</div>
         </button>
       </div>
-    </div>
+    </section>
   </LayoutSistema>
 </template>
-
-<style scoped>
-.row {
-  display: grid;
-  grid-template-columns: 1fr 120px;
-  gap: 10px;
-}
-
-.results {
-  display: grid;
-  gap: 10px;
-}
-
-.resultItem {
-  text-align: left;
-}
-
-.resultTitle {
-  font-weight: 650;
-  margin-bottom: 6px;
-}
-</style>

+ 8 - 8
src/views/pagina-inicial/PaginaInicialView.vue

@@ -24,10 +24,10 @@ async function onLandingSend() {
 
 <template>
   <LayoutSistema>
-    <div v-if="isLanding" class="min-h-[calc(100vh-4rem)] flex items-end justify-center pb-12 md:pb-16">
+    <div v-if="isLanding" class="min-h-[calc(100vh-var(--layout-header-height))] flex items-end justify-center pb-12 md:pb-16">
       <div class="w-full max-w-[760px]">
         <div class="text-3xl font-extrabold tracking-tight">Como posso ajudar?</div>
-        <div class="mt-2 text-sm text-muted-foreground">
+        <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
           Digite sua pergunta no campo abaixo. Você também pode alimentar a base pela barra lateral.
         </div>
 
@@ -36,19 +36,19 @@ async function onLandingSend() {
             v-model="landingDraft"
             rows="2"
             placeholder="Digite sua pergunta..."
-            class="min-h-24"
+            class="min-h-24 resize-none"
+            style="resize: none"
             @keydown.enter.exact.prevent="onLandingSend"
           />
         </div>
 
-        <div v-if="chatError" class="mt-2.5 text-sm text-muted-foreground">Erro: {{ chatError }}</div>
+        <div v-if="chatError" class="mt-2.5 text-sm text-gray-500 dark:text-gray-400">Erro: {{ chatError }}</div>
       </div>
     </div>
 
-    <div v-else class="relative min-h-full w-full">
-      <div class="absolute inset-0 rounded-2xl bg-black/20 dark:bg-black/35" />
-      <div class="relative h-full w-full p-4 md:p-6">
-        <ChatWindow :messages="chatMessages" :loading="chatLoading" :error="chatError" @send="sendMessage" />
+    <div v-else class="relative h-full w-full">
+      <div class="relative flex h-full w-full flex-col p-4 md:p-6">
+        <ChatWindow class="flex-1 min-h-1" :messages="chatMessages" :loading="chatLoading" :error="chatError" @send="sendMessage" />
       </div>
     </div>
   </LayoutSistema>