Browse Source

geo pls stop abusing me because i forgot about the favicon

Matthew Trejo 4 months ago
commit
eed1a7fb93

+ 206 - 0
.gitignore

@@ -0,0 +1,206 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[codz]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py.cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+#   For a library or package, you might want to ignore these files since the code is
+#   intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# UV
+#   Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
+#   This is especially recommended for binary packages to ensure reproducibility, and is more
+#   commonly ignored for libraries.
+#uv.lock
+
+# poetry
+#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+#   This is especially recommended for binary packages to ensure reproducibility, and is more
+#   commonly ignored for libraries.
+#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+#poetry.toml
+
+# pdm
+#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#   pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
+#   https://pdm-project.org/en/latest/usage/project/#working-with-version-control
+#pdm.lock
+#pdm.toml
+.pdm-python
+.pdm-build/
+
+# pixi
+#   Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
+#pixi.lock
+#   Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
+#   in the .venv directory. It is recommended not to include this directory in version control.
+.pixi
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.envrc
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+#  and can be added to the global gitignore or merged into this file.  For a more nuclear
+#  option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+
+# Abstra
+# Abstra is an AI-powered process automation framework.
+# Ignore directories containing user credentials, local state, and settings.
+# Learn more at https://abstra.io/docs
+.abstra/
+
+# Visual Studio Code
+#  Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore 
+#  that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
+#  and can be added to the global gitignore or merged into this file. However, if you prefer, 
+#  you could uncomment the following to ignore the entire vscode folder
+# .vscode/
+
+# Ruff stuff:
+.ruff_cache/
+
+# PyPI configuration file
+.pypirc
+
+# Marimo
+marimo/_static/
+marimo/_lsp/
+__marimo__/
+
+# Streamlit
+.streamlit/secrets.toml
+
+# Database
+url_shortener.db

+ 252 - 0
app.py

@@ -0,0 +1,252 @@
+from flask import Flask, render_template, request, redirect, url_for, jsonify, session
+import sqlite3
+import string
+import random
+import os
+import sys
+import argparse
+from datetime import datetime, timedelta
+import uuid
+
+app = Flask(__name__)
+app.secret_key = 'tu_clave_secreta_aqui'  # Cambiar en producción
+
+# Configuración de la base de datos
+DATABASE = 'url_shortener.db'
+
+def init_db():
+    """Inicializar la base de datos"""
+    conn = sqlite3.connect(DATABASE)
+    cursor = conn.cursor()
+    
+    # Tabla para enlaces acortados
+    cursor.execute('''
+        CREATE TABLE IF NOT EXISTS urls (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            original_url TEXT NOT NULL,
+            short_code TEXT UNIQUE NOT NULL,
+            user_session TEXT,
+            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+            expires_at TIMESTAMP,
+            clicks INTEGER DEFAULT 0
+        )
+    ''')
+    
+    # Agregar columna expires_at si no existe (para bases de datos existentes)
+    try:
+        cursor.execute('ALTER TABLE urls ADD COLUMN expires_at TIMESTAMP')
+        conn.commit()
+    except sqlite3.OperationalError:
+        # La columna ya existe
+        pass
+    
+    conn.commit()
+    conn.close()
+
+def generate_short_code(length=6):
+    """Generar código corto aleatorio"""
+    characters = string.ascii_letters + string.digits
+    return ''.join(random.choice(characters) for _ in range(length))
+
+def get_user_session():
+    """Obtener o crear sesión de usuario anónimo"""
+    if 'user_id' not in session:
+        session['user_id'] = str(uuid.uuid4())
+    return session['user_id']
+
+@app.route('/')
+def index():
+    """Página principal"""
+    return render_template('index.html')
+
+@app.route('/my-links')
+def my_links():
+    """Página de enlaces del usuario"""
+    user_session = get_user_session()
+    
+    # Obtener enlaces del usuario
+    conn = sqlite3.connect(DATABASE)
+    cursor = conn.cursor()
+    cursor.execute('''
+        SELECT original_url, short_code, created_at, clicks, expires_at 
+        FROM urls 
+        WHERE user_session = ? 
+        ORDER BY created_at DESC
+    ''', (user_session,))
+    
+    user_urls = cursor.fetchall()
+    conn.close()
+    
+    return render_template('my_links.html', user_urls=user_urls)
+
+def calculate_expiration(duration):
+    """Calcular fecha de expiración basada en duración"""
+    if duration == 'never':
+        return None
+    elif duration == '1hour':
+        return datetime.now() + timedelta(hours=1)
+    elif duration == '1day':
+        return datetime.now() + timedelta(days=1)
+    elif duration == '1week':
+        return datetime.now() + timedelta(weeks=1)
+    elif duration == '1month':
+        return datetime.now() + timedelta(days=30)
+    elif duration == '1year':
+        return datetime.now() + timedelta(days=365)
+    else:
+        return None
+
+def is_url_expired(expires_at):
+    """Verificar si un URL ha expirado"""
+    if expires_at is None:
+        return False
+    return datetime.now() > datetime.fromisoformat(expires_at)
+
+@app.route('/shorten', methods=['POST'])
+def shorten_url():
+    """Acortar URL"""
+    # Obtener datos tanto de formulario como de JSON
+    if request.is_json:
+        original_url = request.json.get('url')
+        duration = request.json.get('duration', 'never')
+    else:
+        original_url = request.form.get('url')
+        duration = request.form.get('duration', 'never')
+    
+    if not original_url:
+        return jsonify({'error': 'URL es requerida'}), 400
+    
+    # Agregar http:// si no tiene protocolo
+    if not original_url.startswith(('http://', 'https://')):
+        original_url = 'http://' + original_url
+    
+    user_session = get_user_session()
+    expires_at = calculate_expiration(duration)
+    
+    # Generar código corto único
+    while True:
+        short_code = generate_short_code()
+        conn = sqlite3.connect(DATABASE)
+        cursor = conn.cursor()
+        cursor.execute('SELECT id FROM urls WHERE short_code = ?', (short_code,))
+        if not cursor.fetchone():
+            break
+        conn.close()
+    
+    # Guardar en base de datos
+    cursor.execute('''
+        INSERT INTO urls (original_url, short_code, user_session, expires_at)
+        VALUES (?, ?, ?, ?)
+    ''', (original_url, short_code, user_session, expires_at))
+    
+    conn.commit()
+    conn.close()
+    
+    short_url = f"https://please.checkthis.space/s/{short_code}"
+    
+    return jsonify({
+        'success': True,
+        'short_url': short_url,
+        'short_code': short_code,
+        'original_url': original_url,
+        'expires_at': expires_at.isoformat() if expires_at else None
+    })
+
+@app.route('/s/<short_code>')
+def redirect_url(short_code):
+    """Redirigir a URL original"""
+    conn = sqlite3.connect(DATABASE)
+    cursor = conn.cursor()
+    
+    # Buscar URL original y verificar expiración
+    cursor.execute('''
+        SELECT original_url, expires_at FROM urls WHERE short_code = ?
+    ''', (short_code,))
+    
+    result = cursor.fetchone()
+    
+    if result:
+        original_url, expires_at = result
+        
+        # Verificar si el enlace ha expirado
+        if expires_at and is_url_expired(expires_at):
+            conn.close()
+            return render_template('404.html', error_message='Este enlace ha expirado'), 404
+        
+        # Incrementar contador de clicks
+        cursor.execute('''
+            UPDATE urls SET clicks = clicks + 1 WHERE short_code = ?
+        ''', (short_code,))
+        
+        conn.commit()
+        conn.close()
+        
+        return redirect(original_url)
+    else:
+        conn.close()
+        return render_template('404.html'), 404
+
+@app.route('/delete/<short_code>', methods=['POST'])
+def delete_url(short_code):
+    """Eliminar URL acortada"""
+    user_session = get_user_session()
+    
+    conn = sqlite3.connect(DATABASE)
+    cursor = conn.cursor()
+    
+    # Verificar que el enlace pertenece al usuario
+    cursor.execute('''
+        DELETE FROM urls 
+        WHERE short_code = ? AND user_session = ?
+    ''', (short_code, user_session))
+    
+    conn.commit()
+    affected_rows = cursor.rowcount
+    conn.close()
+    
+    if affected_rows > 0:
+        return jsonify({'success': True})
+    else:
+        return jsonify({'error': 'Enlace no encontrado o no autorizado'}), 404
+
+@app.route('/stats')
+def stats():
+    """Estadísticas generales"""
+    conn = sqlite3.connect(DATABASE)
+    cursor = conn.cursor()
+    
+    # Estadísticas generales
+    cursor.execute('SELECT COUNT(*) FROM urls')
+    total_urls = cursor.fetchone()[0]
+    
+    cursor.execute('SELECT SUM(clicks) FROM urls')
+    total_clicks = cursor.fetchone()[0] or 0
+    
+    # URLs más populares
+    cursor.execute('''
+        SELECT original_url, short_code, clicks, created_at 
+        FROM urls 
+        ORDER BY clicks DESC 
+        LIMIT 10
+    ''')
+    popular_urls = cursor.fetchall()
+    
+    conn.close()
+    
+    return render_template('stats.html', 
+                         total_urls=total_urls, 
+                         total_clicks=total_clicks,
+                         popular_urls=popular_urls)
+
+if __name__ == '__main__':
+    init_db()
+    
+    # Configurar argumentos de línea de comandos
+    parser = argparse.ArgumentParser(description='Acortador de URLs')
+    parser.add_argument('--port', '-p', type=int, default=5000, 
+                       help='Puerto en el que ejecutar la aplicación (por defecto: 5000)')
+    
+    args = parser.parse_args()
+    
+    print(f"Ejecutando en puerto {args.port}")
+    app.run(debug=True, host='0.0.0.0', port=args.port)

+ 7 - 0
requirements.txt

@@ -0,0 +1,7 @@
+Flask==3.1.1
+blinker==1.9.0
+click==8.2.1
+itsdangerous==2.2.0
+Jinja2==3.1.6
+MarkupSafe==3.0.2
+Werkzeug==3.1.3

+ 63 - 0
templates/404.html

@@ -0,0 +1,63 @@
+{% extends "base.html" %}
+
+{% block title %}Enlace No Encontrado - Renako{% endblock %}
+
+{% block content %}
+<div class="min-h-96 flex items-center justify-center">
+    <div class="text-center max-w-md mx-auto">
+        <div class="mb-8">
+            {% if error_message and 'expirado' in error_message %}
+                <i class="fas fa-clock text-8xl text-orange-300 mb-4"></i>
+                <h1 class="text-6xl font-bold text-gray-900 mb-4">⏰</h1>
+                <h2 class="text-2xl font-semibold text-gray-700 mb-4">Enlace Expirado</h2>
+                <p class="text-gray-600 mb-8">
+                    Este enlace ha expirado y ya no está disponible. Los enlaces tienen una duración limitada por seguridad.
+                </p>
+            {% else %}
+                <i class="fas fa-unlink text-8xl text-gray-300 mb-4"></i>
+                <h1 class="text-6xl font-bold text-gray-900 mb-4">404</h1>
+                <h2 class="text-2xl font-semibold text-gray-700 mb-4">Enlace No Encontrado</h2>
+                <p class="text-gray-600 mb-8">
+                    Lo sentimos, el enlace que estás buscando no existe o ha sido eliminado.
+                </p>
+            {% endif %}
+        </div>
+        
+        <div class="space-y-4">
+            <a 
+                href="{{ url_for('index') }}" 
+                class="inline-flex items-center px-6 py-3 bg-primary hover:bg-secondary text-white font-semibold rounded-lg transition-all duration-200 transform hover:scale-105"
+            >
+                <i class="fas fa-home mr-2"></i>
+                Volver al Inicio
+            </a>
+            
+            <div class="text-sm text-gray-500">
+                <p>¿Necesitas ayuda? Verifica que el enlace esté escrito correctamente.</p>
+            </div>
+        </div>
+        
+        <!-- Sugerencias -->
+        <div class="mt-12 bg-gray-50 rounded-lg p-6">
+            <h3 class="text-lg font-semibold text-gray-800 mb-4">
+                <i class="fas fa-lightbulb mr-2 text-yellow-500"></i>
+                Posibles causas:
+            </h3>
+            <ul class="text-left text-sm text-gray-600 space-y-2">
+                <li class="flex items-start">
+                    <i class="fas fa-circle text-xs mt-2 mr-2 text-gray-400"></i>
+                    El enlace fue eliminado por su creador
+                </li>
+                <li class="flex items-start">
+                    <i class="fas fa-circle text-xs mt-2 mr-2 text-gray-400"></i>
+                    El código del enlace fue escrito incorrectamente
+                </li>
+                <li class="flex items-start">
+                    <i class="fas fa-circle text-xs mt-2 mr-2 text-gray-400"></i>
+                    El enlace ha expirado o nunca existió
+                </li>
+            </ul>
+        </div>
+    </div>
+</div>
+{% endblock %}

+ 78 - 0
templates/base.html

@@ -0,0 +1,78 @@
+<!DOCTYPE html>
+<html lang="es">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>{% block title %}Renako{% endblock %}</title>
+    <script src="https://cdn.tailwindcss.com"></script>
+    <script>
+        tailwind.config = {
+            theme: {
+                extend: {
+                    colors: {
+                        primary: '#6366F1',
+                        secondary: '#4F46E5'
+                    }
+                }
+            }
+        }
+    </script>
+    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
+</head>
+<body class="bg-gray-50 min-h-screen flex flex-col">
+    <!-- Header Component -->
+    {% include 'components/header.html' %}
+
+    <!-- Main Content -->
+    <main class="flex-1 flex items-center justify-center">
+        <div class="max-w-4xl w-full px-4 py-8">
+            {% block content %}{% endblock %}
+        </div>
+    </main>
+
+    <!-- Toast Notifications -->
+    <div id="toast-container" class="fixed top-4 right-4 z-50 space-y-2"></div>
+
+    <script>
+        // Función para mostrar notificaciones toast
+        function showToast(message, type = 'success') {
+            const toastContainer = document.getElementById('toast-container');
+            const toast = document.createElement('div');
+            
+            const bgColor = type === 'success' ? 'bg-green-500' : 'bg-red-500';
+            const icon = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle';
+            
+            toast.className = `${bgColor} text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-2 transform transition-all duration-300 translate-x-full opacity-0`;
+            toast.innerHTML = `
+                <i class="fas ${icon}"></i>
+                <span>${message}</span>
+                <button onclick="this.parentElement.remove()" class="ml-4 text-white hover:text-gray-200">
+                    <i class="fas fa-times"></i>
+                </button>
+            `;
+            
+            toastContainer.appendChild(toast);
+            
+            // Animar entrada
+            setTimeout(() => {
+                toast.classList.remove('translate-x-full', 'opacity-0');
+            }, 100);
+            
+            // Auto-remover después de 5 segundos
+            setTimeout(() => {
+                toast.classList.add('translate-x-full', 'opacity-0');
+                setTimeout(() => toast.remove(), 300);
+            }, 5000);
+        }
+
+        // Función para copiar al portapapeles
+        function copyToClipboard(text) {
+            navigator.clipboard.writeText(text).then(() => {
+                showToast('¡Enlace copiado al portapapeles!');
+            }).catch(() => {
+                showToast('Error al copiar enlace', 'error');
+            });
+        }
+    </script>
+</body>
+</html>

+ 56 - 0
templates/components/header.html

@@ -0,0 +1,56 @@
+<!-- Header Component -->
+<header class="bg-white border-b border-gray-100">
+    <div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
+        <div class="flex justify-between items-center h-16">
+            <!-- Logo/Brand -->
+            <div class="flex items-center">
+                <a href="/" class="flex items-center space-x-2">
+                    <div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
+                        <i class="fas fa-link text-white text-sm"></i>
+                    </div>
+                    <span class="text-xl font-bold text-gray-900">Renako</span>
+                </a>
+            </div>
+            
+            <!-- Navigation -->
+            <nav class="hidden md:flex items-center space-x-6">
+                <a href="{{ url_for('my_links') }}" class="text-gray-600 hover:text-primary transition-colors duration-200">
+                    <i class="fas fa-link mr-2"></i>Mis Enlaces
+                </a>
+                <a href="{{ url_for('stats') }}" class="text-gray-600 hover:text-primary transition-colors duration-200">
+                    <i class="fas fa-chart-bar mr-2"></i>Estadísticas
+                </a>
+            </nav>
+            
+            <!-- Mobile menu button -->
+            <div class="md:hidden">
+                <button 
+                    id="mobile-menu-button"
+                    class="text-gray-600 hover:text-primary transition-colors duration-200"
+                    onclick="toggleMobileMenu()"
+                >
+                    <i class="fas fa-bars"></i>
+                </button>
+            </div>
+        </div>
+        
+        <!-- Mobile menu -->
+        <div id="mobile-menu" class="hidden md:hidden pb-4">
+            <div class="flex flex-col space-y-2">
+                <a href="{{ url_for('my_links') }}" class="text-gray-600 hover:text-primary transition-colors duration-200 py-2">
+                    <i class="fas fa-link mr-2"></i>Mis Enlaces
+                </a>
+                <a href="{{ url_for('stats') }}" class="text-gray-600 hover:text-primary transition-colors duration-200 py-2">
+                    <i class="fas fa-chart-bar mr-2"></i>Estadísticas
+                </a>
+            </div>
+        </div>
+    </div>
+</header>
+
+<script>
+function toggleMobileMenu() {
+    const menu = document.getElementById('mobile-menu');
+    menu.classList.toggle('hidden');
+}
+</script>

+ 9 - 0
templates/components/hero_section.html

@@ -0,0 +1,9 @@
+<!-- Hero Section Component -->
+<div class="text-center pt-8">
+    <h1 class="text-5xl font-bold text-gray-900 mb-6">
+        Acorta tus <span class="text-primary">enlaces</span>
+    </h1>
+    <p class="text-lg text-gray-600 max-w-lg mx-auto">
+        Convierte URLs largas en enlaces cortos y fáciles de compartir
+    </p>
+</div>

+ 26 - 0
templates/components/stats_cards.html

@@ -0,0 +1,26 @@
+<!-- Statistics Cards Component -->
+<div class="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
+    <!-- Total URLs -->
+    <div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-8 text-center">
+        <div class="inline-flex items-center justify-center w-16 h-16 bg-blue-50 rounded-full mb-4">
+            <i class="fas fa-link text-2xl text-primary"></i>
+        </div>
+        <h3 class="text-3xl font-bold text-gray-900 mb-2">{{ total_urls }}</h3>
+        <p class="text-gray-600">Enlaces Acortados</p>
+        <div class="mt-4 text-sm text-gray-500">
+            Total de enlaces creados
+        </div>
+    </div>
+
+    <!-- Total Clicks -->
+    <div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-8 text-center">
+        <div class="inline-flex items-center justify-center w-16 h-16 bg-green-50 rounded-full mb-4">
+            <i class="fas fa-mouse-pointer text-2xl text-green-600"></i>
+        </div>
+        <h3 class="text-3xl font-bold text-gray-900 mb-2">{{ total_clicks }}</h3>
+        <p class="text-gray-600">Clicks Totales</p>
+        <div class="mt-4 text-sm text-gray-500">
+            Redirecciones realizadas
+        </div>
+    </div>
+</div>

+ 9 - 0
templates/components/stats_hero.html

@@ -0,0 +1,9 @@
+<!-- Stats Hero Section Component -->
+<div class="text-center pt-8">
+    <h1 class="text-4xl font-bold text-gray-900 mb-4">
+        <i class="fas fa-chart-bar mr-3 text-primary"></i>Estadísticas
+    </h1>
+    <p class="text-lg text-gray-600">
+        Información sobre el uso del acortador de enlaces
+    </p>
+</div>

+ 196 - 0
templates/components/url_shortener_form.html

@@ -0,0 +1,196 @@
+<!-- URL Shortener Form Component -->
+<div class="max-w-2xl mx-auto">
+    <div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-8 relative">
+        <form id="shortenForm" class="space-y-6">
+            <div class="relative">
+                <input 
+                    type="url" 
+                    id="url" 
+                    name="url" 
+                    placeholder="Pega tu enlace aquí..." 
+                    class="w-full px-6 py-4 text-lg border border-gray-200 rounded-xl focus:ring-2 focus:ring-primary focus:border-transparent transition-all duration-200 bg-gray-50 focus:bg-white"
+                    required
+                >
+            </div>
+            
+            <div class="relative">
+                <label for="duration" class="block text-sm font-medium text-gray-700 mb-2">
+                    <i class="fas fa-clock mr-1"></i>Duración del enlace
+                </label>
+                <select 
+                    id="duration" 
+                    name="duration" 
+                    class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-primary focus:border-transparent transition-all duration-200 bg-gray-50 focus:bg-white"
+                >
+                    <option value="never">Nunca expira</option>
+                    <option value="1hour">1 hora</option>
+                    <option value="1day">1 día</option>
+                    <option value="1week">1 semana</option>
+                    <option value="1month">1 mes</option>
+                    <option value="1year">1 año</option>
+                </select>
+            </div>
+            
+            <button 
+                type="submit" 
+                class="w-full bg-primary hover:bg-secondary text-white font-medium py-4 px-6 rounded-xl transition-all duration-200 text-lg"
+            >
+                Acortar enlace
+            </button>
+        </form>
+
+        <!-- Success Animation (overlays the form) -->
+        <div id="successAnimation" class="hidden absolute inset-0 bg-white bg-opacity-95 flex items-center justify-center rounded-2xl z-10">
+            <div class="text-center">
+                <div id="checkmarkContainer" class="w-24 h-24 mx-auto mb-4 relative">
+                    <div class="w-24 h-24 bg-green-500 rounded-full flex items-center justify-center transform scale-0 transition-transform duration-600 ease-out" id="checkmarkCircle">
+                        <i class="fas fa-check text-white text-4xl opacity-0 transition-opacity duration-400" id="checkmarkIcon"></i>
+                    </div>
+                </div>
+                <h3 class="text-2xl font-bold text-green-600 opacity-0 transition-opacity duration-500" id="successText">¡Enlace acortado!</h3>
+            </div>
+        </div>
+
+        <!-- Result Section -->
+        <div id="result" class="hidden p-6 bg-green-50 border border-green-200 rounded-xl transform translate-y-4 opacity-0 transition-all duration-500 ease-out">
+            <div class="text-center mb-4">
+                <i class="fas fa-check-circle text-green-600 text-2xl mb-2"></i>
+                <h3 class="text-lg font-medium text-green-800">¡Listo!</h3>
+                <p class="text-green-700">Tu enlace ha sido acortado exitosamente</p>
+            </div>
+            <div class="space-y-4">
+                <div>
+                    <label class="block text-sm font-medium text-green-700 mb-2">Tu enlace acortado:</label>
+                    <div class="flex items-center space-x-3">
+                        <input 
+                            type="text" 
+                            id="shortUrl" 
+                            readonly 
+                            class="flex-1 font-mono bg-white p-3 rounded-lg border focus:outline-none text-center"
+                        >
+                        <button 
+                            onclick="copyToClipboard(document.getElementById('shortUrl').value)" 
+                            class="bg-primary hover:bg-secondary text-white px-4 py-3 rounded-lg transition-colors duration-200 flex-shrink-0"
+                            title="Copiar enlace"
+                        >
+                            <i class="fas fa-copy"></i>
+                        </button>
+                    </div>
+                </div>
+                <div class="text-center pt-4">
+                    <button 
+                        onclick="resetForm()" 
+                        class="bg-gray-600 hover:bg-gray-700 text-white font-medium py-3 px-6 rounded-xl transition-all duration-200"
+                    >
+                        <i class="fas fa-plus mr-2"></i>Acortar otro enlace
+                    </button>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<script>
+// Form submission handler
+document.getElementById('shortenForm').addEventListener('submit', async function(e) {
+    e.preventDefault();
+    
+    const url = document.getElementById('url').value;
+    const duration = document.getElementById('duration').value;
+    const resultDiv = document.getElementById('result');
+    const shortUrlInput = document.getElementById('shortUrl');
+    
+    try {
+        const response = await fetch('/shorten', {
+            method: 'POST',
+            headers: {
+                'Content-Type': 'application/json',
+            },
+            body: JSON.stringify({ url: url, duration: duration })
+        });
+        
+        const data = await response.json();
+        
+        if (data.success) {
+            shortUrlInput.value = data.short_url;
+            
+            // Show success animation first
+            showSuccessAnimation(() => {
+                // Hide form and show result after animation
+                document.getElementById('shortenForm').style.display = 'none';
+                resultDiv.classList.remove('hidden');
+                
+                // Animate result appearance
+                setTimeout(() => {
+                    resultDiv.style.transform = 'translateY(0)';
+                    resultDiv.style.opacity = '1';
+                }, 50);
+                
+                showToast('¡Enlace acortado exitosamente!', 'success');
+            });
+        } else {
+            showToast(data.error || 'Error al acortar el enlace', 'error');
+        }
+    } catch (error) {
+        showToast('Error de conexión', 'error');
+    }
+});
+
+// Function to show success animation
+function showSuccessAnimation(callback) {
+    const successAnimation = document.getElementById('successAnimation');
+    const checkmarkCircle = document.getElementById('checkmarkCircle');
+    const checkmarkIcon = document.getElementById('checkmarkIcon');
+    const successText = document.getElementById('successText');
+    
+    // Show animation container
+    successAnimation.classList.remove('hidden');
+    
+    // Animate checkmark circle
+    setTimeout(() => {
+        checkmarkCircle.style.transform = 'scale(1)';
+    }, 100);
+    
+    // Show checkmark icon
+    setTimeout(() => {
+        checkmarkIcon.style.opacity = '1';
+    }, 400);
+    
+    // Show success text
+    setTimeout(() => {
+        successText.style.opacity = '1';
+    }, 700);
+    
+    // Hide animation and show result
+    setTimeout(() => {
+        successAnimation.classList.add('hidden');
+        // Reset animation states
+        checkmarkCircle.style.transform = 'scale(0)';
+        checkmarkIcon.style.opacity = '0';
+        successText.style.opacity = '0';
+        
+        // Execute callback to show result
+        if (callback) callback();
+    }, 2000);
+}
+
+// Function to reset form
+function resetForm() {
+    const resultDiv = document.getElementById('result');
+    
+    // Animate result disappearance
+    resultDiv.style.transform = 'translateY(4px)';
+    resultDiv.style.opacity = '0';
+    
+    setTimeout(() => {
+        document.getElementById('shortenForm').style.display = 'block';
+        resultDiv.classList.add('hidden');
+        document.getElementById('url').value = '';
+        document.getElementById('shortUrl').value = '';
+        
+        // Reset result animation state
+        resultDiv.style.transform = 'translateY(4px)';
+        resultDiv.style.opacity = '0';
+    }, 300);
+}
+</script>

+ 161 - 0
templates/components/user_urls_list.html

@@ -0,0 +1,161 @@
+<!-- User URLs List Component -->
+{% if user_urls %}
+<div class="max-w-4xl mx-auto">
+    <div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
+        <h2 class="text-xl font-semibold text-gray-900 mb-6">
+            Tus enlaces <span class="text-gray-500 text-base font-normal">({{ user_urls|length }})</span>
+        </h2>
+        
+        <div class="space-y-3">
+            {% for url in user_urls %}
+            <div class="border border-gray-100 rounded-xl p-4 hover:bg-gray-50 transition-colors duration-200">
+                <div class="flex items-center justify-between">
+                    <div class="flex-1 min-w-0">
+                        <div class="flex items-center space-x-3 mb-2">
+                            <code class="text-sm bg-gray-100 text-primary px-3 py-1 rounded-lg font-mono">{{ url[1] }}</code>
+                            <button 
+                                onclick="copyToClipboard('https://please.checkthis.space/s/{{ url[1] }}')"
+                                class="text-gray-400 hover:text-primary transition-colors duration-200"
+                                title="Copiar enlace"
+                            >
+                                <i class="fas fa-copy text-sm"></i>
+                            </button>
+                            <span class="text-xs text-gray-500">{{ url[3] }} clicks</span>
+                            {% if url[4] %}
+                                {% set expires_date = url[4][:19] %}
+                                <span class="text-xs px-2 py-1 rounded-full bg-orange-100 text-orange-600">
+                                    <i class="fas fa-clock mr-1"></i>Expira: {{ expires_date.replace('T', ' ') }}
+                                </span>
+                            {% else %}
+                                <span class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-600">
+                                    <i class="fas fa-infinity mr-1"></i>Permanente
+                                </span>
+                            {% endif %}
+                        </div>
+                        <p class="text-sm text-gray-600 truncate" title="{{ url[0] }}">
+                            {{ url[0] }}
+                        </p>
+                    </div>
+                    
+                    <div class="flex items-center space-x-2 ml-4">
+                        <a 
+                            href="/s/{{ url[1] }}" 
+                            target="_blank" 
+                            class="text-gray-400 hover:text-primary transition-colors duration-200"
+                            title="Visitar enlace"
+                        >
+                            <i class="fas fa-external-link-alt text-sm"></i>
+                        </a>
+                        <button 
+                            onclick="showDeleteModal('{{ url[1] }}')" 
+                            class="text-gray-400 hover:text-red-500 transition-colors duration-200"
+                            title="Eliminar enlace"
+                        >
+                            <i class="fas fa-trash text-sm"></i>
+                        </button>
+                    </div>
+                </div>
+            </div>
+            {% endfor %}
+        </div>
+    </div>
+</div>
+{% else %}
+<div class="max-w-2xl mx-auto text-center">
+    <div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-12">
+        <div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
+            <i class="fas fa-link text-2xl text-gray-400"></i>
+        </div>
+        <h3 class="text-lg font-medium text-gray-700 mb-2">No hay enlaces aún</h3>
+        <p class="text-gray-500">Acorta tu primer enlace arriba</p>
+    </div>
+</div>
+{% endif %}
+
+<!-- Modal de confirmación de eliminación -->
+<div id="deleteModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full hidden z-50">
+    <div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
+        <div class="mt-3 text-center">
+            <div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
+                <i class="fas fa-exclamation-triangle text-red-600 text-xl"></i>
+            </div>
+            <h3 class="text-lg leading-6 font-medium text-gray-900 mt-4">Eliminar enlace</h3>
+            <div class="mt-2 px-7 py-3">
+                <p class="text-sm text-gray-500">
+                    ¿Estás seguro de que quieres eliminar este enlace? Esta acción no se puede deshacer.
+                </p>
+            </div>
+            <div class="items-center px-4 py-3">
+                <button 
+                    id="confirmDelete" 
+                    class="px-4 py-2 bg-red-500 text-white text-base font-medium rounded-md w-24 mr-2 hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-300"
+                >
+                    Eliminar
+                </button>
+                <button 
+                    id="cancelDelete" 
+                    class="px-4 py-2 bg-gray-300 text-gray-800 text-base font-medium rounded-md w-24 hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-300"
+                >
+                    Cancelar
+                </button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<script>
+let currentShortCode = null;
+
+// Mostrar modal de confirmación
+function showDeleteModal(shortCode) {
+    currentShortCode = shortCode;
+    document.getElementById('deleteModal').classList.remove('hidden');
+}
+
+// Ocultar modal de confirmación
+function hideDeleteModal() {
+    document.getElementById('deleteModal').classList.add('hidden');
+    currentShortCode = null;
+}
+
+// Event listeners para el modal
+document.getElementById('confirmDelete').addEventListener('click', function() {
+    if (currentShortCode) {
+        deleteUrl(currentShortCode);
+        hideDeleteModal();
+    }
+});
+
+document.getElementById('cancelDelete').addEventListener('click', hideDeleteModal);
+
+// Cerrar modal al hacer clic fuera de él
+document.getElementById('deleteModal').addEventListener('click', function(e) {
+    if (e.target === this) {
+        hideDeleteModal();
+    }
+});
+
+// Delete URL function
+async function deleteUrl(shortCode) {
+    
+    try {
+        const response = await fetch(`/delete/${shortCode}`, {
+            method: 'POST'
+        });
+        
+        const data = await response.json();
+        
+        if (data.success) {
+            showToast('Enlace eliminado exitosamente', 'success');
+            // Reload page to update the list
+            setTimeout(() => {
+                window.location.reload();
+            }, 1000);
+        } else {
+            showToast(data.error || 'Error al eliminar el enlace', 'error');
+        }
+    } catch (error) {
+        showToast('Error de conexión', 'error');
+    }
+}
+</script>

+ 85 - 0
templates/index.html

@@ -0,0 +1,85 @@
+{% extends "base.html" %}
+
+{% block title %}Renako{% endblock %}
+
+{% block content %}
+<!-- Animación de entrada inicial -->
+<div id="initialAnimation" class="fixed inset-0 bg-gray-50 flex items-center justify-center z-50">
+    <h1 id="initialText" class="text-6xl font-bold text-gray-900 opacity-0 transform scale-95">
+        Acorta tus <span class="text-primary">enlaces</span>
+    </h1>
+</div>
+
+<!-- Contenido principal (inicialmente oculto) -->
+<div id="mainContent" class="space-y-12 opacity-0">
+    <!-- Hero Section -->
+    {% include 'components/hero_section.html' %}
+
+    <!-- URL Shortener Form -->
+    {% include 'components/url_shortener_form.html' %}
+
+    <!-- Quick Access to My Links -->
+    <div class="text-center">
+        <a 
+            href="{{ url_for('my_links') }}" 
+            class="inline-flex items-center px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium rounded-xl transition-all duration-200"
+        >
+            <i class="fas fa-list mr-2"></i>
+            Ver mis enlaces
+        </a>
+    </div>
+</div>
+
+<script>
+// Animación de entrada de la página
+document.addEventListener('DOMContentLoaded', function() {
+    const initialAnimation = document.getElementById('initialAnimation');
+    const initialText = document.getElementById('initialText');
+    const mainContent = document.getElementById('mainContent');
+    const header = document.querySelector('header');
+    
+    // Ocultar header inicialmente
+    if (header) {
+        header.style.opacity = '0';
+        header.style.transform = 'translateY(-20px)';
+    }
+    
+    // Secuencia de animación
+    setTimeout(() => {
+        // Mostrar texto inicial
+        initialText.style.transition = 'all 1s ease-out';
+        initialText.style.opacity = '1';
+        initialText.style.transform = 'scale(1)';
+    }, 300);
+    
+    setTimeout(() => {
+        // Desvanecer texto inicial
+        initialText.style.transition = 'all 0.8s ease-in';
+        initialText.style.opacity = '0';
+        initialText.style.transform = 'scale(1.1)';
+    }, 2500);
+    
+    setTimeout(() => {
+        // Ocultar overlay de animación inicial
+        initialAnimation.style.transition = 'opacity 0.5s ease-out';
+        initialAnimation.style.opacity = '0';
+        
+        setTimeout(() => {
+            initialAnimation.style.display = 'none';
+        }, 500);
+        
+        // Mostrar header
+        if (header) {
+            header.style.transition = 'all 0.8s ease-out';
+            header.style.opacity = '1';
+            header.style.transform = 'translateY(0)';
+        }
+        
+        // Mostrar contenido principal
+        mainContent.style.transition = 'all 1s ease-out';
+        mainContent.style.opacity = '1';
+        
+    }, 3500);
+});
+</script>
+{% endblock %}

+ 63 - 0
templates/my_links.html

@@ -0,0 +1,63 @@
+{% extends "base.html" %}
+
+{% block title %}Mis Enlaces - Renako{% endblock %}
+
+{% block content %}
+<div class="space-y-12">
+    <!-- Page Header -->
+    <div class="text-center pt-8 opacity-0 transform translate-y-6" id="pageHeader">
+        <h1 class="text-4xl font-bold text-gray-900 mb-4">
+            <i class="fas fa-link mr-3 text-primary"></i>Mis Enlaces
+        </h1>
+        <p class="text-lg text-gray-600">
+            Gestiona todos tus enlaces acortados
+        </p>
+    </div>
+
+    <!-- User URLs List Component -->
+    <div class="opacity-0 transform translate-y-6" id="urlsList">
+        {% include 'components/user_urls_list.html' %}
+    </div>
+
+    <!-- Back to Home -->
+    <div class="text-center opacity-0 transform translate-y-6" id="backButton">
+        <a 
+            href="{{ url_for('index') }}" 
+            class="inline-flex items-center px-6 py-3 bg-primary hover:bg-secondary text-white font-medium rounded-xl transition-all duration-200"
+        >
+            <i class="fas fa-arrow-left mr-2"></i>
+            Acortar nuevo enlace
+        </a>
+    </div>
+</div>
+
+<script>
+// Animación de entrada para la página Mis Enlaces
+document.addEventListener('DOMContentLoaded', function() {
+    const pageHeader = document.getElementById('pageHeader');
+    const urlsList = document.getElementById('urlsList');
+    const backButton = document.getElementById('backButton');
+    
+    // Animar header
+    setTimeout(() => {
+        pageHeader.style.transition = 'all 0.8s ease-out';
+        pageHeader.style.opacity = '1';
+        pageHeader.style.transform = 'translateY(0)';
+    }, 200);
+    
+    // Animar lista de URLs
+    setTimeout(() => {
+        urlsList.style.transition = 'all 0.8s ease-out';
+        urlsList.style.opacity = '1';
+        urlsList.style.transform = 'translateY(0)';
+    }, 400);
+    
+    // Animar botón de regreso
+    setTimeout(() => {
+        backButton.style.transition = 'all 0.8s ease-out';
+        backButton.style.opacity = '1';
+        backButton.style.transform = 'translateY(0)';
+    }, 600);
+});
+</script>
+{% endblock %}

+ 39 - 0
templates/stats.html

@@ -0,0 +1,39 @@
+{% extends "base.html" %}
+
+{% block title %}Estadísticas - Renako{% endblock %}
+
+{% block content %}
+<div class="space-y-12">
+    <!-- Stats Hero Section Component -->
+    <div class="opacity-0 transform translate-y-6" id="statsHero">
+        {% include 'components/stats_hero.html' %}
+    </div>
+
+    <!-- Statistics Cards Component -->
+    <div class="opacity-0 transform translate-y-6" id="statsCards">
+        {% include 'components/stats_cards.html' %}
+    </div>
+</div>
+
+<script>
+// Animación de entrada para la página de Estadísticas
+document.addEventListener('DOMContentLoaded', function() {
+    const statsHero = document.getElementById('statsHero');
+    const statsCards = document.getElementById('statsCards');
+    
+    // Animar hero section
+    setTimeout(() => {
+        statsHero.style.transition = 'all 0.8s ease-out';
+        statsHero.style.opacity = '1';
+        statsHero.style.transform = 'translateY(0)';
+    }, 200);
+    
+    // Animar cards de estadísticas
+    setTimeout(() => {
+        statsCards.style.transition = 'all 0.8s ease-out';
+        statsCards.style.opacity = '1';
+        statsCards.style.transform = 'translateY(0)';
+    }, 500);
+});
+</script>
+{% endblock %}