Created
October 12, 2025 11:45
-
-
Save marcellobenigno/07fb27b95325b048562d7597774e5d5a to your computer and use it in GitHub Desktop.
Explorador de Tabelas Supabase
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="pt-BR"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Explorador de Tabelas Supabase com Vue.js</title> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css"> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <style> | |
| body { | |
| background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); | |
| min-height: 10vh; | |
| padding: 2rem 0; | |
| } | |
| .main-container { | |
| background: white; | |
| border-radius: 20px; | |
| box-shadow: 0 8px 30px rgba(0,0,0,0.12); | |
| padding: 2.5rem; | |
| margin-bottom: 2rem; | |
| border: 1px solid #e9ecef; | |
| animation: fadeIn 0.5s ease-in; | |
| } | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(-20px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .page-title { | |
| color: #2d3748; | |
| border-bottom: 4px solid #38a169; | |
| padding-bottom: 1rem; | |
| margin-bottom: 1.5rem; | |
| font-weight: 700; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .info-box { | |
| background: linear-gradient(135deg, #e6fffa 0%, #f0fff4 100%); | |
| border-left: 5px solid #38a169; | |
| border-radius: 12px; | |
| padding: 1.2rem; | |
| margin-bottom: 1.5rem; | |
| box-shadow: 0 2px 8px rgba(56, 161, 105, 0.1); | |
| } | |
| .form-control:focus { | |
| border-color: #38a169; | |
| box-shadow: 0 0 0 0.25rem rgba(56, 161, 105, 0.25); | |
| } | |
| .btn-explore { | |
| background: linear-gradient(135deg, #38a169 0%, #2f855a 100%); | |
| border: none; | |
| padding: 1rem; | |
| font-weight: 600; | |
| transition: all 0.3s; | |
| box-shadow: 0 4px 15px rgba(56, 161, 105, 0.3); | |
| } | |
| .btn-explore:hover { | |
| background: linear-gradient(135deg, #2f855a 0%, #276749 100%); | |
| transform: translateY(-2px); | |
| box-shadow: 0 6px 20px rgba(56, 161, 105, 0.4); | |
| } | |
| .btn-explore:active { | |
| transform: translateY(0); | |
| } | |
| .table-list { | |
| background: white; | |
| border-radius: 15px; | |
| padding: 1.5rem; | |
| border: 1px solid #e9ecef; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.08); | |
| max-height: 70vh; | |
| overflow-y: auto; | |
| } | |
| .table-list::-webkit-scrollbar { | |
| width: 8px; | |
| } | |
| .table-list::-webkit-scrollbar-track { | |
| background: #f1f1f1; | |
| border-radius: 10px; | |
| } | |
| .table-list::-webkit-scrollbar-thumb { | |
| background: #38a169; | |
| border-radius: 10px; | |
| } | |
| .table-item { | |
| padding: 1rem; | |
| margin: 0.5rem 0; | |
| background: #f8f9fa; | |
| border-radius: 10px; | |
| cursor: pointer; | |
| transition: all 0.3s; | |
| border-left: 4px solid #3182ce; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .table-item::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 0; | |
| height: 100%; | |
| background: linear-gradient(90deg, rgba(49, 130, 206, 0.1) 0%, transparent 100%); | |
| transition: width 0.3s; | |
| } | |
| .table-item:hover::before { | |
| width: 100%; | |
| } | |
| .table-item:hover { | |
| background: #e3f2fd; | |
| transform: translateX(8px); | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.1); | |
| } | |
| .table-item.active { | |
| background: linear-gradient(135deg, #d1e7fd 0%, #b3d9ff 100%); | |
| border-left-color: #0d6efd; | |
| font-weight: 600; | |
| box-shadow: 0 4px 12px rgba(13, 110, 253, 0.2); | |
| } | |
| .table-item i { | |
| margin-right: 0.5rem; | |
| color: #3182ce; | |
| } | |
| .preview-container { | |
| background: white; | |
| border-radius: 15px; | |
| padding: 2rem; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.08); | |
| border: 1px solid #e9ecef; | |
| animation: slideIn 0.3s ease-out; | |
| } | |
| @keyframes slideIn { | |
| from { opacity: 0; transform: translateX(20px); } | |
| to { opacity: 1; transform: translateX(0); } | |
| } | |
| .table-responsive { | |
| border-radius: 12px; | |
| overflow: hidden; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.1); | |
| max-height: 60vh; | |
| overflow-y: auto; | |
| } | |
| .table { | |
| margin-bottom: 0; | |
| } | |
| .table thead { | |
| background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%); | |
| color: white; | |
| position: sticky; | |
| top: 0; | |
| z-index: 10; | |
| } | |
| .table thead th { | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| font-size: 0.85rem; | |
| letter-spacing: 0.5px; | |
| padding: 1rem; | |
| border: none; | |
| } | |
| .table tbody tr { | |
| transition: all 0.2s; | |
| } | |
| .table tbody tr:hover { | |
| background-color: #f8f9fa; | |
| } | |
| .table tbody td { | |
| padding: 0.9rem; | |
| vertical-align: middle; | |
| } | |
| .status-icon { | |
| font-size: 1.3rem; | |
| } | |
| .small-text { | |
| font-size: 0.875rem; | |
| color: #6c757d; | |
| margin-top: 0.25rem; | |
| } | |
| .badge-count { | |
| background: #38a169; | |
| color: white; | |
| padding: 0.35rem 0.7rem; | |
| border-radius: 20px; | |
| font-size: 0.85rem; | |
| font-weight: 600; | |
| margin-left: 0.5rem; | |
| } | |
| .input-group-text { | |
| background: #f8f9fa; | |
| border-right: none; | |
| } | |
| .form-control { | |
| border-left: none; | |
| } | |
| .empty-state { | |
| text-align: center; | |
| padding: 3rem 1rem; | |
| color: #6c757d; | |
| } | |
| .empty-state i { | |
| font-size: 4rem; | |
| margin-bottom: 1rem; | |
| opacity: 0.3; | |
| } | |
| .table-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 1rem; | |
| padding-bottom: 1rem; | |
| border-bottom: 2px solid #e9ecef; | |
| } | |
| .btn-refresh { | |
| background: #3182ce; | |
| color: white; | |
| border: none; | |
| padding: 0.5rem 1rem; | |
| border-radius: 8px; | |
| transition: all 0.3s; | |
| font-size: 0.9rem; | |
| } | |
| .btn-refresh:hover { | |
| background: #2c5aa0; | |
| transform: scale(1.05); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app" class="container-fluid"> | |
| <div class="main-container"> | |
| <h1 class="page-title"> | |
| <i class="fas fa-database status-icon"></i> | |
| Explorador de Tabelas Supabase | |
| </h1> | |
| <div class="info-box"> | |
| <strong><i class="fas fa-info-circle"></i> O que faz esta ferramenta?</strong><br> | |
| Permite conectar ao seu projeto Supabase e explorar todas as tabelas disponíveis de forma rápida e simples. Você só precisa da URL do projeto e sua chave API pública (anon key). | |
| </div> | |
| <div class="mb-3"> | |
| <label for="url" class="form-label fw-bold"> | |
| <i class="fas fa-link"></i> URL do Projeto | |
| </label> | |
| <div class="input-group"> | |
| <span class="input-group-text"><i class="fas fa-server"></i></span> | |
| <input | |
| type="text" | |
| class="form-control" | |
| id="url" | |
| placeholder="https://seu-projeto.supabase.co" | |
| v-model="supabaseUrl" | |
| > | |
| </div> | |
| <small class="form-text small-text"> | |
| Encontre sua URL em Settings > API do seu projeto Supabase | |
| </small> | |
| </div> | |
| <div class="mb-3"> | |
| <label for="key" class="form-label fw-bold"> | |
| <i class="fas fa-key"></i> Chave API (anon key) | |
| </label> | |
| <div class="input-group"> | |
| <span class="input-group-text"><i class="fas fa-lock"></i></span> | |
| <input | |
| :type="passwordFieldType" | |
| class="form-control" | |
| id="key" | |
| placeholder="eyJhbGciOi..." | |
| v-model="supabaseKey" | |
| @keypress.enter="listarTabelas" | |
| > | |
| <button class="btn btn-outline-secondary" type="button" @click="togglePasswordVisibility"> | |
| <i class="fas" :class="passwordFieldType === 'password' ? 'fa-eye' : 'fa-eye-slash'"></i> | |
| </button> | |
| </div> | |
| <small class="form-text small-text"> | |
| Sua chave pública/anon key (não use a service_role por segurança) | |
| </small> | |
| </div> | |
| <button class="btn btn-success w-100 btn-explore" @click="listarTabelas"> | |
| <i class="fas fa-rocket"></i> Explorar Tabelas | |
| </button> | |
| <div id="status" class="mt-3" v-html="statusMessage"></div> | |
| </div> | |
| <div class="row" v-show="contentRowVisible"> | |
| <div class="col-lg-3 col-md-4 mb-4"> | |
| <div class="table-list"> | |
| <div class="table-header"> | |
| <div> | |
| <h5 class="mb-0"> | |
| <i class="fas fa-list"></i> Tabelas | |
| <span class="badge-count">{{ tables.length }}</span> | |
| </h5> | |
| </div> | |
| <button class="btn-refresh" @click="listarTabelas" title="Atualizar lista"> | |
| <i class="fas fa-sync-alt"></i> | |
| </button> | |
| </div> | |
| <small class="text-muted d-block mb-3">Clique para visualizar os dados</small> | |
| <div | |
| v-for="table in tables" | |
| :key="table" | |
| class="table-item" | |
| :class="{ active: activeTable === table }" | |
| @click="previewTabela(table)" | |
| > | |
| <i class="fas fa-table"></i> | |
| <strong>{{ table }}</strong> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="col-lg-9 col-md-8"> | |
| <div v-if="!activeTable" class="preview-container"> | |
| <div class="empty-state"> | |
| <i class="fas fa-mouse-pointer"></i> | |
| <h5>Selecione uma tabela</h5> | |
| <p>Clique em uma tabela à esquerda para visualizar seus dados</p> | |
| </div> | |
| </div> | |
| <div v-else class="preview-container"> | |
| <div v-if="tablePreviewLoading" class="alert alert-info" role="alert"> | |
| <span class="spinner-border spinner-border-sm me-2" role="status"></span> | |
| <i class="fas fa-download"></i> Carregando dados de "<strong>{{ activeTable }}</strong>"... | |
| </div> | |
| <div v-else-if="tablePreviewError" class="alert alert-danger" role="alert"> | |
| <i class="fas fa-exclamation-circle"></i> | |
| <strong>Erro ao carregar dados:</strong> {{ tablePreviewError }} | |
| </div> | |
| <div v-else> | |
| <div class="table-header"> | |
| <h4 class="mb-0"> | |
| <i class="fas fa-table text-primary"></i> {{ activeTable }} | |
| <span class="badge-count">{{ tableData.length }} {{ tableData.length === 1 ? 'registro' : 'registros' }}</span> | |
| </h4> | |
| </div> | |
| <div v-if="!tableData.length" class="empty-state"> | |
| <i class="fas fa-inbox"></i> | |
| <h5>Tabela vazia</h5> | |
| <p>Esta tabela ainda não possui dados</p> | |
| </div> | |
| <div v-else> | |
| <p class="text-muted mb-3"> | |
| <i class="fas fa-info-circle"></i> Mostrando até {{ tableData.length }} primeiras linhas | |
| </p> | |
| <div class="table-responsive"> | |
| <table class="table table-hover table-bordered table-sm"> | |
| <thead class="table-dark"> | |
| <tr> | |
| <th v-for="column in tableColumns" :key="column"> | |
| <i class="fas fa-columns"></i> {{ column }} | |
| </th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <tr v-for="(row, rowIndex) in tableData" :key="rowIndex"> | |
| <td v-for="(value, colIndex) in row" :key="colIndex"> | |
| <template v-if="value === null"> | |
| <em class="text-muted">null</em> | |
| </template> | |
| <template v-else-if="typeof value === 'boolean'"> | |
| <span class="badge" :class="value ? 'bg-success' : 'bg-secondary'">{{ value }}</span> | |
| </template> | |
| <template v-else-if="typeof value === 'number'"> | |
| <span class="text-end">{{ value }}</span> | |
| </template> | |
| <template v-else-if="colIndex === 'geom' && typeof value === 'object' && value !== null && value.type"> | |
| {{ value.type }} | |
| </template> | |
| <template v-else> | |
| {{ value }} | |
| </template> | |
| </td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.bundle.min.js"></script> | |
| <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> | |
| <script> | |
| const { createApp, ref } = Vue; | |
| createApp({ | |
| setup() { | |
| const supabaseUrl = ref(''); | |
| const supabaseKey = ref(''); | |
| const passwordFieldType = ref('password'); | |
| const statusMessage = ref(''); | |
| const contentRowVisible = ref(false); | |
| const tables = ref([]); | |
| const activeTable = ref(null); | |
| const tablePreviewLoading = ref(false); | |
| const tablePreviewError = ref(null); | |
| const tableData = ref([]); | |
| const tableColumns = ref([]); | |
| const togglePasswordVisibility = () => { | |
| passwordFieldType.value = passwordFieldType.value === 'password' ? 'text' : 'password'; | |
| }; | |
| const listarTabelas = async () => { | |
| statusMessage.value = ''; | |
| contentRowVisible.value = false; | |
| tables.value = []; | |
| activeTable.value = null; | |
| tableData.value = []; | |
| tableColumns.value = []; | |
| tablePreviewError.value = null; | |
| if (!supabaseUrl.value || !supabaseKey.value) { | |
| statusMessage.value = ` | |
| <div class="alert alert-warning alert-dismissible fade show" role="alert"> | |
| <i class="fas fa-exclamation-triangle"></i> | |
| <strong>Atenção!</strong> Por favor, preencha a URL do projeto e a chave API. | |
| <button type="button" class="btn-close" data-bs-dismiss="alert"></button> | |
| </div> | |
| `; | |
| return; | |
| } | |
| statusMessage.value = ` | |
| <div class="alert alert-info" role="alert"> | |
| <span class="spinner-border spinner-border-sm me-2" role="status"></span> | |
| <i class="fas fa-search"></i> Analisando sua base de dados... | |
| </div> | |
| `; | |
| const gql = `{ | |
| __schema { | |
| queryType { fields { name } } | |
| } | |
| }`; | |
| let fields; | |
| try { | |
| const res = await fetch(`${supabaseUrl.value}/graphql/v1`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'apikey': supabaseKey.value, | |
| 'Authorization': `Bearer ${supabaseKey.value}` | |
| }, | |
| body: JSON.stringify({ query: gql }) | |
| }); | |
| if (!res.ok) throw new Error(res.statusText); | |
| const { data } = await res.json(); | |
| fields = data.__schema.queryType.fields.map(f => f.name); | |
| } catch (err) { | |
| statusMessage.value = ` | |
| <div class="alert alert-danger alert-dismissible fade show" role="alert"> | |
| <i class="fas fa-times-circle"></i> | |
| <strong>Erro de conexão:</strong> ${err.message}<br> | |
| <small>Verifique se a URL e a chave API estão corretas.</small> | |
| <button type="button" class="btn-close" data-bs-dismiss="alert"></button> | |
| </div> | |
| `; | |
| return; | |
| } | |
| const candidates = fields | |
| .filter(n => n.endsWith('Collection')) | |
| .map(n => n.slice(0, -'Collection'.length)); | |
| if (candidates.length === 0) { | |
| statusMessage.value = ` | |
| <div class="alert alert-warning alert-dismissible fade show" role="alert"> | |
| <i class="fas fa-ban"></i> | |
| <strong>Nenhuma tabela encontrada</strong><br> | |
| Não foram encontradas tabelas acessíveis no seu projeto. | |
| <button type="button" class="btn-close" data-bs-dismiss="alert"></button> | |
| </div> | |
| `; | |
| return; | |
| } | |
| statusMessage.value = ` | |
| <div class="alert alert-info" role="alert"> | |
| <span class="spinner-border spinner-border-sm me-2" role="status"></span> | |
| <i class="fas fa-hourglass-half"></i> Verificando acesso a ${candidates.length} tabela(s)... | |
| </div> | |
| `; | |
| const validTables = []; | |
| await Promise.all(candidates.map(async tbl => { | |
| try { | |
| const r = await fetch(`${supabaseUrl.value}/rest/v1/${tbl}?select=*&limit=1`, { | |
| headers: { | |
| 'apikey': supabaseKey.value, | |
| 'Authorization': `Bearer ${supabaseKey.value}` | |
| } | |
| }); | |
| if (r.ok) validTables.push(tbl); | |
| } catch {} | |
| })); | |
| if (validTables.length === 0) { | |
| statusMessage.value = ` | |
| <div class="alert alert-danger alert-dismissible fade show" role="alert"> | |
| <i class="fas fa-lock"></i> | |
| <strong>Acesso negado</strong><br> | |
| As tabelas existem, mas não são acessíveis com sua anon key atual. | |
| <button type="button" class="btn-close" data-bs-dismiss="alert"></button> | |
| </div> | |
| `; | |
| return; | |
| } | |
| statusMessage.value = ` | |
| <div class="alert alert-success alert-dismissible fade show" role="alert"> | |
| <i class="fas fa-check-circle"></i> | |
| <strong>Perfeito!</strong> Foram encontradas ${validTables.length} tabela(s) acessível(is). | |
| <button type="button" class="btn-close" data-bs-dismiss="alert"></button> | |
| </div> | |
| `; | |
| contentRowVisible.value = true; | |
| tables.value = validTables.sort(); | |
| }; | |
| const previewTabela = async (tabla) => { | |
| activeTable.value = tabla; | |
| tablePreviewLoading.value = true; | |
| tablePreviewError.value = null; | |
| tableData.value = []; | |
| tableColumns.value = []; | |
| try { | |
| const r = await fetch(`${supabaseUrl.value}/rest/v1/${tabla}?select=*&limit=10`, { | |
| headers: { | |
| 'apikey': supabaseKey.value, | |
| 'Authorization': `Bearer ${supabaseKey.value}` | |
| } | |
| }); | |
| if (!r.ok) throw new Error(r.statusText); | |
| const data = await r.json(); | |
| tableData.value = data; | |
| if (data.length > 0) { | |
| tableColumns.value = Object.keys(data[0]); | |
| } else { | |
| tableColumns.value = []; | |
| } | |
| } catch (err) { | |
| tablePreviewError.value = err.message; | |
| } finally { | |
| tablePreviewLoading.value = false; | |
| } | |
| }; | |
| return { | |
| supabaseUrl, | |
| supabaseKey, | |
| passwordFieldType, | |
| statusMessage, | |
| contentRowVisible, | |
| tables, | |
| activeTable, | |
| tablePreviewLoading, | |
| tablePreviewError, | |
| tableData, | |
| tableColumns, | |
| togglePasswordVisibility, | |
| listarTabelas, | |
| previewTabela | |
| }; | |
| } | |
| }).mount('#app'); | |
| </script> | |
| </body> | |
| </html> | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment