diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5681fc3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +# Ignorar dependencias locales +# node_modules se utilizará para mandarse desde github actions + +# Ignorar config de Git +.git +.gitignore + +# Archivos temporales o de sistema +.DS_Store +*.log + +# Archivos de desarrollo +.vscode +.idea + +# Otros archivos que no deben ir al contenedor +.env +*.env.local +tests +coverage +README.md diff --git a/.github/workflows/develop-CD.yml b/.github/workflows/develop-CD.yml new file mode 100644 index 0000000..70ce8cc --- /dev/null +++ b/.github/workflows/develop-CD.yml @@ -0,0 +1,50 @@ +name: Deploy Frontend-Develop to GCP VM + +on: + pull_request: + types: [closed] + branches: [develop] + +jobs: + deploy: + if: github.event.pull_request.merged == true + name: 🚀🛠 Deploy DEVELOP Next.js to GCP + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: develop + + # 👉 Build la imagen Docker + - name: Build Docker image + run: docker build -f Dockerfile.prod -t ${{ secrets.DOCKER_USERNAME }}/frontend-dev:stage-${{ github.sha }} . + + # 👉 Log in to DockerHub + - name: DockerHub Login + run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin + + # 👉 Push la imagen + - name: Push Docker image to DockerHub + run: docker push ${{ secrets.DOCKER_USERNAME }}/frontend-dev:stage-${{ github.sha }} + + # 👉 Add VM to known_hosts + - name: Add VM to known_hosts + run: | + mkdir -p ~/.ssh + ssh-keyscan ${{ secrets.GCP_VM_IP }} >> ~/.ssh/known_hosts + + # 👉 Desplegar en la VM + - name: Deploy on GCP VM + uses: appleboy/ssh-action@v0.1.4 + with: + host: ${{ secrets.GCP_VM_IP }} + username: ${{ secrets.GCP_VM_USER }} + key: ${{ secrets.GCP_SSH_KEY }} + script: | + cd /home/proyectosdanils/ + docker compose down frontend-dev + docker pull ${{ secrets.DOCKER_USERNAME }}/frontend-dev:stage-${{ github.sha }} + export DOCKER_IMAGE_FRONTEND_DEV=${{ secrets.DOCKER_USERNAME }}/frontend-dev:stage-${{ github.sha }} + docker compose up -d frontend-dev diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index fa52047..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Deploy Frontend to GCP VM - -on: - push: - branches: - - master - -jobs: - deploy: - name: 🚀 Deploy Next.js to GCP - runs-on: ubuntu-latest - - steps: - # 1. Checkout del código - - name: Checkout repository - uses: actions/checkout@v4 - - # 2. Configurar Node.js - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - # 3. Instalar dependencias y hacer build - - name: Install and Build - run: | - npm install - npm run format - npm run build - - # 4. Enviar archivos a la VM via SSH (usando rsync) - - name: Deploy to GCP VM - uses: appleboy/scp-action@master - with: - host: ${{ secrets.GCP_VM_IP }} - username: ${{ secrets.GCP_VM_USER }} - key: ${{ secrets.GCP_SSH_KEY }} - source: "." - target: "/home/proyectosdanils/frontend" - port: 22 - timeout: 30s - rm: true - - # 5. Reiniciar PM2 en la VM (opcional, si usas PM2) - - name: Restart PM2 - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.GCP_VM_IP }} - username: ${{ secrets.GCP_VM_USER }} - key: ${{ secrets.GCP_SSH_KEY }} - script: | - cd /home/proyectosdanils/frontend - pm2 reload frontend --update-env # Recarga sin crear nuevas instancias diff --git a/.github/workflows/master-CD.yml b/.github/workflows/master-CD.yml new file mode 100644 index 0000000..bd001d3 --- /dev/null +++ b/.github/workflows/master-CD.yml @@ -0,0 +1,51 @@ +name: Deploy Frontend to GCP VM + +on: + push: + branches: + - master + pull_request: + types: [closed] # (mergeado) + branches: [master] + +jobs: + deploy: + if: github.event.pull_request.merged == true # Solo si se hizo merge + name: 🚀 PRODUCTION Deploy Next.js to GCP + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # 👉 Build la imagen Docker + - name: Build Docker image + run: docker build -f Dockerfile.prod -t ${{ secrets.DOCKER_USERNAME }}/frontend-prod:${{ github.sha }} . + + # 👉 Log in to DockerHub + - name: DockerHub Login + run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin + + # 👉 Push la imagen + - name: Push Docker image to DockerHub + run: docker push ${{ secrets.DOCKER_USERNAME }}/frontend-prod:${{ github.sha }} + + # 👉 Add VM to known_hosts + - name: Add VM to known_hosts + run: | + mkdir -p ~/.ssh + ssh-keyscan ${{ secrets.GCP_VM_IP }} >> ~/.ssh/known_hosts + + # 👉 Desplegar en la VM + - name: Deploy on GCP VM + uses: appleboy/ssh-action@v0.1.4 + with: + host: ${{ secrets.GCP_VM_IP }} + username: ${{ secrets.GCP_VM_USER }} + key: ${{ secrets.GCP_SSH_KEY }} + script: | + cd /home/proyectosdanils/ + docker compose down frontend-prod + docker pull ${{ secrets.DOCKER_USERNAME }}/frontend-prod:${{ github.sha }} + export DOCKER_IMAGE_FRONTEND_PROD=${{ secrets.DOCKER_USERNAME }}/frontend-prod:${{ github.sha }} + docker compose up -d frontend-prod \ No newline at end of file diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..b44a2df --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,16 @@ +FROM node:22-alpine + +RUN mkdir -p /home/frontend-prod + +WORKDIR /home/frontend-prod + +COPY package.json package-lock.json ./ +RUN npm install + +COPY . . + +RUN npm run build + +EXPOSE 3000 + +CMD ["npm", "run", "start"] diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..1fb5c6c --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,16 @@ +import Header from '../_components/Header'; +import Footer from '../_components/Footer'; + +export default function NoLoggedLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+
+
{children}
+
+ ); +} diff --git a/app/(nologged)/login-gest/page.tsx b/app/(auth)/login-gest/page.tsx similarity index 89% rename from app/(nologged)/login-gest/page.tsx rename to app/(auth)/login-gest/page.tsx index 9bb89cc..e5b3a5a 100644 --- a/app/(nologged)/login-gest/page.tsx +++ b/app/(auth)/login-gest/page.tsx @@ -2,8 +2,8 @@ import Link from 'next/link'; export default function LoginGest() { return ( -
-
+
+

Entra como Invitado y Únete a la Conversación @@ -36,7 +36,7 @@ export default function LoginGest() {

-
-
+ + ); } diff --git a/app/(auth)/login/_components/DesktopForm.tsx b/app/(auth)/login/_components/DesktopForm.tsx new file mode 100644 index 0000000..ba34169 --- /dev/null +++ b/app/(auth)/login/_components/DesktopForm.tsx @@ -0,0 +1,98 @@ +export default function DesktopForm({ setMode }: { setMode: (mode: string) => void }) { + return ( +
+
+
+

+ Mantente Comunicado en Cualquier Lugar +

+

Inicia sesión para acceder a tus salas

+
+
+
+ + +
+
+ + +
+ +
+
+

¿Aún no tienes una cuenta?

+ +
+
+ +
+
+

Regístrate y Conecta

+

Podrás guardar tus conversaciones y salas

+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+

¿Ya tienes una cuenta?

+ +
+
+
+ ); +} diff --git a/app/(auth)/login/_components/MobileForm.tsx b/app/(auth)/login/_components/MobileForm.tsx new file mode 100644 index 0000000..47dab7b --- /dev/null +++ b/app/(auth)/login/_components/MobileForm.tsx @@ -0,0 +1,106 @@ +interface Props { + mobileModeLogin: boolean; + setMobileModeLogin: (value: boolean) => void; +} +export default function MobileForm({ mobileModeLogin, setMobileModeLogin }: Props) { + return ( +
+
+
+

+ Mantente Comunicado en Cualquier Lugar +

+

Inicia sesión para acceder a tus salas

+
+
+
+ + +
+
+ + +
+ +
+
+

¿Aún no tienes una cuenta?

+ +
+
+ +
+
+

Regístrate y Conecta

+

Podrás guardar tus conversaciones y salas

+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+

¿Ya tienes una cuenta?

+ +
+
+
+ ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..072b80e --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,36 @@ +'use client'; +import Image from 'next/image'; +import { useState } from 'react'; +import DesktopForm from './_components/DesktopForm'; +import MobileForm from './_components/MobileForm'; + +export default function Login() { + const [mode, setMode] = useState('login'); + const [mobileModeLogin, setMobileModeLogin] = useState(true); + + return ( +
+ multicolor +
+ {/* Desktop */} + + + {/* Mobile */} + +
+ multicolor +
+ ); +} diff --git a/app/(nologged)/page.tsx b/app/(auth)/page.tsx similarity index 96% rename from app/(nologged)/page.tsx rename to app/(auth)/page.tsx index b3807b4..0c74bf1 100644 --- a/app/(nologged)/page.tsx +++ b/app/(auth)/page.tsx @@ -1,8 +1,8 @@ import Image from 'next/image'; - +import homeDecoration from '@/public/home-decoration.svg'; export default function Home() { return ( -
+
@@ -20,11 +20,13 @@ export default function Home() {
Chat Simulation
diff --git a/app/(nologged)/login/page.tsx b/app/(nologged)/login/page.tsx deleted file mode 100644 index af873f5..0000000 --- a/app/(nologged)/login/page.tsx +++ /dev/null @@ -1,116 +0,0 @@ -'use client'; -import Image from 'next/image'; -import { useState } from 'react'; - -export default function Login() { - const [mode, setMode] = useState('login'); - - return ( -
- multicolor -
-
-
-
-

Mantente Comunicado en Cualquier Lugar

-

Inicia sesión para acceder a tus salas

-
-
-
- - -
-
- - -
- -
-
-

¿Aún no tienes una cuenta?

- -
-
- -
-
-

Regístrate y Conecta

-

Podrás guardar tus conversaciones y salas

-
-
-
- - -
-
- - -
-
- - -
- -
-
-

¿Ya tienes una cuenta?

- -
-
-
-
- multicolor -
- ); -} diff --git a/app/(protected)/dashboard/_components/RoomList.tsx b/app/(protected)/dashboard/_components/RoomList.tsx new file mode 100644 index 0000000..43e114d --- /dev/null +++ b/app/(protected)/dashboard/_components/RoomList.tsx @@ -0,0 +1,133 @@ +import { CancelIcon, DoorBellIcon } from '@/app/_ui/icons'; +import { Tooltip } from '@mui/material'; +import Link from 'next/link'; + +const salas = [ + { + id: 1, + name: 'Sala 1', + created_by: 'Usuario 1', + created_at: '2023-10-01', + }, + { + id: 2, + name: 'Sala 2', + created_by: 'Usuario 2', + created_at: '2023-10-02', + }, + { + id: 3, + name: 'Sala 3', + created_by: 'Usuario 3', + created_at: '2023-10-03', + }, + { + id: 4, + name: 'Sala 4', + created_by: 'Usuario 4', + created_at: '2023-10-04', + }, + { + id: 5, + name: 'Sala 5', + created_by: 'Usuario 5', + created_at: '2023-10-05', + }, + { + id: 6, + name: 'Sala 6', + created_by: 'Usuario 6', + created_at: '2023-10-06', + }, + { + id: 7, + name: 'Sala 7', + created_by: 'Usuario 7', + created_at: '2023-10-07', + }, + { + id: 8, + name: 'Sala 8', + created_by: 'Usuario 8', + created_at: '2023-10-08', + }, + { + id: 9, + name: 'Sala 9', + created_by: 'Usuario 9', + created_at: '2023-10-09', + }, + { + id: 10, + name: 'Sala 10', + created_by: 'Usuario 10', + created_at: '2023-10-10', + }, + { + id: 11, + name: 'Sala 11', + created_by: 'Usuario 11', + created_at: '2023-10-11', + }, + { + id: 12, + name: 'Sala 12', + created_by: 'Usuario 12', + created_at: '2023-10-12', + }, + { + id: 13, + name: 'Sala 13', + created_by: 'Usuario 13', + created_at: '2023-10-13', + }, + { + id: 14, + name: 'Sala 14', + created_by: 'Usuario 14', + created_at: '2023-10-14', + }, + { + id: 15, + name: 'Sala 15', + created_by: 'Usuario 15', + created_at: '2023-10-14', + }, +]; + +export default function RoomList() { + return ( +
+ {salas.map(sala => ( +
+ +

{sala.name}

+
+

+ Creado por: {sala.created_by} +

+

+ Fecha: {sala.created_at} +

+
+ +
+ + + + + + +
+
+ ))} +
+ ); +} diff --git a/app/(protected)/dashboard/page.tsx b/app/(protected)/dashboard/page.tsx new file mode 100644 index 0000000..63049b6 --- /dev/null +++ b/app/(protected)/dashboard/page.tsx @@ -0,0 +1,14 @@ +import RoomList from './_components/RoomList'; + +export default function Dashboard() { + return ( +
+
+

Mis Chats

+
+
+ +
+
+ ); +} diff --git a/app/(protected)/dashboard/room/[id]/loading.tsx b/app/(protected)/dashboard/room/[id]/loading.tsx new file mode 100644 index 0000000..b8fd4ed --- /dev/null +++ b/app/(protected)/dashboard/room/[id]/loading.tsx @@ -0,0 +1,7 @@ +export default function RoomLoading() { + return ( +
+
+
+ ); +} diff --git a/app/(protected)/dashboard/room/[id]/page.tsx b/app/(protected)/dashboard/room/[id]/page.tsx new file mode 100644 index 0000000..40623e6 --- /dev/null +++ b/app/(protected)/dashboard/room/[id]/page.tsx @@ -0,0 +1,111 @@ +import MessagesList from '../_components/MessagesList'; +import { IMessage } from '@/app/_interfaces/IMessage'; + +interface RouterProps { + params: Promise<{ + id: string; + }>; +} +export default async function RoomPage({ params }: RouterProps) { + const { id } = await params; + const messages = await fetchMessages(); + return ( +
+
+

Nombre de la sala {id}

+

Pública

+
+ +
+ ); +} + +async function fetchMessages(): Promise { + // Simula una llamada a una API externa + return new Promise(resolve => { + setTimeout(() => { + resolve([ + { + id: '1', + send_by: 'Danils', + send_at: new Date().toISOString(), + content: 'Hola, bienvenido a la sala!', + }, + { + id: '2', + send_by: 'Valtimore', + send_at: new Date().toISOString(), + content: 'Hola! ¿Cómo están?', + }, + { + id: '3', + send_by: 'Zers', + send_at: new Date().toISOString(), + content: '¡Todo bien! ¿Y tú?', + }, + { + id: '4', + send_by: 'Liferip', + send_at: new Date().toISOString(), + content: '¡Genial! ¿Qué tal el clima?', + }, + { + id: '5', + send_by: 'LIFERIP', + send_at: new Date().toISOString(), + content: 'Sigue lloviendo, pero no importa.', + }, + { + id: '6', + send_by: 'user1', + send_at: new Date().toISOString(), + content: '¡Qué suerte! Aquí hace mucho calor.', + }, + { + id: '7', + send_by: 'user2', + send_at: new Date().toISOString(), + content: '¿Alguien sabe qué hora es?', + }, + { + id: '8', + send_by: 'user1', + send_at: new Date().toISOString(), + content: 'Son las 3 PM.', + }, + { + id: '9', + send_by: 'user2', + send_at: new Date().toISOString(), + content: 'Gracias!', + }, + { + id: '10', + send_by: 'user1', + send_at: new Date().toISOString(), + content: 'De nada!', + }, + { + id: '11', + send_by: 'user2', + send_at: new Date().toISOString(), + content: '¿Alguien quiere jugar un juego?', + }, + { + id: '12', + send_by: 'user1', + send_at: new Date().toISOString(), + content: '¡Sí! ¿Qué juego?', + }, + { + id: '13', + send_by: 'user2', + send_at: new Date().toISOString(), + content: + 'Podemos jugar a adivinar el tamaño de mi aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aa a a a aaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + ]); + }, 2000); + }); +} diff --git a/app/(protected)/dashboard/room/_components/Message.tsx b/app/(protected)/dashboard/room/_components/Message.tsx new file mode 100644 index 0000000..664211d --- /dev/null +++ b/app/(protected)/dashboard/room/_components/Message.tsx @@ -0,0 +1,45 @@ +import { IMessage } from '@/app/_interfaces/IMessage'; + +interface Props { + message: IMessage; +} + +const colorList = [ + 'rgba(255, 99, 132, 0.1)', // Rojo opaco + 'rgba(54, 162, 235, 0.1)', // Azul opaco + 'rgba(255, 159, 64, 0.1)', // Naranja opaco + 'rgba(75, 192, 192, 0.1)', // Verde opaco + 'rgba(153, 102, 255, 0.1)', // Púrpura opaco + 'rgba(255, 205, 86, 0.1)', // Amarillo opaco + 'rgba(255, 99, 71, 0.1)', // Tomate opaco + 'rgba(173, 216, 230, 0.1)', // Azul claro opaco + 'rgba(240, 128, 128, 0.1)', // Rosa claro opaco + 'rgba(144, 238, 144, 0.1)', // Verde claro opaco +]; + +function stringToRGBa(str: string) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash += str.charCodeAt(i); + } + const index = hash % colorList.length; + return colorList[index]; +} + +export default function Message({ message }: Props) { + const color = stringToRGBa(message.send_by); + return ( +
+

{message.send_by}

+

{message.content}

+

+ {message.send_at !== 'pending' && + message.send_at.toString().split('T')[1].split('.')[0].split(':').slice(0, 2).join(':')} + {message.send_at === 'pending' && '⏱'} +

+
+ ); +} diff --git a/app/(protected)/dashboard/room/_components/MessageInput.tsx b/app/(protected)/dashboard/room/_components/MessageInput.tsx new file mode 100644 index 0000000..287f9ef --- /dev/null +++ b/app/(protected)/dashboard/room/_components/MessageInput.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { SendIcon } from '@/app/_ui/icons'; +import { useEffect, useRef, useState } from 'react'; + +interface Props { + onSend: (msg: string) => void; + bottomRef: React.RefObject; +} + +export default function MessageInput({ onSend, bottomRef }: Props) { + const [message, setMessage] = useState(''); + const textareaRef = useRef(null); + + useEffect(() => { + const textarea = textareaRef.current; + if (textarea) { + // Resetear altura para cálculo + textarea.style.height = 'auto'; + // Obtener altura máxima del CSS (max-h-48 = 192px) + const maxHeight = parseInt(getComputedStyle(textarea).maxHeight, 10); + // Aplicar la menor altura entre contenido y máximo + textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight) - 10}px`; + } + }, [message]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!message.trim()) return; + onSend(message.trim()); + setTimeout(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, 150); + setMessage(''); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (message.trim()) { + onSend(message.trim()); + setMessage(''); + } + } + }; + + return ( +
+
+