adiciona arquivos estáticos, templates e configuração da api do django ninja

This commit is contained in:
2025-06-18 13:27:00 -04:00
parent 84c22c3b9b
commit c17f7c35f5
16 changed files with 3242 additions and 0 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,76 @@
.bg-light-gradient {
background: linear-gradient(to right, #f8f9fa, #e9ecef);
}
.search-card {
border-radius: 15px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
border: none;
}
.result-card {
transition: transform 0.2s, box-shadow 0.2s;
border-radius: 10px;
}
.result-card:hover {
transform: translateY(-3px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
}
.page-item.active .page-link {
background-color: #0d6efd;
border-color: #0d6efd;
}
.date-picker {
background-color: #fff;
border: 1px solid #ced4da;
border-radius: 4px;
padding: 0.375rem 0.75rem;
}
.best-match {
border-left: 4px solid #0d6efd;
background-color: rgba(13, 110, 253, 0.05);
}
.page-content {
max-height: 400px;
overflow-y: auto;
font-size: 0.9rem;
}
.accordion-button:not(.collapsed) {
background-color: rgba(13, 110, 253, 0.1);
color: #0d6efd;
}
.accordion-button:focus {
box-shadow: none;
}
mark {
background-color: #fff3cd;
padding: 0.1em 0.2em;
border-radius: 2px;
}
.page-badge {
background-color: #6c757d;
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
}
.match-score {
color: #0d6efd;
font-size: 0.85rem;
font-weight: 500;
}
.btn-sort {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
.btn-sort.active {
background-color: #0d6efd;
color: white;
}
.search-options {
display: flex;
gap: 10px;
}
@media (max-width: 768px) {
.search-options {
flex-direction: column;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
const API_BASE_URL = "http://109.199.98.226";

View File

@ -0,0 +1,242 @@
document.addEventListener('alpine:init', () => {
Alpine.data('searchApp', () => ({
searchParams: {
q: '',
numero_diario: '',
data_inicio: '',
data_fim: '',
modo_busca: 'exata',
ordenar_por: 'data_asc',
page: 1,
page_size: 10
},
searchResults: null,
isLoading: false,
hasSearched: false,
error: null,
showAdvanced: false,
expandedContents: {},
suggestion: null,
ultimoTermoBuscado: '',
get shouldShowSuggestion() {
if (!this.suggestion || !this.searchParams.q) return false;
// Função para remover acentos e converter para minúsculas
const normalize = (text) => {
return text.toLowerCase()
.normalize('NFD')
.replace("/", " ")
.replace(/[^a-z0-9\s]/g, " ") // Substitui todos os outros símbolos por espaço
.replace(/[\u0300-\u036f]/g, '');
};
const normalizedQuery = normalize(this.searchParams.q);
const normalizedSuggestion = normalize(this.suggestion);
// Só mostra a sugestão se for diferente do termo buscado (ignorando acentos e caixa)
return normalizedQuery !== normalizedSuggestion;
},
// Usa a sugestão como novo termo de busca
usesuggestion() {
this.searchParams.q = this.suggestion;
this.searchParams.page = 1;
this.performSearch();
},
// Verifica se um diário tem uma melhor correspondência
hasBestMatch(diario) {
return diario.paginas && diario.paginas.length > 0;
},
// Muda a ordenação e faz uma nova busca
changeOrder(order) {
if (this.searchParams.ordenar_por !== order) {
this.searchParams.ordenar_por = order;
this.searchParams.page = 1; // Volta para a primeira página
this.performSearch();
}
},
// Obter sugestão da API
async getSuggestion(query) {
if (!query) return null;
try {
const url = new URL('http://192.168.235.234/api/v1/diarios/sugestao');
url.searchParams.append('q', query);
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
return data.sugestao;
}
return null;
} catch (error) {
console.error('Erro ao buscar sugestão:', error);
return null;
}
},
get totalPages() {
if (!this.searchResults) return 0;
return Math.ceil(this.searchResults.total / this.searchResults.por_pagina);
},
get paginationArray() {
const pages = [];
const currentPage = this.searchParams.page;
const totalPages = this.totalPages;
// Função auxiliar para adicionar páginas
const addPage = (page) => {
if (page >= 1 && page <= totalPages && !pages.includes(page)) {
pages.push(page);
}
};
// Sempre mostrar primeira página, página atual, última página
// e 1-2 páginas adjacentes à página atual
addPage(1);
addPage(currentPage - 2);
addPage(currentPage - 1);
addPage(currentPage);
addPage(currentPage + 1);
addPage(currentPage + 2);
addPage(totalPages);
// Ordenar e adicionar separadores
const result = pages.sort((a, b) => a - b);
return result;
},
// Retorna a melhor página (maior score) de um diário
getBestPage(paginas) {
if (!paginas || paginas.length === 0) return null;
// Ordena as páginas por score (se disponível) ou pelo número da página se não houver score
const sortedPages = [...paginas].sort((a, b) => {
if (a.score === undefined || b.score === undefined) return 0;
if (a.score === undefined) return 1;
if (b.score === undefined) return -1;
return b.score - a.score;
});
return sortedPages[0];
},
// Retorna todas as páginas exceto a melhor
getOtherPages(paginas) {
if (!paginas || paginas.length <= 1) return [];
const bestPage = this.getBestPage(paginas);
if (!bestPage) return paginas;
return paginas.filter(p => p.numero !== bestPage.numero)
.sort((a, b) => a.numero - b.numero); // Ordena por número da página
},
// Controla a exibição do conteúdo completo de uma página
toggleFullContent(diarioIndex, paginaNumero) {
const key = `${diarioIndex}-${paginaNumero}`;
this.expandedContents[key] = !this.expandedContents[key];
},
// Verifica se um conteúdo está expandido
isFullContentVisible(diarioIndex, paginaNumero) {
const key = `${diarioIndex}-${paginaNumero}`;
return this.expandedContents[key] === true;
},
async performSearch() {
this.isLoading = true;
this.error = null;
this.hasSearched = true;
this.expandedContents = {}; // Resetar estados expandidos
this.suggestion = null; // Resetar a sugestão
try {
let suggestionPromise = null;
if (this.searchParams.q) {
suggestionPromise = this.getSuggestion(this.searchParams.q);
}
if (this.searchParams.q !== this.ultimoTermoBuscado) {
this.searchParams.page = 1;
}
this.ultimoTermoBuscado = this.searchParams.q;
// Usando agora o endpoint busca
const url = new URL('http://192.168.235.234/api/v1/diarios/busca');
// Adicionar parâmetros à URL
Object.entries(this.searchParams).forEach(([key, value]) => {
if (value !== '' && value !== null) {
url.searchParams.append(key, value);
}
});
const response = await fetch(url);
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.message || `Erro HTTP: ${response.status}`);
}
this.searchResults = await response.json();
// Processar os resultados para garantir que as páginas tenham score
if (this.searchResults && this.searchResults.resultados) {
this.searchResults.resultados.forEach(diario => {
if (diario.paginas) {
// Atribuir scores padrão se não existirem
diario.paginas.forEach((pagina, index) => {
if (pagina.score === undefined || pagina.score === null) {
pagina.score = diario.paginas.length - index; // Score inversamente proporcional ao índice
}
});
}
});
}
if (suggestionPromise) {
this.suggestion = await suggestionPromise;
}
} catch (error) {
console.error('Erro na busca:', error);
this.error = `Erro ao buscar diários: ${error.message}`;
} finally {
this.isLoading = false;
}
},
formatDate(dateString) {
const options = { day: '2-digit', month: '2-digit', year: 'numeric' };
return new Date(dateString + 'T00:00:00').toLocaleDateString('pt-BR', options);
},
goToPage(page) {
if (page < 1 || page > this.totalPages) return;
this.searchParams.page = page;
this.performSearch();
window.scrollTo({ top: 0, behavior: 'smooth' });
},
resetSearch() {
this.searchParams = {
q: '',
numero_diario: '',
data_inicio: '',
data_fim: '',
modo_busca: 'exata',
ordenar_por: 'relevancia',
page: 1,
page_size: 10
};
this.searchResults = null;
this.hasSearched = false;
this.error = null;
this.expandedContents = {};
}
}));
});

View File

@ -0,0 +1,394 @@
{% load static %}
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Diários Oficiais</title>
<!-- Bootstrap 5 CSS -->
<link href="{% static 'css/bootstrap-custom.min.css' %}" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="{% static 'css/bootstrap-icons.css' %}">
<!-- Alpine.js -->
<script defer src="{% static 'js/alpine.min.js' %}"></script>
<!-- Estilos customizados -->
<link rel="stylesheet" href="{% static 'css/styles.css' %}">
<link rel="icon" href="{% static 'images/favicon.ico' %}" type="image/x-icon">
</head>
<body class="bg-light-gradient">
<div class="container py-5" x-data="searchApp"> <!-- Container do Logo e Imagem Direita -->
<div class="row justify-content-center mb-4">
<div class="col-12 col-lg-10">
<div class="d-flex justify-content-between align-items-center mb-5">
<!-- Logo à esquerda -->
<div class="logo-container">
<img src="{% static 'images/logo.jpg' %}" alt="Logo" class="img-fluid">
</div>
<!-- Imagem à direita -->
<div class="risco-container">
<img src="{% static 'images/risco.jpg' %}" alt="Risco" class="img-fluid">
</div>
</div>
</div>
<div class="row justify-content-center mb-4">
<div class="col-12 col-lg-10">
<div class="text-center mb-5">
<h1 class="display-5 fw-bold text-primary mb-3">
<i class="bi bi-search me-2"></i>Diários Oficiais
</h1>
</div>
<div class="card search-card mb-5">
<div class="card-body p-4">
<form @submit.prevent="performSearch" class="row g-3">
<div class="col-12">
<div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control form-control-lg"
x-model="searchParams.q"
placeholder="Digite o termo de busca"
aria-label="Termo de busca">
<button class="btn btn-primary" type="submit">
<span x-show="!isLoading">Buscar</span>
<span x-show="isLoading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
</button>
</div>
</div>
<!-- Opções básicas de busca (sempre visíveis) -->
<div class="col-12 mt-2">
<div class="row align-items-end">
<div class="col-md-4 mb-2 mb-md-0">
<label for="numero_diario" class="form-label">Número do Diário</label>
<input type="text" class="form-control" id="numero_diario"
x-model="searchParams.numero_diario"
placeholder="Ex: 1234">
</div>
<div class="col-md-4 mb-2 mb-md-0">
<label for="modo_busca" class="form-label">Modo de Busca</label>
<select class="form-select" id="modo_busca" x-model="searchParams.modo_busca">
<option value="exata">Busca exata</option>
<option value="qualquer">Qualquer termo</option>
</select>
</div>
<div class="col-md-6 col-xl-4">
<div class="d-flex flex-column">
<label class="form-label">Ordenar por</label>
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-secondary btn-sm"
:class="{'active': searchParams.ordenar_por === 'relevancia'}"
@click="changeOrder('relevancia')">
<i class="bi bi-star me-1"></i>Relevância
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
:class="{'active': searchParams.ordenar_por === 'data_desc'}"
@click="changeOrder('data_desc')">
<i class="bi bi-sort-down-alt me-1"></i>Data<br>(Decrescente)
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
:class="{'active': searchParams.ordenar_por === 'data_asc'}"
@click="changeOrder('data_asc')">
<i class="bi bi-sort-down me-1"></i>Data<br>(Crescente)
</button>
</div>
</div>
</div>
</div>
</div>
<div class="col-12 mt-3">
<button class="btn btn-sm btn-outline-secondary" type="button" @click="showAdvanced = !showAdvanced">
<span x-text="showAdvanced ? 'Ocultar filtros avançados' : 'Mostrar filtros avançados'"></span>
<i class="bi" :class="showAdvanced ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
</button>
</div>
<div class="col-12" x-show="showAdvanced" x-transition>
<div class="row g-3 mt-1">
<div class="col-md-6">
<label for="data_inicio" class="form-label">Data inicial</label>
<input type="date" class="form-control date-picker" id="data_inicio" x-model="searchParams.data_inicio">
</div>
<div class="col-md-6">
<label for="data_fim" class="form-label">Data final</label>
<input type="date" class="form-control date-picker" id="data_fim" x-model="searchParams.data_fim">
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-6">
<label for="page_size" class="form-label">Resultados por página</label>
<select class="form-select" id="page_size" x-model="searchParams.page_size">
<option value="10">10</option>
<option value="20">20</option>
<option value="30">30</option>
<option value="50">50</option>
</select>
</div>
</div>
</div>
</form>
</div>
</div>
<!-- Seção Você quis dizer -->
<template x-if="!isLoading && !error && searchResults && suggestion && shouldShowSuggestion">
<div class="mb-3 mt-3 alert alert-info d-flex align-items-center">
<i class="bi bi-lightbulb-fill me-2"></i>
<span>Você quis dizer:
<a href="#" @click.prevent="usesuggestion" class="alert-link" x-text="suggestion"></a>?
</span>
</div>
</template>
<!-- Resultados -->
<div x-show="hasSearched" class="mb-4">
<template x-if="isLoading">
<div class="d-flex justify-content-center my-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Carregando...</span>
</div>
</div>
</template>
<template x-if="!isLoading && error">
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<span x-text="error"></span>
</div>
</template>
<template x-if="!isLoading && !error && searchResults">
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="h4 m-0">
<span x-text="searchResults.total"></span> Resultados encontrados
<template x-if="searchParams.q">
<span>para "<span x-text="searchParams.q"></span>"</span>
</template>
</h2>
<button @click="resetSearch" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-counterclockwise me-1"></i> Nova busca
</button>
</div>
<template x-if="searchResults.total === 0">
<div class="alert alert-info" role="alert">
<i class="bi bi-info-circle-fill me-2"></i>
Nenhum resultado encontrado para os critérios de busca informados.
</div>
</template>
<template x-if="searchResults.total > 0">
<div>
<!-- Lista de resultados -->
<div class="mb-4">
<template x-for="(diario, diarioIndex) in searchResults.resultados" :key="diario.id">
<div class="card result-card mb-4 border-0 shadow-sm">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
<span class="badge bg-primary me-2" x-text="diario.tipo"></span>
<span x-text="diario.numero"></span>
</h5>
<span class="text-muted" x-text="formatDate(diario.data)"></span>
</div>
<div class="card-body">
<template x-if="diario.paginas && diario.paginas.length > 0">
<div>
<!-- Melhor página encontrada (mostrada apenas se estiver ordenado por relevância e tiver score) -->
<template x-if="hasBestMatch(diario)">
<div class="mb-4 p-3 best-match rounded">
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<span class="page-badge me-2">Página <span x-text="getBestPage(diario.paginas).numero"></span></span>
<span class="match-score">
<i class="bi bi-star-fill me-1 small"></i>
Melhor correspondência no diário
</span>
</div>
</div>
<div class="page-content">
<div x-html="getBestPage(diario.paginas).conteudo"></div>
</div>
</div>
</template>
<!-- Accordion de páginas -->
<template x-if="diario.paginas.length > 1">
<div class="accordion mt-3" :id="'accordionDiario' + diario.id">
<div class="accordion-item border-0 mb-2">
<h2 class="accordion-header">
<button class="accordion-button collapsed shadow-sm" type="button"
data-bs-toggle="collapse"
:data-bs-target="'#collapse' + diario.id"
aria-expanded="false">
<i class="bi bi-list-ul me-2"></i>
<template x-if="hasBestMatch(diario) && diario.paginas.length > 1">
<span>Ver mais <span class="mx-1" x-text="getOtherPages(diario.paginas).length"></span> páginas deste diário</span>
</template>
<template x-if="!hasBestMatch(diario) && diario.paginas.length > 1">
<span>Ver <span class="mx-1" x-text="diario.paginas.length"></span> páginas deste diário</span>
</template>
</button>
</h2>
<div :id="'collapse' + diario.id" class="accordion-collapse collapse"
:data-bs-parent="'#accordionDiario' + diario.id">
<div class="accordion-body p-0">
<div class="list-group list-group-flush">
<template x-for="pagina in hasBestMatch(diario) ? getOtherPages(diario.paginas) : diario.paginas" :key="pagina.numero">
<div class="list-group-item border-0 py-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="page-badge">Página <span x-text="pagina.numero"></span></span>
</div>
<div class="page-preview" x-html="pagina.conteudo.substring(0, 200) + '...'"></div>
<div x-show="isFullContentVisible(diarioIndex, pagina.numero)" x-transition class="mt-2 page-content border-top pt-3">
<div x-html="pagina.conteudo"></div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<div class="d-flex justify-content-end mt-3">
<a :href="diario.link" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="bi bi-file-earmark-pdf me-1"></i> Ver Diário Completo
</a>
</div>
</div>
</div>
</template>
</div>
<!-- Paginação -->
<nav aria-label="Navegação de páginas" x-show="totalPages > 1">
<ul class="pagination justify-content-center">
<li class="page-item" :class="{ 'disabled': searchParams.page <= 1 }">
<a class="page-link" href="#" @click.prevent="goToPage(searchParams.page - 1)" aria-label="Anterior">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
<template x-for="page in paginationArray" :key="page">
<li class="page-item" :class="{ 'active': page === searchParams.page }">
<a class="page-link" href="#" @click.prevent="goToPage(page)" x-text="page"></a>
</li>
</template>
<li class="page-item" :class="{ 'disabled': searchParams.page >= totalPages }">
<a class="page-link" href="#" @click.prevent="goToPage(searchParams.page + 1)" aria-label="Próximo">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
</div>
</template>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- Footer -->
<footer class="bg-dark text-white py-4 mt-5">
<div class="container">
<div class="row">
<div class="col-md-6">
<h5>Diários Oficiais</h5>
</div>
<div class="col-md-6 text-md-end">
<p class="small mb-0">&copy; 2025 Todos os direitos reservados</p>
</div>
</div>
</div>
</footer>
<!-- Botão de ajuda que abre o modal -->
<button type="button" class="btn btn-outline-secondary position-fixed bottom-0 end-0 m-3" data-bs-toggle="modal" data-bs-target="#helpModal">
<i class="bi bi-question-circle"></i> Ajuda
</button>
<!-- Modal de Ajuda -->
<div class="modal fade" id="helpModal" tabindex="-1" aria-labelledby="helpModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="helpModalLabel">Ajuda - Sistema de Busca de Diários Oficiais</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Fechar"></button>
</div>
<div class="modal-body">
<div class="mb-4">
<h6>Atenção quanto à qualidade dos dados</h6>
<p>Alguns documentos podem conter erros de leitura devido à baixa qualidade das imagens dos PDFs originais. Isso pode afetar a precisão da busca, especialmente no modo de <strong>busca exata</strong>.</p>
<p>Nesses casos, recomendamos utilizar o modo <strong>qualquer termo</strong>, que é mais tolerante a pequenas falhas de reconhecimento de texto.</p>
</div>
<h5>Como realizar buscas</h5>
<hr>
<div class="mb-4">
<h6>Busca básica</h6>
<p>Digite o termo que deseja buscar no campo principal e clique em "Buscar". O sistema irá localizar ocorrências desse termo nos Diários Oficiais.</p>
<ul>
<li><strong>Número do Diário:</strong> Se souber o número específico do diário, digite-o neste campo para filtrar os resultados.</li>
<li><strong>Modo de Busca:</strong>
<ul>
<li><em>Busca exata</em> - Encontra apenas documentos que contenham exatamente o termo informado.</li>
<li><em>Qualquer termo</em> - Encontra documentos que contenham qualquer um dos termos informados.</li>
</ul>
</li>
</ul>
</div>
<div class="mb-4">
<h6>Ordenação dos resultados</h6>
<p>Você pode ordenar os resultados de três formas:</p>
<ul>
<li><strong>Relevância:</strong> Mostra primeiro os documentos mais relevantes para sua busca.</li>
<li><strong>Data (Decrescente):</strong> Mostra os diários mais recentes primeiro.</li>
<li><strong>Data (Crescente):</strong> Mostra os diários mais antigos primeiro.</li>
</ul>
</div>
<div class="mb-4">
<h6>Filtros avançados</h6>
<p>Clique em "Mostrar filtros avançados" para acessar opções adicionais:</p>
<ul>
<li><strong>Data inicial e Data final:</strong> Restringe a busca a diários publicados dentro do período informado.</li>
<li><strong>Resultados por página:</strong> Define quantos resultados serão exibidos em cada página.</li>
</ul>
</div>
<div class="mb-4">
<h6>Resultados da busca</h6>
<p>Nos resultados, você verá:</p>
<ul>
<li>Tipo e número do diário, com a data de publicação.</li>
<li>A página com melhor correspondência aparecerá destacada.</li>
<li>Para ver mais páginas do mesmo diário, clique no botão de expansão.</li>
<li>Use o botão "Ver Diário Completo" para abrir o arquivo PDF do diário inteiro.</li>
</ul>
</div>
<div class="mb-4">
<h6>Sugestões de busca</h6>
<p>Se o sistema encontrar um termo semelhante ao que você buscou, mostrará uma sugestão que você pode clicar para realizar uma nova busca com o termo sugerido.</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Entendi</button>
</div>
</div>
</div>
</div>
<!-- Bootstrap Bundle with Popper -->
<script src="{% static 'js/bootstrap.bundle.min.js' %}"></script>
<!-- Script da aplicação -->
<script src="{% static 'js/config.js' %}"></script>
<script src="{% static 'js/script.js' %}"></script>
</body>
</html>