Merge pull request 'Rewrite' (#1) from gridlayout into main
All checks were successful
Docker Build and Publish / build (push) Successful in 12s
All checks were successful
Docker Build and Publish / build (push) Successful in 12s
Reviewed-on: #1
This commit is contained in:
commit
dcd66c3c76
42
.gitea/workflows/docker-publish.yml
Normal file
42
.gitea/workflows/docker-publish.yml
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
name: Docker Build and Publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
tags: [ 'v*' ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: git.dws.rip/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=sha
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: git.dws.rip
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m -r -s /bin/bash appuser
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
WORKDIR /app
|
||||||
|
RUN mkdir -p /app/uploads /app/thumbnails
|
||||||
|
RUN chown -R appuser:appuser /app
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
|
COPY --chown=appuser:appuser templates /app/templates
|
||||||
|
COPY --chown=appuser:appuser app.py config.py models.py steganography.py ./
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# Use gunicorn for production
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "app:app"]
|
117
README.md
Normal file
117
README.md
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# Spectra
|
||||||
|
|
||||||
|
> A variation on the masonry grid image gallery with the row alighment constraint removed. Oh, it also has an admin interface so you can set it up and forget it.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Color Analysis**: Automatically extracts color palettes from images to create cohesive galleries
|
||||||
|
- **Smart Thumbnails**: Generates and caches responsive thumbnails in multiple sizes
|
||||||
|
- **EXIF Preservation**: Maintains all photo metadata through processing
|
||||||
|
- **Ownership Verification**: Embeds steganographic proofs in images
|
||||||
|
- **Live Configuration**: Hot-reload config changes without restarts
|
||||||
|
- **Production Ready**: Fully Dockerized with Traefik integration
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
Clone the repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.dws.rip/your-username/spectra
|
||||||
|
cd spectra
|
||||||
|
```
|
||||||
|
|
||||||
|
Set up Python virtual environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # or venv\Scripts\activate on Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
Install dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Create config from template
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.example.toml config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Run development server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
Create required network
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create traefik-public
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure your domain
|
||||||
|
```bash
|
||||||
|
sed -i 's/photos.dws.rip/your.domain.here/g' docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Launch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Essential Settings
|
||||||
|
```toml
|
||||||
|
[server]
|
||||||
|
host = "0.0.0.0"
|
||||||
|
port = 5000
|
||||||
|
[security]
|
||||||
|
max_upload_size_mb = 80
|
||||||
|
rate_limit = 100 # requests per minute
|
||||||
|
[admin]
|
||||||
|
password = "change-this-password" # Required
|
||||||
|
```
|
||||||
|
|
||||||
|
See `config.example.toml` for all available options.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
spectra/
|
||||||
|
├── app.py # Application entry point
|
||||||
|
├── config.py # Configuration management
|
||||||
|
├── models.py # Database models
|
||||||
|
├── steganography.py # Image verification
|
||||||
|
├── templates/ # Jinja2 templates
|
||||||
|
├── uploads/ # Original images
|
||||||
|
└── thumbnails/ # Generated thumbnails
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
#### Public Endpoints
|
||||||
|
- `GET /` - Main gallery view
|
||||||
|
- `GET /api/images` - Get paginated image list
|
||||||
|
- `GET /verify/<filename>` - Verify image authenticity
|
||||||
|
|
||||||
|
#### Admin Endpoints
|
||||||
|
- `POST /admin/login` - Admin authentication
|
||||||
|
- `POST /admin/upload` - Upload new images
|
||||||
|
- `POST /admin/update_photo/<id>` - Update image metadata
|
||||||
|
- `POST /admin/delete_photo/<id>` - Delete image
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
- `FLASK_ENV`: Set to 'production' in production
|
||||||
|
- `WORKERS`: Number of Gunicorn workers (default: 4)
|
||||||
|
- `PORT`: Override default port (default: 5000)
|
514
app.py
Normal file
514
app.py
Normal file
@ -0,0 +1,514 @@
|
|||||||
|
from flask import Flask, request, jsonify, render_template, redirect, url_for, flash, session, send_from_directory
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from models import Session as DBSession, Photo
|
||||||
|
from config import load_or_create_config
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from PIL import Image, ExifTags
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
import random
|
||||||
|
from colorthief import ColorThief
|
||||||
|
import colorsys
|
||||||
|
from steganography import embed_message, extract_message
|
||||||
|
import hashlib
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
from watchdog.events import FileSystemEventHandler
|
||||||
|
import toml
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import atexit
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = os.urandom(24)
|
||||||
|
config = load_or_create_config()
|
||||||
|
|
||||||
|
UPLOAD_FOLDER = config['directories']['upload']
|
||||||
|
THUMBNAIL_FOLDER = config['directories']['thumbnail']
|
||||||
|
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
||||||
|
THUMBNAIL_SIZES = [256, 512, 768, 1024, 1536, 2048]
|
||||||
|
|
||||||
|
# Create upload and thumbnail directories if they don't exist
|
||||||
|
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||||
|
os.makedirs(THUMBNAIL_FOLDER, exist_ok=True)
|
||||||
|
|
||||||
|
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||||
|
app.config['THUMBNAIL_FOLDER'] = THUMBNAIL_FOLDER
|
||||||
|
app.config['MAX_CONTENT_LENGTH'] = 80 * 1024 * 1024 # 80MB limit
|
||||||
|
|
||||||
|
scheduler = BackgroundScheduler()
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = {
|
||||||
|
'server': {
|
||||||
|
'host': '0.0.0.0',
|
||||||
|
'port': 5000
|
||||||
|
},
|
||||||
|
'directories': {
|
||||||
|
'upload': 'uploads',
|
||||||
|
'thumbnail': 'thumbnails'
|
||||||
|
},
|
||||||
|
'admin': {
|
||||||
|
'password': 'changeme' # Default password
|
||||||
|
},
|
||||||
|
'appearance': {
|
||||||
|
'accent_color': '#007bff'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def merge_configs(default, user):
|
||||||
|
"""Recursively merge user config with default config"""
|
||||||
|
result = default.copy()
|
||||||
|
for key, value in user.items():
|
||||||
|
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
||||||
|
result[key] = merge_configs(result[key], value)
|
||||||
|
else:
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
class ConfigFileHandler(FileSystemEventHandler):
|
||||||
|
def on_modified(self, event):
|
||||||
|
if event.src_path.endswith('config.toml'):
|
||||||
|
global config
|
||||||
|
try:
|
||||||
|
new_config = load_or_create_config()
|
||||||
|
config.update(new_config)
|
||||||
|
app.logger.info("Configuration reloaded successfully")
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"Error reloading configuration: {e}")
|
||||||
|
|
||||||
|
def load_or_create_config():
|
||||||
|
config_path = 'config.toml'
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.path.exists(config_path):
|
||||||
|
with open(config_path, 'r') as f:
|
||||||
|
user_config = toml.load(f)
|
||||||
|
else:
|
||||||
|
user_config = {}
|
||||||
|
|
||||||
|
# Merge with defaults
|
||||||
|
final_config = merge_configs(DEFAULT_CONFIG, user_config)
|
||||||
|
|
||||||
|
# Save complete config back to file
|
||||||
|
with open(config_path, 'w') as f:
|
||||||
|
toml.dump(final_config, f)
|
||||||
|
|
||||||
|
return final_config
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"Error loading config: {e}")
|
||||||
|
return DEFAULT_CONFIG.copy()
|
||||||
|
|
||||||
|
def start_config_watcher():
|
||||||
|
observer = Observer()
|
||||||
|
observer.schedule(ConfigFileHandler(), path='.', recursive=False)
|
||||||
|
observer.start()
|
||||||
|
|
||||||
|
# Register cleanup on app shutdown
|
||||||
|
def cleanup():
|
||||||
|
observer.stop()
|
||||||
|
observer.join()
|
||||||
|
|
||||||
|
atexit.register(cleanup)
|
||||||
|
|
||||||
|
start_config_watcher()
|
||||||
|
|
||||||
|
def allowed_file(filename):
|
||||||
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||||
|
|
||||||
|
def get_highlight_color(image_path):
|
||||||
|
color_thief = ColorThief(image_path)
|
||||||
|
palette = color_thief.get_palette(color_count=6, quality=1)
|
||||||
|
|
||||||
|
# Convert RGB to HSV and find the color with the highest saturation
|
||||||
|
highlight_color = max(palette, key=lambda rgb: colorsys.rgb_to_hsv(*rgb)[1])
|
||||||
|
|
||||||
|
return '#{:02x}{:02x}{:02x}'.format(*highlight_color)
|
||||||
|
|
||||||
|
def generate_thumbnails(filename):
|
||||||
|
original_path = os.path.join(UPLOAD_FOLDER, filename)
|
||||||
|
thumb_dir = os.path.join(THUMBNAIL_FOLDER, os.path.splitext(filename)[0])
|
||||||
|
os.makedirs(thumb_dir, exist_ok=True)
|
||||||
|
|
||||||
|
for size in THUMBNAIL_SIZES:
|
||||||
|
thumb_path = os.path.join(thumb_dir, f"{size}_{filename}")
|
||||||
|
if not os.path.exists(thumb_path):
|
||||||
|
with Image.open(original_path) as img:
|
||||||
|
# Extract EXIF data
|
||||||
|
exif_data = None
|
||||||
|
if "exif" in img.info:
|
||||||
|
exif_data = img.info["exif"]
|
||||||
|
|
||||||
|
# Resize image
|
||||||
|
img.thumbnail((size, size), Image.LANCZOS)
|
||||||
|
|
||||||
|
# Save image with EXIF data
|
||||||
|
if exif_data:
|
||||||
|
img.save(thumb_path, exif=exif_data, optimize=True, quality=85)
|
||||||
|
else:
|
||||||
|
img.save(thumb_path, optimize=True, quality=85)
|
||||||
|
|
||||||
|
def generate_all_thumbnails():
|
||||||
|
for filename in os.listdir(UPLOAD_FOLDER):
|
||||||
|
if allowed_file(filename):
|
||||||
|
generate_thumbnails(filename)
|
||||||
|
|
||||||
|
scheduler.add_job(generate_all_thumbnails, 'interval', minutes=5)
|
||||||
|
scheduler.add_job(generate_all_thumbnails, 'date', run_date=datetime.now()) # Run once at startup
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html', accent_color=config['appearance']['accent_color'])
|
||||||
|
|
||||||
|
@app.route('/api/images')
|
||||||
|
def get_images():
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
per_page = 20
|
||||||
|
db_session = DBSession()
|
||||||
|
photos = db_session.query(Photo).order_by(Photo.date_taken.desc()).offset((page - 1) * per_page).limit(per_page).all()
|
||||||
|
|
||||||
|
images = []
|
||||||
|
for photo in photos:
|
||||||
|
factor = random.randint(2, 3)
|
||||||
|
if photo.height < 4000 or photo.width < 4000:
|
||||||
|
factor = 1
|
||||||
|
if photo.orientation == 6 or photo.orientation == 8:
|
||||||
|
width, height = photo.height, photo.width
|
||||||
|
else:
|
||||||
|
width, height = photo.width, photo.height
|
||||||
|
images.append({
|
||||||
|
'imgSrc': f'/static/thumbnails/{os.path.splitext(photo.input_filename)[0]}/1536_{photo.input_filename}',
|
||||||
|
'width': width / factor,
|
||||||
|
'height': height / factor,
|
||||||
|
'caption': photo.input_filename,
|
||||||
|
'date': photo.date_taken.strftime('%y %m %d'),
|
||||||
|
'technicalInfo': f"{photo.focal_length}MM | F/{photo.aperture} | {photo.shutter_speed} | ISO{photo.iso}",
|
||||||
|
'highlightColor': photo.highlight_color
|
||||||
|
})
|
||||||
|
|
||||||
|
has_more = db_session.query(Photo).count() > page * per_page
|
||||||
|
db_session.close()
|
||||||
|
|
||||||
|
return jsonify({'images': images, 'hasMore': has_more})
|
||||||
|
|
||||||
|
@app.route('/admin')
|
||||||
|
def admin():
|
||||||
|
if 'logged_in' not in session:
|
||||||
|
return redirect(url_for('admin_login'))
|
||||||
|
|
||||||
|
db_session = DBSession()
|
||||||
|
photos = db_session.query(Photo).order_by(Photo.date_taken.desc()).all()
|
||||||
|
db_session.close()
|
||||||
|
|
||||||
|
return render_template('admin.html', photos=photos, accent_color=config['appearance']['accent_color'])
|
||||||
|
|
||||||
|
@app.route('/admin/login', methods=['GET', 'POST'])
|
||||||
|
def admin_login():
|
||||||
|
if request.method == 'POST':
|
||||||
|
if request.form['password'] == config['admin']['password']:
|
||||||
|
session['logged_in'] = True
|
||||||
|
return redirect(url_for('admin'))
|
||||||
|
else:
|
||||||
|
flash('Invalid password')
|
||||||
|
return render_template('admin_login.html', accent_color=config['appearance']['accent_color'])
|
||||||
|
|
||||||
|
@app.route('/admin/upload', methods=['POST'])
|
||||||
|
def admin_upload():
|
||||||
|
if 'logged_in' not in session:
|
||||||
|
return redirect(url_for('admin_login'))
|
||||||
|
|
||||||
|
if 'file' not in request.files:
|
||||||
|
flash('No file part')
|
||||||
|
return redirect(url_for('admin'))
|
||||||
|
|
||||||
|
file = request.files['file']
|
||||||
|
if file.filename == '':
|
||||||
|
flash('No selected file')
|
||||||
|
return redirect(url_for('admin'))
|
||||||
|
|
||||||
|
if file and allowed_file(file.filename):
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||||
|
file.save(file_path)
|
||||||
|
|
||||||
|
# Extract EXIF data
|
||||||
|
exif = None
|
||||||
|
exifraw = None
|
||||||
|
with Image.open(file_path) as img:
|
||||||
|
exifraw = img.info['exif']
|
||||||
|
width, height = img.size
|
||||||
|
exif = {
|
||||||
|
ExifTags.TAGS[k]: v
|
||||||
|
for k, v in img._getexif().items()
|
||||||
|
if k in ExifTags.TAGS
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate a unique key for the image
|
||||||
|
unique_key = hashlib.sha256(f"{filename}{datetime.now().isoformat()}".encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
# Embed the unique key into the image
|
||||||
|
try:
|
||||||
|
embed_message(file_path, unique_key, exifraw)
|
||||||
|
except ValueError as e:
|
||||||
|
flash(f"Error embedding key: {str(e)}")
|
||||||
|
os.remove(file_path)
|
||||||
|
return redirect(url_for('admin'))
|
||||||
|
|
||||||
|
|
||||||
|
# Generate thumbnails
|
||||||
|
generate_thumbnails(filename)
|
||||||
|
|
||||||
|
# Get image dimensions
|
||||||
|
with Image.open(file_path) as img:
|
||||||
|
width, height = img.size
|
||||||
|
|
||||||
|
exposure_time = exif['ExposureTime']
|
||||||
|
if isinstance(exposure_time, tuple):
|
||||||
|
exposure_fraction = f"{exposure_time[0]}/{exposure_time[1]}"
|
||||||
|
else:
|
||||||
|
exposure_fraction = f"1/{int(1/float(exposure_time))}"
|
||||||
|
|
||||||
|
# Create database entry
|
||||||
|
db_session = DBSession()
|
||||||
|
new_photo = Photo(
|
||||||
|
input_filename=filename,
|
||||||
|
thumbnail_filename=f"{os.path.splitext(filename)[0]}/256_{filename}",
|
||||||
|
focal_length=str(exif.get('FocalLengthIn35mmFilm', exif.get('FocalLength', ''))),
|
||||||
|
aperture=str(exif.get('FNumber', '')),
|
||||||
|
shutter_speed=exposure_fraction,
|
||||||
|
date_taken=datetime.strptime(str(exif.get('DateTime', '1970:01:01 00:00:00')), '%Y:%m:%d %H:%M:%S'),
|
||||||
|
iso=int(exif.get('ISOSpeedRatings', 0)),
|
||||||
|
orientation=int(exif.get('Orientation', 1)),
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
highlight_color=get_highlight_color(THUMBNAIL_FOLDER + f"/{os.path.splitext(filename)[0]}/256_{filename}"),
|
||||||
|
unique_key=unique_key
|
||||||
|
)
|
||||||
|
db_session.add(new_photo)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.close()
|
||||||
|
|
||||||
|
flash('File uploaded successfully')
|
||||||
|
return redirect(url_for('admin'))
|
||||||
|
|
||||||
|
flash('Invalid file type')
|
||||||
|
return redirect(url_for('admin'))
|
||||||
|
|
||||||
|
@app.route('/admin/logout')
|
||||||
|
def admin_logout():
|
||||||
|
session.pop('logged_in', None)
|
||||||
|
flash('You have been logged out')
|
||||||
|
return redirect(url_for('admin_login'))
|
||||||
|
|
||||||
|
@app.route('/static/thumbnails/<path:filename>')
|
||||||
|
def serve_thumbnail(filename):
|
||||||
|
return send_from_directory(THUMBNAIL_FOLDER, filename)
|
||||||
|
|
||||||
|
@app.route('/admin/update_photo/<int:photo_id>', methods=['POST'])
|
||||||
|
def update_photo(photo_id):
|
||||||
|
if 'logged_in' not in session:
|
||||||
|
return jsonify({'success': False, 'error': 'Not logged in'}), 401
|
||||||
|
|
||||||
|
data = request.json
|
||||||
|
db_session = DBSession()
|
||||||
|
photo = db_session.query(Photo).get(photo_id)
|
||||||
|
|
||||||
|
if not photo:
|
||||||
|
db_session.close()
|
||||||
|
return jsonify({'success': False, 'error': 'Photo not found'}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
for field, value in data.items():
|
||||||
|
if field == 'date_taken':
|
||||||
|
value = datetime.strptime(value, '%Y-%m-%d %H:%M:%S')
|
||||||
|
elif field == 'iso':
|
||||||
|
value = int(value)
|
||||||
|
setattr(photo, field, value)
|
||||||
|
|
||||||
|
db_session.commit()
|
||||||
|
db_session.close()
|
||||||
|
return jsonify({'success': True})
|
||||||
|
except Exception as e:
|
||||||
|
db_session.rollback()
|
||||||
|
db_session.close()
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/admin/delete_photo/<int:photo_id>', methods=['POST'])
|
||||||
|
def delete_photo(photo_id):
|
||||||
|
if 'logged_in' not in session:
|
||||||
|
return jsonify({'success': False, 'error': 'Not logged in'}), 401
|
||||||
|
|
||||||
|
db_session = DBSession()
|
||||||
|
photo = db_session.query(Photo).get(photo_id)
|
||||||
|
|
||||||
|
if not photo:
|
||||||
|
db_session.close()
|
||||||
|
return jsonify({'success': False, 'error': 'Photo not found'}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Delete the original file
|
||||||
|
original_path = os.path.join(UPLOAD_FOLDER, photo.input_filename)
|
||||||
|
if os.path.exists(original_path):
|
||||||
|
os.remove(original_path)
|
||||||
|
|
||||||
|
# Delete the thumbnail directory
|
||||||
|
thumb_dir = os.path.join(THUMBNAIL_FOLDER, os.path.splitext(photo.input_filename)[0])
|
||||||
|
if os.path.exists(thumb_dir):
|
||||||
|
for thumb_file in os.listdir(thumb_dir):
|
||||||
|
os.remove(os.path.join(thumb_dir, thumb_file))
|
||||||
|
os.rmdir(thumb_dir)
|
||||||
|
|
||||||
|
# Delete the database entry
|
||||||
|
db_session.delete(photo)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.close()
|
||||||
|
|
||||||
|
return jsonify({'success': True})
|
||||||
|
except Exception as e:
|
||||||
|
db_session.rollback()
|
||||||
|
db_session.close()
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/verify/<filename>', methods=['GET'])
|
||||||
|
def verify_image(filename):
|
||||||
|
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
return jsonify({'verified': False, 'error': 'Image not found'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
extracted_key = extract_message(file_path, 16)
|
||||||
|
db_session = DBSession()
|
||||||
|
photo = db_session.query(Photo).filter_by(input_filename=filename).first()
|
||||||
|
db_session.close()
|
||||||
|
|
||||||
|
if photo and photo.unique_key == extracted_key:
|
||||||
|
return jsonify({'verified': True, 'message': 'Image ownership verified'})
|
||||||
|
else:
|
||||||
|
return jsonify({'verified': False, 'message': 'Image ownership could not be verified'})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'verified': False, 'error': str(e)})
|
||||||
|
|
||||||
|
limiter = Limiter(
|
||||||
|
app=app,
|
||||||
|
key_func=get_remote_address,
|
||||||
|
default_limits=["100 per minute"],
|
||||||
|
storage_uri="memory://"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate a strong secret key at startup
|
||||||
|
app.secret_key = secrets.token_hex(32)
|
||||||
|
|
||||||
|
# Add security headers middleware
|
||||||
|
@app.after_request
|
||||||
|
def add_security_headers(response):
|
||||||
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||||
|
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||||
|
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||||
|
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
||||||
|
response.headers['Content-Security-Policy'] = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline';"
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Add rate limiting to sensitive endpoints
|
||||||
|
@app.route('/admin/login', methods=['POST'])
|
||||||
|
@limiter.limit("5 per minute")
|
||||||
|
def admin_login():
|
||||||
|
if request.method == 'POST':
|
||||||
|
if request.form['password'] == config['admin']['password']:
|
||||||
|
session['logged_in'] = True
|
||||||
|
return redirect(url_for('admin'))
|
||||||
|
else:
|
||||||
|
flash('Invalid password')
|
||||||
|
return render_template('admin_login.html', accent_color=config['appearance']['accent_color'])
|
||||||
|
|
||||||
|
@app.route('/admin/upload', methods=['POST'])
|
||||||
|
@limiter.limit("10 per minute")
|
||||||
|
def admin_upload():
|
||||||
|
if 'logged_in' not in session:
|
||||||
|
return redirect(url_for('admin_login'))
|
||||||
|
|
||||||
|
if 'file' not in request.files:
|
||||||
|
flash('No file part')
|
||||||
|
return redirect(url_for('admin'))
|
||||||
|
|
||||||
|
file = request.files['file']
|
||||||
|
if file.filename == '':
|
||||||
|
flash('No selected file')
|
||||||
|
return redirect(url_for('admin'))
|
||||||
|
|
||||||
|
if file and allowed_file(file.filename):
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||||
|
file.save(file_path)
|
||||||
|
|
||||||
|
# Extract EXIF data
|
||||||
|
exif = None
|
||||||
|
exifraw = None
|
||||||
|
with Image.open(file_path) as img:
|
||||||
|
exifraw = img.info['exif']
|
||||||
|
width, height = img.size
|
||||||
|
exif = {
|
||||||
|
ExifTags.TAGS[k]: v
|
||||||
|
for k, v in img._getexif().items()
|
||||||
|
if k in ExifTags.TAGS
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate a unique key for the image
|
||||||
|
unique_key = hashlib.sha256(f"{filename}{datetime.now().isoformat()}".encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
# Embed the unique key into the image
|
||||||
|
try:
|
||||||
|
embed_message(file_path, unique_key, exifraw)
|
||||||
|
except ValueError as e:
|
||||||
|
flash(f"Error embedding key: {str(e)}")
|
||||||
|
os.remove(file_path)
|
||||||
|
return redirect(url_for('admin'))
|
||||||
|
|
||||||
|
|
||||||
|
# Generate thumbnails
|
||||||
|
generate_thumbnails(filename)
|
||||||
|
|
||||||
|
# Get image dimensions
|
||||||
|
with Image.open(file_path) as img:
|
||||||
|
width, height = img.size
|
||||||
|
|
||||||
|
exposure_time = exif['ExposureTime']
|
||||||
|
if isinstance(exposure_time, tuple):
|
||||||
|
exposure_fraction = f"{exposure_time[0]}/{exposure_time[1]}"
|
||||||
|
else:
|
||||||
|
exposure_fraction = f"1/{int(1/float(exposure_time))}"
|
||||||
|
|
||||||
|
# Create database entry
|
||||||
|
db_session = DBSession()
|
||||||
|
new_photo = Photo(
|
||||||
|
input_filename=filename,
|
||||||
|
thumbnail_filename=f"{os.path.splitext(filename)[0]}/256_{filename}",
|
||||||
|
focal_length=str(exif.get('FocalLengthIn35mmFilm', exif.get('FocalLength', ''))),
|
||||||
|
aperture=str(exif.get('FNumber', '')),
|
||||||
|
shutter_speed=exposure_fraction,
|
||||||
|
date_taken=datetime.strptime(str(exif.get('DateTime', '1970:01:01 00:00:00')), '%Y:%m:%d %H:%M:%S'),
|
||||||
|
iso=int(exif.get('ISOSpeedRatings', 0)),
|
||||||
|
orientation=int(exif.get('Orientation', 1)),
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
highlight_color=get_highlight_color(THUMBNAIL_FOLDER + f"/{os.path.splitext(filename)[0]}/256_{filename}"),
|
||||||
|
unique_key=unique_key
|
||||||
|
)
|
||||||
|
db_session.add(new_photo)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.close()
|
||||||
|
|
||||||
|
flash('File uploaded successfully')
|
||||||
|
return redirect(url_for('admin'))
|
||||||
|
|
||||||
|
flash('Invalid file type')
|
||||||
|
return redirect(url_for('admin'))
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(
|
||||||
|
debug=True,
|
||||||
|
port=config['server']['port'],
|
||||||
|
host=config['server']['host']
|
||||||
|
)
|
20
config.example.toml
Normal file
20
config.example.toml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
[server]
|
||||||
|
host = "0.0.0.0"
|
||||||
|
port = 5000
|
||||||
|
|
||||||
|
[directories]
|
||||||
|
upload = "/app/uploads"
|
||||||
|
thumbnail = "/app/thumbnails"
|
||||||
|
|
||||||
|
[admin]
|
||||||
|
# Change this password!
|
||||||
|
password = "changeme"
|
||||||
|
|
||||||
|
[appearance]
|
||||||
|
accent_color = "#007bff"
|
||||||
|
|
||||||
|
[security]
|
||||||
|
# Add these security settings
|
||||||
|
max_upload_size_mb = 80
|
||||||
|
allowed_extensions = ["jpg", "jpeg", "png", "gif"]
|
||||||
|
rate_limit = 100 # requests per minute
|
32
config.py
Normal file
32
config.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import toml
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
CONFIG_FILE = 'config.toml'
|
||||||
|
|
||||||
|
def load_or_create_config():
|
||||||
|
if not os.path.exists(CONFIG_FILE):
|
||||||
|
admin_password = secrets.token_urlsafe(16)
|
||||||
|
config = {
|
||||||
|
'admin': {
|
||||||
|
'password': admin_password
|
||||||
|
},
|
||||||
|
'directories': {
|
||||||
|
'upload': 'uploads',
|
||||||
|
'thumbnail': 'thumbnails'
|
||||||
|
},
|
||||||
|
'appearance': {
|
||||||
|
'accent_color': '#ff6600'
|
||||||
|
},
|
||||||
|
'server': {
|
||||||
|
'host': '0.0.0.0',
|
||||||
|
'port': 5002
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with open(CONFIG_FILE, 'w') as f:
|
||||||
|
toml.dump(config, f)
|
||||||
|
print(f"Generated new config file with admin password: {admin_password}")
|
||||||
|
else:
|
||||||
|
with open(CONFIG_FILE, 'r') as f:
|
||||||
|
config = toml.load(f)
|
||||||
|
return config
|
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.photos.rule=Host(`photos.dws.rip`)"
|
||||||
|
- "traefik.http.services.photos.loadbalancer.server.port=5000"
|
||||||
|
- "traefik.http.routers.photos.tls=true"
|
||||||
|
- "traefik.http.routers.photos.tls.certresolver=default"
|
||||||
|
volumes:
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
- ./thumbnails:/app/thumbnails
|
||||||
|
- ./config.toml:/app/config.toml
|
||||||
|
- ./photos.db:/app/photos.db
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
- FLASK_ENV=production
|
||||||
|
- WORKERS=4
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- traefik-public
|
||||||
|
- default
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik-public:
|
||||||
|
external: true
|
69
index.html
69
index.html
@ -1,69 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Tanishq Dubey Photography</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap" rel="stylesheet">
|
|
||||||
<link href="styles/styles.css" rel="stylesheet" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="sidebar">
|
|
||||||
<h1>Tanishq Dubey Photography</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="photocollage">
|
|
||||||
<div class="photo">
|
|
||||||
<div class="photocontent">
|
|
||||||
<div class="photoimage">
|
|
||||||
<img class="photoimagesrc" src="images/1.JPG" alt="A Picture">
|
|
||||||
</div>
|
|
||||||
<div class="photodetails noto-sans-mono-font">
|
|
||||||
<p>2024-10-07</p>
|
|
||||||
<p>f/1.4 | 24MM | 1/100S | ISO100</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="photo">
|
|
||||||
<div class="photocontent">
|
|
||||||
<div class = "photoimage">
|
|
||||||
<img class="photoimagesrc" src="images/2.JPG" alt="A Picture">
|
|
||||||
</div>
|
|
||||||
<div class="photodetails noto-sans-mono-font">
|
|
||||||
<p>2024-10-07</p>
|
|
||||||
<p>f/1.4 | 24MM | 1/100S | ISO100</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="photo">
|
|
||||||
<div class="photocontent">
|
|
||||||
<div class = "photoimage">
|
|
||||||
<img class="photoimagesrc" src="images/3.JPG" alt="A Picture">
|
|
||||||
</div>
|
|
||||||
<div class="photodetails noto-sans-mono-font">
|
|
||||||
<p>2024-10-07</p>
|
|
||||||
<p>f/1.4 | 24MM | 1/100S | ISO100</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="photo">
|
|
||||||
<div class="photocontent">
|
|
||||||
<div class = "photoimage">
|
|
||||||
<img class="photoimagesrc" src="images/4.JPG" alt="A Picture">
|
|
||||||
</div>
|
|
||||||
<div class="photodetails noto-sans-mono-font">
|
|
||||||
<p>2024-10-07</p>
|
|
||||||
<p>f/1.4 | 24MM | 1/100S | ISO100</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="scripts/script.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
26
models.py
Normal file
26
models.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Float
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
class Photo(Base):
|
||||||
|
__tablename__ = 'photos'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
input_filename = Column(String, nullable=False)
|
||||||
|
thumbnail_filename = Column(String, nullable=False)
|
||||||
|
focal_length = Column(String)
|
||||||
|
aperture = Column(String)
|
||||||
|
shutter_speed = Column(String)
|
||||||
|
date_taken = Column(DateTime)
|
||||||
|
iso = Column(Integer)
|
||||||
|
width = Column(Integer)
|
||||||
|
height = Column(Integer)
|
||||||
|
highlight_color = Column(String)
|
||||||
|
orientation = Column(Integer)
|
||||||
|
unique_key = Column(String(16), nullable=False) # Add this line
|
||||||
|
|
||||||
|
engine = create_engine('sqlite:///photos.db')
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
Session = sessionmaker(bind=engine)
|
5
pyvenv.cfg
Normal file
5
pyvenv.cfg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
home = /usr/bin
|
||||||
|
include-system-site-packages = false
|
||||||
|
version = 3.12.7
|
||||||
|
executable = /usr/bin/python3.12
|
||||||
|
command = /usr/bin/python3 -m venv /home/dubey/projects/photoportfolio/pythonserver
|
23
requirements.txt
Normal file
23
requirements.txt
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
APScheduler==3.10.4
|
||||||
|
blinker==1.8.2
|
||||||
|
click==8.1.7
|
||||||
|
colorthief==0.2.1
|
||||||
|
exif==1.6.0
|
||||||
|
Flask==3.0.3
|
||||||
|
greenlet==3.1.1
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
Jinja2==3.1.4
|
||||||
|
MarkupSafe==3.0.1
|
||||||
|
numpy==2.1.2
|
||||||
|
piexif==1.1.3
|
||||||
|
pillow==11.0.0
|
||||||
|
plum-py==0.8.7
|
||||||
|
pytz==2024.2
|
||||||
|
six==1.16.0
|
||||||
|
SQLAlchemy==2.0.36
|
||||||
|
toml==0.10.2
|
||||||
|
typing_extensions==4.12.2
|
||||||
|
tzlocal==5.2
|
||||||
|
watchdog==6.0.0
|
||||||
|
Werkzeug==3.0.4
|
||||||
|
gunicorn
|
51
steganography.py
Normal file
51
steganography.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
from PIL import Image
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
def string_to_binary(message):
|
||||||
|
return ''.join(format(ord(char), '08b') for char in message)
|
||||||
|
|
||||||
|
def embed_message(image_path, message, exifraw):
|
||||||
|
# Open the image
|
||||||
|
img = Image.open(image_path)
|
||||||
|
# Convert image to numpy array
|
||||||
|
img_array = np.array(img)
|
||||||
|
|
||||||
|
# Flatten the array
|
||||||
|
flat_array = img_array.flatten()
|
||||||
|
|
||||||
|
# Convert message to binary
|
||||||
|
binary_message = string_to_binary(message)
|
||||||
|
|
||||||
|
# Check if the message can fit in the image
|
||||||
|
if len(binary_message) > len(flat_array):
|
||||||
|
raise ValueError("Message is too long to be embedded in this image")
|
||||||
|
|
||||||
|
# Embed the message
|
||||||
|
for i, bit in enumerate(binary_message):
|
||||||
|
flat_array[i] = (flat_array[i] & 0xFE) | int(bit)
|
||||||
|
|
||||||
|
# Reshape the array back to the original image shape
|
||||||
|
stego_array = flat_array.reshape(img_array.shape)
|
||||||
|
|
||||||
|
# Create a new image from the modified array
|
||||||
|
stego_img = Image.fromarray(stego_array.astype('uint8'), img.mode)
|
||||||
|
|
||||||
|
# Save the image
|
||||||
|
stego_img.save(image_path, exif=exifraw)
|
||||||
|
|
||||||
|
def extract_message(image_path, message_length):
|
||||||
|
# Open the image
|
||||||
|
img = Image.open(image_path)
|
||||||
|
# Convert image to numpy array
|
||||||
|
img_array = np.array(img)
|
||||||
|
|
||||||
|
# Flatten the array
|
||||||
|
flat_array = img_array.flatten()
|
||||||
|
|
||||||
|
# Extract the binary message
|
||||||
|
binary_message = ''.join([str(pixel & 1) for pixel in flat_array[:message_length * 8]])
|
||||||
|
|
||||||
|
# Convert binary to string
|
||||||
|
message = ''.join([chr(int(binary_message[i:i+8], 2)) for i in range(0, len(binary_message), 8)])
|
||||||
|
|
||||||
|
return message
|
@ -1,78 +0,0 @@
|
|||||||
html {
|
|
||||||
margin: 0;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100vh;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.photocollage {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: flex-start;
|
|
||||||
/* flex-flow: row wrap; */
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-content: flex-start;
|
|
||||||
|
|
||||||
height: 100%;
|
|
||||||
padding: 15px;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.noto-sans-mono-font {
|
|
||||||
font-family: "Noto Sans Mono", monospace;
|
|
||||||
font-optical-sizing: auto;
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
font-variation-settings:
|
|
||||||
"wdth" 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.photo {
|
|
||||||
background-color: #eeeeee;
|
|
||||||
filter: drop-shadow(0px 3px 3px #888888);
|
|
||||||
display: inline-block;
|
|
||||||
padding: 1vh;
|
|
||||||
margin: 1vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.photocontent {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: normal;
|
|
||||||
align-content: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.photoimage {
|
|
||||||
display: block;
|
|
||||||
flex-grow: 1;
|
|
||||||
flex-shrink: 1;
|
|
||||||
flex-basis: auto;
|
|
||||||
align-self: center;
|
|
||||||
order: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.photoimagesrc {
|
|
||||||
max-width:40vw;
|
|
||||||
max-height:40vw;
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.photodetails {
|
|
||||||
display: block;
|
|
||||||
flex-grow: 0;
|
|
||||||
flex-shrink: 1;
|
|
||||||
flex-basis: auto;
|
|
||||||
align-self: flex-end;
|
|
||||||
order: 0;
|
|
||||||
text-align: right;
|
|
||||||
line-height: 70%;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
255
templates/admin.html
Normal file
255
templates/admin.html
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Admin Interface - Tanishq Dubey Photography</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Noto Sans Mono', monospace;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: {{ accent_color }};
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.upload-form {
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
input[type="file"] {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
input[type="submit"] {
|
||||||
|
background-color: {{ accent_color }};
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
input[type="submit"]:hover {
|
||||||
|
background-color: {{ accent_color }}e0; /* Slightly darker version of the accent color */
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
tr:hover {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
.thumbnail {
|
||||||
|
max-width: 100px;
|
||||||
|
max-height: 100px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.flash-messages {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.flash-messages p {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.logout {
|
||||||
|
text-align: right;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.logout a {
|
||||||
|
color: {{ accent_color }};
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.logout a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.editable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.editable:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
.editing {
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
.delete-btn {
|
||||||
|
background-color: #ff4136;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
.delete-btn:hover {
|
||||||
|
background-color: #ff1a1a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="logout">
|
||||||
|
<a href="{{ url_for('admin_logout') }}">Logout</a>
|
||||||
|
</div>
|
||||||
|
<h1>Admin Interface</h1>
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="flash-messages">
|
||||||
|
{% for message in messages %}
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
<div class="upload-form">
|
||||||
|
<h2>Upload New Image</h2>
|
||||||
|
<form action="{{ url_for('admin_upload') }}" method="POST" enctype="multipart/form-data">
|
||||||
|
<input type="file" name="file" accept=".jpg,.jpeg,.png,.gif" required>
|
||||||
|
<input type="submit" value="Upload">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<h2>Uploaded Images</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Thumbnail</th>
|
||||||
|
<th>Filename</th>
|
||||||
|
<th>Date Taken</th>
|
||||||
|
<th>Focal Length</th>
|
||||||
|
<th>Aperture</th>
|
||||||
|
<th>Shutter Speed</th>
|
||||||
|
<th>ISO</th>
|
||||||
|
<th>Dimensions</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for photo in photos %}
|
||||||
|
<tr data-id="{{ photo.id }}">
|
||||||
|
<td><img src="{{ url_for('serve_thumbnail', filename=photo.thumbnail_filename) }}" alt="Thumbnail" class="thumbnail"></td>
|
||||||
|
<td class="editable" data-field="input_filename">{{ photo.input_filename }}</td>
|
||||||
|
<td class="editable" data-field="date_taken">{{ photo.date_taken.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||||
|
<td class="editable" data-field="focal_length">{{ photo.focal_length }}</td>
|
||||||
|
<td class="editable" data-field="aperture">{{ photo.aperture }}</td>
|
||||||
|
<td class="editable" data-field="shutter_speed">{{ photo.shutter_speed }}</td>
|
||||||
|
<td class="editable" data-field="iso">{{ photo.iso }}</td>
|
||||||
|
<td>{{ photo.width }}x{{ photo.height }}</td>
|
||||||
|
<td>
|
||||||
|
<button onclick="saveChanges(this)">Save</button>
|
||||||
|
<button onclick="deletePhoto(this)" class="delete-btn">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function makeEditable(element) {
|
||||||
|
const value = element.textContent;
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.value = value;
|
||||||
|
input.classList.add('editing');
|
||||||
|
element.textContent = '';
|
||||||
|
element.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
input.addEventListener('blur', function() {
|
||||||
|
element.textContent = this.value;
|
||||||
|
element.classList.remove('editing');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.editable').forEach(el => {
|
||||||
|
el.addEventListener('click', function() {
|
||||||
|
if (!this.classList.contains('editing')) {
|
||||||
|
makeEditable(this);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function saveChanges(button) {
|
||||||
|
const row = button.closest('tr');
|
||||||
|
const photoId = row.dataset.id;
|
||||||
|
const updatedData = {};
|
||||||
|
|
||||||
|
row.querySelectorAll('.editable').forEach(el => {
|
||||||
|
updatedData[el.dataset.field] = el.textContent;
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch('/admin/update_photo/' + photoId, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updatedData),
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
alert('Changes saved successfully!');
|
||||||
|
} else {
|
||||||
|
alert('Error saving changes: ' + data.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('An error occurred while saving changes.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePhoto(button) {
|
||||||
|
if (confirm('Are you sure you want to delete this photo?')) {
|
||||||
|
const row = button.closest('tr');
|
||||||
|
const photoId = row.dataset.id;
|
||||||
|
|
||||||
|
fetch('/admin/delete_photo/' + photoId, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
row.remove();
|
||||||
|
alert('Photo deleted successfully!');
|
||||||
|
} else {
|
||||||
|
alert('Error deleting photo: ' + data.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('An error occurred while deleting the photo.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
77
templates/admin_login.html
Normal file
77
templates/admin_login.html
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Admin Login - Tanishq Dubey Photography</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Noto Sans Mono', monospace;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
.login-container {
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: {{ accent_color }};
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
input[type="submit"] {
|
||||||
|
background-color: {{ accent_color }};
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
input[type="submit"]:hover {
|
||||||
|
background-color: #e55c00;
|
||||||
|
}
|
||||||
|
.flash-messages {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #ff0000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-container">
|
||||||
|
<h1>Admin Login</h1>
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="flash-messages">
|
||||||
|
{% for message in messages %}
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
<form method="POST">
|
||||||
|
<input type="password" name="password" required placeholder="Enter password">
|
||||||
|
<input type="submit" value="Login">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
350
templates/index.html
Normal file
350
templates/index.html
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Tanishq Dubey Photography</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.grid-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(10px, 1fr));
|
||||||
|
grid-auto-rows: 10px;
|
||||||
|
grid-auto-flow: dense;
|
||||||
|
gap: 15px;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.polaroid {
|
||||||
|
position: relative;
|
||||||
|
background-color: #e8e8ea;
|
||||||
|
box-shadow: 0 0 2px 0 #f4f4f6 inset,
|
||||||
|
1px 5px 5px 0px #99999955;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.polaroid img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
flex-grow: 1;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
text-align: right;
|
||||||
|
color: #ec5a11;
|
||||||
|
font-family: "Noto Sans Mono", monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0;
|
||||||
|
text-shadow: 0 0 2px #ec5a11;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.polaroid:hover .date-overlay {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.polaroid .caption {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: block;
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 1;
|
||||||
|
flex-basis: auto;
|
||||||
|
align-self: flex-end;
|
||||||
|
order: 0;
|
||||||
|
text-align: right;
|
||||||
|
line-height: 70%;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noto-sans-mono-font {
|
||||||
|
font-family: "Noto Sans Mono", monospace;
|
||||||
|
font-optical-sizing: auto;
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-variation-settings:
|
||||||
|
"wdth" 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 20rem;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.sidebar-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.sidebar-nav {
|
||||||
|
font-size: 1rem;
|
||||||
|
flex-grow: 1;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.sidebar-nav ul {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.sidebar-nav a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: {{ accent_color }};
|
||||||
|
}
|
||||||
|
.nav-toggle {
|
||||||
|
display: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.grid-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.polaroid {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
body {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
position: static;
|
||||||
|
padding: 10px;
|
||||||
|
overflow-y: visible;
|
||||||
|
}
|
||||||
|
.sidebar-nav ul {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.nav-toggle {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.sidebar-nav.active ul {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-nav noto-sans-mono-font">
|
||||||
|
<div class="nav-toggle">☰</div>
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">Home</a></li>
|
||||||
|
<li><hr></li>
|
||||||
|
<li>Powered by <a href="https://dws.rip">DWS</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-title noto-sans-mono-font">
|
||||||
|
<h1>Tanishq Dubey Photography</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main-content">
|
||||||
|
<div class="grid-container" id="polaroid-grid"></div>
|
||||||
|
<div id="loading">Loading more images...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const gridContainer = document.getElementById('polaroid-grid');
|
||||||
|
const loadingIndicator = document.getElementById('loading');
|
||||||
|
const baseSize = 110; // Size of one grid cell in pixels
|
||||||
|
let page = 1;
|
||||||
|
let isLoading = false;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
function createPolaroid(polaroid) {
|
||||||
|
const polaroidElement = document.createElement('div');
|
||||||
|
polaroidElement.className = 'polaroid';
|
||||||
|
if (polaroid.height > polaroid.width) {
|
||||||
|
polaroidElement.classList.add('vertical');
|
||||||
|
}
|
||||||
|
|
||||||
|
polaroidElement.style.backgroundColor = `${polaroid.highlightColor}33`;
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.alt = polaroid.caption;
|
||||||
|
img.setAttribute('data-original-width', polaroid.width);
|
||||||
|
img.setAttribute('data-original-height', polaroid.height);
|
||||||
|
img.setAttribute('data-base-src', polaroid.imgSrc);
|
||||||
|
img.src = polaroid.imgSrc;
|
||||||
|
|
||||||
|
//img.onload = () => loadOptimalThumbnail(img); // Load optimal thumbnail after initial load
|
||||||
|
|
||||||
|
const dateOverlay = document.createElement('div');
|
||||||
|
dateOverlay.className = 'date-overlay';
|
||||||
|
dateOverlay.textContent = polaroid.date;
|
||||||
|
|
||||||
|
const caption = document.createElement('div');
|
||||||
|
caption.className = 'caption noto-sans-mono-font';
|
||||||
|
caption.textContent = polaroid.technicalInfo;
|
||||||
|
|
||||||
|
polaroidElement.appendChild(img);
|
||||||
|
polaroidElement.appendChild(dateOverlay);
|
||||||
|
polaroidElement.appendChild(caption);
|
||||||
|
|
||||||
|
return polaroidElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateGridSpan(dimension) {
|
||||||
|
return Math.ceil(dimension / baseSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionPolaroid(polaroidElement, polaroid) {
|
||||||
|
const width = calculateGridSpan(polaroid.width + 20); // Add 20px for padding
|
||||||
|
const height = calculateGridSpan(polaroid.height + 40) + 1; // Add 40px for padding and caption
|
||||||
|
|
||||||
|
polaroidElement.style.gridColumnEnd = `span ${width}`;
|
||||||
|
polaroidElement.style.gridRowEnd = `span ${height}`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptimalThumbnailSize(width, height) {
|
||||||
|
const sizes = [256, 512, 768, 1024, 1536, 2048];
|
||||||
|
const maxDimension = Math.max(width, height);
|
||||||
|
return sizes.find(size => size >= maxDimension) || sizes[sizes.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOptimalThumbnail(img) {
|
||||||
|
// Use a small delay to ensure the image has rendered
|
||||||
|
setTimeout(() => {
|
||||||
|
const containerWidth = img.offsetWidth;
|
||||||
|
const containerHeight = img.offsetHeight;
|
||||||
|
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||||
|
|
||||||
|
// If dimensions are still 0, use the original image dimensions
|
||||||
|
const width = containerWidth || parseInt(img.getAttribute('data-original-width'));
|
||||||
|
const height = containerHeight || parseInt(img.getAttribute('data-original-height'));
|
||||||
|
|
||||||
|
const optimalSize = getOptimalThumbnailSize(
|
||||||
|
width * devicePixelRatio,
|
||||||
|
height * devicePixelRatio
|
||||||
|
);
|
||||||
|
|
||||||
|
const newSrc = img.getAttribute('data-base-src').replace('1536_', `${optimalSize}_`);
|
||||||
|
|
||||||
|
if (newSrc !== img.src) {
|
||||||
|
const tempImg = new Image();
|
||||||
|
tempImg.onload = function() {
|
||||||
|
img.src = newSrc;
|
||||||
|
img.style.opacity = 1;
|
||||||
|
};
|
||||||
|
tempImg.src = newSrc;
|
||||||
|
img.style.opacity = 0.5;
|
||||||
|
}
|
||||||
|
}, 100); // 100ms delay
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
const polaroids = document.querySelectorAll('.polaroid');
|
||||||
|
polaroids.forEach(polaroid => {
|
||||||
|
const img = polaroid.querySelector('img');
|
||||||
|
const width = parseInt(img.getAttribute('data-original-width'));
|
||||||
|
const height = parseInt(img.getAttribute('data-original-height'));
|
||||||
|
positionPolaroid(polaroid, { width, height });
|
||||||
|
//loadOptimalThumbnail(img);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadImages() {
|
||||||
|
if (isLoading || !hasMore) return;
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
loadingIndicator.style.display = 'block';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/images?page=${page}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
data.images.forEach(polaroid => {
|
||||||
|
const polaroidElement = createPolaroid(polaroid);
|
||||||
|
positionPolaroid(polaroidElement, polaroid);
|
||||||
|
gridContainer.appendChild(polaroidElement);
|
||||||
|
// loadOptimalThumbnail is now called in the img.onload event
|
||||||
|
});
|
||||||
|
|
||||||
|
hasMore = data.hasMore;
|
||||||
|
page++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading images:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
loadingIndicator.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 500) {
|
||||||
|
loadImages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
loadImages(); // Initial load
|
||||||
|
|
||||||
|
function setupNavToggle() {
|
||||||
|
const navToggle = document.querySelector('.nav-toggle');
|
||||||
|
const sidebarNav = document.querySelector('.sidebar-nav');
|
||||||
|
|
||||||
|
navToggle.addEventListener('click', () => {
|
||||||
|
sidebarNav.classList.toggle('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupNavToggle();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in New Issue
Block a user