frist commit
This commit is contained in:
+38
@@ -0,0 +1,38 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y openssh-server && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Crear directorio de datos con permisos abiertos para lectura del usuario ssh
|
||||||
|
RUN mkdir -p /app/data && chmod 777 /app/data
|
||||||
|
|
||||||
|
# Configurar SSH
|
||||||
|
RUN mkdir -p /var/run/sshd && \
|
||||||
|
useradd -m -s /bin/bash craig && \
|
||||||
|
echo 'craig:madrid' | chpasswd && \
|
||||||
|
mkdir -p /home/craig/.ssh && \
|
||||||
|
chown craig:craig /home/craig/.ssh
|
||||||
|
|
||||||
|
# Habilitar autenticacion por contrasena
|
||||||
|
RUN sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/' /etc/ssh/sshd_config && \
|
||||||
|
sed -i 's/PasswordAuthentication no/PasswordAuthentication yes/' /etc/ssh/sshd_config
|
||||||
|
|
||||||
|
# Al conectar como craig, forzar la ejecucion del visor de anuncios
|
||||||
|
RUN echo '' >> /etc/ssh/sshd_config && \
|
||||||
|
echo 'Match User craig' >> /etc/ssh/sshd_config && \
|
||||||
|
echo ' ForceCommand python3 /app/ssh_display.py' >> /etc/ssh/sshd_config && \
|
||||||
|
echo ' PermitTTY yes' >> /etc/ssh/sshd_config && \
|
||||||
|
echo ' AllowTcpForwarding no' >> /etc/ssh/sshd_config && \
|
||||||
|
echo ' X11Forwarding no' >> /etc/ssh/sshd_config
|
||||||
|
|
||||||
|
RUN chmod +x /app/start.sh
|
||||||
|
|
||||||
|
EXPOSE 5000 22
|
||||||
|
|
||||||
|
CMD ["/app/start.sh"]
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,66 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
import models
|
||||||
|
|
||||||
|
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/anuncios', methods=['GET'])
|
||||||
|
def api_listar():
|
||||||
|
categoria = request.args.get('categoria', '').strip() or None
|
||||||
|
busqueda = request.args.get('q', '').strip() or None
|
||||||
|
ciudad = request.args.get('ciudad', '').strip() or None
|
||||||
|
anuncios = models.listar_anuncios(categoria=categoria, busqueda=busqueda, ciudad=ciudad)
|
||||||
|
return jsonify(anuncios)
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/anuncios/<int:anuncio_id>', methods=['GET'])
|
||||||
|
def api_ver(anuncio_id):
|
||||||
|
anuncio = models.obtener_anuncio(anuncio_id)
|
||||||
|
if not anuncio:
|
||||||
|
return jsonify({'error': 'Anuncio no encontrado'}), 404
|
||||||
|
return jsonify(anuncio)
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/anuncios', methods=['POST'])
|
||||||
|
def api_crear():
|
||||||
|
data = request.get_json(force=True, silent=True) or request.form
|
||||||
|
titulo = (data.get('titulo') or '').strip()
|
||||||
|
contenido = (data.get('contenido') or '').strip()
|
||||||
|
categoria = (data.get('categoria') or 'general').strip()
|
||||||
|
contacto = (data.get('contacto') or 'madrid@craigslist.local').strip()
|
||||||
|
ciudad = (data.get('ciudad') or 'madrid').strip()
|
||||||
|
|
||||||
|
if not titulo or not contenido:
|
||||||
|
return jsonify({'error': 'Titulo y contenido son obligatorios'}), 400
|
||||||
|
|
||||||
|
anuncio_id, token = models.crear_anuncio(titulo, contenido, categoria, contacto, ciudad)
|
||||||
|
return jsonify({'id': anuncio_id, 'token': token, 'message': 'Anuncio creado'}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/anuncios/<int:anuncio_id>', methods=['DELETE'])
|
||||||
|
def api_borrar(anuncio_id):
|
||||||
|
data = request.get_json(force=True, silent=True) or request.form
|
||||||
|
token = (data.get('token') or '').strip()
|
||||||
|
if models.borrar_anuncio(anuncio_id, token):
|
||||||
|
return jsonify({'message': 'Anuncio borrado'}), 200
|
||||||
|
return jsonify({'error': 'Anuncio no encontrado o token incorrecto'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/anuncios/<int:anuncio_id>', methods=['PUT'])
|
||||||
|
def api_actualizar(anuncio_id):
|
||||||
|
data = request.get_json(force=True, silent=True) or request.form
|
||||||
|
campos = {}
|
||||||
|
for k in ('titulo', 'contenido', 'categoria', 'contacto', 'ciudad'):
|
||||||
|
if k in data:
|
||||||
|
campos[k] = data[k]
|
||||||
|
if not campos:
|
||||||
|
return jsonify({'error': 'No hay campos para actualizar'}), 400
|
||||||
|
if models.actualizar_anuncio(anuncio_id, **campos):
|
||||||
|
return jsonify({'message': 'Anuncio actualizado'})
|
||||||
|
return jsonify({'error': 'Anuncio no encontrado'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/categorias', methods=['GET'])
|
||||||
|
def api_categorias():
|
||||||
|
ciudad = request.args.get('ciudad', '').strip() or None
|
||||||
|
return jsonify(models.listar_categorias(ciudad=ciudad))
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from flask import Flask, render_template, request, redirect, url_for
|
||||||
|
import models
|
||||||
|
from api import api_bp
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(api_bp)
|
||||||
|
|
||||||
|
models.init_db()
|
||||||
|
|
||||||
|
CIUDADES = [
|
||||||
|
'madrid', 'barcelona', 'valencia', 'sevilla', 'zaragoza',
|
||||||
|
'malaga', 'murcia', 'palma', 'las-palmas', 'bilbao',
|
||||||
|
'alicante', 'cordoba', 'valladolid', 'vigo'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html', modo='inicio', ciudades=CIUDADES)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/c/<city>')
|
||||||
|
def ciudad(city):
|
||||||
|
categoria = request.args.get('categoria', '').strip() or None
|
||||||
|
busqueda = request.args.get('q', '').strip() or None
|
||||||
|
anuncios = models.listar_anuncios(categoria=categoria, busqueda=busqueda, ciudad=city)
|
||||||
|
categorias = models.listar_categorias(ciudad=city)
|
||||||
|
return render_template(
|
||||||
|
'index.html',
|
||||||
|
modo='ciudad',
|
||||||
|
city=city,
|
||||||
|
anuncios=anuncios,
|
||||||
|
categorias=categorias,
|
||||||
|
categoria_actual=categoria or '',
|
||||||
|
busqueda_actual=busqueda or ''
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/nuevo', methods=['POST'])
|
||||||
|
def nuevo():
|
||||||
|
titulo = request.form.get('titulo', '').strip()
|
||||||
|
contenido = request.form.get('contenido', '').strip()
|
||||||
|
categoria = request.form.get('categoria', 'general').strip()
|
||||||
|
contacto = request.form.get('contacto', 'madrid@craigslist.local').strip()
|
||||||
|
ciudad = request.form.get('ciudad', 'madrid').strip()
|
||||||
|
|
||||||
|
if titulo and contenido:
|
||||||
|
anuncio_id, token = models.crear_anuncio(titulo, contenido, categoria, contacto, ciudad)
|
||||||
|
return redirect(f'/c/{ciudad}?nuevo={anuncio_id}&token={token}')
|
||||||
|
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/borrar/<int:anuncio_id>', methods=['POST'])
|
||||||
|
def borrar(anuncio_id):
|
||||||
|
token = request.form.get('token', '').strip()
|
||||||
|
models.borrar_anuncio(anuncio_id, token)
|
||||||
|
return redirect(request.referrer or '/')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host='0.0.0.0', port=5000)
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,34 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"titulo": "test",
|
||||||
|
"contenido": "test",
|
||||||
|
"categoria": "general",
|
||||||
|
"fecha": "2026-04-30 14:42",
|
||||||
|
"contacto": "madrid@craigslist.local"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"titulo": "Vendo bicicleta de carretera",
|
||||||
|
"contenido": "Bicicleta de carretera en buen estado. Precio negociable. Zona Chamberi, Madrid.",
|
||||||
|
"categoria": "venta",
|
||||||
|
"fecha": "2025-04-30 10:00",
|
||||||
|
"contacto": "bicis@madrid.es"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"titulo": "Clases de guitarra flamenca",
|
||||||
|
"contenido": "Profesor con 10 anos de experiencia. Clases en persona o online. Primera clase gratis.",
|
||||||
|
"categoria": "servicios",
|
||||||
|
"fecha": "2025-04-30 11:30",
|
||||||
|
"contacto": "guitarra@flamenco.local"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"titulo": "Se busca companero de piso",
|
||||||
|
"contenido": "Piso compartido en Malasana. 400 EUR/mes gastos incluidos. No fumadores.",
|
||||||
|
"categoria": "vivienda",
|
||||||
|
"fecha": "2025-04-30 09:15",
|
||||||
|
"contacto": "piso@malasana.local"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
services:
|
||||||
|
madcraig:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
- "2222:22"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import random
|
||||||
|
|
||||||
|
DATA_DIR = os.environ.get('DATA_DIR', '/app/data')
|
||||||
|
DB_PATH = os.path.join(DATA_DIR, 'anuncios.db')
|
||||||
|
OLD_JSON_PATH = os.path.join(DATA_DIR, 'anuncios.json')
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_conn():
|
||||||
|
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _add_column_if_missing(conn, table, column, col_type):
|
||||||
|
"""SQLite-safe ALTER TABLE ADD COLUMN if the column does not exist."""
|
||||||
|
cur = conn.execute(f"PRAGMA table_info({table})")
|
||||||
|
cols = [row['name'] for row in cur.fetchall()]
|
||||||
|
if column not in cols:
|
||||||
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
|
with _lock:
|
||||||
|
conn = _get_conn()
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS anuncios (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
titulo TEXT NOT NULL,
|
||||||
|
contenido TEXT NOT NULL,
|
||||||
|
categoria TEXT NOT NULL DEFAULT 'general',
|
||||||
|
contacto TEXT NOT NULL DEFAULT 'madrid@craigslist.local',
|
||||||
|
ciudad TEXT NOT NULL DEFAULT 'madrid',
|
||||||
|
fecha TEXT NOT NULL
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
_add_column_if_missing(conn, 'anuncios', 'ciudad', "TEXT NOT NULL DEFAULT 'madrid'")
|
||||||
|
_add_column_if_missing(conn, 'anuncios', 'token', "TEXT")
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_categoria ON anuncios(categoria)
|
||||||
|
''')
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ciudad ON anuncios(ciudad)
|
||||||
|
''')
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fecha ON anuncios(fecha DESC)
|
||||||
|
''')
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
_migrate_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_json():
|
||||||
|
if not os.path.exists(OLD_JSON_PATH):
|
||||||
|
return
|
||||||
|
with _lock:
|
||||||
|
conn = _get_conn()
|
||||||
|
cur = conn.execute('SELECT COUNT(*) as total FROM anuncios')
|
||||||
|
if cur.fetchone()['total'] > 0:
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(OLD_JSON_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
anuncios = json.load(f)
|
||||||
|
|
||||||
|
for a in anuncios:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO anuncios (id, titulo, contenido, categoria, contacto, ciudad, fecha)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''', (
|
||||||
|
a.get('id'),
|
||||||
|
a.get('titulo', ''),
|
||||||
|
a.get('contenido', ''),
|
||||||
|
a.get('categoria', 'general'),
|
||||||
|
a.get('contacto', 'madrid@craigslist.local'),
|
||||||
|
a.get('ciudad', 'madrid'),
|
||||||
|
a.get('fecha', datetime.now().strftime('%Y-%m-%d %H:%M'))
|
||||||
|
))
|
||||||
|
conn.execute("DELETE FROM sqlite_sequence WHERE name='anuncios'")
|
||||||
|
if anuncios:
|
||||||
|
max_id = max(a.get('id', 0) for a in anuncios)
|
||||||
|
conn.execute("INSERT INTO sqlite_sequence(name,seq) VALUES ('anuncios', ?)", (max_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.rename(OLD_JSON_PATH, OLD_JSON_PATH + '.backup')
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def listar_anuncios(categoria=None, busqueda=None, ciudad=None):
|
||||||
|
query = 'SELECT * FROM anuncios WHERE 1=1'
|
||||||
|
params = []
|
||||||
|
if ciudad:
|
||||||
|
query += ' AND ciudad = ?'
|
||||||
|
params.append(ciudad)
|
||||||
|
if categoria:
|
||||||
|
query += ' AND categoria = ?'
|
||||||
|
params.append(categoria)
|
||||||
|
if busqueda:
|
||||||
|
query += ' AND (titulo LIKE ? OR contenido LIKE ?)'
|
||||||
|
params.extend([f'%{busqueda}%', f'%{busqueda}%'])
|
||||||
|
query += ' ORDER BY fecha DESC'
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
conn = _get_conn()
|
||||||
|
cur = conn.execute(query, params)
|
||||||
|
rows = [dict(r) for r in cur.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def obtener_anuncio(anuncio_id):
|
||||||
|
with _lock:
|
||||||
|
conn = _get_conn()
|
||||||
|
cur = conn.execute('SELECT * FROM anuncios WHERE id = ?', (anuncio_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def crear_anuncio(titulo, contenido, categoria, contacto, ciudad='madrid'):
|
||||||
|
fecha = datetime.now().strftime('%Y-%m-%d %H:%M')
|
||||||
|
token = secrets.token_urlsafe(16)
|
||||||
|
with _lock:
|
||||||
|
conn = _get_conn()
|
||||||
|
cur = conn.execute('''
|
||||||
|
INSERT INTO anuncios (titulo, contenido, categoria, contacto, ciudad, fecha, token)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''', (titulo, contenido, categoria, contacto, ciudad, fecha, token))
|
||||||
|
anuncio_id = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return anuncio_id, token
|
||||||
|
|
||||||
|
|
||||||
|
def borrar_anuncio(anuncio_id, token=None):
|
||||||
|
with _lock:
|
||||||
|
conn = _get_conn()
|
||||||
|
cur = conn.execute('SELECT token FROM anuncios WHERE id = ?', (anuncio_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
return False
|
||||||
|
stored_token = row['token']
|
||||||
|
if stored_token and stored_token != token:
|
||||||
|
conn.close()
|
||||||
|
return False
|
||||||
|
cur = conn.execute('DELETE FROM anuncios WHERE id = ?', (anuncio_id,))
|
||||||
|
deleted = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return deleted > 0
|
||||||
|
|
||||||
|
|
||||||
|
def actualizar_anuncio(anuncio_id, titulo=None, contenido=None, categoria=None, contacto=None, ciudad=None):
|
||||||
|
campos = []
|
||||||
|
valores = []
|
||||||
|
if titulo is not None:
|
||||||
|
campos.append('titulo = ?')
|
||||||
|
valores.append(titulo)
|
||||||
|
if contenido is not None:
|
||||||
|
campos.append('contenido = ?')
|
||||||
|
valores.append(contenido)
|
||||||
|
if categoria is not None:
|
||||||
|
campos.append('categoria = ?')
|
||||||
|
valores.append(categoria)
|
||||||
|
if contacto is not None:
|
||||||
|
campos.append('contacto = ?')
|
||||||
|
valores.append(contacto)
|
||||||
|
if ciudad is not None:
|
||||||
|
campos.append('ciudad = ?')
|
||||||
|
valores.append(ciudad)
|
||||||
|
if not campos:
|
||||||
|
return False
|
||||||
|
valores.append(anuncio_id)
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
conn = _get_conn()
|
||||||
|
cur = conn.execute(
|
||||||
|
f"UPDATE anuncios SET {', '.join(campos)} WHERE id = ?",
|
||||||
|
valores
|
||||||
|
)
|
||||||
|
updated = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return updated > 0
|
||||||
|
|
||||||
|
|
||||||
|
def listar_categorias(ciudad=None):
|
||||||
|
query = 'SELECT DISTINCT categoria FROM anuncios WHERE 1=1'
|
||||||
|
params = []
|
||||||
|
if ciudad:
|
||||||
|
query += ' AND ciudad = ?'
|
||||||
|
params.append(ciudad)
|
||||||
|
query += ' ORDER BY categoria'
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
conn = _get_conn()
|
||||||
|
cur = conn.execute(query, params)
|
||||||
|
rows = [r['categoria'] for r in cur.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Seed / migración helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_TITULOS = {
|
||||||
|
'trabajo': [
|
||||||
|
'Desarrollador backend senior',
|
||||||
|
'Community manager freelance',
|
||||||
|
'Repartidor con moto propia',
|
||||||
|
'Camarero/a para fin de semana',
|
||||||
|
'Diseñador UX/UI remoto',
|
||||||
|
'Ingeniero devops',
|
||||||
|
'Profesor de inglés nativo',
|
||||||
|
'Ayudante de almacén',
|
||||||
|
],
|
||||||
|
'venta': [
|
||||||
|
'Sofa chaise longue casi nuevo',
|
||||||
|
'iPhone 14 pro 256gb',
|
||||||
|
'Bicicleta de montaña trek',
|
||||||
|
'Libros de programación pack',
|
||||||
|
'Monitor 27 pulgadas 4k',
|
||||||
|
'Nevera combi no frost',
|
||||||
|
'Coche citroen c3 2019',
|
||||||
|
'Patinete electrico xiaomi',
|
||||||
|
],
|
||||||
|
'servicios': [
|
||||||
|
'Limpieza de hogar por horas',
|
||||||
|
'Reparación de electrodomesticos',
|
||||||
|
'Clases particulares de mates',
|
||||||
|
'Diseño web profesional',
|
||||||
|
'Fontanero económico',
|
||||||
|
'Paseador de perros',
|
||||||
|
'Traducciones inglés-español',
|
||||||
|
'Electricista autorizado',
|
||||||
|
],
|
||||||
|
'alquiler': [
|
||||||
|
'Piso centro 2 habitaciones',
|
||||||
|
'Habitación en piso compartido',
|
||||||
|
'Estudio luminoso con terraza',
|
||||||
|
'Alquiler temporal verano',
|
||||||
|
'Duplex con parking incluido',
|
||||||
|
'Chalet adosado zona norte',
|
||||||
|
'Loft industrial reformado',
|
||||||
|
'Apartamento playa primera línea',
|
||||||
|
],
|
||||||
|
'compra': [
|
||||||
|
'Busco piso en el centro',
|
||||||
|
'Compro coche gasolina manual',
|
||||||
|
'Se busca mobiliario vintage',
|
||||||
|
'Quiero comprar portatil gaming',
|
||||||
|
'Busco terreno para autocaravana',
|
||||||
|
'Compro material de escalada',
|
||||||
|
'Se busca piano de pared',
|
||||||
|
'Quiero comprar patinete eléctrico',
|
||||||
|
],
|
||||||
|
'eventos': [
|
||||||
|
'Concierto indie en la sala',
|
||||||
|
'Fiesta de inauguración local',
|
||||||
|
'Mercadillo artesanal domingo',
|
||||||
|
'Taller de cocina vegana',
|
||||||
|
'Ruta senderista guiada',
|
||||||
|
'Exposición fotográfica gratis',
|
||||||
|
'Open mic comedy night',
|
||||||
|
'Yoga en el parque sábados',
|
||||||
|
],
|
||||||
|
'tecnologia': [
|
||||||
|
'Raspberry pi 4 kit completo',
|
||||||
|
'Teclado mecánico custom',
|
||||||
|
'Router mesh wifi 6',
|
||||||
|
'Gafas realidad virtual',
|
||||||
|
'Disco duro externo 2tb',
|
||||||
|
'Webcam 4k para streaming',
|
||||||
|
'Impresora 3d ender 3',
|
||||||
|
'Switch oled con juegos',
|
||||||
|
],
|
||||||
|
'hogar': [
|
||||||
|
'Lámpara de pie nordica',
|
||||||
|
'Juego de sillas de comedor',
|
||||||
|
'Cortinas blackout a medida',
|
||||||
|
'Aspirador robot con mapeo',
|
||||||
|
'Planta monstera grande',
|
||||||
|
'Mesa de centro elevable',
|
||||||
|
'Colchón viscoelástico 150',
|
||||||
|
'Cubertería acero inox 72 piezas',
|
||||||
|
],
|
||||||
|
'ocio': [
|
||||||
|
'Entradas cine miercoles',
|
||||||
|
'Mazo cartas magic coleccion',
|
||||||
|
'Patines en línea talla 42',
|
||||||
|
'Videojuego elden ring ps5',
|
||||||
|
'Kitesurf completo principiante',
|
||||||
|
'Libro cocina japonesa',
|
||||||
|
'Entrada festival música',
|
||||||
|
'Guitarra acústica yamaha',
|
||||||
|
],
|
||||||
|
'contactos': [
|
||||||
|
'Busco compañero de piso',
|
||||||
|
'Quedada rol en la taberna',
|
||||||
|
'Club de lectura mensual',
|
||||||
|
'Grupo de running mañanas',
|
||||||
|
'Busco duo para ranked lol',
|
||||||
|
'Quedada idiomas intercambio',
|
||||||
|
'Compañero de escalada',
|
||||||
|
'Busco banda para batería',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
_DESCRIPCIONES = {
|
||||||
|
'trabajo': 'Horario flexible. Se valora experiencia previa. Envía CV por correo.',
|
||||||
|
'venta': 'En perfecto estado. Precio negociable. Entrega en mano preferible.',
|
||||||
|
'servicios': 'Presupuesto sin compromiso. Atención rápida y profesional.',
|
||||||
|
'alquiler': 'Sin comisión de agencia. Gastos incluidos o no según caso.',
|
||||||
|
'compra': 'Busco buen estado y precio razonable. Pago al contado.',
|
||||||
|
'eventos': 'Entrada libre hasta completar aforo. Más info por privado.',
|
||||||
|
'tecnologia': 'Con garantía y caja original. Factura disponible.',
|
||||||
|
'hogar': 'Muy cuidado, sin ralladuras. Recogida en domicilio.',
|
||||||
|
'ocio': 'Perfecto para regalo o uso personal. Consulta detalles.',
|
||||||
|
'contactos': 'Gente con buen rollo y ganas de pasarlo bien. Únete!',
|
||||||
|
}
|
||||||
|
|
||||||
|
_CATEGORIAS = list(_TITULOS.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def _random_date(days_back=90):
|
||||||
|
delta = timedelta(days=random.randint(0, days_back), hours=random.randint(0, 23))
|
||||||
|
return (datetime.now() - delta).strftime('%Y-%m-%d %H:%M')
|
||||||
|
|
||||||
|
|
||||||
|
def seed_anuncios(ciudades, por_ciudad=4):
|
||||||
|
"""Genera anuncios aleatorios distribuidos por ciudades."""
|
||||||
|
with _lock:
|
||||||
|
conn = _get_conn()
|
||||||
|
for ciudad in ciudades:
|
||||||
|
for _ in range(por_ciudad):
|
||||||
|
cat = random.choice(_CATEGORIAS)
|
||||||
|
titulo = random.choice(_TITULOS[cat])
|
||||||
|
contenido = _DESCRIPCIONES[cat]
|
||||||
|
contacto = f"{ciudad}@craigslist.local"
|
||||||
|
fecha = _random_date()
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO anuncios (titulo, contenido, categoria, contacto, ciudad, fecha)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
''', (titulo, contenido, cat, contacto, ciudad, fecha))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def migrar_ciudad_existentes(ciudad_default='madrid'):
|
||||||
|
"""Asigna ciudad a anuncios que aún no la tengan definida."""
|
||||||
|
with _lock:
|
||||||
|
conn = _get_conn()
|
||||||
|
conn.execute('''
|
||||||
|
UPDATE anuncios SET ciudad = ? WHERE ciudad IS NULL OR ciudad = ''
|
||||||
|
''', (ciudad_default,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Flask==3.0.0
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
import models
|
||||||
|
|
||||||
|
CIUDADES = [
|
||||||
|
'madrid', 'barcelona', 'valencia', 'sevilla', 'zaragoza',
|
||||||
|
'malaga', 'murcia', 'palma', 'las-palmas', 'bilbao',
|
||||||
|
'alicante', 'cordoba', 'valladolid', 'vigo'
|
||||||
|
]
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print("Inicializando base de datos...")
|
||||||
|
models.init_db()
|
||||||
|
|
||||||
|
print("Migrando ciudad en anuncios existentes...")
|
||||||
|
models.migrar_ciudad_existentes(ciudad_default='madrid')
|
||||||
|
|
||||||
|
conn = sqlite3.connect(os.path.join(os.environ.get('DATA_DIR', '/app/data'), 'anuncios.db'))
|
||||||
|
cur = conn.execute('SELECT COUNT(*) FROM anuncios')
|
||||||
|
count = cur.fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
print("Generando anuncios aleatorios...")
|
||||||
|
models.seed_anuncios(ciudades=CIUDADES, por_ciudad=4)
|
||||||
|
else:
|
||||||
|
print(f"Ya existen {count} anuncios. No se generan seeds nuevos.")
|
||||||
|
|
||||||
|
print("Listo.")
|
||||||
+281
@@ -0,0 +1,281 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
import models
|
||||||
|
|
||||||
|
|
||||||
|
def limpiar():
|
||||||
|
os.system('clear' if os.name != 'nt' else 'cls')
|
||||||
|
|
||||||
|
|
||||||
|
def pausa():
|
||||||
|
try:
|
||||||
|
input("\n [ENTER para continuar]")
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def leer_linea(prompt, por_defecto=''):
|
||||||
|
try:
|
||||||
|
if por_defecto:
|
||||||
|
val = input(f" {prompt} [{por_defecto}]: ").strip()
|
||||||
|
return val if val else por_defecto
|
||||||
|
return input(f" {prompt}: ").strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cabecera(titulo=""):
|
||||||
|
limpiar()
|
||||||
|
print()
|
||||||
|
print("=" * 64)
|
||||||
|
print(" MadCraig :: Madrid, Espana :: Terminal / SSH")
|
||||||
|
if titulo:
|
||||||
|
print(f" {titulo}")
|
||||||
|
print("=" * 64)
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def formatear_anuncio(a, idx=None):
|
||||||
|
prefijo = f" [{idx}] " if idx is not None else " "
|
||||||
|
lineas = []
|
||||||
|
lineas.append(f"{prefijo}[{a['categoria'].upper()}] {a['fecha']} (ID: {a['id']})")
|
||||||
|
lineas.append(f" Titulo: {a['titulo']}")
|
||||||
|
lineas.append(f" Contacto: {a['contacto']}")
|
||||||
|
lineas.append(" " + "-" * 58)
|
||||||
|
for parrafo in textwrap.wrap(a['contenido'], width=58):
|
||||||
|
lineas.append(f" {parrafo}")
|
||||||
|
lineas.append(" " + "=" * 58)
|
||||||
|
return "\n".join(lineas)
|
||||||
|
|
||||||
|
|
||||||
|
def menu_principal():
|
||||||
|
cabecera("Menu Principal")
|
||||||
|
print(" [L] Listar todos los anuncios")
|
||||||
|
print(" [C] Filtrar por categoria")
|
||||||
|
print(" [B] Buscar anuncios")
|
||||||
|
print(" [N] Nuevo anuncio")
|
||||||
|
print(" [M] Modificar anuncio")
|
||||||
|
print(" [E] Eliminar anuncio")
|
||||||
|
print(" [V] Ver anuncio por ID")
|
||||||
|
print(" [S] Salir")
|
||||||
|
print()
|
||||||
|
try:
|
||||||
|
op = input(" Opcion: ").strip().lower()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
op = 's'
|
||||||
|
return op
|
||||||
|
|
||||||
|
|
||||||
|
def pantalla_listar(anuncios=None, titulo="Anuncios"):
|
||||||
|
if anuncios is None:
|
||||||
|
anuncios = models.listar_anuncios()
|
||||||
|
cabecera(titulo)
|
||||||
|
if not anuncios:
|
||||||
|
print(" No hay anuncios.")
|
||||||
|
pausa()
|
||||||
|
return
|
||||||
|
for i, a in enumerate(anuncios, start=1):
|
||||||
|
print(formatear_anuncio(a, idx=i))
|
||||||
|
print()
|
||||||
|
print(f" Total: {len(anuncios)} anuncio(s)")
|
||||||
|
pausa()
|
||||||
|
|
||||||
|
|
||||||
|
def pantalla_filtrar_categoria():
|
||||||
|
cabecera("Filtrar por categoria")
|
||||||
|
categorias = models.listar_categorias()
|
||||||
|
if not categorias:
|
||||||
|
print(" No hay categorias disponibles.")
|
||||||
|
pausa()
|
||||||
|
return
|
||||||
|
print(" Categorias disponibles:")
|
||||||
|
for i, cat in enumerate(categorias, start=1):
|
||||||
|
print(f" [{i}] {cat}")
|
||||||
|
print()
|
||||||
|
cat = leer_linea("Nombre de la categoria")
|
||||||
|
if cat is None:
|
||||||
|
return
|
||||||
|
if not cat:
|
||||||
|
print(" Categoria vacia, mostrando todos.")
|
||||||
|
pausa()
|
||||||
|
pantalla_listar()
|
||||||
|
return
|
||||||
|
anuncios = models.listar_anuncios(categoria=cat)
|
||||||
|
pantalla_listar(anuncios, f"Categoria '{cat}'")
|
||||||
|
|
||||||
|
|
||||||
|
def pantalla_buscar():
|
||||||
|
cabecera("Buscar anuncios")
|
||||||
|
termino = leer_linea("Buscar (titulo/contenido)")
|
||||||
|
if termino is None:
|
||||||
|
return
|
||||||
|
if not termino:
|
||||||
|
print(" Busqueda vacia, mostrando todos.")
|
||||||
|
pausa()
|
||||||
|
pantalla_listar()
|
||||||
|
return
|
||||||
|
anuncios = models.listar_anuncios(busqueda=termino)
|
||||||
|
pantalla_listar(anuncios, f"Resultados para '{termino}'")
|
||||||
|
|
||||||
|
|
||||||
|
def pantalla_nuevo():
|
||||||
|
cabecera("Nuevo anuncio")
|
||||||
|
titulo = leer_linea("Titulo")
|
||||||
|
if titulo is None:
|
||||||
|
return
|
||||||
|
if not titulo:
|
||||||
|
print(" El titulo es obligatorio. Cancelado.")
|
||||||
|
pausa()
|
||||||
|
return
|
||||||
|
contenido = leer_linea("Contenido")
|
||||||
|
if contenido is None:
|
||||||
|
return
|
||||||
|
if not contenido:
|
||||||
|
print(" El contenido es obligatorio. Cancelado.")
|
||||||
|
pausa()
|
||||||
|
return
|
||||||
|
categoria = leer_linea("Categoria", "general")
|
||||||
|
if categoria is None:
|
||||||
|
return
|
||||||
|
contacto = leer_linea("Contacto", "madrid@craigslist.local")
|
||||||
|
if contacto is None:
|
||||||
|
return
|
||||||
|
aid = models.crear_anuncio(titulo, contenido, categoria, contacto)
|
||||||
|
print(f"\n Anuncio creado con ID {aid}.")
|
||||||
|
pausa()
|
||||||
|
|
||||||
|
|
||||||
|
def pantalla_editar():
|
||||||
|
cabecera("Modificar anuncio")
|
||||||
|
sid = leer_linea("ID del anuncio a modificar")
|
||||||
|
if sid is None:
|
||||||
|
return
|
||||||
|
if not sid.isdigit():
|
||||||
|
print(" ID no valido.")
|
||||||
|
pausa()
|
||||||
|
return
|
||||||
|
a = models.obtener_anuncio(int(sid))
|
||||||
|
if not a:
|
||||||
|
print(" Anuncio no encontrado.")
|
||||||
|
pausa()
|
||||||
|
return
|
||||||
|
print()
|
||||||
|
print(formatear_anuncio(a))
|
||||||
|
print()
|
||||||
|
print(" Deja en blanco para mantener el valor actual.")
|
||||||
|
print()
|
||||||
|
titulo = leer_linea("Nuevo titulo", a['titulo'])
|
||||||
|
if titulo is None:
|
||||||
|
return
|
||||||
|
contenido = leer_linea("Nuevo contenido", a['contenido'])
|
||||||
|
if contenido is None:
|
||||||
|
return
|
||||||
|
categoria = leer_linea("Nueva categoria", a['categoria'])
|
||||||
|
if categoria is None:
|
||||||
|
return
|
||||||
|
contacto = leer_linea("Nuevo contacto", a['contacto'])
|
||||||
|
if contacto is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
campos = {}
|
||||||
|
if titulo != a['titulo']:
|
||||||
|
campos['titulo'] = titulo
|
||||||
|
if contenido != a['contenido']:
|
||||||
|
campos['contenido'] = contenido
|
||||||
|
if categoria != a['categoria']:
|
||||||
|
campos['categoria'] = categoria
|
||||||
|
if contacto != a['contacto']:
|
||||||
|
campos['contacto'] = contacto
|
||||||
|
|
||||||
|
if not campos:
|
||||||
|
print(" No se realizaron cambios.")
|
||||||
|
pausa()
|
||||||
|
return
|
||||||
|
|
||||||
|
if models.actualizar_anuncio(int(sid), **campos):
|
||||||
|
print(" Anuncio actualizado correctamente.")
|
||||||
|
else:
|
||||||
|
print(" No se pudo actualizar.")
|
||||||
|
pausa()
|
||||||
|
|
||||||
|
|
||||||
|
def pantalla_eliminar():
|
||||||
|
cabecera("Eliminar anuncio")
|
||||||
|
sid = leer_linea("ID del anuncio a eliminar")
|
||||||
|
if sid is None:
|
||||||
|
return
|
||||||
|
if not sid.isdigit():
|
||||||
|
print(" ID no valido.")
|
||||||
|
pausa()
|
||||||
|
return
|
||||||
|
a = models.obtener_anuncio(int(sid))
|
||||||
|
if not a:
|
||||||
|
print(" Anuncio no encontrado.")
|
||||||
|
pausa()
|
||||||
|
return
|
||||||
|
print()
|
||||||
|
print(formatear_anuncio(a))
|
||||||
|
print()
|
||||||
|
try:
|
||||||
|
conf = input(" Confirmar eliminacion [s/N]: ").strip().lower()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
conf = 'n'
|
||||||
|
if conf == 's':
|
||||||
|
if models.borrar_anuncio(int(sid)):
|
||||||
|
print(" Anuncio eliminado.")
|
||||||
|
else:
|
||||||
|
print(" No se pudo eliminar.")
|
||||||
|
else:
|
||||||
|
print(" Cancelado.")
|
||||||
|
pausa()
|
||||||
|
|
||||||
|
|
||||||
|
def pantalla_ver():
|
||||||
|
cabecera("Ver anuncio")
|
||||||
|
sid = leer_linea("ID del anuncio")
|
||||||
|
if sid is None:
|
||||||
|
return
|
||||||
|
if not sid.isdigit():
|
||||||
|
print(" ID no valido.")
|
||||||
|
pausa()
|
||||||
|
return
|
||||||
|
a = models.obtener_anuncio(int(sid))
|
||||||
|
if not a:
|
||||||
|
print(" Anuncio no encontrado.")
|
||||||
|
pausa()
|
||||||
|
return
|
||||||
|
print()
|
||||||
|
print(formatear_anuncio(a))
|
||||||
|
pausa()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
models.init_db()
|
||||||
|
while True:
|
||||||
|
op = menu_principal()
|
||||||
|
if op == 'l':
|
||||||
|
pantalla_listar()
|
||||||
|
elif op == 'c':
|
||||||
|
pantalla_filtrar_categoria()
|
||||||
|
elif op == 'b':
|
||||||
|
pantalla_buscar()
|
||||||
|
elif op == 'n':
|
||||||
|
pantalla_nuevo()
|
||||||
|
elif op == 'm':
|
||||||
|
pantalla_editar()
|
||||||
|
elif op == 'e':
|
||||||
|
pantalla_eliminar()
|
||||||
|
elif op == 'v':
|
||||||
|
pantalla_ver()
|
||||||
|
elif op == 's':
|
||||||
|
cabecera("Hasta luego!")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print(" Opcion no valida.")
|
||||||
|
pausa()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Arrancar servidor SSH
|
||||||
|
/usr/sbin/sshd
|
||||||
|
|
||||||
|
# Arrancar aplicacion web Flask
|
||||||
|
exec python3 /app/app.py
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{% if modo == 'ciudad' %}madcraig — {{ city }}{% else %}madcraig{% endif %}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-body: #f5f7fa;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--text-main: #1f2937;
|
||||||
|
--text-muted: #6b7280;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--radius: 12px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--shadow: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.03);
|
||||||
|
--shadow-hover: 0 4px 6px rgba(0,0,0,0.05), 0 2px 4px rgba(0,0,0,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "JetBrains Mono", "JetBrainsMono Nerd Font", "Fira Code", monospace;
|
||||||
|
background: var(--bg-body);
|
||||||
|
color: var(--text-main);
|
||||||
|
margin: 0;
|
||||||
|
padding: 40px 24px;
|
||||||
|
line-height: 1.55;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 a {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
margin: 32px 0 12px 0;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* City list */
|
||||||
|
.city-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-card {
|
||||||
|
display: block;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95em;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-card:hover {
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: #3b82f6;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-card span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.85em;
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.anuncio {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 4px solid #9ca3af;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px 18px;
|
||||||
|
margin: 14px 0;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anuncio:hover {
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.82em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta form {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.78em;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #4b5563;
|
||||||
|
border: none;
|
||||||
|
margin-right: 6px;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Colores por categoría */
|
||||||
|
.tag[data-categoria="general"] { background: #dbeafe; color: #1e40af; }
|
||||||
|
.tag[data-categoria="trabajo"] { background: #dcfce7; color: #166534; }
|
||||||
|
.tag[data-categoria="venta"] { background: #ffedd5; color: #9a3412; }
|
||||||
|
.tag[data-categoria="servicios"] { background: #f3e8ff; color: #6b21a8; }
|
||||||
|
.tag[data-categoria="alquiler"] { background: #ccfbf1; color: #0f766e; }
|
||||||
|
.tag[data-categoria="compra"] { background: #fce7f3; color: #9d174d; }
|
||||||
|
.tag[data-categoria="eventos"] { background: #fef9c3; color: #854d0e; }
|
||||||
|
.tag[data-categoria="tecnologia"] { background: #e0e7ff; color: #3730a3; }
|
||||||
|
.tag[data-categoria="hogar"] { background: #d1fae5; color: #065f46; }
|
||||||
|
.tag[data-categoria="ocio"] { background: #fee2e2; color: #991b1b; }
|
||||||
|
.tag[data-categoria="contactos"] { background: #cffafe; color: #155e75; }
|
||||||
|
|
||||||
|
.anuncio[data-categoria="general"] { border-left-color: #3b82f6; }
|
||||||
|
.anuncio[data-categoria="trabajo"] { border-left-color: #22c55e; }
|
||||||
|
.anuncio[data-categoria="venta"] { border-left-color: #f97316; }
|
||||||
|
.anuncio[data-categoria="servicios"] { border-left-color: #a855f7; }
|
||||||
|
.anuncio[data-categoria="alquiler"] { border-left-color: #14b8a6; }
|
||||||
|
.anuncio[data-categoria="compra"] { border-left-color: #ec4899; }
|
||||||
|
.anuncio[data-categoria="eventos"] { border-left-color: #eab308; }
|
||||||
|
.anuncio[data-categoria="tecnologia"] { border-left-color: #6366f1; }
|
||||||
|
.anuncio[data-categoria="hogar"] { border-left-color: #10b981; }
|
||||||
|
.anuncio[data-categoria="ocio"] { border-left-color: #ef4444; }
|
||||||
|
.anuncio[data-categoria="contactos"] { border-left-color: #06b6d4; }
|
||||||
|
|
||||||
|
.anuncio strong {
|
||||||
|
font-size: 1.02em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anuncio p {
|
||||||
|
margin: 6px 0 0 0;
|
||||||
|
color: #374151;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 0.92em;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Botones */
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
background: #111827;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 8px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.85em;
|
||||||
|
transition: background 0.15s ease, transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta button {
|
||||||
|
background: transparent;
|
||||||
|
color: #ef4444;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.78em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta button:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Formularios */
|
||||||
|
form {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
margin-top: 16px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filtros {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filtros > div {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filtros > div:last-child {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4b5563;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
font-family: inherit;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 0.95em;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, textarea:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filtros input, .filtros select {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 28px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.95em;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>{% if modo == 'ciudad' %}<a href="/" style="color:inherit;text-decoration:none;">madcraig</a> <span style="color:var(--text-muted);font-weight:400;">// {{ city }}</span>{% else %}madcraig{% endif %}</h1>
|
||||||
|
|
||||||
|
{% if modo == 'inicio' %}
|
||||||
|
<p class="subtitle">Selecciona una ciudad para ver los anuncios.</p>
|
||||||
|
<div class="city-list">
|
||||||
|
{% for c in ciudades %}
|
||||||
|
<a class="city-card" href="/c/{{ c }}">
|
||||||
|
{{ c|replace('-', ' ') }}
|
||||||
|
<span>/c/{{ c }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<h2>Buscar anuncios</h2>
|
||||||
|
<form method="get" action="/c/{{ city }}" class="filtros">
|
||||||
|
<div>
|
||||||
|
<label for="q">Busqueda</label>
|
||||||
|
<input type="text" id="q" name="q" value="{{ busqueda_actual }}" placeholder="Titulo o contenido...">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="categoria">Categoria</label>
|
||||||
|
<select id="categoria" name="categoria">
|
||||||
|
<option value="">Todas</option>
|
||||||
|
{% for cat in categorias %}
|
||||||
|
<option value="{{ cat }}" {% if categoria_actual == cat %}selected{% endif %}>{{ cat }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit">Filtrar</button>
|
||||||
|
{% if categoria_actual or busqueda_actual %}
|
||||||
|
<a href="/c/{{ city }}">Limpiar</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<h2>Anuncios</h2>
|
||||||
|
{% for a in anuncios %}
|
||||||
|
<div class="anuncio" data-categoria="{{ a.categoria|lower }}" data-id="{{ a.id }}">
|
||||||
|
<div class="meta">
|
||||||
|
<span>
|
||||||
|
<span class="tag" data-categoria="{{ a.categoria|lower }}">{{ a.categoria }}</span>
|
||||||
|
<span>{{ a.fecha }} · {{ a.contacto }}</span>
|
||||||
|
</span>
|
||||||
|
<form class="borrar-form" action="/borrar/{{ a.id }}" method="post" onsubmit="return confirm('Borrar este anuncio?');" style="display:none">
|
||||||
|
<input type="hidden" name="token" class="token-input">
|
||||||
|
<button type="submit">Borrar</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<strong>{{ a.titulo }}</strong>
|
||||||
|
<p>{{ a.contenido }}</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty">No hay anuncios todavia.</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<p class="footer">madcraig — No rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const KEY = 'mc_tokens';
|
||||||
|
function getTokens() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(KEY)) || {}; } catch(e) { return {}; }
|
||||||
|
}
|
||||||
|
function setTokens(obj) {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guardar token de un anuncio recién creado (viene por query string)
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const nuevoId = params.get('nuevo');
|
||||||
|
const nuevoToken = params.get('token');
|
||||||
|
if (nuevoId && nuevoToken) {
|
||||||
|
const t = getTokens();
|
||||||
|
t[nuevoId] = nuevoToken;
|
||||||
|
setTokens(t);
|
||||||
|
// Limpiar query string sin recargar
|
||||||
|
history.replaceState({}, '', location.pathname + location.hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mostrar botón borrar solo para anuncios propios
|
||||||
|
const tokens = getTokens();
|
||||||
|
document.querySelectorAll('.anuncio').forEach(function(card) {
|
||||||
|
const id = card.getAttribute('data-id');
|
||||||
|
const form = card.querySelector('.borrar-form');
|
||||||
|
const input = card.querySelector('.token-input');
|
||||||
|
if (form && tokens[id]) {
|
||||||
|
input.value = tokens[id];
|
||||||
|
form.style.display = 'inline';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user