Primera versión
This commit is contained in:
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal 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
22
Dockerfile
Normal 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
93
control.sh
Normal 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
12
docker-compose.yml
Normal 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
7
next.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
swcMinify: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
22
package.json
Normal file
22
package.json
Normal 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
150
src/app/Gallery.tsx
Normal 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'
|
||||||
|
}}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/app/layout.tsx
Normal file
18
src/app/layout.tsx
Normal 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
40
src/app/page.tsx
Normal 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
27
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user