Migrate from Next.js to Flask: Complete motorcycle adventure community website
- Replace Next.js/React implementation with Python Flask - Add colorful blue-purple-teal gradient theme replacing red design - Integrate logo and Transalpina panoramic background image - Implement complete authentication system with Flask-Login - Add community features for stories and tracks sharing - Create responsive design with Tailwind CSS - Add error handling with custom 404/500 pages - Include Docker deployment configuration - Add favicon support and proper SEO structure - Update content for Pensiune BuonGusto accommodation - Remove deprecated Next.js files and dependencies Features: ✅ Landing page with hero section and featured content ✅ User registration and login system ✅ Community section for adventure sharing ✅ Admin panel for content management ✅ Responsive mobile-first design ✅ Docker containerization with PostgreSQL ✅ Email integration with Flask-Mail ✅ Form validation with WTForms ✅ SQLAlchemy database models ✅ Error pages and favicon handling
This commit is contained in:
29
.env.example
29
.env.example
@@ -1,29 +0,0 @@
|
||||
# Database
|
||||
DATABASE_URL="postgresql://username:password@localhost:5432/moto_adventure"
|
||||
|
||||
# NextAuth
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="your-secret-key-here"
|
||||
|
||||
# Email Service (for password recovery)
|
||||
EMAIL_HOST="smtp.gmail.com"
|
||||
EMAIL_PORT="587"
|
||||
EMAIL_USER="your-email@gmail.com"
|
||||
EMAIL_PASS="your-app-password"
|
||||
EMAIL_FROM="noreply@moto-adv.com"
|
||||
|
||||
# File Upload
|
||||
UPLOAD_DIR="./public/uploads"
|
||||
MAX_FILE_SIZE="10485760" # 10MB
|
||||
|
||||
# Site Configuration
|
||||
SITE_URL="http://localhost:3000"
|
||||
SITE_NAME="Moto Adventure Community"
|
||||
|
||||
# Admin
|
||||
ADMIN_EMAIL="admin@moto-adv.com"
|
||||
|
||||
# Pensiunea Buongusto
|
||||
PENSIUNEA_PHONE="+40-xxx-xxx-xxx"
|
||||
PENSIUNEA_EMAIL="info@pensiunebuongusto.ro"
|
||||
PENSIUNEA_WEBSITE="https://pensiunebuongusto.ro"
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
80
.gitignore
vendored
80
.gitignore
vendored
@@ -1,80 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Production builds
|
||||
.next/
|
||||
out/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# Next.js
|
||||
.next
|
||||
|
||||
# Prisma
|
||||
prisma/migrations/
|
||||
|
||||
# Uploads
|
||||
public/uploads/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /opt/site/flask-moto-adventure
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create uploads directory
|
||||
RUN mkdir -p uploads/images uploads/gpx
|
||||
|
||||
# Create non-root user
|
||||
RUN adduser --disabled-password --gecos '' appuser && chown -R appuser:appuser /opt/site/flask-moto-adventure
|
||||
USER appuser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5000
|
||||
|
||||
# Run the application with Gunicorn
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--timeout", "120", "run:app"]
|
||||
171
README.md
171
README.md
@@ -1,62 +1,159 @@
|
||||
# Moto Adventure Website
|
||||
# Motorcycle Adventure Community
|
||||
|
||||
A Next.js-based website for motorcycle adventure enthusiasts, featuring route tracking, blog posts, and community features.
|
||||
A Flask-based web application for motorcycle enthusiasts featuring adventure stories, route sharing, and accommodation recommendations.
|
||||
|
||||
## Features
|
||||
|
||||
- 🏍️ Motorcycle adventure route tracking with GPX support
|
||||
- 📝 Blog system for sharing adventure stories
|
||||
- 🗺️ Interactive maps with Leaflet integration
|
||||
- 👤 User authentication with NextAuth.js
|
||||
- 📊 Analytics and charts with Recharts
|
||||
- 📱 Responsive design with Tailwind CSS
|
||||
- 🖼️ Image upload and processing with Sharp
|
||||
- 🎨 Smooth animations with Framer Motion
|
||||
- **Landing Page**: Hero section with call-to-action, featured adventures, and accommodation promotion
|
||||
- **User Authentication**: Registration and login system with Flask-Login
|
||||
- **Community Section**: Share stories, tracks, and experiences
|
||||
- **Admin Panel**: Content management for administrators
|
||||
- **Responsive Design**: Built with Tailwind CSS for mobile-first design
|
||||
- **Error Handling**: Custom 404 and 500 error pages
|
||||
|
||||
## Tech Stack
|
||||
## Technology Stack
|
||||
|
||||
- **Framework:** Next.js 14 with App Router
|
||||
- **Language:** TypeScript
|
||||
- **Styling:** Tailwind CSS
|
||||
- **Database:** Prisma ORM
|
||||
- **Authentication:** NextAuth.js
|
||||
- **Maps:** React Leaflet
|
||||
- **Forms:** React Hook Form
|
||||
- **Icons:** Lucide React
|
||||
- **Animation:** Framer Motion
|
||||
- **Backend**: Python Flask 3.0.0
|
||||
- **Database**: SQLAlchemy with PostgreSQL support
|
||||
- **Authentication**: Flask-Login
|
||||
- **Email**: Flask-Mail
|
||||
- **Forms**: WTForms with validation
|
||||
- **Styling**: Tailwind CSS (CDN)
|
||||
- **Deployment**: Docker with Gunicorn
|
||||
|
||||
## Getting Started
|
||||
## Project Structure
|
||||
|
||||
1. Install dependencies:
|
||||
```
|
||||
/
|
||||
├── app/
|
||||
│ ├── __init__.py # Flask app factory
|
||||
│ ├── models.py # Database models
|
||||
│ ├── forms.py # WTForm definitions
|
||||
│ ├── routes/ # Route blueprints
|
||||
│ │ ├── main.py # Main routes (index, about)
|
||||
│ │ ├── auth.py # Authentication routes
|
||||
│ │ └── community.py # Community features
|
||||
│ ├── templates/ # Jinja2 templates
|
||||
│ │ ├── base.html # Base template
|
||||
│ │ ├── index.html # Landing page
|
||||
│ │ ├── auth/ # Auth templates
|
||||
│ │ └── errors/ # Error pages
|
||||
│ └── static/ # Static assets
|
||||
│ ├── favicon.ico
|
||||
│ └── images/
|
||||
├── config.py # Application configuration
|
||||
├── requirements.txt # Python dependencies
|
||||
├── run.py # Application entry point
|
||||
├── Dockerfile # Docker configuration
|
||||
└── docker-compose.yml # Docker Compose setup
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Local Development
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
npm install
|
||||
git clone <repository-url>
|
||||
cd motorcycle-adventure-community
|
||||
```
|
||||
|
||||
2. Set up environment variables:
|
||||
2. **Create virtual environment**
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
```
|
||||
|
||||
3. Set up the database:
|
||||
3. **Install dependencies**
|
||||
```bash
|
||||
npx prisma generate
|
||||
npx prisma db push
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. Run the development server:
|
||||
4. **Set environment variables**
|
||||
```bash
|
||||
npm run dev
|
||||
export FLASK_APP=run.py
|
||||
export FLASK_ENV=development
|
||||
export SECRET_KEY="your-secret-key-here"
|
||||
export DATABASE_URL="sqlite:///motorcycle_adventures.db"
|
||||
```
|
||||
|
||||
5. Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||
5. **Initialize database**
|
||||
```bash
|
||||
flask db init
|
||||
flask db migrate -m "Initial migration"
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
## Development
|
||||
6. **Run the application**
|
||||
```bash
|
||||
python run.py
|
||||
```
|
||||
|
||||
- `npm run dev` - Start development server
|
||||
- `npm run build` - Build for production
|
||||
- `npm run start` - Start production server
|
||||
- `npm run lint` - Run ESLint
|
||||
### Docker Deployment
|
||||
|
||||
1. **Build and run with Docker Compose**
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
2. **Access the application**
|
||||
- Web application: http://localhost:5000
|
||||
- PostgreSQL database: localhost:5432
|
||||
|
||||
## Configuration
|
||||
|
||||
The application supports multiple environments through environment variables:
|
||||
|
||||
- `FLASK_ENV`: development, testing, production
|
||||
- `SECRET_KEY`: Flask secret key for sessions
|
||||
- `DATABASE_URL`: Database connection string
|
||||
- `MAIL_SERVER`: SMTP server for email
|
||||
- `MAIL_USERNAME`: Email username
|
||||
- `MAIL_PASSWORD`: Email password
|
||||
|
||||
## Features Overview
|
||||
|
||||
### Landing Page
|
||||
- Hero section with stunning mountain panorama background
|
||||
- Call-to-action for adventure community
|
||||
- Featured adventures showcase
|
||||
- Pensiune BuonGusto accommodation promotion
|
||||
|
||||
### Authentication System
|
||||
- User registration with email validation
|
||||
- Secure login/logout functionality
|
||||
- Password hashing with Werkzeug
|
||||
- Session management with Flask-Login
|
||||
|
||||
### Community Features
|
||||
- Story and track sharing
|
||||
- User profiles
|
||||
- Image uploads for adventures
|
||||
- GPX file support for route sharing
|
||||
- Comment system
|
||||
- Like/rating system
|
||||
|
||||
### Admin Panel
|
||||
- User management
|
||||
- Content moderation
|
||||
- Analytics dashboard
|
||||
- System configuration
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## License
|
||||
|
||||
This project is private and proprietary.
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Beautiful Transalpina panorama background image
|
||||
- Tailwind CSS for responsive design
|
||||
- Flask community for excellent documentation
|
||||
- Bootstrap icons for UI elements
|
||||
|
||||
60
app/__init__.py
Normal file
60
app/__init__.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager
|
||||
from flask_mail import Mail
|
||||
from config import config
|
||||
import os
|
||||
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
login_manager = LoginManager()
|
||||
mail = Mail()
|
||||
|
||||
def create_app(config_name=None):
|
||||
app = Flask(__name__)
|
||||
|
||||
config_name = config_name or os.environ.get('FLASK_CONFIG') or 'default'
|
||||
app.config.from_object(config[config_name])
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
login_manager.init_app(app)
|
||||
mail.init_app(app)
|
||||
|
||||
# Configure login manager
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Please log in to access this page.'
|
||||
login_manager.login_message_category = 'info'
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
from app.models import User
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
# Import models
|
||||
from app.models import User, Post, PostImage, GPXFile, Comment, Like
|
||||
|
||||
# Register blueprints
|
||||
from app.routes.main import main
|
||||
app.register_blueprint(main)
|
||||
|
||||
from app.routes.auth import auth
|
||||
app.register_blueprint(auth, url_prefix='/auth')
|
||||
|
||||
from app.routes.community import community
|
||||
app.register_blueprint(community, url_prefix='/community')
|
||||
|
||||
# Create upload directories
|
||||
upload_dir = os.path.join(app.instance_path, 'uploads')
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
os.makedirs(os.path.join(upload_dir, 'images'), exist_ok=True)
|
||||
os.makedirs(os.path.join(upload_dir, 'gpx'), exist_ok=True)
|
||||
|
||||
return app
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
from app.models import User
|
||||
return User.query.get(int(user_id))
|
||||
54
app/forms.py
Normal file
54
app/forms.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, TextAreaField, PasswordField, BooleanField, SelectField, FileField, SubmitField
|
||||
from wtforms.validators import DataRequired, Email, Length, EqualTo, NumberRange
|
||||
from flask_wtf.file import FileAllowed, FileRequired
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
email = StringField('Email', validators=[DataRequired(), Email()],
|
||||
render_kw={"placeholder": "Enter your email"})
|
||||
password = PasswordField('Password', validators=[DataRequired()],
|
||||
render_kw={"placeholder": "Enter your password"})
|
||||
remember_me = BooleanField('Remember Me')
|
||||
submit = SubmitField('Sign In')
|
||||
|
||||
class RegisterForm(FlaskForm):
|
||||
nickname = StringField('Nickname', validators=[DataRequired(), Length(min=3, max=20)],
|
||||
render_kw={"placeholder": "Choose a nickname"})
|
||||
email = StringField('Email', validators=[DataRequired(), Email()],
|
||||
render_kw={"placeholder": "Enter your email"})
|
||||
password = PasswordField('Password', validators=[DataRequired(), Length(min=8)],
|
||||
render_kw={"placeholder": "Create a password"})
|
||||
password2 = PasswordField('Confirm Password',
|
||||
validators=[DataRequired(), EqualTo('password')],
|
||||
render_kw={"placeholder": "Confirm your password"})
|
||||
submit = SubmitField('Register')
|
||||
|
||||
class ForgotPasswordForm(FlaskForm):
|
||||
email = StringField('Email', validators=[DataRequired(), Email()],
|
||||
render_kw={"placeholder": "Enter your email"})
|
||||
submit = SubmitField('Reset Password')
|
||||
|
||||
class PostForm(FlaskForm):
|
||||
title = StringField('Title', validators=[DataRequired(), Length(min=5, max=200)],
|
||||
render_kw={"placeholder": "Give your adventure a catchy title"})
|
||||
subtitle = StringField('Subtitle', validators=[Length(max=300)],
|
||||
render_kw={"placeholder": "A brief description (optional)"})
|
||||
content = TextAreaField('Content', validators=[DataRequired(), Length(min=50)],
|
||||
render_kw={"placeholder": "Tell us about your adventure...", "rows": 10})
|
||||
difficulty = SelectField('Difficulty',
|
||||
choices=[('1', 'Very Easy'), ('2', 'Easy'), ('3', 'Moderate'),
|
||||
('4', 'Hard'), ('5', 'Very Hard')],
|
||||
validators=[DataRequired()], default='3')
|
||||
images = FileField('Photos',
|
||||
validators=[FileAllowed(['jpg', 'jpeg', 'png', 'gif'], 'Images only!')],
|
||||
render_kw={"multiple": True, "accept": "image/*"})
|
||||
gpx_file = FileField('GPX Track',
|
||||
validators=[FileAllowed(['gpx'], 'GPX files only!')],
|
||||
render_kw={"accept": ".gpx"})
|
||||
published = BooleanField('Publish immediately', default=True)
|
||||
submit = SubmitField('Create Post')
|
||||
|
||||
class CommentForm(FlaskForm):
|
||||
content = TextAreaField('Comment', validators=[DataRequired(), Length(min=10, max=1000)],
|
||||
render_kw={"placeholder": "Share your thoughts...", "rows": 3})
|
||||
submit = SubmitField('Post Comment')
|
||||
126
app/models.py
Normal file
126
app/models.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from datetime import datetime
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import UserMixin
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
nickname = db.Column(db.String(80), unique=True, nullable=False)
|
||||
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
is_admin = db.Column(db.Boolean, default=False, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
posts = db.relationship('Post', backref='author', lazy='dynamic', cascade='all, delete-orphan')
|
||||
comments = db.relationship('Comment', backref='author', lazy='dynamic', cascade='all, delete-orphan')
|
||||
likes = db.relationship('Like', backref='user', lazy='dynamic', cascade='all, delete-orphan')
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.nickname}>'
|
||||
|
||||
class Post(db.Model):
|
||||
__tablename__ = 'posts'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
subtitle = db.Column(db.String(300))
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
difficulty = db.Column(db.Integer, default=3, nullable=False) # 1-5 scale
|
||||
published = db.Column(db.Boolean, default=False, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Foreign Keys
|
||||
author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
# Relationships
|
||||
images = db.relationship('PostImage', backref='post', lazy='dynamic', cascade='all, delete-orphan')
|
||||
gpx_files = db.relationship('GPXFile', backref='post', lazy='dynamic', cascade='all, delete-orphan')
|
||||
comments = db.relationship('Comment', backref='post', lazy='dynamic', cascade='all, delete-orphan')
|
||||
likes = db.relationship('Like', backref='post', lazy='dynamic', cascade='all, delete-orphan')
|
||||
|
||||
def get_difficulty_label(self):
|
||||
labels = ['Very Easy', 'Easy', 'Moderate', 'Hard', 'Very Hard']
|
||||
return labels[self.difficulty - 1] if 1 <= self.difficulty <= 5 else 'Unknown'
|
||||
|
||||
def get_like_count(self):
|
||||
return self.likes.count()
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Post {self.title}>'
|
||||
|
||||
class PostImage(db.Model):
|
||||
__tablename__ = 'post_images'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
filename = db.Column(db.String(255), nullable=False)
|
||||
original_name = db.Column(db.String(255), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
size = db.Column(db.Integer, nullable=False)
|
||||
mime_type = db.Column(db.String(100), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Foreign Keys
|
||||
post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<PostImage {self.filename}>'
|
||||
|
||||
class GPXFile(db.Model):
|
||||
__tablename__ = 'gpx_files'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
filename = db.Column(db.String(255), nullable=False)
|
||||
original_name = db.Column(db.String(255), nullable=False)
|
||||
size = db.Column(db.Integer, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Foreign Keys
|
||||
post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<GPXFile {self.filename}>'
|
||||
|
||||
class Comment(db.Model):
|
||||
__tablename__ = 'comments'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Foreign Keys
|
||||
author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Comment {self.id}>'
|
||||
|
||||
class Like(db.Model):
|
||||
__tablename__ = 'likes'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Foreign Keys
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=False)
|
||||
|
||||
# Unique constraint
|
||||
__table_args__ = (db.UniqueConstraint('user_id', 'post_id', name='unique_user_post_like'),)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Like {self.user_id}-{self.post_id}>'
|
||||
106
app/routes/auth.py
Normal file
106
app/routes/auth.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from werkzeug.security import check_password_hash
|
||||
from app.models import User, db
|
||||
from app.forms import LoginForm, RegisterForm, ForgotPasswordForm
|
||||
import re
|
||||
|
||||
auth = Blueprint('auth', __name__)
|
||||
|
||||
@auth.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""User login page"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(email=form.email.data).first()
|
||||
|
||||
if user and user.check_password(form.password.data):
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
next_page = request.args.get('next')
|
||||
if not next_page or not next_page.startswith('/'):
|
||||
next_page = url_for('community.index')
|
||||
flash(f'Welcome back, {user.nickname}!', 'success')
|
||||
return redirect(next_page)
|
||||
else:
|
||||
flash('Invalid email or password.', 'error')
|
||||
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
@auth.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
"""User registration page"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
form = RegisterForm()
|
||||
if form.validate_on_submit():
|
||||
# Check if user already exists
|
||||
if User.query.filter_by(email=form.email.data).first():
|
||||
flash('Email address already registered.', 'error')
|
||||
return render_template('auth/register.html', form=form)
|
||||
|
||||
if User.query.filter_by(nickname=form.nickname.data).first():
|
||||
flash('Nickname already taken.', 'error')
|
||||
return render_template('auth/register.html', form=form)
|
||||
|
||||
# Validate password strength
|
||||
if not is_valid_password(form.password.data):
|
||||
flash('Password must be at least 8 characters long and contain at least one letter and one number.', 'error')
|
||||
return render_template('auth/register.html', form=form)
|
||||
|
||||
# Create new user
|
||||
user = User(
|
||||
nickname=form.nickname.data,
|
||||
email=form.email.data
|
||||
)
|
||||
user.set_password(form.password.data)
|
||||
|
||||
try:
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
flash('Registration successful! You can now log in.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash('An error occurred during registration. Please try again.', 'error')
|
||||
|
||||
return render_template('auth/register.html', form=form)
|
||||
|
||||
@auth.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
"""User logout"""
|
||||
logout_user()
|
||||
flash('You have been logged out.', 'info')
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
@auth.route('/forgot-password', methods=['GET', 'POST'])
|
||||
def forgot_password():
|
||||
"""Forgot password page"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
form = ForgotPasswordForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(email=form.email.data).first()
|
||||
if user:
|
||||
# TODO: Implement email sending for password reset
|
||||
flash('If an account with that email exists, we\'ve sent password reset instructions.', 'info')
|
||||
else:
|
||||
flash('If an account with that email exists, we\'ve sent password reset instructions.', 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('auth/forgot_password.html', form=form)
|
||||
|
||||
def is_valid_password(password):
|
||||
"""Validate password strength"""
|
||||
if len(password) < 8:
|
||||
return False
|
||||
if not re.search(r'[A-Za-z]', password):
|
||||
return False
|
||||
if not re.search(r'\d', password):
|
||||
return False
|
||||
return True
|
||||
195
app/routes/community.py
Normal file
195
app/routes/community.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from app.models import Post, PostImage, GPXFile, User, Comment, Like, db
|
||||
from app.forms import PostForm, CommentForm
|
||||
from werkzeug.utils import secure_filename
|
||||
from werkzeug.exceptions import RequestEntityTooLarge
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from PIL import Image
|
||||
import gpxpy
|
||||
|
||||
community = Blueprint('community', __name__)
|
||||
|
||||
@community.route('/')
|
||||
def index():
|
||||
"""Community posts listing page"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
posts = Post.query.filter_by(published=True).order_by(Post.created_at.desc()).paginate(
|
||||
page=page, per_page=10, error_out=False
|
||||
)
|
||||
return render_template('community/index.html', posts=posts)
|
||||
|
||||
@community.route('/post/<int:id>')
|
||||
def post_detail(id):
|
||||
"""Individual post detail page"""
|
||||
post = Post.query.get_or_404(id)
|
||||
if not post.published and (not current_user.is_authenticated or
|
||||
(current_user.id != post.author_id and not current_user.is_admin)):
|
||||
flash('Post not found.', 'error')
|
||||
return redirect(url_for('community.index'))
|
||||
|
||||
form = CommentForm()
|
||||
comments = Comment.query.filter_by(post_id=id).order_by(Comment.created_at.asc()).all()
|
||||
|
||||
return render_template('community/post_detail.html', post=post, form=form, comments=comments)
|
||||
|
||||
@community.route('/new-post', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def new_post():
|
||||
"""Create new post page"""
|
||||
form = PostForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
# Create post
|
||||
post = Post(
|
||||
title=form.title.data,
|
||||
subtitle=form.subtitle.data,
|
||||
content=form.content.data,
|
||||
difficulty=int(form.difficulty.data),
|
||||
published=form.published.data,
|
||||
author_id=current_user.id
|
||||
)
|
||||
|
||||
db.session.add(post)
|
||||
db.session.flush() # Get the post ID
|
||||
|
||||
# Handle image uploads
|
||||
if form.images.data and form.images.data.filename:
|
||||
images = request.files.getlist('images')
|
||||
for image_file in images:
|
||||
if image_file and image_file.filename:
|
||||
result = save_image(image_file, post.id)
|
||||
if result['success']:
|
||||
post_image = PostImage(
|
||||
filename=result['filename'],
|
||||
original_name=image_file.filename,
|
||||
size=result['size'],
|
||||
mime_type=image_file.content_type,
|
||||
post_id=post.id
|
||||
)
|
||||
db.session.add(post_image)
|
||||
|
||||
# Handle GPX file upload
|
||||
if form.gpx_file.data and form.gpx_file.data.filename:
|
||||
result = save_gpx_file(form.gpx_file.data, post.id)
|
||||
if result['success']:
|
||||
gpx_file = GPXFile(
|
||||
filename=result['filename'],
|
||||
original_name=form.gpx_file.data.filename,
|
||||
size=result['size'],
|
||||
post_id=post.id
|
||||
)
|
||||
db.session.add(gpx_file)
|
||||
|
||||
db.session.commit()
|
||||
flash('Your adventure has been shared!', 'success')
|
||||
return redirect(url_for('community.post_detail', id=post.id))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash('An error occurred while creating your post. Please try again.', 'error')
|
||||
current_app.logger.error(f'Error creating post: {str(e)}')
|
||||
|
||||
return render_template('community/new_post.html', form=form)
|
||||
|
||||
@community.route('/post/<int:id>/comment', methods=['POST'])
|
||||
@login_required
|
||||
def add_comment(id):
|
||||
"""Add comment to post"""
|
||||
post = Post.query.get_or_404(id)
|
||||
form = CommentForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
comment = Comment(
|
||||
content=form.content.data,
|
||||
author_id=current_user.id,
|
||||
post_id=post.id
|
||||
)
|
||||
db.session.add(comment)
|
||||
db.session.commit()
|
||||
flash('Your comment has been added.', 'success')
|
||||
|
||||
return redirect(url_for('community.post_detail', id=id))
|
||||
|
||||
@community.route('/post/<int:id>/like', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_like(id):
|
||||
"""Toggle like on post"""
|
||||
post = Post.query.get_or_404(id)
|
||||
existing_like = Like.query.filter_by(user_id=current_user.id, post_id=post.id).first()
|
||||
|
||||
if existing_like:
|
||||
db.session.delete(existing_like)
|
||||
liked = False
|
||||
else:
|
||||
like = Like(user_id=current_user.id, post_id=post.id)
|
||||
db.session.add(like)
|
||||
liked = True
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'liked': liked, 'count': post.get_like_count()})
|
||||
|
||||
def save_image(image_file, post_id):
|
||||
"""Save uploaded image file"""
|
||||
try:
|
||||
# Create upload directory
|
||||
upload_dir = os.path.join(current_app.instance_path, 'uploads', 'images')
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
# Generate unique filename
|
||||
filename = secure_filename(f"{uuid.uuid4().hex}_{image_file.filename}")
|
||||
filepath = os.path.join(upload_dir, filename)
|
||||
|
||||
# Save and resize image
|
||||
image = Image.open(image_file)
|
||||
if image.mode in ('RGBA', 'LA', 'P'):
|
||||
image = image.convert('RGB')
|
||||
|
||||
# Resize if too large
|
||||
max_size = (1920, 1080)
|
||||
image.thumbnail(max_size, Image.Resampling.LANCZOS)
|
||||
image.save(filepath, 'JPEG', quality=85, optimize=True)
|
||||
|
||||
file_size = os.path.getsize(filepath)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'filename': filename,
|
||||
'size': file_size
|
||||
}
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Error saving image: {str(e)}')
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def save_gpx_file(gpx_file, post_id):
|
||||
"""Save uploaded GPX file"""
|
||||
try:
|
||||
# Create upload directory
|
||||
upload_dir = os.path.join(current_app.instance_path, 'uploads', 'gpx')
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
# Generate unique filename
|
||||
filename = secure_filename(f"{uuid.uuid4().hex}_{gpx_file.filename}")
|
||||
filepath = os.path.join(upload_dir, filename)
|
||||
|
||||
# Validate GPX file
|
||||
gpx_content = gpx_file.read()
|
||||
gpx = gpxpy.parse(gpx_content.decode('utf-8'))
|
||||
|
||||
# Save file
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(gpx_content)
|
||||
|
||||
file_size = len(gpx_content)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'filename': filename,
|
||||
'size': file_size
|
||||
}
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Error saving GPX file: {str(e)}')
|
||||
return {'success': False, 'error': str(e)}
|
||||
39
app/routes/main.py
Normal file
39
app/routes/main.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from app.models import Post, PostImage, GPXFile, User, db
|
||||
from werkzeug.utils import secure_filename
|
||||
from werkzeug.exceptions import RequestEntityTooLarge
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
main = Blueprint('main', __name__)
|
||||
|
||||
@main.route('/')
|
||||
def index():
|
||||
"""Landing page with about, accommodation, and community sections"""
|
||||
return render_template('index.html')
|
||||
|
||||
@main.route('/favicon.ico')
|
||||
def favicon():
|
||||
"""Serve favicon"""
|
||||
return redirect(url_for('static', filename='favicon.ico'))
|
||||
|
||||
@main.route('/health')
|
||||
def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {'status': 'healthy', 'timestamp': datetime.utcnow().isoformat()}
|
||||
|
||||
@main.app_errorhandler(404)
|
||||
def not_found_error(error):
|
||||
return render_template('errors/404.html'), 404
|
||||
|
||||
@main.app_errorhandler(500)
|
||||
def internal_error(error):
|
||||
db.session.rollback()
|
||||
return render_template('errors/500.html'), 500
|
||||
|
||||
@main.app_errorhandler(RequestEntityTooLarge)
|
||||
def file_too_large(error):
|
||||
flash('File is too large. Maximum file size is 16MB.', 'error')
|
||||
return redirect(request.url)
|
||||
BIN
app/static/favicon.ico
Normal file
BIN
app/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 602 KiB |
BIN
app/static/images/logo.png
Normal file
BIN
app/static/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 602 KiB |
BIN
app/static/images/pano transalpina.jpg
Normal file
BIN
app/static/images/pano transalpina.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 MiB |
1
app/templates/README.md
Normal file
1
app/templates/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# Create templates directory structure
|
||||
66
app/templates/auth/login.html
Normal file
66
app/templates/auth/login.html
Normal file
@@ -0,0 +1,66 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Sign In - Moto Adventure{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen bg-gray-50 py-20">
|
||||
<div class="max-w-md mx-auto bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
<div class="bg-gradient-to-r from-blue-500 via-purple-500 to-teal-500 p-6 text-white text-center">
|
||||
<h2 class="text-2xl font-bold">
|
||||
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="Moto Adventure" class="h-8 w-8 inline mr-2">
|
||||
Welcome Back
|
||||
</h2>
|
||||
<p class="text-blue-100 mt-1">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" class="p-6 space-y-6">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div>
|
||||
{{ form.email.label(class="block text-sm font-medium text-gray-700 mb-1") }}
|
||||
{{ form.email(class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent") }}
|
||||
{% if form.email.errors %}
|
||||
<div class="text-red-500 text-sm mt-1">
|
||||
{% for error in form.email.errors %}
|
||||
<p>{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form.password.label(class="block text-sm font-medium text-gray-700 mb-1") }}
|
||||
{{ form.password(class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent") }}
|
||||
{% if form.password.errors %}
|
||||
<div class="text-red-500 text-sm mt-1">
|
||||
{% for error in form.password.errors %}
|
||||
<p>{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
{{ form.remember_me(class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded") }}
|
||||
{{ form.remember_me.label(class="ml-2 block text-sm text-gray-700") }}
|
||||
</div>
|
||||
<a href="{{ url_for('auth.forgot_password') }}" class="text-sm text-purple-600 hover:text-purple-700">
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{ form.submit(class="w-full bg-gradient-to-r from-blue-600 to-purple-600 text-white py-2 px-4 rounded-lg hover:from-blue-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition font-medium") }}
|
||||
</form>
|
||||
|
||||
<div class="px-6 pb-6 text-center">
|
||||
<p class="text-gray-600">
|
||||
Don't have an account?
|
||||
<a href="{{ url_for('auth.register') }}" class="text-blue-600 hover:text-blue-700 font-medium">
|
||||
Sign up here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
83
app/templates/auth/register.html
Normal file
83
app/templates/auth/register.html
Normal file
@@ -0,0 +1,83 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Join Community - Moto Adventure{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen bg-gray-50 py-20">
|
||||
<div class="max-w-md mx-auto bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
<div class="bg-gradient-to-r from-emerald-500 via-cyan-500 to-blue-500 p-6 text-white text-center">
|
||||
<h2 class="text-2xl font-bold">
|
||||
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="Moto Adventure" class="h-8 w-8 inline mr-2">
|
||||
Join the Adventure
|
||||
</h2>
|
||||
<p class="text-cyan-100 mt-1">Create your account</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" class="p-6 space-y-6">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div>
|
||||
{{ form.nickname.label(class="block text-sm font-medium text-gray-700 mb-1") }}
|
||||
{{ form.nickname(class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent") }}
|
||||
{% if form.nickname.errors %}
|
||||
<div class="text-red-500 text-sm mt-1">
|
||||
{% for error in form.nickname.errors %}
|
||||
<p>{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form.email.label(class="block text-sm font-medium text-gray-700 mb-1") }}
|
||||
{{ form.email(class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent") }}
|
||||
{% if form.email.errors %}
|
||||
<div class="text-red-500 text-sm mt-1">
|
||||
{% for error in form.email.errors %}
|
||||
<p>{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form.password.label(class="block text-sm font-medium text-gray-700 mb-1") }}
|
||||
{{ form.password(class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent") }}
|
||||
{% if form.password.errors %}
|
||||
<div class="text-red-500 text-sm mt-1">
|
||||
{% for error in form.password.errors %}
|
||||
<p>{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
At least 8 characters with letters and numbers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form.password2.label(class="block text-sm font-medium text-gray-700 mb-1") }}
|
||||
{{ form.password2(class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent") }}
|
||||
{% if form.password2.errors %}
|
||||
<div class="text-red-500 text-sm mt-1">
|
||||
{% for error in form.password2.errors %}
|
||||
<p>{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{{ form.submit(class="w-full bg-orange-600 text-white py-2 px-4 rounded-lg hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 transition font-medium") }}
|
||||
</form>
|
||||
|
||||
<div class="px-6 pb-6 text-center">
|
||||
<p class="text-gray-600">
|
||||
Already have an account?
|
||||
<a href="{{ url_for('auth.login') }}" class="text-orange-600 hover:text-orange-700 font-medium">
|
||||
Sign in here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
173
app/templates/base.html
Normal file
173
app/templates/base.html
Normal file
@@ -0,0 +1,173 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Moto Adventure Community{% endblock %}</title>
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<!-- Navigation -->
|
||||
<nav class="bg-gradient-to-r from-blue-600 via-purple-600 to-teal-600 shadow-lg fixed w-full z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<a href="{{ url_for('main.index') }}" class="text-white text-xl font-bold flex items-center">
|
||||
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="Moto Adventure" class="h-8 w-8 mr-2">
|
||||
Moto Adventure
|
||||
</a>
|
||||
</div>
|
||||
<div class="hidden md:flex items-center space-x-8">
|
||||
<a href="{{ url_for('main.index') }}#about" class="text-white hover:text-blue-200 transition">About</a>
|
||||
<a href="{{ url_for('main.index') }}#accommodation" class="text-white hover:text-purple-200 transition">Accommodation</a>
|
||||
<a href="{{ url_for('community.index') }}" class="text-white hover:text-teal-200 transition">Stories & Tracks</a>
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('community.new_post') }}" class="bg-gradient-to-r from-green-500 to-emerald-500 text-white px-4 py-2 rounded-lg hover:from-green-400 hover:to-emerald-400 transition transform hover:scale-105">
|
||||
<i class="fas fa-plus mr-2"></i>New Post
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="text-white hover:text-yellow-200 transition">
|
||||
<i class="fas fa-cog mr-1"></i>Admin
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('auth.logout') }}" class="text-white hover:text-red-200 transition">
|
||||
<i class="fas fa-sign-out-alt mr-1"></i>Logout
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login') }}" class="text-white hover:text-blue-200 transition">Login</a>
|
||||
<a href="{{ url_for('auth.register') }}" class="bg-gradient-to-r from-green-500 to-emerald-500 text-white px-4 py-2 rounded-lg hover:from-green-400 hover:to-emerald-400 transition transform hover:scale-105">Register</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- Mobile menu button -->
|
||||
<div class="md:hidden flex items-center">
|
||||
<button type="button" class="text-white hover:text-orange-200 focus:outline-none focus:text-orange-200" id="mobile-menu-btn">
|
||||
<i class="fas fa-bars text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div class="md:hidden bg-gradient-to-r from-blue-700 via-purple-700 to-teal-700 hidden" id="mobile-menu">
|
||||
<div class="px-2 pt-2 pb-3 space-y-1">
|
||||
<a href="{{ url_for('main.index') }}#about" class="text-white block px-3 py-2 hover:bg-blue-600 rounded">About</a>
|
||||
<a href="{{ url_for('main.index') }}#accommodation" class="text-white block px-3 py-2 hover:bg-purple-600 rounded">Accommodation</a>
|
||||
<a href="{{ url_for('community.index') }}" class="text-white block px-3 py-2 hover:bg-teal-600 rounded">Stories & Tracks</a>
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('community.new_post') }}" class="text-white block px-3 py-2 hover:bg-green-600 rounded">
|
||||
<i class="fas fa-plus mr-2"></i>New Post
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="text-white block px-3 py-2 hover:bg-yellow-600 rounded">
|
||||
<i class="fas fa-cog mr-1"></i>Admin
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('auth.logout') }}" class="text-white block px-3 py-2 hover:bg-red-600 rounded">
|
||||
<i class="fas fa-sign-out-alt mr-1"></i>Logout
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login') }}" class="text-white block px-3 py-2 hover:bg-blue-600 rounded">Login</a>
|
||||
<a href="{{ url_for('auth.register') }}" class="text-white block px-3 py-2 hover:bg-green-600 rounded">Register</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="fixed top-20 right-4 z-50 space-y-2">
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} px-4 py-3 rounded-lg shadow-lg max-w-sm
|
||||
{% if category == 'error' %}bg-red-500 text-white
|
||||
{% elif category == 'success' %}bg-green-500 text-white
|
||||
{% elif category == 'warning' %}bg-yellow-500 text-white
|
||||
{% else %}bg-blue-500 text-white
|
||||
{% endif %}">
|
||||
<div class="flex justify-between items-center">
|
||||
<span>{{ message }}</span>
|
||||
<button type="button" class="ml-2 text-white hover:text-gray-200" onclick="this.parentElement.parentElement.style.display='none'">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="pt-16">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-gray-800 text-white py-12">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold mb-4">
|
||||
<i class="fas fa-motorcycle mr-2"></i>Moto Adventure
|
||||
</h3>
|
||||
<p class="text-gray-300">
|
||||
Join our community of motorcycle enthusiasts exploring the beautiful landscapes of Romania and beyond.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold mb-4">Quick Links</h4>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="{{ url_for('main.index') }}#about" class="text-gray-300 hover:text-white transition">About</a></li>
|
||||
<li><a href="{{ url_for('main.index') }}#accommodation" class="text-gray-300 hover:text-white transition">Accommodation</a></li>
|
||||
<li><a href="{{ url_for('community.index') }}" class="text-gray-300 hover:text-white transition">Stories & Tracks</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold mb-4">Contact</h4>
|
||||
<div class="space-y-2 text-gray-300">
|
||||
<p><i class="fas fa-map-marker-alt mr-2"></i>Pensiunea Buongusto, Romania</p>
|
||||
<p><i class="fas fa-envelope mr-2"></i>info@moto-adv.com</p>
|
||||
<p><i class="fas fa-phone mr-2"></i>+40 123 456 789</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-700 mt-8 pt-8 text-center text-gray-300">
|
||||
<p>© 2024 Moto Adventure Community. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Mobile menu toggle
|
||||
document.getElementById('mobile-menu-btn').addEventListener('click', function() {
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
mobileMenu.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
// Auto-hide flash messages after 5 seconds
|
||||
setTimeout(function() {
|
||||
const alerts = document.querySelectorAll('.alert');
|
||||
alerts.forEach(function(alert) {
|
||||
alert.style.display = 'none';
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
// Smooth scrolling for anchor links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
35
app/templates/errors/404.html
Normal file
35
app/templates/errors/404.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Page Not Found - Moto Adventure{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen bg-gray-50 py-20">
|
||||
<div class="max-w-2xl mx-auto text-center">
|
||||
<div class="bg-white rounded-lg shadow-lg p-8">
|
||||
<div class="mb-6">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-500 text-6xl mb-4"></i>
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-2">404 - Page Not Found</h1>
|
||||
<p class="text-xl text-gray-600">
|
||||
Oops! The page you're looking for seems to have taken a detour.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg p-6 mb-6">
|
||||
<p class="text-gray-700 mb-4">
|
||||
Don't worry, every great adventure has its unexpected turns!
|
||||
Let's get you back on the right path.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href="{{ url_for('main.index') }}" class="bg-gradient-to-r from-blue-600 to-purple-600 text-white px-6 py-3 rounded-lg font-semibold hover:from-blue-700 hover:to-purple-700 transition transform hover:scale-105">
|
||||
<i class="fas fa-home mr-2"></i>Back to Home
|
||||
</a>
|
||||
<a href="{{ url_for('community.index') }}" class="border-2 border-blue-600 text-blue-600 px-6 py-3 rounded-lg font-semibold hover:bg-blue-600 hover:text-white transition transform hover:scale-105">
|
||||
<i class="fas fa-users mr-2"></i>Browse Community
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
35
app/templates/errors/500.html
Normal file
35
app/templates/errors/500.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Server Error - Moto Adventure{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen bg-gray-50 py-20">
|
||||
<div class="max-w-2xl mx-auto text-center">
|
||||
<div class="bg-white rounded-lg shadow-lg p-8">
|
||||
<div class="mb-6">
|
||||
<i class="fas fa-tools text-red-500 text-6xl mb-4"></i>
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-2">500 - Server Error</h1>
|
||||
<p class="text-xl text-gray-600">
|
||||
Something went wrong on our end. We're working to fix it!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gradient-to-r from-red-50 to-orange-50 rounded-lg p-6 mb-6">
|
||||
<p class="text-gray-700 mb-4">
|
||||
Our technical team has been notified and is working to resolve the issue.
|
||||
Please try again in a few moments.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href="{{ url_for('main.index') }}" class="bg-gradient-to-r from-blue-600 to-purple-600 text-white px-6 py-3 rounded-lg font-semibold hover:from-blue-700 hover:to-purple-700 transition transform hover:scale-105">
|
||||
<i class="fas fa-home mr-2"></i>Back to Home
|
||||
</a>
|
||||
<button onclick="window.location.reload()" class="border-2 border-blue-600 text-blue-600 px-6 py-3 rounded-lg font-semibold hover:bg-blue-600 hover:text-white transition transform hover:scale-105">
|
||||
<i class="fas fa-redo mr-2"></i>Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
275
app/templates/index.html
Normal file
275
app/templates/index.html
Normal file
@@ -0,0 +1,275 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Moto Adventure Community - Explore Romania on Two Wheels{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Hero Section -->
|
||||
<section class="relative bg-gradient-to-br from-blue-600 via-purple-600 to-teal-600 text-white py-24" style="background-image: url('{{ url_for('static', filename='images/pano transalpina.jpg') }}'); background-size: cover; background-position: center; background-blend-mode: overlay;">
|
||||
<div class="absolute inset-0 bg-black opacity-40"></div>
|
||||
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<div class="flex justify-center mb-6">
|
||||
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="Moto Adventure" class="h-24 w-24 mb-4">
|
||||
</div>
|
||||
<h1 class="text-5xl md:text-7xl font-bold mb-6 leading-tight drop-shadow-lg">
|
||||
Adventure Awaits
|
||||
</h1>
|
||||
<p class="text-xl md:text-2xl mb-8 max-w-3xl mx-auto leading-relaxed drop-shadow-md">
|
||||
Discover Romania's most breathtaking motorcycle routes, connect with fellow riders,
|
||||
and find the perfect accommodation for your next adventure.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href="#about" class="bg-white text-blue-600 px-8 py-4 rounded-lg font-semibold text-lg hover:bg-gray-100 transition transform hover:scale-105 shadow-lg">
|
||||
<i class="fas fa-compass mr-2"></i>Start Exploring
|
||||
</a>
|
||||
<a href="{{ url_for('community.index') }}" class="border-2 border-white text-white px-8 py-4 rounded-lg font-semibold text-lg hover:bg-white hover:text-purple-600 transition transform hover:scale-105">
|
||||
<i class="fas fa-users mr-2"></i>Join Community
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- About Section -->
|
||||
<section id="about" class="py-20 bg-white">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-4xl font-bold text-gray-900 mb-4">
|
||||
<i class="fas fa-route text-blue-600 mr-3"></i>
|
||||
Discover Romania's Hidden Gems
|
||||
</h2>
|
||||
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
Join our passionate community of motorcycle enthusiasts as we explore the most spectacular
|
||||
routes Romania has to offer. From winding mountain passes to scenic coastal roads.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="bg-blue-100 p-3 rounded-lg">
|
||||
<i class="fas fa-mountain text-blue-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Epic Mountain Routes</h3>
|
||||
<p class="text-gray-600">
|
||||
Experience breathtaking rides through the Carpathian Mountains, including the famous
|
||||
Transfăgărășan and Transalpina highways.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="bg-purple-100 p-3 rounded-lg">
|
||||
<i class="fas fa-users text-purple-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Vibrant Community</h3>
|
||||
<p class="text-gray-600">
|
||||
Connect with fellow riders, share your adventures, and discover new routes through
|
||||
our active community platform.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="bg-teal-100 p-3 rounded-lg">
|
||||
<i class="fas fa-map-marked-alt text-teal-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">GPS Tracks & Guides</h3>
|
||||
<p class="text-gray-600">
|
||||
Download detailed GPS tracks, get insider tips, and access comprehensive guides
|
||||
for the best motorcycle routes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div class="bg-gradient-to-br from-emerald-400 via-cyan-400 to-blue-500 rounded-2xl p-8 text-white shadow-2xl transform rotate-3 hover:rotate-0 transition-transform duration-300">
|
||||
<div class="bg-white bg-opacity-20 rounded-lg p-6 mb-6">
|
||||
<i class="fas fa-motorcycle text-4xl mb-4"></i>
|
||||
<h3 class="text-2xl font-bold mb-2">Adventure Stats</h3>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold">150+</div>
|
||||
<div class="text-blue-100">Routes Mapped</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold">500+</div>
|
||||
<div class="text-cyan-100">Active Riders</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold">50K+</div>
|
||||
<div class="text-emerald-100">KMs Explored</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold">25+</div>
|
||||
<div class="text-blue-100">Counties Covered</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Accommodation Section -->
|
||||
<section id="accommodation" class="py-20 bg-gray-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-4xl font-bold text-gray-900 mb-4">
|
||||
<i class="fas fa-bed text-purple-600 mr-3"></i>
|
||||
Pensiune BuonGusto
|
||||
</h2>
|
||||
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
Your perfect base camp for motorcycle adventures. Enjoy comfortable accommodation,
|
||||
secure parking, and warm hospitality in the heart of Romania's scenic landscapes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<div class="space-y-6">
|
||||
<div class="bg-white p-6 rounded-xl shadow-lg border-l-4 border-blue-500">
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-3 flex items-center">
|
||||
<i class="fas fa-shield-alt text-blue-600 mr-2"></i>
|
||||
Secure Motorcycle Parking
|
||||
</h3>
|
||||
<p class="text-gray-600">
|
||||
Rest easy knowing your motorcycle is safe in our covered, monitored parking area.
|
||||
We understand how precious your bike is to you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-xl shadow-lg border-l-4 border-teal-500">
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-3 flex items-center">
|
||||
<i class="fas fa-map-marked-alt text-teal-600 mr-2"></i>
|
||||
Local Route Expertise
|
||||
</h3>
|
||||
<p class="text-gray-600">
|
||||
Our staff are passionate about motorcycling and can recommend the best local routes,
|
||||
hidden gems, and must-see attractions in the area.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div class="bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||
<div class="bg-gradient-to-r from-purple-500 to-blue-500 p-6 text-white">
|
||||
<h3 class="text-2xl font-bold mb-2">
|
||||
<i class="fas fa-star mr-2"></i>
|
||||
Premium Accommodation
|
||||
</h3>
|
||||
<p class="text-blue-100">Experience comfort and convenience</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Comfortable Rooms</span>
|
||||
<i class="fas fa-check text-green-500"></i>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Free WiFi</span>
|
||||
<i class="fas fa-check text-green-500"></i>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Draft Beers for Responsible Drivers</span>
|
||||
<i class="fas fa-check text-green-500"></i>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Route Planning</span>
|
||||
<i class="fas fa-check text-green-500"></i>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4">
|
||||
<div class="flex items-center justify-between text-lg font-semibold">
|
||||
<span>Starting from</span>
|
||||
<span class="text-purple-600">€45/night</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="https://buongusto.ro/index.php/book-now/" target="_blank" class="w-full bg-gradient-to-r from-purple-600 to-blue-600 text-white py-3 rounded-lg font-semibold hover:from-purple-700 hover:to-blue-700 transition transform hover:scale-105 block text-center">
|
||||
<i class="fas fa-calendar-alt mr-2"></i>
|
||||
Book Now
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Community Section -->
|
||||
<section id="community" class="py-20 bg-white">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-4xl font-bold text-gray-900 mb-4">
|
||||
<i class="fas fa-users text-teal-600 mr-3"></i>
|
||||
Stories & Tracks Community
|
||||
</h2>
|
||||
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
Share your adventures, discover new routes, and connect with fellow motorcycle enthusiasts.
|
||||
Every ride has a story - what's yours?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 mb-12">
|
||||
<div class="text-center group">
|
||||
<div class="bg-emerald-100 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:bg-emerald-200 transition">
|
||||
<i class="fas fa-camera text-emerald-600 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Share Photos</h3>
|
||||
<p class="text-gray-600">
|
||||
Upload stunning photos from your rides and inspire others to explore new destinations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center group">
|
||||
<div class="bg-blue-100 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:bg-blue-200 transition">
|
||||
<i class="fas fa-route text-blue-600 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Upload GPS Tracks</h3>
|
||||
<p class="text-gray-600">
|
||||
Share your favorite routes with detailed GPS tracks that others can download and follow.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center group">
|
||||
<div class="bg-purple-100 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:bg-purple-200 transition">
|
||||
<i class="fas fa-comments text-purple-600 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Connect & Discuss</h3>
|
||||
<p class="text-gray-600">
|
||||
Join conversations, get tips, and plan group rides with our active community.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="bg-gradient-to-r from-emerald-500 via-cyan-500 to-blue-500 rounded-2xl p-8 text-white">
|
||||
<h3 class="text-2xl font-bold mb-4">Ready to Share Your Adventure?</h3>
|
||||
<p class="text-blue-100 mb-6 text-lg">
|
||||
Join thousands of riders who are already sharing their stories and discovering new routes.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('community.new_post') }}" class="bg-white text-blue-600 px-8 py-3 rounded-lg font-semibold hover:bg-gray-100 transition transform hover:scale-105">
|
||||
<i class="fas fa-plus mr-2"></i>Create New Post
|
||||
</a>
|
||||
<a href="{{ url_for('community.index') }}" class="border-2 border-white text-white px-8 py-3 rounded-lg font-semibold hover:bg-white hover:text-cyan-600 transition transform hover:scale-105">
|
||||
<i class="fas fa-eye mr-2"></i>Browse Stories
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.register') }}" class="bg-white text-blue-600 px-8 py-3 rounded-lg font-semibold hover:bg-gray-100 transition transform hover:scale-105">
|
||||
<i class="fas fa-user-plus mr-2"></i>Join Community
|
||||
</a>
|
||||
<a href="{{ url_for('community.index') }}" class="border-2 border-white text-white px-8 py-3 rounded-lg font-semibold hover:bg-white hover:text-cyan-600 transition transform hover:scale-105">
|
||||
<i class="fas fa-eye mr-2"></i>Browse Stories
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
48
config.py
Normal file
48
config.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'postgresql://postgres:password@localhost/moto_adventure'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
# File Upload Configuration
|
||||
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER') or 'app/static/uploads'
|
||||
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'gpx'}
|
||||
|
||||
# Email Configuration
|
||||
MAIL_SERVER = os.environ.get('MAIL_SERVER') or 'smtp.gmail.com'
|
||||
MAIL_PORT = int(os.environ.get('MAIL_PORT') or 587)
|
||||
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1']
|
||||
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
|
||||
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
|
||||
MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER') or 'noreply@moto-adv.com'
|
||||
|
||||
# Pensiunea Buongusto Configuration
|
||||
PENSIUNEA_PHONE = os.environ.get('PENSIUNEA_PHONE') or '+40-xxx-xxx-xxx'
|
||||
PENSIUNEA_EMAIL = os.environ.get('PENSIUNEA_EMAIL') or 'info@pensiunebuongusto.ro'
|
||||
PENSIUNEA_WEBSITE = os.environ.get('PENSIUNEA_WEBSITE') or 'https://pensiunebuongusto.ro'
|
||||
|
||||
# Redis Configuration (for Celery)
|
||||
REDIS_URL = os.environ.get('REDIS_URL') or 'redis://localhost:6379/0'
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
DEBUG = True
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or 'postgresql://postgres:password@localhost/moto_adventure_dev'
|
||||
|
||||
class ProductionConfig(Config):
|
||||
DEBUG = False
|
||||
|
||||
class TestingConfig(Config):
|
||||
TESTING = True
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||
|
||||
config = {
|
||||
'development': DevelopmentConfig,
|
||||
'production': ProductionConfig,
|
||||
'testing': TestingConfig,
|
||||
'default': DevelopmentConfig
|
||||
}
|
||||
46
docker-compose.yml
Normal file
46
docker-compose.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
- DATABASE_URL=postgresql://moto_user:moto_pass@db:5432/moto_adventure
|
||||
- SECRET_KEY=your-super-secret-key-change-this
|
||||
- MAIL_SERVER=smtp.gmail.com
|
||||
- MAIL_PORT=587
|
||||
- MAIL_USE_TLS=true
|
||||
- MAIL_USERNAME=your-email@gmail.com
|
||||
- MAIL_PASSWORD=your-app-password
|
||||
volumes:
|
||||
- ./uploads:/opt/site/flask-moto-adventure/uploads
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
- POSTGRES_DB=moto_adventure
|
||||
- POSTGRES_USER=moto_user
|
||||
- POSTGRES_PASSWORD=moto_pass
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||
- ./uploads:/opt/site/flask-moto-adventure/uploads:ro
|
||||
depends_on:
|
||||
- app
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
5
next-env.d.ts
vendored
5
next-env.d.ts
vendored
@@ -1,5 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
@@ -1,15 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
domains: ['localhost', 'res.cloudinary.com', 'images.unsplash.com'],
|
||||
},
|
||||
webpack: (config) => {
|
||||
config.module.rules.push({
|
||||
test: /\.gpx$/,
|
||||
use: 'raw-loader',
|
||||
});
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
8179
package-lock.json
generated
8179
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
51
package.json
51
package.json
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"name": "moto-adv-website",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.18",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"@types/node": "^20.17.9",
|
||||
"@types/react": "^18.3.17",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"typescript": "^5.7.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"postcss": "^8.4.41",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "14.2.18",
|
||||
"framer-motion": "^11.11.17",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react-hook-form": "^7.54.0",
|
||||
"next-auth": "^4.24.10",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"prisma": "^5.22.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"@types/leaflet": "^1.9.14",
|
||||
"gpxparser": "^3.0.8",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"@types/multer": "^1.4.12",
|
||||
"sharp": "^0.33.5",
|
||||
"react-dropzone": "^14.2.10",
|
||||
"recharts": "^2.13.3",
|
||||
"nodemailer": "^6.9.8",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"@types/jsonwebtoken": "^9.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/forms": "^0.5.9"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique
|
||||
userId String
|
||||
expires DateTime
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
nickname String @unique
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
isActive Boolean @default(true)
|
||||
isAdmin Boolean @default(false)
|
||||
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
posts Post[]
|
||||
comments Comment[]
|
||||
likes Like[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
|
||||
@@unique([identifier, token])
|
||||
}
|
||||
|
||||
model Post {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
subtitle String?
|
||||
content String @db.Text
|
||||
difficulty Int @default(3) // 1-5 scale
|
||||
published Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
authorId String
|
||||
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||
|
||||
images PostImage[]
|
||||
gpxFiles GPXFile[]
|
||||
comments Comment[]
|
||||
likes Like[]
|
||||
|
||||
@@map("posts")
|
||||
}
|
||||
|
||||
model PostImage {
|
||||
id String @id @default(cuid())
|
||||
filename String
|
||||
originalName String
|
||||
description String?
|
||||
size Int
|
||||
mimeType String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
postId String
|
||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("post_images")
|
||||
}
|
||||
|
||||
model GPXFile {
|
||||
id String @id @default(cuid())
|
||||
filename String
|
||||
originalName String
|
||||
size Int
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
postId String
|
||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("gpx_files")
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id String @id @default(cuid())
|
||||
content String @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
authorId String
|
||||
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||
|
||||
postId String
|
||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("comments")
|
||||
}
|
||||
|
||||
model Like {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
postId String
|
||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, postId])
|
||||
@@map("likes")
|
||||
}
|
||||
19
requirements.txt
Normal file
19
requirements.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
Flask==3.0.0
|
||||
Werkzeug==3.0.1
|
||||
Jinja2==3.1.2
|
||||
SQLAlchemy==2.0.23
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Flask-Migrate==4.0.5
|
||||
Flask-Login==0.6.3
|
||||
Flask-WTF==1.2.1
|
||||
WTForms==3.1.1
|
||||
Flask-Mail==0.9.1
|
||||
Pillow==10.1.0
|
||||
python-dotenv==1.0.0
|
||||
bcrypt==4.1.2
|
||||
email-validator==2.1.0
|
||||
gpxpy==1.5.0
|
||||
gunicorn==21.2.0
|
||||
psycopg2-binary==2.9.9
|
||||
redis==5.0.1
|
||||
celery==5.3.4
|
||||
BIN
resources/logo.png
Normal file
BIN
resources/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 602 KiB |
48
run.py
Normal file
48
run.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import os
|
||||
from app import create_app, db
|
||||
from app.models import User, Post, PostImage, GPXFile, Comment, Like
|
||||
|
||||
app = create_app()
|
||||
|
||||
@app.shell_context_processor
|
||||
def make_shell_context():
|
||||
return {
|
||||
'db': db,
|
||||
'User': User,
|
||||
'Post': Post,
|
||||
'PostImage': PostImage,
|
||||
'GPXFile': GPXFile,
|
||||
'Comment': Comment,
|
||||
'Like': Like
|
||||
}
|
||||
|
||||
@app.cli.command()
|
||||
def init_db():
|
||||
"""Initialize the database."""
|
||||
db.create_all()
|
||||
print('Database initialized.')
|
||||
|
||||
@app.cli.command()
|
||||
def create_admin():
|
||||
"""Create an admin user."""
|
||||
admin_email = os.environ.get('ADMIN_EMAIL', 'admin@moto-adv.com')
|
||||
admin_password = os.environ.get('ADMIN_PASSWORD', 'admin123')
|
||||
|
||||
admin = User.query.filter_by(email=admin_email).first()
|
||||
if admin:
|
||||
print(f'Admin user {admin_email} already exists.')
|
||||
return
|
||||
|
||||
admin = User(
|
||||
nickname='admin',
|
||||
email=admin_email,
|
||||
is_admin=True
|
||||
)
|
||||
admin.set_password(admin_password)
|
||||
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
print(f'Admin user created: {admin_email}')
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
@@ -1,428 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
Users,
|
||||
FileText,
|
||||
Settings,
|
||||
BarChart3,
|
||||
Shield,
|
||||
Bell,
|
||||
Database,
|
||||
Globe,
|
||||
Upload,
|
||||
Download,
|
||||
Trash2,
|
||||
Edit,
|
||||
Eye,
|
||||
UserCheck,
|
||||
UserX
|
||||
} from 'lucide-react'
|
||||
|
||||
export default function AdminPage() {
|
||||
const [activeTab, setActiveTab] = useState('dashboard')
|
||||
|
||||
const stats = {
|
||||
totalUsers: 1247,
|
||||
totalPosts: 342,
|
||||
totalRoutes: 156,
|
||||
pendingPosts: 8
|
||||
}
|
||||
|
||||
const recentPosts = [
|
||||
{ id: 1, title: "Carpathian Adventure", author: "RiderMike", status: "published", date: "2024-07-22" },
|
||||
{ id: 2, title: "Transylvanian Loop", author: "MotoSarah", status: "pending", date: "2024-07-21" },
|
||||
{ id: 3, title: "Danube Delta Ride", author: "AdventureAlex", status: "published", date: "2024-07-20" },
|
||||
]
|
||||
|
||||
const users = [
|
||||
{ id: 1, nickname: "RiderMike", email: "mike@example.com", posts: 12, status: "active", joined: "2024-01-15" },
|
||||
{ id: 2, nickname: "MotoSarah", email: "sarah@example.com", posts: 8, status: "active", joined: "2024-02-20" },
|
||||
{ id: 3, nickname: "AdventureAlex", email: "alex@example.com", posts: 15, status: "inactive", joined: "2024-03-10" },
|
||||
]
|
||||
|
||||
const TabButton = ({ id, label, icon }: { id: string, label: string, icon: React.ReactNode }) => (
|
||||
<button
|
||||
onClick={() => setActiveTab(id)}
|
||||
className={`flex items-center gap-3 w-full px-4 py-3 rounded-lg text-left transition-colors ${
|
||||
activeTab === id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
<span className="font-medium">{label}</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
const StatCard = ({ title, value, icon, color }: { title: string, value: number, icon: React.ReactNode, color: string }) => (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm">{title}</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{value.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-full ${color}`}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
🏍️ Admin Dashboard
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<button className="p-2 text-gray-600 hover:text-gray-900 relative">
|
||||
<Bell className="w-6 h-6" />
|
||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||
3
|
||||
</span>
|
||||
</button>
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-medium">
|
||||
A
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
{/* Sidebar */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<nav className="space-y-2">
|
||||
<TabButton
|
||||
id="dashboard"
|
||||
label="Dashboard"
|
||||
icon={<BarChart3 className="w-5 h-5" />}
|
||||
/>
|
||||
<TabButton
|
||||
id="users"
|
||||
label="Users"
|
||||
icon={<Users className="w-5 h-5" />}
|
||||
/>
|
||||
<TabButton
|
||||
id="posts"
|
||||
label="Posts"
|
||||
icon={<FileText className="w-5 h-5" />}
|
||||
/>
|
||||
<TabButton
|
||||
id="frontend"
|
||||
label="Frontend"
|
||||
icon={<Globe className="w-5 h-5" />}
|
||||
/>
|
||||
<TabButton
|
||||
id="backend"
|
||||
label="Backend"
|
||||
icon={<Database className="w-5 h-5" />}
|
||||
/>
|
||||
<TabButton
|
||||
id="settings"
|
||||
label="Settings"
|
||||
icon={<Settings className="w-5 h-5" />}
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-3">
|
||||
{/* Dashboard Tab */}
|
||||
{activeTab === 'dashboard' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-8"
|
||||
>
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
title="Total Users"
|
||||
value={stats.totalUsers}
|
||||
icon={<Users className="w-6 h-6 text-white" />}
|
||||
color="bg-blue-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Posts"
|
||||
value={stats.totalPosts}
|
||||
icon={<FileText className="w-6 h-6 text-white" />}
|
||||
color="bg-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="GPX Routes"
|
||||
value={stats.totalRoutes}
|
||||
icon={<Upload className="w-6 h-6 text-white" />}
|
||||
color="bg-purple-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Pending Posts"
|
||||
value={stats.pendingPosts}
|
||||
icon={<Shield className="w-6 h-6 text-white" />}
|
||||
color="bg-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Posts */}
|
||||
<div className="bg-white rounded-xl shadow-lg">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900">Recent Posts</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Title
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Author
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{recentPosts.map((post) => (
|
||||
<tr key={post.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{post.title}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{post.author}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
post.status === 'published'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{post.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{post.date}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<button className="text-blue-600 hover:text-blue-900">
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="text-green-600 hover:text-green-900">
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="text-red-600 hover:text-red-900">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Users Tab */}
|
||||
{activeTab === 'users' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-xl shadow-lg"
|
||||
>
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900">User Management</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Posts
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Joined
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{user.nickname}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{user.email}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{user.posts}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
user.status === 'active'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{user.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{user.joined}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<button className="text-green-600 hover:text-green-900">
|
||||
<UserCheck className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="text-red-600 hover:text-red-900">
|
||||
<UserX className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Frontend Management Tab */}
|
||||
{activeTab === 'frontend' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">Frontend Management</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">Site Content</h3>
|
||||
<button className="w-full p-4 border border-gray-300 rounded-lg hover:bg-gray-50 text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<Edit className="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium">Edit Homepage</div>
|
||||
<div className="text-sm text-gray-500">Update hero section, about text</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button className="w-full p-4 border border-gray-300 rounded-lg hover:bg-gray-50 text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings className="w-5 h-5 text-green-600" />
|
||||
<div>
|
||||
<div className="font-medium">Site Settings</div>
|
||||
<div className="text-sm text-gray-500">Logo, contact info, footer</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">Accommodation</h3>
|
||||
<button className="w-full p-4 border border-gray-300 rounded-lg hover:bg-gray-50 text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<Edit className="w-5 h-5 text-amber-600" />
|
||||
<div>
|
||||
<div className="font-medium">Edit Pensiunea Section</div>
|
||||
<div className="text-sm text-gray-500">Update accommodation details</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Backend Management Tab */}
|
||||
{activeTab === 'backend' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">Backend Management</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">Database</h3>
|
||||
<button className="w-full p-4 border border-gray-300 rounded-lg hover:bg-gray-50 text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium">Database Backup</div>
|
||||
<div className="text-sm text-gray-500">Create backup of all data</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button className="w-full p-4 border border-gray-300 rounded-lg hover:bg-gray-50 text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<Upload className="w-5 h-5 text-green-600" />
|
||||
<div>
|
||||
<div className="font-medium">Import Data</div>
|
||||
<div className="text-sm text-gray-500">Import users or posts</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">System</h3>
|
||||
<button className="w-full p-4 border border-gray-300 rounded-lg hover:bg-gray-50 text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<BarChart3 className="w-5 h-5 text-purple-600" />
|
||||
<div>
|
||||
<div className="font-medium">System Logs</div>
|
||||
<div className="text-sm text-gray-500">View application logs</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button className="w-full p-4 border border-gray-300 rounded-lg hover:bg-gray-50 text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-5 h-5 text-red-600" />
|
||||
<div>
|
||||
<div className="font-medium">Security Settings</div>
|
||||
<div className="text-sm text-gray-500">Manage security policies</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { MapPin, Clock, Camera, Heart, Share2, Calendar } from 'lucide-react'
|
||||
import Header from '@/components/Header'
|
||||
import Image from 'next/image'
|
||||
|
||||
export default function AdventuresPage() {
|
||||
const adventures = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Pacific Coast Highway Adventure",
|
||||
location: "California, USA",
|
||||
duration: "7 days",
|
||||
distance: "650 miles",
|
||||
difficulty: "Intermediate",
|
||||
likes: 234,
|
||||
date: "2024-06-15",
|
||||
image: "https://images.unsplash.com/photo-1558618666-fcd25c85cd64?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1000&q=80",
|
||||
description: "An epic coastal ride through California's most scenic highways, featuring breathtaking ocean views and winding mountain roads.",
|
||||
tags: ["coastal", "scenic", "mountains"]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Alpine Loop Challenge",
|
||||
location: "Swiss Alps",
|
||||
duration: "5 days",
|
||||
distance: "800 miles",
|
||||
difficulty: "Advanced",
|
||||
likes: 189,
|
||||
date: "2024-07-20",
|
||||
image: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1000&q=80",
|
||||
description: "Navigate through stunning Alpine passes with challenging curves and spectacular mountain vistas.",
|
||||
tags: ["mountains", "challenging", "scenic"]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Desert Sunset Expedition",
|
||||
location: "Arizona, USA",
|
||||
duration: "3 days",
|
||||
distance: "450 miles",
|
||||
difficulty: "Beginner",
|
||||
likes: 156,
|
||||
date: "2024-05-10",
|
||||
image: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1000&q=80",
|
||||
description: "Experience the magic of desert landscapes with incredible sunset views and star-filled nights.",
|
||||
tags: ["desert", "sunset", "easy"]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Northern Lights Quest",
|
||||
location: "Norway",
|
||||
duration: "10 days",
|
||||
distance: "1200 miles",
|
||||
difficulty: "Advanced",
|
||||
likes: 312,
|
||||
date: "2024-03-01",
|
||||
image: "https://images.unsplash.com/photo-1531366936337-7c912a4589a7?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1000&q=80",
|
||||
description: "Chase the Aurora Borealis through Norway's dramatic fjords and Arctic landscapes.",
|
||||
tags: ["arctic", "northern lights", "fjords"]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Tuscany Wine Trail",
|
||||
location: "Tuscany, Italy",
|
||||
duration: "6 days",
|
||||
distance: "520 miles",
|
||||
difficulty: "Intermediate",
|
||||
likes: 278,
|
||||
date: "2024-08-05",
|
||||
image: "https://images.unsplash.com/photo-1523906834658-6e24ef2386f9?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1000&q=80",
|
||||
description: "Ride through rolling hills, vineyards, and medieval towns in Italy's most beautiful region.",
|
||||
tags: ["vineyards", "culture", "hills"]
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "Great Ocean Road",
|
||||
location: "Victoria, Australia",
|
||||
duration: "4 days",
|
||||
distance: "380 miles",
|
||||
difficulty: "Beginner",
|
||||
likes: 201,
|
||||
date: "2024-04-18",
|
||||
image: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1000&q=80",
|
||||
description: "Explore Australia's iconic coastal route with limestone cliffs, beaches, and rainforest sections.",
|
||||
tags: ["coastal", "iconic", "beaches"]
|
||||
}
|
||||
]
|
||||
|
||||
const getDifficultyColor = (difficulty: string) => {
|
||||
switch (difficulty) {
|
||||
case 'Beginner': return 'bg-green-100 text-green-800'
|
||||
case 'Intermediate': return 'bg-yellow-100 text-yellow-800'
|
||||
case 'Advanced': return 'bg-red-100 text-red-800'
|
||||
default: return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50">
|
||||
<Header />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="pt-16 bg-gradient-to-r from-blue-600 to-purple-600 text-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center"
|
||||
>
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-6">
|
||||
Epic Adventures Await
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-blue-100 mb-8 max-w-3xl mx-auto">
|
||||
Discover incredible motorcycle journeys from riders around the world.
|
||||
Get inspired, plan your next trip, and share your own adventures.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<button className="bg-white text-blue-600 px-6 py-3 rounded-lg font-medium hover:bg-blue-50 transition-colors">
|
||||
Share Your Adventure
|
||||
</button>
|
||||
<button className="border border-white text-white px-6 py-3 rounded-lg font-medium hover:bg-white hover:text-blue-600 transition-colors">
|
||||
Browse by Region
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Filters */}
|
||||
<section className="py-8 bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-wrap gap-4 items-center justify-between">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium">All</button>
|
||||
<button className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-200 transition-colors">Coastal</button>
|
||||
<button className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-200 transition-colors">Mountains</button>
|
||||
<button className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-200 transition-colors">Desert</button>
|
||||
<button className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-200 transition-colors">Urban</button>
|
||||
</div>
|
||||
<select className="px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option>Sort by Date</option>
|
||||
<option>Sort by Popularity</option>
|
||||
<option>Sort by Distance</option>
|
||||
<option>Sort by Difficulty</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Adventures Grid */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{adventures.map((adventure, index) => (
|
||||
<motion.div
|
||||
key={adventure.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
viewport={{ once: true }}
|
||||
className="bg-white rounded-2xl shadow-lg overflow-hidden hover:shadow-xl transition-all duration-300 group cursor-pointer"
|
||||
>
|
||||
<div className="relative h-64 overflow-hidden">
|
||||
<div className="w-full h-full bg-gradient-to-br from-blue-400 to-purple-500" />
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
<div className="absolute top-4 left-4">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getDifficultyColor(adventure.difficulty)}`}>
|
||||
{adventure.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute top-4 right-4 flex gap-2">
|
||||
<button className="p-2 bg-white/20 backdrop-blur-sm rounded-full text-white hover:bg-white/30 transition-colors">
|
||||
<Heart className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-2 bg-white/20 backdrop-blur-sm rounded-full text-white hover:bg-white/30 transition-colors">
|
||||
<Share2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm mb-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{new Date(adventure.date).toLocaleDateString()}
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2 group-hover:text-blue-600 transition-colors">
|
||||
{adventure.title}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-2 text-gray-600 mb-3">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span className="text-sm">{adventure.location}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
|
||||
{adventure.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
{adventure.duration}
|
||||
</div>
|
||||
<div>{adventure.distance}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{adventure.tags.slice(0, 2).map((tag) => (
|
||||
<span key={tag} className="px-2 py-1 bg-blue-50 text-blue-600 text-xs rounded-full">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-gray-500">
|
||||
<Heart className="w-4 h-4" />
|
||||
<span className="text-sm">{adventure.likes}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Load More */}
|
||||
<section className="py-8 text-center">
|
||||
<button className="bg-blue-600 text-white px-8 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
||||
Load More Adventures
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Upload,
|
||||
X,
|
||||
Image as ImageIcon,
|
||||
MapPin,
|
||||
Star,
|
||||
FileText,
|
||||
Camera,
|
||||
Route,
|
||||
Save,
|
||||
Eye
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface UploadedImage {
|
||||
id: string
|
||||
file: File
|
||||
url: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export default function NewPostPage() {
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
subtitle: '',
|
||||
content: '',
|
||||
difficulty: 3
|
||||
})
|
||||
const [uploadedImages, setUploadedImages] = useState<UploadedImage[]>([])
|
||||
const [gpxFile, setGpxFile] = useState<File | null>(null)
|
||||
const [isPreview, setIsPreview] = useState(false)
|
||||
|
||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||
const gpxInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (files) {
|
||||
Array.from(files).forEach(file => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const newImage: UploadedImage = {
|
||||
id: Date.now().toString() + Math.random(),
|
||||
file,
|
||||
url: e.target?.result as string,
|
||||
description: ''
|
||||
}
|
||||
setUploadedImages(prev => [...prev, newImage])
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleGpxUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file && file.name.endsWith('.gpx')) {
|
||||
setGpxFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
const removeImage = (id: string) => {
|
||||
setUploadedImages(prev => prev.filter(img => img.id !== id))
|
||||
}
|
||||
|
||||
const updateImageDescription = (id: string, description: string) => {
|
||||
setUploadedImages(prev =>
|
||||
prev.map(img => img.id === id ? { ...img, description } : img)
|
||||
)
|
||||
}
|
||||
|
||||
const removeGpxFile = () => {
|
||||
setGpxFile(null)
|
||||
if (gpxInputRef.current) {
|
||||
gpxInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// TODO: Implement post creation logic
|
||||
console.log('Post data:', {
|
||||
...formData,
|
||||
images: uploadedImages,
|
||||
gpxFile
|
||||
})
|
||||
}
|
||||
|
||||
const getDifficultyLabel = (difficulty: number) => {
|
||||
const labels = ['Very Easy', 'Easy', 'Moderate', 'Hard', 'Very Hard']
|
||||
return labels[difficulty - 1]
|
||||
}
|
||||
|
||||
const getDifficultyColor = (difficulty: number) => {
|
||||
const colors = [
|
||||
'text-green-600',
|
||||
'text-lime-600',
|
||||
'text-yellow-600',
|
||||
'text-orange-600',
|
||||
'text-red-600'
|
||||
]
|
||||
return colors[difficulty - 1]
|
||||
}
|
||||
|
||||
if (isPreview) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50">
|
||||
{/* Preview Header */}
|
||||
<div className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setIsPreview(false)}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
Back to Editor
|
||||
</button>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setIsPreview(false)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Content */}
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<motion.article
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl shadow-lg overflow-hidden"
|
||||
>
|
||||
{/* GPX Map Preview */}
|
||||
{gpxFile && (
|
||||
<div className="h-64 bg-gradient-to-br from-green-400 to-blue-500 relative">
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
<div className="absolute top-4 right-4 bg-white/90 backdrop-blur-sm px-3 py-2 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Route className="w-4 h-4" />
|
||||
<span>GPX Route Preview</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4">
|
||||
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2">
|
||||
<Upload className="w-4 h-4" />
|
||||
Download GPX
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className={`px-3 py-1 rounded-full text-sm font-medium ${getDifficultyColor(formData.difficulty)} bg-gray-100`}>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: formData.difficulty }).map((_, i) => (
|
||||
<Star key={i} className="w-3 h-3 fill-current" />
|
||||
))}
|
||||
<span className="ml-1">{getDifficultyLabel(formData.difficulty)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
{formData.title || 'Your Adventure Title'}
|
||||
</h1>
|
||||
|
||||
{formData.subtitle && (
|
||||
<p className="text-xl text-gray-600">
|
||||
{formData.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="prose max-w-none mb-8">
|
||||
<div className="text-gray-700 leading-relaxed whitespace-pre-wrap">
|
||||
{formData.content || 'Your adventure story will appear here...'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
{uploadedImages.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{uploadedImages.map((image) => (
|
||||
<div key={image.id} className="space-y-2">
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.description}
|
||||
className="w-full rounded-xl shadow-lg"
|
||||
/>
|
||||
{image.description && (
|
||||
<p className="text-gray-600 italic text-center">
|
||||
{image.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.article>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/community" className="flex items-center gap-2 text-gray-600 hover:text-gray-900">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
Back to Community
|
||||
</Link>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setIsPreview(true)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 flex items-center gap-2"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl shadow-lg p-8"
|
||||
>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Share Your Adventure</h1>
|
||||
<p className="text-gray-600">Tell the community about your epic motorcycle journey</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Adventure Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-lg"
|
||||
placeholder="e.g., Epic Ride Through the Carpathian Mountains"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Subtitle
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="subtitle"
|
||||
value={formData.subtitle}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="A brief description of your adventure"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Difficulty Rating */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-4">
|
||||
Difficulty Rating *
|
||||
</label>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3, 4, 5].map((level) => (
|
||||
<label key={level} className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="difficulty"
|
||||
value={level}
|
||||
checked={formData.difficulty === level}
|
||||
onChange={(e) => setFormData({ ...formData, difficulty: parseInt(e.target.value) })}
|
||||
className="w-4 h-4 text-blue-600"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex">
|
||||
{Array.from({ length: level }).map((_, i) => (
|
||||
<Star key={i} className={`w-4 h-4 fill-current ${getDifficultyColor(level)}`} />
|
||||
))}
|
||||
{Array.from({ length: 5 - level }).map((_, i) => (
|
||||
<Star key={i} className="w-4 h-4 text-gray-300" />
|
||||
))}
|
||||
</div>
|
||||
<span className={`font-medium ${getDifficultyColor(level)}`}>
|
||||
{getDifficultyLabel(level)}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Adventure Story *
|
||||
</label>
|
||||
<textarea
|
||||
name="content"
|
||||
value={formData.content}
|
||||
onChange={handleInputChange}
|
||||
rows={12}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
placeholder="Share your adventure story... Where did you go? What did you see? What challenges did you face? What made it special?"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-4">
|
||||
Photos
|
||||
</label>
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
className="w-full border-2 border-dashed border-gray-300 rounded-lg p-6 hover:border-blue-500 transition-colors"
|
||||
>
|
||||
<div className="text-center">
|
||||
<ImageIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<div className="mt-2">
|
||||
<span className="text-sm font-medium text-gray-900">Upload photos</span>
|
||||
<p className="text-sm text-gray-500">PNG, JPG up to 10MB each</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Uploaded Images */}
|
||||
{uploadedImages.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{uploadedImages.map((image) => (
|
||||
<div key={image.id} className="relative group">
|
||||
<img
|
||||
src={image.url}
|
||||
alt="Uploaded"
|
||||
className="w-full h-48 object-cover rounded-lg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(image.id)}
|
||||
className="absolute top-2 right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add photo description..."
|
||||
value={image.description}
|
||||
onChange={(e) => updateImageDescription(image.id, e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GPX File Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-4">
|
||||
GPX Route File
|
||||
</label>
|
||||
<div className="space-y-4">
|
||||
{!gpxFile ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => gpxInputRef.current?.click()}
|
||||
className="w-full border-2 border-dashed border-gray-300 rounded-lg p-6 hover:border-green-500 transition-colors"
|
||||
>
|
||||
<div className="text-center">
|
||||
<Route className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<div className="mt-2">
|
||||
<span className="text-sm font-medium text-gray-900">Upload GPX file</span>
|
||||
<p className="text-sm text-gray-500">Share your route with the community</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Route className="w-5 h-5 text-green-600" />
|
||||
<span className="text-sm font-medium text-green-900">{gpxFile.name}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeGpxFile}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={gpxInputRef}
|
||||
type="file"
|
||||
accept=".gpx"
|
||||
onChange={handleGpxUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Mail, Lock, User, Eye, EyeOff, ArrowLeft } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function CommunityPage() {
|
||||
const [isLogin, setIsLogin] = useState(true)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showForgotPassword, setShowForgotPassword] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
nickname: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// TODO: Implement authentication logic
|
||||
console.log('Form submitted:', formData)
|
||||
}
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
email: '',
|
||||
password: '',
|
||||
nickname: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
}
|
||||
|
||||
const switchMode = (mode: 'login' | 'register') => {
|
||||
setIsLogin(mode === 'login')
|
||||
setShowForgotPassword(false)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
if (showForgotPassword) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-blue-900 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="bg-white rounded-2xl shadow-2xl overflow-hidden w-full max-w-md"
|
||||
>
|
||||
<div className="p-8">
|
||||
<div className="text-center mb-8">
|
||||
<button
|
||||
onClick={() => setShowForgotPassword(false)}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Login
|
||||
</button>
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">Reset Password</h2>
|
||||
<p className="text-gray-600">Enter your email to receive reset instructions</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 text-white py-3 rounded-lg font-medium hover:from-blue-700 hover:to-purple-700 transition-all transform hover:scale-105"
|
||||
>
|
||||
Send Reset Link
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-blue-900 flex items-center justify-center p-4">
|
||||
<div className="absolute top-4 left-4">
|
||||
<Link href="/" className="flex items-center gap-2 text-white hover:text-gray-300 transition-colors">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="bg-white rounded-2xl shadow-2xl overflow-hidden w-full max-w-md"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-purple-600 p-8 text-white text-center">
|
||||
<h1 className="text-2xl font-bold mb-2">
|
||||
🏍️ Moto Adventure
|
||||
</h1>
|
||||
<p className="text-blue-100">
|
||||
Join our Stories & Tracks community
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
{isLogin ? 'Welcome Back' : 'Join Community'}
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
{isLogin
|
||||
? 'Sign in to share your adventures'
|
||||
: 'Create account to start sharing'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{!isLogin && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nickname
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
name="nickname"
|
||||
value={formData.nickname}
|
||||
onChange={handleInputChange}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Choose a nickname"
|
||||
required={!isLogin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className="w-full pl-10 pr-12 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLogin && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Confirm your password"
|
||||
required={!isLogin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLogin && (
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center">
|
||||
<input type="checkbox" className="w-4 h-4 text-blue-600 rounded" />
|
||||
<span className="ml-2 text-sm text-gray-600">Remember me</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForgotPassword(true)}
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 text-white py-3 rounded-lg font-medium hover:from-blue-700 hover:to-purple-700 transition-all transform hover:scale-105"
|
||||
>
|
||||
{isLogin ? 'Sign In' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-gray-600">
|
||||
{isLogin ? "Don't have an account?" : "Already have an account?"}
|
||||
<button
|
||||
onClick={() => switchMode(isLogin ? 'register' : 'login')}
|
||||
className="ml-1 text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
{isLogin ? 'Sign up' : 'Sign in'}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isLogin && (
|
||||
<div className="mt-6 text-xs text-gray-500 text-center">
|
||||
By creating an account, you agree to our Terms of Service and Privacy Policy
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom styles for motorcycle adventure theme */
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--muted: 210 40% 98%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--accent: 210 40% 96%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Adventure theme colors */
|
||||
.adventure-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
background: linear-gradient(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.6)),
|
||||
url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><defs><pattern id="a" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="50" cy="50" r="2" fill="%23ffffff" opacity="0.1"/></pattern></defs><rect width="100%" height="100%" fill="%23111827"/><rect width="100%" height="100%" fill="url(%23a)"/></svg>');
|
||||
}
|
||||
|
||||
/* Smooth scroll */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Moto Adventure - Epic Motorcycle Journeys',
|
||||
description: 'Discover epic motorcycle adventures, share your journeys, and connect with fellow riders. Track your routes, share stories, and explore the world on two wheels.',
|
||||
keywords: 'motorcycle, adventure, riding, travel, GPS, routes, touring, bike',
|
||||
authors: [{ name: 'Moto Adventure Team' }],
|
||||
openGraph: {
|
||||
title: 'Moto Adventure - Epic Motorcycle Journeys',
|
||||
description: 'Discover epic motorcycle adventures and share your journeys with fellow riders.',
|
||||
url: 'https://moto-adv.com',
|
||||
siteName: 'Moto Adventure',
|
||||
type: 'website',
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className="scroll-smooth">
|
||||
<body className={inter.className}>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50">
|
||||
{children}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
398
src/app/page.tsx
398
src/app/page.tsx
@@ -1,398 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { MapPin, Users, Home, ArrowRight, Star, Bed, Phone, Mail, Download } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Navigation */}
|
||||
<nav className="fixed top-0 w-full bg-white/95 backdrop-blur-md border-b border-gray-200 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-xl md:text-2xl font-bold text-gray-900">
|
||||
🏍️ <span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">Moto Adventure</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<div className="ml-10 flex items-baseline space-x-4">
|
||||
<a href="#about" className="text-gray-900 hover:text-blue-600 px-3 py-2 rounded-md text-sm font-medium transition-colors">About</a>
|
||||
<a href="#accommodation" className="text-gray-600 hover:text-blue-600 px-3 py-2 rounded-md text-sm font-medium transition-colors">Stay</a>
|
||||
<a href="#community" className="text-gray-600 hover:text-blue-600 px-3 py-2 rounded-md text-sm font-medium transition-colors">Community</a>
|
||||
<Link href="/community" className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">
|
||||
Join Community
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile menu button */}
|
||||
<div className="md:hidden">
|
||||
<Link href="/community" className="bg-blue-600 text-white px-3 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">
|
||||
Join
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section - Full Screen */}
|
||||
<section id="hero" className="min-h-screen hero-bg flex items-center relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/50 via-black/30 to-black/50" />
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 relative z-10">
|
||||
<div className="text-center">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-4xl md:text-6xl lg:text-7xl font-bold text-white mb-6"
|
||||
>
|
||||
Welcome to Your
|
||||
<span className="block bg-gradient-to-r from-yellow-400 to-orange-500 bg-clip-text text-transparent">
|
||||
Adventure Community
|
||||
</span>
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="text-xl md:text-2xl text-gray-200 mb-12 max-w-4xl mx-auto leading-relaxed"
|
||||
>
|
||||
Share your epic motorcycle journeys, discover incredible routes, and connect with fellow riders.
|
||||
We created this platform to bring our community closer together.
|
||||
</motion.p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="flex flex-col sm:flex-row gap-4 justify-center items-center"
|
||||
>
|
||||
<Link href="/community" className="bg-gradient-to-r from-blue-600 to-purple-600 text-white px-8 py-4 rounded-lg text-lg font-medium hover:from-blue-700 hover:to-purple-700 transition-all transform hover:scale-105 flex items-center gap-2">
|
||||
Join Our Community
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
<a href="#community" className="border-2 border-white text-white px-8 py-4 rounded-lg text-lg font-medium hover:bg-white hover:text-gray-900 transition-all">
|
||||
Explore Stories & Tracks
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* About Section */}
|
||||
<section id="about" className="py-20 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-3xl md:text-5xl font-bold text-gray-900 mb-8">
|
||||
Created for Our Community
|
||||
</h2>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<p className="text-xl md:text-2xl text-gray-600 mb-8 leading-relaxed">
|
||||
We built this platform with one goal in mind - to be closer to our customers and create
|
||||
a space where motorcycle enthusiasts can share their passion, experiences, and discoveries.
|
||||
</p>
|
||||
<p className="text-lg text-gray-700 leading-relaxed">
|
||||
Whether you're planning your next adventure, looking for the perfect route, or want to share
|
||||
your incredible journey with fellow riders, this is your home. Every story shared, every track
|
||||
uploaded, and every connection made brings our community closer together.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mt-16">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center p-8 bg-gradient-to-br from-blue-50 to-purple-50 rounded-2xl"
|
||||
>
|
||||
<Users className="w-12 h-12 text-blue-600 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Community First</h3>
|
||||
<p className="text-gray-600">
|
||||
Built by riders, for riders. Every feature designed to bring our community closer.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center p-8 bg-gradient-to-br from-green-50 to-blue-50 rounded-2xl"
|
||||
>
|
||||
<MapPin className="w-12 h-12 text-green-600 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Share & Discover</h3>
|
||||
<p className="text-gray-600">
|
||||
Share your routes and stories, discover new adventures from fellow riders.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center p-8 bg-gradient-to-br from-purple-50 to-pink-50 rounded-2xl"
|
||||
>
|
||||
<Home className="w-12 h-12 text-purple-600 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">Your Adventure Hub</h3>
|
||||
<p className="text-gray-600">
|
||||
Everything you need for your motorcycle adventures in one place.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Accommodation Section */}
|
||||
<section id="accommodation" className="py-20 bg-gradient-to-br from-amber-50 to-orange-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-3xl md:text-5xl font-bold text-gray-900 mb-8">
|
||||
Stay with Us in Sibiu
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 mb-8 max-w-3xl mx-auto">
|
||||
Make your Transylvania adventure complete with comfortable accommodation at Pensiunea Buongusto Sibiu
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="bg-white p-8 rounded-2xl shadow-lg">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Bed className="w-8 h-8 text-amber-600" />
|
||||
<h3 className="text-2xl font-bold text-gray-900">Pensiunea Buongusto Sibiu</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-6 leading-relaxed">
|
||||
Experience authentic Romanian hospitality in the heart of Sibiu. Our guesthouse offers
|
||||
comfortable rooms, delicious local cuisine, and the perfect base for your Transylvanian
|
||||
motorcycle adventures.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-5 h-5 text-yellow-500 fill-current" />
|
||||
<span className="text-gray-700">Perfect location for motorcycle tours</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-5 h-5 text-yellow-500 fill-current" />
|
||||
<span className="text-gray-700">Secure parking for motorcycles</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-5 h-5 text-yellow-500 fill-current" />
|
||||
<span className="text-gray-700">Local route recommendations</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-5 h-5 text-yellow-500 fill-current" />
|
||||
<span className="text-gray-700">Traditional Romanian breakfast</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<a
|
||||
href="https://pensiunebuongusto.ro"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 bg-amber-600 text-white px-6 py-4 rounded-lg font-medium hover:bg-amber-700 transition-colors text-center"
|
||||
>
|
||||
Visit Our Website
|
||||
</a>
|
||||
<a
|
||||
href="tel:+40-xxx-xxx-xxx"
|
||||
className="flex-1 border-2 border-amber-600 text-amber-600 px-6 py-4 rounded-lg font-medium hover:bg-amber-600 hover:text-white transition-colors text-center"
|
||||
>
|
||||
Call to Book
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="relative"
|
||||
>
|
||||
<div className="bg-gradient-to-br from-amber-400 to-orange-500 rounded-2xl p-8 text-white">
|
||||
<h4 className="text-xl font-bold mb-4">Why Stay with Us?</h4>
|
||||
<ul className="space-y-3">
|
||||
<li className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 bg-white/20 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-xs">✓</span>
|
||||
</div>
|
||||
<span>Prime location in historic Sibiu</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 bg-white/20 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-xs">✓</span>
|
||||
</div>
|
||||
<span>Motorcycle-friendly facilities</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 bg-white/20 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-xs">✓</span>
|
||||
</div>
|
||||
<span>Local expertise for route planning</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 bg-white/20 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-xs">✓</span>
|
||||
</div>
|
||||
<span>Authentic Romanian experience</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Community Section - Stories and Tracks */}
|
||||
<section id="community" className="py-20 bg-gradient-to-br from-slate-900 to-blue-900 text-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-3xl md:text-5xl font-bold mb-8">
|
||||
Stories & Tracks
|
||||
</h2>
|
||||
<p className="text-xl text-gray-300 mb-8 max-w-3xl mx-auto">
|
||||
Join our community to share your adventures, download incredible routes, and connect with fellow riders from around the world.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<h3 className="text-2xl font-bold mb-6">What You Can Do:</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4 p-4 bg-white/10 backdrop-blur-sm rounded-lg">
|
||||
<Download className="w-6 h-6 text-blue-400 flex-shrink-0 mt-1" />
|
||||
<div>
|
||||
<h4 className="font-semibold">Share Your Adventures</h4>
|
||||
<p className="text-gray-300 text-sm">Post trip reports with photos, descriptions, and GPX tracks</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4 p-4 bg-white/10 backdrop-blur-sm rounded-lg">
|
||||
<MapPin className="w-6 h-6 text-green-400 flex-shrink-0 mt-1" />
|
||||
<div>
|
||||
<h4 className="font-semibold">Download GPX Routes</h4>
|
||||
<p className="text-gray-300 text-sm">Access community-shared routes with difficulty ratings and maps</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4 p-4 bg-white/10 backdrop-blur-sm rounded-lg">
|
||||
<Users className="w-6 h-6 text-purple-400 flex-shrink-0 mt-1" />
|
||||
<div>
|
||||
<h4 className="font-semibold">Connect with Riders</h4>
|
||||
<p className="text-gray-300 text-sm">Build connections, plan group rides, and share experiences</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="bg-white/10 backdrop-blur-sm rounded-2xl p-8"
|
||||
>
|
||||
<h3 className="text-2xl font-bold mb-6 text-center">Ready to Join?</h3>
|
||||
<p className="text-gray-300 mb-8 text-center">
|
||||
Sign up with just your email, password, and a nickname. Start sharing your adventures today!
|
||||
</p>
|
||||
<div className="text-center">
|
||||
<Link href="/community" className="bg-gradient-to-r from-blue-500 to-purple-600 text-white px-8 py-4 rounded-lg text-lg font-medium hover:from-blue-600 hover:to-purple-700 transition-all transform hover:scale-105 inline-flex items-center gap-2">
|
||||
Join Community Now
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-8 text-center text-sm text-gray-400">
|
||||
<p>✓ Free to join • ✓ Easy signup • ✓ Share immediately</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold mb-4">🏍️ Moto Adventure Community</h3>
|
||||
<p className="text-gray-400 mb-4">
|
||||
Connecting motorcycle enthusiasts through shared adventures and unforgettable routes.
|
||||
</p>
|
||||
<a
|
||||
href="https://pensiunebuongusto.ro"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
Visit Pensiunea Buongusto →
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-4">Quick Links</h4>
|
||||
<ul className="space-y-2 text-gray-400">
|
||||
<li><a href="#about" className="hover:text-white transition-colors">About Us</a></li>
|
||||
<li><a href="#accommodation" className="hover:text-white transition-colors">Accommodation</a></li>
|
||||
<li><Link href="/community" className="hover:text-white transition-colors">Community</Link></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-4">Contact</h4>
|
||||
<ul className="space-y-2 text-gray-400">
|
||||
<li className="flex items-center gap-2">
|
||||
<Phone className="w-4 h-4" />
|
||||
<span>+40 xxx xxx xxx</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4" />
|
||||
<span>info@pensiunebuongusto.ro</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>Sibiu, Romania</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-700 mt-8 pt-8 text-center text-gray-400">
|
||||
<p>© 2025 Moto Adventure Community. Built with ❤️ for riders.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { MapPin, Download, Star, Filter, Search, Upload } from 'lucide-react'
|
||||
import Header from '@/components/Header'
|
||||
|
||||
export default function RoutesPage() {
|
||||
const routes = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Coastal Highway 1",
|
||||
distance: "145 miles",
|
||||
difficulty: "Intermediate",
|
||||
rating: 4.8,
|
||||
downloads: 2341,
|
||||
startPoint: "San Francisco, CA",
|
||||
endPoint: "Monterey, CA",
|
||||
terrain: "Coastal",
|
||||
description: "Stunning coastal views with challenging curves and breathtaking cliff-side roads.",
|
||||
tags: ["scenic", "coastal", "curves"]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Alpine Adventure Loop",
|
||||
distance: "230 miles",
|
||||
difficulty: "Advanced",
|
||||
rating: 4.9,
|
||||
downloads: 1876,
|
||||
startPoint: "Interlaken, Switzerland",
|
||||
endPoint: "Interlaken, Switzerland",
|
||||
terrain: "Mountain",
|
||||
description: "High-altitude mountain passes with snow-capped peaks and pristine lakes.",
|
||||
tags: ["mountains", "challenging", "scenic"]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Desert Sunrise Route",
|
||||
distance: "89 miles",
|
||||
difficulty: "Beginner",
|
||||
rating: 4.6,
|
||||
downloads: 3102,
|
||||
startPoint: "Phoenix, AZ",
|
||||
endPoint: "Sedona, AZ",
|
||||
terrain: "Desert",
|
||||
description: "Easy desert ride perfect for beginners with amazing sunrise views.",
|
||||
tags: ["desert", "easy", "sunrise"]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Black Forest Trail",
|
||||
distance: "167 miles",
|
||||
difficulty: "Intermediate",
|
||||
rating: 4.7,
|
||||
downloads: 1654,
|
||||
startPoint: "Stuttgart, Germany",
|
||||
endPoint: "Freiburg, Germany",
|
||||
terrain: "Forest",
|
||||
description: "Winding forest roads through Germany's famous Black Forest region.",
|
||||
tags: ["forest", "winding", "cultural"]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Fjord Explorer",
|
||||
distance: "312 miles",
|
||||
difficulty: "Advanced",
|
||||
rating: 4.9,
|
||||
downloads: 987,
|
||||
startPoint: "Bergen, Norway",
|
||||
endPoint: "Geiranger, Norway",
|
||||
terrain: "Fjord",
|
||||
description: "Epic fjord landscapes with waterfalls and dramatic mountain views.",
|
||||
tags: ["fjords", "waterfalls", "dramatic"]
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Tuscan Hills Circuit",
|
||||
distance: "124 miles",
|
||||
difficulty: "Intermediate",
|
||||
rating: 4.8,
|
||||
downloads: 2198,
|
||||
startPoint: "Florence, Italy",
|
||||
endPoint: "Siena, Italy",
|
||||
terrain: "Hills",
|
||||
description: "Rolling hills through vineyards and medieval towns in Tuscany.",
|
||||
tags: ["hills", "vineyards", "historic"]
|
||||
}
|
||||
]
|
||||
|
||||
const getDifficultyColor = (difficulty: string) => {
|
||||
switch (difficulty) {
|
||||
case 'Beginner': return 'bg-green-100 text-green-800 border-green-200'
|
||||
case 'Intermediate': return 'bg-yellow-100 text-yellow-800 border-yellow-200'
|
||||
case 'Advanced': return 'bg-red-100 text-red-800 border-red-200'
|
||||
default: return 'bg-gray-100 text-gray-800 border-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50">
|
||||
<Header />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="pt-16 bg-gradient-to-r from-green-600 to-blue-600 text-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center"
|
||||
>
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-6">
|
||||
Discover Epic Routes
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-green-100 mb-8 max-w-3xl mx-auto">
|
||||
Download GPS tracks from fellow riders worldwide. Find your perfect route
|
||||
based on difficulty, terrain, and scenic beauty.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<button className="bg-white text-green-600 px-6 py-3 rounded-lg font-medium hover:bg-green-50 transition-colors flex items-center gap-2">
|
||||
<Upload className="w-5 h-5" />
|
||||
Upload Your Route
|
||||
</button>
|
||||
<button className="border border-white text-white px-6 py-3 rounded-lg font-medium hover:bg-white hover:text-green-600 transition-colors">
|
||||
Browse by Country
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<section className="py-8 bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col lg:flex-row gap-4 items-center justify-between">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search routes by name or location..."
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<select className="px-4 py-3 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500">
|
||||
<option>All Difficulties</option>
|
||||
<option>Beginner</option>
|
||||
<option>Intermediate</option>
|
||||
<option>Advanced</option>
|
||||
</select>
|
||||
|
||||
<select className="px-4 py-3 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500">
|
||||
<option>All Terrains</option>
|
||||
<option>Coastal</option>
|
||||
<option>Mountain</option>
|
||||
<option>Desert</option>
|
||||
<option>Forest</option>
|
||||
<option>Urban</option>
|
||||
</select>
|
||||
|
||||
<select className="px-4 py-3 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500">
|
||||
<option>Sort by Popularity</option>
|
||||
<option>Sort by Distance</option>
|
||||
<option>Sort by Rating</option>
|
||||
<option>Sort by Date Added</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Routes Grid */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{routes.map((route, index) => (
|
||||
<motion.div
|
||||
key={route.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
viewport={{ once: true }}
|
||||
className="bg-white rounded-2xl shadow-lg overflow-hidden hover:shadow-xl transition-all duration-300 group"
|
||||
>
|
||||
<div className="relative h-48 bg-gradient-to-br from-green-400 to-blue-500">
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
<div className="absolute top-4 left-4">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium border ${getDifficultyColor(route.difficulty)}`}>
|
||||
{route.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute top-4 right-4">
|
||||
<div className="flex items-center gap-1 bg-white/20 backdrop-blur-sm rounded-full px-2 py-1 text-white text-sm">
|
||||
<Star className="w-4 h-4 fill-current" />
|
||||
{route.rating}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4 right-4">
|
||||
<h3 className="text-xl font-bold text-white mb-1 group-hover:text-yellow-300 transition-colors">
|
||||
{route.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-white/90 text-sm">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>{route.startPoint} → {route.endPoint}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-2xl font-bold text-gray-900">{route.distance}</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-500">Terrain</div>
|
||||
<div className="font-semibold text-gray-900">{route.terrain}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
{route.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{route.tags.map((tag) => (
|
||||
<span key={tag} className="px-2 py-1 bg-green-50 text-green-600 text-xs rounded-full">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Download className="w-4 h-4" />
|
||||
<span className="text-sm">{route.downloads.toLocaleString()} downloads</span>
|
||||
</div>
|
||||
<button className="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-green-700 transition-colors flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Download GPX
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Upload Section */}
|
||||
<section className="py-16 bg-gradient-to-r from-green-600 to-blue-600">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-white mb-6">
|
||||
Share Your Favorite Routes
|
||||
</h2>
|
||||
<p className="text-xl text-green-100 mb-8 max-w-2xl mx-auto">
|
||||
Help fellow riders discover amazing routes by sharing your GPX tracks and experiences.
|
||||
</p>
|
||||
<button className="bg-white text-green-600 px-8 py-4 rounded-lg text-lg font-bold hover:bg-green-50 transition-colors flex items-center gap-2 mx-auto">
|
||||
<Upload className="w-5 h-5" />
|
||||
Upload Your Route
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Load More */}
|
||||
<section className="py-8 text-center">
|
||||
<button className="bg-green-600 text-white px-8 py-3 rounded-lg font-medium hover:bg-green-700 transition-colors">
|
||||
Load More Routes
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
// Dynamic import to avoid SSR issues with Leaflet
|
||||
const DynamicMap = dynamic(() => import('./LeafletMap'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="w-full h-64 bg-gradient-to-br from-green-400 to-blue-500 rounded-lg flex items-center justify-center">
|
||||
<div className="text-white">Loading map...</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
interface GPXMapProps {
|
||||
gpxFile?: File | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function GPXMap({ gpxFile, className = "w-full h-64" }: GPXMapProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{gpxFile ? (
|
||||
<DynamicMap gpxFile={gpxFile} />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-green-400 to-blue-500 rounded-lg flex items-center justify-center">
|
||||
<div className="text-white text-center">
|
||||
<div className="text-lg font-semibold mb-2">GPX Route Preview</div>
|
||||
<div className="text-sm opacity-90">Upload a GPX file to see the route</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Menu, X } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function Header() {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Home', href: '/' },
|
||||
{ name: 'Adventures', href: '/adventures' },
|
||||
{ name: 'Routes', href: '/routes' },
|
||||
{ name: 'Community', href: '/community' },
|
||||
{ name: 'Blog', href: '/blog' },
|
||||
]
|
||||
|
||||
return (
|
||||
<nav className="fixed top-0 w-full bg-white/90 backdrop-blur-md border-b border-gray-200 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="text-2xl font-bold text-gray-900">
|
||||
🏍️ <span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">Moto Adventure</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:block">
|
||||
<div className="ml-10 flex items-baseline space-x-4">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className="text-gray-600 hover:text-blue-600 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">
|
||||
Get Started
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="md:hidden">
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className="text-gray-600 hover:text-gray-900 p-2"
|
||||
>
|
||||
{isMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
{isMenuOpen && (
|
||||
<div className="md:hidden">
|
||||
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3 bg-white border-t border-gray-200">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className="text-gray-600 hover:text-blue-600 block px-3 py-2 rounded-md text-base font-medium transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
<button className="w-full text-left bg-blue-600 text-white px-3 py-2 rounded-lg text-base font-medium hover:bg-blue-700 transition-colors mt-4">
|
||||
Get Started
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface LeafletMapProps {
|
||||
gpxFile: File
|
||||
}
|
||||
|
||||
export default function LeafletMap({ gpxFile }: LeafletMapProps) {
|
||||
const [gpxData, setGpxData] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (gpxFile) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
setGpxData(e.target?.result as string)
|
||||
}
|
||||
reader.readAsText(gpxFile)
|
||||
}
|
||||
}, [gpxFile])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && gpxData) {
|
||||
// Dynamic import of Leaflet to avoid SSR issues
|
||||
Promise.all([
|
||||
import('leaflet'),
|
||||
import('react-leaflet'),
|
||||
import('gpxparser')
|
||||
]).then(([L, ReactLeaflet, GPXParser]) => {
|
||||
// Initialize map logic here when we have all dependencies
|
||||
console.log('GPX data loaded:', gpxData.substring(0, 100))
|
||||
}).catch(err => {
|
||||
console.error('Failed to load map dependencies:', err)
|
||||
})
|
||||
}
|
||||
}, [gpxData])
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-gradient-to-br from-green-400 to-blue-500 rounded-lg flex items-center justify-center relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
<div className="text-white text-center relative z-10">
|
||||
<div className="text-lg font-semibold mb-2">🗺️ GPX Route Preview</div>
|
||||
<div className="text-sm opacity-90">
|
||||
{gpxFile ? `Loaded: ${gpxFile.name}` : 'Interactive map will display here'}
|
||||
</div>
|
||||
<div className="mt-4 text-xs opacity-75">
|
||||
Map functionality will be implemented with Leaflet integration
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder route visualization */}
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full opacity-20"
|
||||
viewBox="0 0 400 200"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M50,150 Q150,50 250,100 T350,80"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
strokeDasharray="5,5"
|
||||
/>
|
||||
<circle cx="50" cy="150" r="4" fill="white" />
|
||||
<circle cx="350" cy="80" r="4" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#fef7ee',
|
||||
100: '#fcecd6',
|
||||
200: '#f8d4ad',
|
||||
300: '#f3b679',
|
||||
400: '#ec8f43',
|
||||
500: '#e8711e',
|
||||
600: '#d95914',
|
||||
700: '#b44213',
|
||||
800: '#903517',
|
||||
900: '#752d15',
|
||||
950: '#3f1408',
|
||||
},
|
||||
dark: {
|
||||
50: '#f6f6f6',
|
||||
100: '#e7e7e7',
|
||||
200: '#d1d1d1',
|
||||
300: '#b0b0b0',
|
||||
400: '#888888',
|
||||
500: '#6d6d6d',
|
||||
600: '#5d5d5d',
|
||||
700: '#4f4f4f',
|
||||
800: '#454545',
|
||||
900: '#3d3d3d',
|
||||
950: '#1a1a1a',
|
||||
},
|
||||
},
|
||||
backgroundImage: {
|
||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||
'gradient-conic':
|
||||
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
||||
'hero-pattern': "url('/images/hero-bg.jpg')",
|
||||
},
|
||||
fontFamily: {
|
||||
'adventure': ['Roboto Condensed', 'sans-serif'],
|
||||
'body': ['Inter', 'sans-serif'],
|
||||
},
|
||||
animation: {
|
||||
'parallax': 'parallax 1s ease-out forwards',
|
||||
'fade-in': 'fadeIn 0.6s ease-out forwards',
|
||||
'slide-up': 'slideUp 0.8s ease-out forwards',
|
||||
},
|
||||
keyframes: {
|
||||
parallax: {
|
||||
'0%': { transform: 'translateY(0px)' },
|
||||
'100%': { transform: 'translateY(-50px)' },
|
||||
},
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0', transform: 'translateY(20px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0px)' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(60px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0px)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/typography'),
|
||||
require('@tailwindcss/forms'),
|
||||
],
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "es6"],
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user