All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 58s
- Reemplazar CSS nativo con Tailwind CSS v4 y utilidades custom - Crear librería de componentes UI basada en shadcn-vue (Radix Vue) - Componentes UI: Button, Card, Input, Textarea, Badge, Dialog, Avatar, DropdownMenu - Migrar todos los componentes existentes a Tailwind utilities - Convertir EventCard.js (htm) a EventCard.vue (SFC) - Implementar sistema de temas dark/light con clase .dark - Mantener efectos glassmorphism via @utility custom - Eliminar styles.css legacy
197 lines
6.4 KiB
Vue
197 lines
6.4 KiB
Vue
<script setup>
|
||
import { onMounted, ref, computed } from 'vue';
|
||
import { Button, Badge, Input, Card } from '@/components/ui';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
const emit = defineEmits(['toggle-fullscreen']);
|
||
|
||
const tables = ref([]);
|
||
const active = ref('');
|
||
const columns = ref([]);
|
||
const rows = ref([]);
|
||
const loading = ref(false);
|
||
const total = ref(0);
|
||
const limit = ref(100);
|
||
const offset = ref(0);
|
||
const q = ref('');
|
||
const sortBy = ref('');
|
||
const sortDir = ref('asc');
|
||
|
||
function fmt(v) {
|
||
if (v === null || v === undefined) return '';
|
||
if (typeof v === 'object') return JSON.stringify(v);
|
||
return String(v);
|
||
}
|
||
|
||
async function loadTables() {
|
||
const res = await fetch('/api/db/tables');
|
||
const data = await res.json();
|
||
tables.value = data.items || [];
|
||
if (tables.value.length && !active.value) {
|
||
await select(tables.value[0]);
|
||
}
|
||
}
|
||
|
||
async function select(t) {
|
||
active.value = t;
|
||
offset.value = 0;
|
||
await reload();
|
||
}
|
||
|
||
async function reload() {
|
||
if (!active.value) return;
|
||
loading.value = true;
|
||
try {
|
||
const params = new URLSearchParams({ limit: String(limit.value), offset: String(offset.value) });
|
||
const res = await fetch(`/api/db/table/${encodeURIComponent(active.value)}?` + params.toString());
|
||
const data = await res.json();
|
||
columns.value = data.columns || [];
|
||
rows.value = data.rows || [];
|
||
total.value = data.total || 0;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function next() { if (offset.value + limit.value < total.value) { offset.value += limit.value; reload(); } }
|
||
function prev() { if (offset.value - limit.value >= 0) { offset.value -= limit.value; reload(); } else { offset.value = 0; reload(); } }
|
||
|
||
const filteredRows = computed(() => {
|
||
if (!q.value) return rows.value;
|
||
const t = q.value.toLowerCase();
|
||
return rows.value.filter(r => JSON.stringify(r).toLowerCase().includes(t));
|
||
});
|
||
|
||
const sortedRows = computed(() => {
|
||
if (!sortBy.value) return filteredRows.value;
|
||
const dir = sortDir.value === 'asc' ? 1 : -1;
|
||
const key = sortBy.value;
|
||
const arr = filteredRows.value.slice();
|
||
arr.sort((a, b) => {
|
||
const va = a[key];
|
||
const vb = b[key];
|
||
const na = typeof va === 'number' || (/^-?\d+(\.\d+)?$/.test(String(va)) ? Number(va) : NaN);
|
||
const nb = typeof vb === 'number' || (/^-?\d+(\.\d+)?$/.test(String(vb)) ? Number(vb) : NaN);
|
||
if (!Number.isNaN(na) && !Number.isNaN(nb)) return (na - nb) * dir;
|
||
const sa = String(va ?? '').toLowerCase();
|
||
const sb = String(vb ?? '').toLowerCase();
|
||
return sa.localeCompare(sb) * dir;
|
||
});
|
||
return arr;
|
||
});
|
||
|
||
onMounted(() => { loadTables(); });
|
||
|
||
function toggleSort(c) {
|
||
if (sortBy.value !== c) { sortBy.value = c; sortDir.value = 'asc'; return; }
|
||
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
|
||
}
|
||
|
||
function toCsv(cols, rowsData) {
|
||
const esc = v => {
|
||
const s = v === null || v === undefined ? '' : (typeof v === 'object' ? JSON.stringify(v) : String(v));
|
||
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
||
};
|
||
const lines = [cols.join(',')];
|
||
for (const r of rowsData) lines.push(cols.map(c => esc(r[c])).join(','));
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function exportCsv() {
|
||
const csv = toCsv(columns.value, sortedRows.value);
|
||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
const ts = new Date().toISOString().replace(/[:T]/g, '-').split('.')[0];
|
||
a.href = url;
|
||
a.download = `${active.value || 'table'}-${ts}.csv`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<!-- Table selector -->
|
||
<div class="flex flex-wrap gap-1.5 mb-3">
|
||
<Button
|
||
v-for="t in tables"
|
||
:key="t"
|
||
:variant="t === active ? 'default' : 'ghost'"
|
||
size="sm"
|
||
:class="t === active && 'ring-2 ring-pink-400/60'"
|
||
@click="select(t)"
|
||
>
|
||
{{ t }}
|
||
</Button>
|
||
</div>
|
||
|
||
<!-- Controls -->
|
||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||
<Input
|
||
v-model="q"
|
||
placeholder="Buscar en página (texto)"
|
||
class="flex-1 min-w-[240px]"
|
||
/>
|
||
<label class="flex items-center gap-2 glass glass-border rounded-md px-3 py-2">
|
||
<span class="text-sm">Tamaño de página</span>
|
||
<select
|
||
v-model.number="limit"
|
||
@change="reload()"
|
||
class="bg-transparent border-none text-inherit text-sm cursor-pointer"
|
||
>
|
||
<option :value="50">50</option>
|
||
<option :value="100">100</option>
|
||
<option :value="250">250</option>
|
||
<option :value="500">500</option>
|
||
</select>
|
||
</label>
|
||
<Badge>{{ offset + 1 }}–{{ Math.min(offset + limit, total) }} / {{ total }}</Badge>
|
||
<div class="flex gap-1.5">
|
||
<Button size="sm" :disabled="offset <= 0" @click="prev">Anterior</Button>
|
||
<Button size="sm" :disabled="offset + limit >= total" @click="next">Siguiente</Button>
|
||
</div>
|
||
<Button size="sm" @click="exportCsv">Exportar CSV</Button>
|
||
<Button size="sm" variant="ghost" @click="emit('toggle-fullscreen')">Fullscreen</Button>
|
||
</div>
|
||
|
||
<!-- Table -->
|
||
<Card variant="panel" class="max-h-[60vh] border-pink-200 dark:border-pink-600/50">
|
||
<div class="overflow-auto p-3 scroll-custom">
|
||
<div v-if="loading" class="text-muted">Cargando…</div>
|
||
<div v-else>
|
||
<table v-if="columns.length" class="w-full border-collapse">
|
||
<thead>
|
||
<tr>
|
||
<th
|
||
v-for="c in columns"
|
||
:key="c"
|
||
@click="toggleSort(c)"
|
||
class="select-none cursor-pointer text-left p-1.5 border-b border-white/10 text-sm font-medium hover:bg-white/5"
|
||
>
|
||
{{ c }}
|
||
<span v-if="sortBy === c" class="ml-1">{{ sortDir === 'asc' ? '▲' : '▼' }}</span>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(row, idx) in sortedRows" :key="idx" class="hover:bg-white/5">
|
||
<td
|
||
v-for="c in columns"
|
||
:key="c"
|
||
class="p-1.5 border-b border-white/5 text-xs"
|
||
>
|
||
<pre class="m-0 whitespace-pre-wrap font-mono">{{ fmt(row[c]) }}</pre>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<div v-else class="text-muted">Sin columnas</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
</template>
|