Skip to content

Instantly share code, notes, and snippets.

@marcellobenigno
Created October 12, 2025 11:45
Show Gist options
  • Save marcellobenigno/07fb27b95325b048562d7597774e5d5a to your computer and use it in GitHub Desktop.
Save marcellobenigno/07fb27b95325b048562d7597774e5d5a to your computer and use it in GitHub Desktop.
Explorador de Tabelas Supabase
<!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