Despliegue en VPS
This commit is contained in:
Generated
+3241
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,14 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"server": "node server/index.js",
|
||||||
|
"dev:all": "concurrently -k -n vite,api -c cyan,green \"npm run dev\" \"npm run server\"",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.10.0",
|
||||||
|
"express": "^5.2.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -16,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"concurrently": "^10.0.0",
|
||||||
"vite": "^5.4.0"
|
"vite": "^5.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { dirname, join } from "path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const db = new Database(join(__dirname, "..", "localesp.db"));
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS businesses (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
contact TEXT NOT NULL DEFAULT '',
|
||||||
|
lat REAL NOT NULL,
|
||||||
|
lng REAL NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
createdAt TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const empty = db.prepare("SELECT COUNT(*) AS n FROM businesses").get().n === 0;
|
||||||
|
if (empty) {
|
||||||
|
const ins = db.prepare(`
|
||||||
|
INSERT INTO businesses
|
||||||
|
(id, name, category, address, description, contact, lat, lng, status, createdAt)
|
||||||
|
VALUES
|
||||||
|
(@id, @name, @category, @address, @description, @contact, @lat, @lng, @status, @createdAt)
|
||||||
|
`);
|
||||||
|
for (const b of [
|
||||||
|
{ id: "1", name: "Café del Mercado", category: "Cafetería", address: "Plaza Mayor 3, Madrid", description: "Café de especialidad y bollería artesana.", contact: "hola@cafedelmercado.example", lat: 40.4155, lng: -3.7074, status: "approved", createdAt: "2025-01-10T10:00:00Z" },
|
||||||
|
{ id: "2", name: "Librería El Faro", category: "Librería", address: "Calle Luna 12, Madrid", description: "Librería independiente con sección infantil.", contact: "", lat: 40.4221, lng: -3.7059, status: "approved", createdAt: "2025-01-12T10:00:00Z" },
|
||||||
|
{ id: "3", name: "Taller Bicicletas Rueda", category: "Servicios", address: "Calle Embajadores 45, Madrid", description: "Reparación y venta de segunda mano.", contact: "+34 600 000 000", lat: 40.4078, lng: -3.7053, status: "approved", createdAt: "2025-01-15T10:00:00Z" },
|
||||||
|
]) ins.run(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default db;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { dirname, join } from "path";
|
||||||
|
import db from "./db.js";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(join(__dirname, "../dist")));
|
||||||
|
|
||||||
|
app.get("/api/businesses", (_req, res) => {
|
||||||
|
res.json(db.prepare("SELECT * FROM businesses WHERE status = 'approved' ORDER BY createdAt DESC").all());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/businesses", (req, res) => {
|
||||||
|
const { name, category, address, description, contact, lat, lng } = req.body;
|
||||||
|
const business = {
|
||||||
|
id: randomUUID(),
|
||||||
|
name, category, address, description,
|
||||||
|
contact: contact || "",
|
||||||
|
lat, lng,
|
||||||
|
status: "pending",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO businesses (id, name, category, address, description, contact, lat, lng, status, createdAt)
|
||||||
|
VALUES (@id, @name, @category, @address, @description, @contact, @lat, @lng, @status, @createdAt)
|
||||||
|
`).run(business);
|
||||||
|
res.status(201).json(business);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/admin/pending", (_req, res) => {
|
||||||
|
res.json(db.prepare("SELECT * FROM businesses WHERE status = 'pending' ORDER BY createdAt ASC").all());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/admin/businesses/:id", (req, res) => {
|
||||||
|
const { status } = req.body;
|
||||||
|
if (!["approved", "rejected"].includes(status))
|
||||||
|
return res.status(400).json({ error: "status must be 'approved' or 'rejected'" });
|
||||||
|
const info = db.prepare("UPDATE businesses SET status = ? WHERE id = ?").run(status, req.params.id);
|
||||||
|
if (info.changes === 0) return res.status(404).json({ error: "not found" });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(/^\/(?!api\/).*/, (_req, res) => {
|
||||||
|
res.sendFile(join(__dirname, "../dist/index.html"));
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
app.listen(PORT, () => console.log(`API listening on http://localhost:${PORT}`));
|
||||||
+36
-15
@@ -2,50 +2,71 @@ import { useEffect, useState } from "react";
|
|||||||
import MapView from "./components/MapView.jsx";
|
import MapView from "./components/MapView.jsx";
|
||||||
import Sidebar from "./components/Sidebar.jsx";
|
import Sidebar from "./components/Sidebar.jsx";
|
||||||
import SubmitForm from "./components/SubmitForm.jsx";
|
import SubmitForm from "./components/SubmitForm.jsx";
|
||||||
|
import AdminPanel from "./components/AdminPanel.jsx";
|
||||||
import { getApproved, submit } from "./services/businessRepository.js";
|
import { getApproved, submit } from "./services/businessRepository.js";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [businesses, setBusinesses] = useState([]);
|
const [businesses, setBusinesses] = useState([]);
|
||||||
const [selectedId, setSelectedId] = useState(null);
|
const [selectedId, setSelectedId] = useState(null);
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formStep, setFormStep] = useState(null); // null | 'picking' | 'editing'
|
||||||
const [pickedLocation, setPickedLocation] = useState(null);
|
const [pickedLocation, setPickedLocation] = useState(null);
|
||||||
|
const [adminOpen, setAdminOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
async function loadBusinesses() {
|
||||||
setBusinesses(getApproved());
|
setBusinesses(await getApproved());
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
|
useEffect(() => { loadBusinesses(); }, []);
|
||||||
|
|
||||||
const selected = businesses.find((b) => b.id === selectedId) || null;
|
const selected = businesses.find((b) => b.id === selectedId) || null;
|
||||||
|
|
||||||
function handleSubmit(data) {
|
function handlePickLocation(loc) {
|
||||||
submit(data);
|
setPickedLocation(loc);
|
||||||
setFormOpen(false);
|
if (formStep === "picking") setFormStep("editing");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(data) {
|
||||||
|
await submit(data);
|
||||||
|
setFormStep(null);
|
||||||
setPickedLocation(null);
|
setPickedLocation(null);
|
||||||
alert("¡Gracias! Tu propuesta queda pendiente de revisión.");
|
alert("¡Gracias! Tu propuesta queda pendiente de revisión.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
setFormStep(null);
|
||||||
|
setPickedLocation(null);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
business={selected}
|
business={selected}
|
||||||
onContribute={() => setFormOpen(true)}
|
onContribute={() => setFormStep("picking")}
|
||||||
onClose={() => setSelectedId(null)}
|
onClose={() => setSelectedId(null)}
|
||||||
|
picking={formStep === "picking"}
|
||||||
|
onCancelPicking={handleCancel}
|
||||||
|
onAdmin={() => setAdminOpen(true)}
|
||||||
/>
|
/>
|
||||||
<MapView
|
<MapView
|
||||||
businesses={businesses}
|
businesses={businesses}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onSelect={setSelectedId}
|
onSelect={setSelectedId}
|
||||||
pickingLocation={formOpen}
|
pickingLocation={formStep === "picking"}
|
||||||
onPickLocation={setPickedLocation}
|
onPickLocation={handlePickLocation}
|
||||||
pickedLocation={pickedLocation}
|
pickedLocation={pickedLocation}
|
||||||
/>
|
/>
|
||||||
{formOpen && (
|
{formStep === "editing" && (
|
||||||
<SubmitForm
|
<SubmitForm
|
||||||
pickedLocation={pickedLocation}
|
pickedLocation={pickedLocation}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={() => {
|
onCancel={handleCancel}
|
||||||
setFormOpen(false);
|
onRepick={() => setFormStep("picking")}
|
||||||
setPickedLocation(null);
|
/>
|
||||||
}}
|
)}
|
||||||
|
{adminOpen && (
|
||||||
|
<AdminPanel
|
||||||
|
onClose={() => setAdminOpen(false)}
|
||||||
|
onApproved={loadBusinesses}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getPending, setStatus } from "../services/businessRepository.js";
|
||||||
|
|
||||||
|
export default function AdminPanel({ onClose, onApproved }) {
|
||||||
|
const [pending, setPending] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setPending(await getPending());
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
async function handle(id, status) {
|
||||||
|
await setStatus(id, status);
|
||||||
|
await load();
|
||||||
|
onApproved();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal admin-panel" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="admin-header">
|
||||||
|
<h2>Propuestas pendientes</h2>
|
||||||
|
<button className="close" onClick={onClose} aria-label="Cerrar">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className="admin-empty">Cargando…</p>}
|
||||||
|
|
||||||
|
{!loading && pending.length === 0 && (
|
||||||
|
<p className="admin-empty">No hay propuestas pendientes.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && pending.map((b) => (
|
||||||
|
<div key={b.id} className="admin-item">
|
||||||
|
<div className="admin-item-info">
|
||||||
|
<strong>{b.name}</strong>
|
||||||
|
<span className="badge">{b.category}</span>
|
||||||
|
<p>{b.address}</p>
|
||||||
|
<p className="admin-desc">{b.description}</p>
|
||||||
|
{b.contact && <p className="admin-contact">{b.contact}</p>}
|
||||||
|
<p className="admin-coords">{b.lat.toFixed(4)}, {b.lng.toFixed(4)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-actions">
|
||||||
|
<button className="approve" onClick={() => handle(b.id, "approved")}>Aprobar</button>
|
||||||
|
<button className="reject" onClick={() => handle(b.id, "rejected")}>Rechazar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export default function Sidebar({ business, onContribute, onClose }) {
|
export default function Sidebar({ business, onContribute, onClose, picking, onCancelPicking, onAdmin }) {
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<header className="sidebar-header">
|
<header className="sidebar-header">
|
||||||
@@ -7,7 +7,11 @@ export default function Sidebar({ business, onContribute, onClose }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="sidebar-body">
|
<div className="sidebar-body">
|
||||||
{business ? (
|
{picking ? (
|
||||||
|
<div className="empty">
|
||||||
|
<p>Haz clic en el mapa para fijar la ubicación del negocio.</p>
|
||||||
|
</div>
|
||||||
|
) : business ? (
|
||||||
<BusinessDetails business={business} onClose={onClose} />
|
<BusinessDetails business={business} onClose={onClose} />
|
||||||
) : (
|
) : (
|
||||||
<Empty />
|
<Empty />
|
||||||
@@ -15,9 +19,18 @@ export default function Sidebar({ business, onContribute, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="sidebar-footer">
|
<footer className="sidebar-footer">
|
||||||
|
{picking ? (
|
||||||
|
<button onClick={onCancelPicking}>Cancelar</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<button className="primary" onClick={onContribute}>
|
<button className="primary" onClick={onContribute}>
|
||||||
+ Añadir un negocio
|
+ Añadir un negocio
|
||||||
</button>
|
</button>
|
||||||
|
<button className="admin-btn" onClick={onAdmin} title="Panel de administración">
|
||||||
|
⚙ Admin
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const EMPTY = {
|
|||||||
contact: "",
|
contact: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SubmitForm({ pickedLocation, onSubmit, onCancel }) {
|
export default function SubmitForm({ pickedLocation, onSubmit, onCancel, onRepick }) {
|
||||||
const [values, setValues] = useState(EMPTY);
|
const [values, setValues] = useState(EMPTY);
|
||||||
|
|
||||||
function update(field) {
|
function update(field) {
|
||||||
@@ -17,10 +17,6 @@ export default function SubmitForm({ pickedLocation, onSubmit, onCancel }) {
|
|||||||
|
|
||||||
function handleSubmit(e) {
|
function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!pickedLocation) {
|
|
||||||
alert("Selecciona una ubicación haciendo clic en el mapa.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSubmit({ ...values, lat: pickedLocation.lat, lng: pickedLocation.lng });
|
onSubmit({ ...values, lat: pickedLocation.lat, lng: pickedLocation.lng });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,9 +80,10 @@ export default function SubmitForm({ pickedLocation, onSubmit, onCancel }) {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="location-status">
|
<div className="location-status">
|
||||||
{pickedLocation
|
{`Ubicación: ${pickedLocation.lat.toFixed(4)}, ${pickedLocation.lng.toFixed(4)}`}
|
||||||
? `Ubicación: ${pickedLocation.lat.toFixed(4)}, ${pickedLocation.lng.toFixed(4)}`
|
<button type="button" className="repick-link" onClick={onRepick}>
|
||||||
: "Haz clic en el mapa para fijar la ubicación."}
|
Cambiar
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
|
|||||||
@@ -1,65 +1,31 @@
|
|||||||
// Capa de acceso a datos. Toda la app habla con el backend a través de este
|
export async function getApproved() {
|
||||||
// módulo. Para cambiar a Supabase u otro BaaS, basta con reescribir las dos
|
const res = await fetch(`${import.meta.env.BASE_URL}api/businesses`);
|
||||||
// funciones exportadas manteniendo la misma firma.
|
if (!res.ok) throw new Error("Error cargando negocios");
|
||||||
//
|
return res.json();
|
||||||
// Modelo de negocio:
|
|
||||||
// { id, name, category, address, description, contact, lat, lng, status, createdAt }
|
|
||||||
//
|
|
||||||
// `status` es uno de: "pending" | "approved" | "rejected".
|
|
||||||
|
|
||||||
const sampleBusinesses = [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
name: "Café del Mercado",
|
|
||||||
category: "Cafetería",
|
|
||||||
address: "Plaza Mayor 3, Madrid",
|
|
||||||
description: "Café de especialidad y bollería artesana.",
|
|
||||||
contact: "hola@cafedelmercado.example",
|
|
||||||
lat: 40.4155,
|
|
||||||
lng: -3.7074,
|
|
||||||
status: "approved",
|
|
||||||
createdAt: "2025-01-10T10:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
name: "Librería El Faro",
|
|
||||||
category: "Librería",
|
|
||||||
address: "Calle Luna 12, Madrid",
|
|
||||||
description: "Librería independiente con sección infantil.",
|
|
||||||
contact: "",
|
|
||||||
lat: 40.4221,
|
|
||||||
lng: -3.7059,
|
|
||||||
status: "approved",
|
|
||||||
createdAt: "2025-01-12T10:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
name: "Taller Bicicletas Rueda",
|
|
||||||
category: "Servicios",
|
|
||||||
address: "Calle Embajadores 45, Madrid",
|
|
||||||
description: "Reparación y venta de segunda mano.",
|
|
||||||
contact: "+34 600 000 000",
|
|
||||||
lat: 40.4078,
|
|
||||||
lng: -3.7053,
|
|
||||||
status: "approved",
|
|
||||||
createdAt: "2025-01-15T10:00:00Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Estado en memoria. Se reinicia al recargar. Suficiente para el prototipo.
|
|
||||||
let businesses = [...sampleBusinesses];
|
|
||||||
|
|
||||||
export function getApproved() {
|
|
||||||
return businesses.filter((b) => b.status === "approved");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function submit(data) {
|
export async function submit(data) {
|
||||||
const newBusiness = {
|
const res = await fetch(`${import.meta.env.BASE_URL}api/businesses`, {
|
||||||
...data,
|
method: "POST",
|
||||||
id: crypto.randomUUID(),
|
headers: { "Content-Type": "application/json" },
|
||||||
status: "pending",
|
body: JSON.stringify(data),
|
||||||
createdAt: new Date().toISOString(),
|
});
|
||||||
};
|
if (!res.ok) throw new Error("Error enviando propuesta");
|
||||||
businesses = [...businesses, newBusiness];
|
return res.json();
|
||||||
return newBusiness;
|
}
|
||||||
|
|
||||||
|
export async function getPending() {
|
||||||
|
const res = await fetch(`${import.meta.env.BASE_URL}api/admin/pending`);
|
||||||
|
if (!res.ok) throw new Error("Error cargando pendientes");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setStatus(id, status) {
|
||||||
|
const res = await fetch(`${import.meta.env.BASE_URL}api/admin/businesses/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Error actualizando estado");
|
||||||
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|||||||
+115
@@ -240,6 +240,25 @@ button.primary:hover {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repick-link {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-color: var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repick-link:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
@@ -248,6 +267,102 @@ button.primary:hover {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Admin */
|
||||||
|
.admin-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel {
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header .close {
|
||||||
|
position: static;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-item-info {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-item-info strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-item-info p {
|
||||||
|
margin: 2px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-desc {
|
||||||
|
color: var(--text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-contact, .admin-coords {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions .approve {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions .approve:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions .reject {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #c0392b;
|
||||||
|
border-color: #e0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions .reject:hover {
|
||||||
|
background: #fff5f5;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.app {
|
.app {
|
||||||
|
|||||||
@@ -3,4 +3,21 @@ import react from "@vitejs/plugin-react";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
base: "/",
|
||||||
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
strictPort: true,
|
||||||
|
allowedHosts: true,
|
||||||
|
cors: {
|
||||||
|
origin: "*",
|
||||||
|
methods: "GET,HEAD,PUT,PATCH,POST,DELETE",
|
||||||
|
preflightContinue: false,
|
||||||
|
optionsSuccessStatus: 204,
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET,HEAD,PUT,PATCH,POST,DELETE",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type,Authorization",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user