Add support for sqlite
This commit is contained in:
Generated
+1622
-1012
File diff suppressed because it is too large
Load Diff
+9
-4
@@ -2,18 +2,23 @@
|
|||||||
"name": "locales-espanoles",
|
"name": "locales-espanoles",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"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": {
|
"dependencies": {
|
||||||
"firebase": "^10.12.0",
|
|
||||||
"react": "^18.2.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": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"vite": "^5.2.0"
|
"vite": "^5.2.0",
|
||||||
|
"concurrently": "^8.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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
@@ -1,112 +1,16 @@
|
|||||||
// firebase.js
|
// firebase.js - Re-exports local API instead of Firebase
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
export {
|
||||||
// PASO 1 — Pega tu config de Firebase (Configuración ⚙️ → Tu app → Web)
|
escucharAuth,
|
||||||
// PASO 2 — Activa Authentication → Sign-in method → Email/Password
|
loginAdmin,
|
||||||
// PASO 3 — Crea Firestore Database en modo producción (región: eur3)
|
registrarAdmin,
|
||||||
// PASO 4 — Pega estas Reglas en Firestore → Reglas y publica:
|
cerrarSesion,
|
||||||
//
|
suscribirLocales,
|
||||||
// rules_version = '2';
|
deleteLocal,
|
||||||
// service cloud.firestore {
|
enviarPropuesta,
|
||||||
// match /databases/{database}/documents {
|
suscribirPendientes,
|
||||||
// // Locales aprobados: solo lectura pública, escritura solo autenticados
|
aprobarPropuesta,
|
||||||
// match /locales/{id} {
|
rechazarPropuesta,
|
||||||
// allow read: if true;
|
obtenerPalabrasFiltradas,
|
||||||
// allow write: if request.auth != null;
|
guardarPalabrasFiltradas,
|
||||||
// }
|
contienepalabrasProhibidas,
|
||||||
// // Propuestas pendientes: cualquiera puede crear, solo admin gestiona
|
} from "./api.js";
|
||||||
// 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)));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,4 +3,12 @@ import react from '@vitejs/plugin-react'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user