Primera versión

This commit is contained in:
2026-02-19 04:13:21 +01:00
parent 45cf230bbe
commit 1ffca489e4
10 changed files with 413 additions and 0 deletions

22
.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
# Dependencias
node_modules/
.pnp
.pnp.js
# Compilación de Next.js
.next/
out/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS
.DS_Store
Thumbs.db
# Fotos (Opcional)
# Si no quieres subir tus fotos reales al repo de Gitea,
# descomenta la línea de abajo:
# public/imagenes/*.webp

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:20-alpine
# Establecemos el directorio de trabajo dentro del contenedor
WORKDIR /app
# Copiamos los archivos de dependencias
COPY package*.json ./
# Instalamos las dependencias
RUN npm install
# Copiamos el resto del proyecto
COPY . .
# Construimos la aplicación Next.js
RUN npm run build
# Exponemos el puerto
EXPOSE 3000
# Iniciamos el servidor en modo producción
CMD ["npm", "start"]

93
control.sh Normal file
View File

@@ -0,0 +1,93 @@
#!/bin/bash
# Colores para los mensajes
GREEN='\033[0;32m'
BLUE='\033[0;34m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Función de ayuda
show_help() {
echo -e "${BLUE}===================== CONTROL DEL BLOG DE JOSEMI =================${NC}"
echo " "
echo " Uso: ./control.sh [comando] "
echo ""
echo "Comandos disponibles:"
echo -e " ${GREEN}start${NC} -> Levanta todo el entorno (en segundo plano)"
echo -e " ${GREEN}stop${NC} -> Detiene los contenedores (sin borrarlos)"
echo -e " ${GREEN}restart${NC} -> Reinicia los contenedores"
echo -e " ${GREEN}build${NC} -> Reconstruye las imágenes (útil si instalas nuevas librerías)"
echo -e " ${GREEN}logs${NC} -> Ver logs de todo en tiempo real (Ctrl+C para salir)"
echo -e " ${GREEN}status${NC} -> Ver estado de los contenedores"
echo -e " ${GREEN}shell-be${NC} -> Entrar a la terminal del Backend"
echo -e " ${GREEN}shell-fe${NC} -> Entrar a la terminal del Frontend"
echo -e " ${RED}reset${NC} -> ¡PELIGRO! Borra la Base de Datos y la crea de cero (útil si cambias contraseñas)"
echo -e " ${YELLOW}info${NC} -> Muestra las URLs y credenciales de acceso"
echo -e " ${RED}down${NC} -> Deletea todo el container"
echo "=================================================================="
}
# Lógica del script
case "$1" in
start)
echo -e "${GREEN}Arrancando el sistema...${NC}"
sudo docker-compose up -d
echo -e "${BLUE}¡Listo! Usa './control.sh info' para ver los accesos.${NC}"
;;
stop)
echo -e "${YELLOW}Deteniendo contenedores...${NC}"
sudo docker-compose stop
;;
restart)
echo -e "${YELLOW}Reiniciando...${NC}"
sudo docker-compose restart
;;
build)
echo -e "${BLUE}Reconstruyendo imágenes (esto puede tardar)...${NC}"
sudo docker-compose up -d --build
;;
logs)
echo -e "${BLUE}Mostrando logs (Ctrl+C para salir)...${NC}"
sudo docker-compose logs -f
;;
status)
sudo docker-compose ps
;;
shell-be)
echo -e "${GREEN}Entrando al contenedor del Backend...${NC}"
sudo docker exec -it blog_backend sh
;;
shell-fe)
echo -e "${GREEN}Entrando al contenedor del Frontend...${NC}"
sudo docker exec -it blog_frontend sh
;;
reset)
echo -e "${RED}ATENCIÓN: Esto borrará todos los datos de la base de datos.${NC}"
read -p "¿Estás seguro? (s/n): " confirm
if [[ $confirm == [sS] || $confirm == [sS][yY] ]]; then
echo -e "${YELLOW}Borrando todo...${NC}"
sudo docker-compose down -v
docker system prune -f
echo -e "${GREEN}Levantando de nuevo...${NC}"
sudo docker-compose up -d
else
echo "Operación cancelada."
fi
;;
info)
echo -e "${BLUE}=== INFORMACIÓN DE ACCESO ===${NC}"
echo -e "Frontend (Web): ${GREEN}http://localhost:9001${NC}"
echo -e "Backend (API): ${GREEN}http://localhost:9002${NC}"
echo -e "Base de Datos: ${GREEN}localhost:9003${NC}"
echo -e "Usuario DB: ${YELLOW}josemi${NC}"
echo -e "Contraseña DB: ${YELLOW}josemivi${NC}"
;;
down)
sudo docker-compose down -v
sudo docker system prune -f
;;
*)
show_help
;;
esac

12
docker-compose.yml Normal file
View File

@@ -0,0 +1,12 @@
services:
portal-ferroviario:
build: .
container_name: portal_fotos_provincia
ports:
- "3000:3000"
volumes:
# Vinculamos la carpeta de fotos local con la del contenedor
- ./public/imagenes:/app/public/imagenes
environment:
- NODE_ENV=production
restart: unless-stopped

7
next.config.js Normal file
View File

@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
}
module.exports = nextConfig

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "album-fotos-provincia",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.3",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"typescript": "^5"
}
}

150
src/app/Gallery.tsx Normal file
View File

@@ -0,0 +1,150 @@
'use client';
import React, { useState, useMemo } from 'react';
type ImageObj = {
fileName: string;
displayTitle: string;
tags: string[];
};
export default function Gallery({ initialImages }: { initialImages: ImageObj[] }) {
const [searchTerm, setSearchTerm] = useState('');
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const filteredImages = useMemo(() => {
return initialImages.filter(img => {
const search = searchTerm.toLowerCase();
return img.displayTitle.toLowerCase().includes(search) ||
img.tags.some(t => t.includes(search));
});
}, [initialImages, searchTerm]);
return (
<div style={{ backgroundColor: '#f8f9fa', minHeight: '100vh', paddingBottom: '50px' }}>
{/* Header Estilizado */}
<header style={{
padding: '60px 20px',
textAlign: 'center',
background: 'linear-gradient(135deg, #1a2a6c, #b21f1f, #fdbb2d)',
color: 'white',
marginBottom: '40px'
}}>
<h1 style={{ fontSize: '3rem', margin: '0', fontWeight: '800', letterSpacing: '-1px' }}>Portal Ferroviario</h1>
<p style={{ opacity: 0.9, fontSize: '1.1rem', marginTop: '10px' }}>Explora el patrimonio ferroviario de la provincia</p>
</header>
<div style={{ maxWidth: '1400px', margin: '0 auto', padding: '0 20px' }}>
{/* Buscador */}
<div style={{ position: 'relative', maxWidth: '700px', margin: '-70px auto 50px auto' }}>
<input
type="text"
placeholder="Escribe para buscar trenes, estaciones o lugares..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
width: '100%',
padding: '20px 30px',
fontSize: '1.2rem',
borderRadius: '50px',
border: 'none',
boxShadow: '0 10px 25px rgba(0,0,0,0.1)',
outline: 'none',
boxSizing: 'border-box'
}}
/>
</div>
{/* Grid de Fotos */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))',
gap: '30px'
}}>
{filteredImages.map(img => (
<div
key={img.fileName}
onClick={() => setSelectedImage(img.fileName)}
style={{
backgroundColor: 'white',
borderRadius: '16px',
overflow: 'hidden',
boxShadow: '0 4px 6px rgba(0,0,0,0.02)',
cursor: 'pointer',
transition: 'transform 0.3s ease',
display: 'flex',
flexDirection: 'column'
}}
onMouseEnter={(e) => e.currentTarget.style.transform = 'translateY(-5px)'}
onMouseLeave={(e) => e.currentTarget.style.transform = 'translateY(0)'}
>
<div style={{ width: '100%', height: '250px', overflow: 'hidden' }}>
<img
src={`/imagenes/${img.fileName}`}
alt={img.displayTitle}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
<div style={{ padding: '20px' }}>
<h3 style={{
margin: '0 0 12px 0',
fontSize: '1.25rem',
color: '#1e293b',
textTransform: 'capitalize'
}}>
{img.displayTitle}
</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{img.tags.map((tag, idx) => (
<span key={idx} style={{
backgroundColor: '#f1f5f9',
color: '#64748b',
padding: '4px 12px',
borderRadius: '20px',
fontSize: '0.8rem',
fontWeight: '500'
}}>
{tag}
</span>
))}
</div>
</div>
</div>
))}
</div>
{/* Mensaje vacío */}
{filteredImages.length === 0 && (
<div style={{ textAlign: 'center', padding: '100px', color: '#94a3b8' }}>
<p style={{ fontSize: '1.5rem' }}>No hay fotos que coincidan con tu búsqueda.</p>
</div>
)}
</div>
{/* MODAL - Visor de imagen grande */}
{selectedImage && (
<div
onClick={() => setSelectedImage(null)}
style={{
position: 'fixed',
top: 0, left: 0, width: '100%', height: '100%',
backgroundColor: 'rgba(0,0,0,0.9)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000, cursor: 'zoom-out', padding: '20px'
}}
>
<img
src={`/imagenes/${selectedImage}`}
style={{ maxHeight: '90%', maxWidth: '90%', borderRadius: '8px', boxShadow: '0 0 30px rgba(0,0,0,0.5)' }}
alt="Grande"
/>
<button style={{
position: 'absolute', top: '20px', right: '30px',
background: 'none', border: 'none', color: 'white',
fontSize: '3rem', cursor: 'pointer'
}}>&times;</button>
</div>
)}
</div>
);
}

18
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
export const metadata = {
title: 'Portal Ferroviario',
description: 'Álbum de fotos ferroviarias de la provincia',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="es">
<body style={{ margin: 0, padding: 0, fontFamily: 'system-ui, -apple-system, sans-serif', backgroundColor: '#f4f4f9', color: '#333' }}>
{children}
</body>
</html>
)
}

40
src/app/page.tsx Normal file
View File

@@ -0,0 +1,40 @@
import fs from 'fs';
import path from 'path';
import Gallery from './Gallery';
export default async function Page() {
const imagesDir = path.join(process.cwd(), 'public', 'imagenes');
let images: { fileName: string, displayTitle: string, tags: string[] }[] = [];
try {
if (fs.existsSync(imagesDir)) {
const fileNames = fs.readdirSync(imagesDir);
images = fileNames
.filter(file => file.toLowerCase().endsWith('.webp'))
.map(fileName => {
// 1. Quitar la extensión .webp
const nameWithoutExt = fileName.replace(/\.webp$/i, '');
// 2. Título: Reemplazar guiones por espacios y poner bonita la primera letra
const displayTitle = nameWithoutExt.replace(/[-_]+/g, ' ');
// 3. Tags: Separar por cualquier símbolo y tratar cada palabra como un tag individual
const tags = nameWithoutExt
.split(/[-_]+/)
.filter(word => word.length > 2) // Filtramos palabras muy cortas si quieres
.map(word => word.toLowerCase());
return { fileName, displayTitle, tags };
});
}
} catch (error) {
console.error("Error leyendo imágenes:", error);
}
return (
<main style={{ minHeight: '100vh', padding: '0' }}>
<Gallery initialImages={images} />
</main>
);
}

27
tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}