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