Add support for sqlite

This commit is contained in:
2026-06-07 23:58:53 +02:00
parent 1b67900d54
commit bd4282c426
7 changed files with 1946 additions and 1129 deletions
+1623 -1013
View File
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -2,18 +2,23 @@
"name": "locales-espanoles",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"server": "node server/index.js",
"dev:all": "concurrently -k -n vite,api -c cyan,green \"npm run dev\" \"npm run server\""
},
"dependencies": {
"firebase": "^10.12.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"better-sqlite3": "^12.0.0",
"express": "^5.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.2.0"
"vite": "^5.2.0",
"concurrently": "^8.0.0"
}
}
+52
View File
@@ -0,0 +1,52 @@
import Database from "better-sqlite3";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const dbPath = join(__dirname, "../localesp.db");
const db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS locales (
id TEXT PRIMARY KEY,
nombre TEXT NOT NULL,
descripcion TEXT,
ubicacion TEXT,
telefono TEXT,
email TEXT,
web TEXT,
categoria TEXT,
fecha TEXT DEFAULT CURRENT_TIMESTAMP,
lat REAL,
lng REAL
);
CREATE TABLE IF NOT EXISTS pendientes (
id TEXT PRIMARY KEY,
nombre TEXT NOT NULL,
descripcion TEXT,
ubicacion TEXT,
telefono TEXT,
email TEXT,
web TEXT,
categoria TEXT,
estado TEXT DEFAULT 'pendiente',
fechaPropuesta TEXT DEFAULT CURRENT_TIMESTAMP,
lat REAL,
lng REAL
);
CREATE TABLE IF NOT EXISTS config (
id TEXT PRIMARY KEY,
datos TEXT
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
created TEXT DEFAULT CURRENT_TIMESTAMP
);
`);
export default db;
+95
View File
@@ -0,0 +1,95 @@
import express from "express";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import db from "./db.js";
import { randomUUID } from "crypto";
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
app.use(express.json());
app.use(express.static(join(__dirname, "../dist")));
// Locales (público, solo lectura)
app.get("/api/locales", (_req, res) => {
const locales = db.prepare("SELECT * FROM locales ORDER BY fecha DESC").all();
res.json(locales);
});
// Pendientes (admin)
app.get("/api/pendientes", (_req, res) => {
const pendientes = db.prepare("SELECT * FROM pendientes ORDER BY fechaPropuesta DESC").all();
res.json(pendientes);
});
// Enviar propuesta
app.post("/api/propuestas", (req, res) => {
const { nombre, descripcion, ubicacion, telefono, email, web, categoria, lat, lng } = req.body;
const id = randomUUID();
const fechaPropuesta = new Date().toISOString();
db.prepare(`
INSERT INTO pendientes (id, nombre, descripcion, ubicacion, telefono, email, web, categoria, estado, fechaPropuesta, lat, lng)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pendiente', ?, ?, ?)
`).run(id, nombre, descripcion, ubicacion, telefono, email, web, categoria, fechaPropuesta, lat, lng);
res.status(201).json({ id, nombre, descripcion, ubicacion, telefono, email, web, categoria, estado: "pendiente", fechaPropuesta, lat, lng });
});
// Aprobar propuesta
app.post("/api/aprobar/:id", (req, res) => {
const { id } = req.params;
const propuesta = db.prepare("SELECT * FROM pendientes WHERE id = ?").get(id);
if (!propuesta) return res.status(404).json({ error: "Propuesta no encontrada" });
const localeId = randomUUID();
const fecha = new Date().toISOString();
db.prepare(`
INSERT INTO locales (id, nombre, descripcion, ubicacion, telefono, email, web, categoria, fecha, lat, lng)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(localeId, propuesta.nombre, propuesta.descripcion, propuesta.ubicacion, propuesta.telefono, propuesta.email, propuesta.web, propuesta.categoria, fecha, propuesta.lat, propuesta.lng);
db.prepare("DELETE FROM pendientes WHERE id = ?").run(id);
res.json({ ok: true });
});
// Rechazar propuesta
app.post("/api/rechazar/:id", (req, res) => {
const { id } = req.params;
db.prepare("DELETE FROM pendientes WHERE id = ?").run(id);
res.json({ ok: true });
});
// Eliminar local
app.delete("/api/locales/:id", (req, res) => {
const { id } = req.params;
db.prepare("DELETE FROM locales WHERE id = ?").run(id);
res.json({ ok: true });
});
// Config (palabras filtradas)
app.get("/api/config/filtro_palabras", (_req, res) => {
const config = db.prepare("SELECT datos FROM config WHERE id = ?").get("filtro_palabras");
if (config) {
res.json({ palabras: JSON.parse(config.datos) });
} else {
res.json({ palabras: [] });
}
});
app.post("/api/config/filtro_palabras", (req, res) => {
const { palabras } = req.body;
db.prepare("INSERT OR REPLACE INTO config (id, datos) VALUES (?, ?)").run("filtro_palabras", JSON.stringify(palabras));
res.json({ ok: true });
});
// SPA fallback
app.get(/^\/(?!api\/).*/, (_req, res) => {
res.sendFile(join(__dirname, "../dist/index.html"));
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server listening on http://localhost:${PORT}`));
+143
View File
@@ -0,0 +1,143 @@
// Local SQLite API instead of Firebase
const API_URL = "/api";
export async function suscribirLocales(onData, onError) {
try {
const response = await fetch(`${API_URL}/locales`);
const data = await response.json();
onData(data);
// Simulate real-time by polling every 5 seconds
const interval = setInterval(async () => {
try {
const res = await fetch(`${API_URL}/locales`);
const newData = await res.json();
onData(newData);
} catch (err) {
console.error(err);
}
}, 5000);
return () => clearInterval(interval);
} catch (err) {
onError(err);
}
}
export async function suscribirPendientes(onData, onError) {
try {
const response = await fetch(`${API_URL}/pendientes`);
const data = await response.json();
onData(data);
// Simulate real-time by polling every 5 seconds
const interval = setInterval(async () => {
try {
const res = await fetch(`${API_URL}/pendientes`);
const newData = await res.json();
onData(newData);
} catch (err) {
console.error(err);
}
}, 5000);
return () => clearInterval(interval);
} catch (err) {
onError(err);
}
}
export async function enviarPropuesta(local) {
const { id: _x, ...datos } = local;
const response = await fetch(`${API_URL}/propuestas`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(datos),
});
return response.json();
}
export async function aprobarPropuesta(p) {
const response = await fetch(`${API_URL}/aprobar/${p.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
return response.json();
}
export async function rechazarPropuesta(id) {
const response = await fetch(`${API_URL}/rechazar/${id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
return response.json();
}
export async function deleteLocal(id) {
const response = await fetch(`${API_URL}/locales/${id}`, {
method: "DELETE",
});
return response.json();
}
export async function obtenerPalabrasFiltradas() {
try {
const response = await fetch(`${API_URL}/config/filtro_palabras`);
const data = await response.json();
return data.palabras || [];
} catch {
return [];
}
}
export async function guardarPalabrasFiltradas(palabras) {
const response = await fetch(`${API_URL}/config/filtro_palabras`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ palabras }),
});
return response.json();
}
export function contienepalabrasProhibidas(texto, lista) {
if (!lista?.length) return false;
const norm = s => s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
const t = norm(texto);
return lista.some(p => t.includes(norm(p)));
}
// Auth helpers (local implementation)
let currentUser = null;
export function escucharAuth(cb) {
// Simple localStorage-based auth
const user = localStorage.getItem("user");
currentUser = user ? JSON.parse(user) : null;
cb(currentUser);
// Listen for storage changes
const handleStorageChange = () => {
const user = localStorage.getItem("user");
currentUser = user ? JSON.parse(user) : null;
cb(currentUser);
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}
export async function loginAdmin(email, password) {
// Simple email/password check (in production use proper auth)
if (email && password) {
const user = { email, uid: email };
localStorage.setItem("user", JSON.stringify(user));
currentUser = user;
return user;
}
throw new Error("Invalid email or password");
}
export async function registrarAdmin(email, password) {
return loginAdmin(email, password);
}
export async function cerrarSesion() {
localStorage.removeItem("user");
currentUser = null;
}
+16 -112
View File
@@ -1,112 +1,16 @@
// firebase.js
// ─────────────────────────────────────────────────────────────────────────────
// PASO 1 — Pega tu config de Firebase (Configuración ⚙️ → Tu app → Web)
// PASO 2 — Activa Authentication → Sign-in method → Email/Password
// PASO 3 — Crea Firestore Database en modo producción (región: eur3)
// PASO 4 — Pega estas Reglas en Firestore → Reglas y publica:
//
// rules_version = '2';
// service cloud.firestore {
// match /databases/{database}/documents {
// // Locales aprobados: solo lectura pública, escritura solo autenticados
// match /locales/{id} {
// allow read: if true;
// allow write: if request.auth != null;
// }
// // Propuestas pendientes: cualquiera puede crear, solo admin gestiona
// match /pendientes/{id} {
// allow create: if true;
// allow read, update, delete: if request.auth != null;
// }
// // Config (filtro de palabras): lectura pública, escritura solo admin
// match /config/{id} {
// allow read: if true;
// allow write: if request.auth != null;
// }
// }
// }
// ─────────────────────────────────────────────────────────────────────────────
import { initializeApp } from "firebase/app";
import {
getFirestore, collection, addDoc, deleteDoc,
doc, onSnapshot, query, orderBy, getDoc, setDoc,
} from "firebase/firestore";
import {
getAuth, signInWithEmailAndPassword, createUserWithEmailAndPassword,
signOut, onAuthStateChanged,
} from "firebase/auth";
// ▼▼▼ PEGA AQUÍ TU CONFIG DE FIREBASE ▼▼▼
const firebaseConfig = {
apiKey: "PEGA_TU_API_KEY",
authDomain: "PEGA_TU_AUTH_DOMAIN",
projectId: "PEGA_TU_PROJECT_ID",
storageBucket: "PEGA_TU_STORAGE_BUCKET",
messagingSenderId: "PEGA_TU_SENDER_ID",
appId: "PEGA_TU_APP_ID",
};
// ▲▲▲ ─────────────────────────────── ▲▲▲
const fbApp = initializeApp(firebaseConfig);
export const db = getFirestore(fbApp);
export const auth = getAuth(fbApp);
// ── Auth ──────────────────────────────────────────────────────────────────────
export const escucharAuth = (cb) => onAuthStateChanged(auth, cb);
export const loginAdmin = (e, p) => signInWithEmailAndPassword(auth, e, p);
export const registrarAdmin = (e, p) => createUserWithEmailAndPassword(auth, e, p);
export const cerrarSesion = () => signOut(auth);
// ── Locales aprobados (público, solo lectura) ─────────────────────────────────
export function suscribirLocales(onData, onError) {
const q = query(collection(db, "locales"), orderBy("fecha", "desc"));
return onSnapshot(q, snap => onData(snap.docs.map(d => ({ id: d.id, ...d.data() }))), onError);
}
export const deleteLocal = (id) => deleteDoc(doc(db, "locales", id));
// ── Propuestas pendientes ─────────────────────────────────────────────────────
/** Cualquier visitante puede enviar una propuesta */
export async function enviarPropuesta(local) {
const { id: _x, ...datos } = local;
return addDoc(collection(db, "pendientes"), {
...datos,
estado: "pendiente",
fechaPropuesta: new Date().toISOString(),
});
}
/** Solo el admin suscribe y gestiona pendientes */
export function suscribirPendientes(onData, onError) {
const q = query(collection(db, "pendientes"), orderBy("fechaPropuesta", "desc"));
return onSnapshot(q, snap => onData(snap.docs.map(d => ({ id: d.id, ...d.data() }))), onError);
}
/** Admin aprueba: mueve a /locales y borra de /pendientes */
export async function aprobarPropuesta(p) {
const { id, estado, fechaPropuesta, ...datos } = p;
await addDoc(collection(db, "locales"), { ...datos, fecha: new Date().toISOString() });
await deleteDoc(doc(db, "pendientes", id));
}
/** Admin rechaza: borra de /pendientes */
export const rechazarPropuesta = (id) => deleteDoc(doc(db, "pendientes", id));
// ── Filtro de palabras ────────────────────────────────────────────────────────
export async function obtenerPalabrasFiltradas() {
try {
const snap = await getDoc(doc(db, "config", "filtro_palabras"));
if (snap.exists()) return snap.data().palabras || [];
} catch {}
return [];
}
export const guardarPalabrasFiltradas = (palabras) =>
setDoc(doc(db, "config", "filtro_palabras"), { palabras });
/** Devuelve true si el texto contiene alguna palabra prohibida */
export function contienepalabrasProhibidas(texto, lista) {
if (!lista?.length) return false;
const norm = s => s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
const t = norm(texto);
return lista.some(p => t.includes(norm(p)));
}
// firebase.js - Re-exports local API instead of Firebase
export {
escucharAuth,
loginAdmin,
registrarAdmin,
cerrarSesion,
suscribirLocales,
deleteLocal,
enviarPropuesta,
suscribirPendientes,
aprobarPropuesta,
rechazarPropuesta,
obtenerPalabrasFiltradas,
guardarPalabrasFiltradas,
contienepalabrasProhibidas,
} from "./api.js";
+8
View File
@@ -3,4 +3,12 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
}
}
}
})