Initial commit: LisiLou Photography Portfolio
Features: - Immich integration with automatic album image carousel - Dynamic configuration via JSON files - Nginx proxy for Immich API access - Docker deployment ready - Gitea Actions CI/CD workflow Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(docker compose:*)",
|
||||
"Bash(curl:*)",
|
||||
"WebFetch(domain:photography.lisilou.com)",
|
||||
"WebFetch(domain:immich.app)",
|
||||
"WebFetch(domain:docs.immich.app)",
|
||||
"WebFetch(domain:api.immich.app)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
REGISTRY: your-registry.com # Update with your registry
|
||||
IMAGE_NAME: lisilou-portfolio
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=sha,prefix=
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@v1.0.0
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_KEY }}
|
||||
script: |
|
||||
cd /opt/lisilou-portfolio
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
docker image prune -f
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Local config overrides (keep default configs in git)
|
||||
config/local.json
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
@@ -0,0 +1,71 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
LisiLou Photography Portfolio is a lightweight, zero-framework photography portfolio website designed to integrate with Immich for image hosting and client gallery access. It's a single-page application with all code in one HTML file, using vanilla JavaScript and inline CSS.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Frontend:** Vanilla HTML5, CSS3, JavaScript (no framework)
|
||||
- **Server:** Nginx (Alpine-based)
|
||||
- **Deployment:** Docker + Docker Compose
|
||||
- **CI/CD:** Gitea Actions
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Start development server (serves at http://localhost:8080)
|
||||
docker compose up -d
|
||||
|
||||
# Rebuild after code changes to src/index.html
|
||||
docker compose build && docker compose up -d
|
||||
|
||||
# View container logs
|
||||
docker logs lisilou-portfolio
|
||||
```
|
||||
|
||||
**Note:** Configuration (`config/`) and image (`public/images/`) changes are applied immediately without rebuild since they're mounted as volumes.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Single-File SPA
|
||||
The entire application lives in `src/index.html` (~865 lines):
|
||||
- Lines 1-605: Inline CSS with CSS custom properties for theming
|
||||
- Lines 606-865: HTML structure and vanilla JavaScript
|
||||
|
||||
### Configuration-Driven Content
|
||||
All site content is loaded dynamically from `config/site.json` at runtime:
|
||||
- Site branding (title, tagline, logo)
|
||||
- Theme colors (applied to CSS variables)
|
||||
- Portfolio categories with Immich album links
|
||||
- Social media links
|
||||
- Client gallery access settings
|
||||
|
||||
### Immich Integration
|
||||
The app serves as a gateway to Immich shared albums:
|
||||
- Portfolio categories link to Immich albums via `immichAlbumId`
|
||||
- Client gallery codes are Immich share IDs
|
||||
- Full URLs constructed as: `{baseUrl}{publicAlbumPrefix}{albumId}`
|
||||
|
||||
### Multi-Tenant Support
|
||||
Multiple photographers can be supported via `config/profiles.json`, mapping domains to different configuration files.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/index.html` | Complete SPA (CSS + HTML + JS) |
|
||||
| `config/site.json` | Runtime configuration for all content |
|
||||
| `config/profiles.json` | Multi-tenant profile routing |
|
||||
| `nginx.conf` | Caching rules, security headers, SPA routing |
|
||||
| `Dockerfile` | Multi-stage build (Node Alpine → Nginx Alpine) |
|
||||
| `.gitea/workflows/deploy.yml` | CI/CD pipeline |
|
||||
|
||||
## Nginx Configuration Highlights
|
||||
|
||||
- **Caching:** Images = 1 year, CSS/JS = 1 month, Config = 5 minutes
|
||||
- **Health Check:** `/health` endpoint for container monitoring
|
||||
- **SPA Routing:** Falls back to `index.html` for all unmatched routes
|
||||
- **Security Headers:** X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Multi-stage build for lightweight production image
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files if they exist (for future npm dependencies)
|
||||
COPY package*.json ./
|
||||
RUN if [ -f package.json ]; then npm ci --only=production; fi
|
||||
|
||||
# Production image using nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Install envsubst for runtime config substitution
|
||||
RUN apk add --no-cache gettext
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copy static files
|
||||
COPY src/ /usr/share/nginx/html/
|
||||
COPY config/ /usr/share/nginx/html/config/
|
||||
COPY public/ /usr/share/nginx/html/
|
||||
|
||||
# Create directories for runtime config
|
||||
RUN mkdir -p /usr/share/nginx/html/config
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://localhost:80/ || exit 1
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,234 @@
|
||||
# LisiLou Photography Portfolio
|
||||
|
||||
A lightweight, configurable photography portfolio website designed to integrate with Immich for image hosting and client gallery access.
|
||||
|
||||
## Features
|
||||
|
||||
- 🎨 **Elegant, modern design** - Clean photography-focused aesthetic
|
||||
- ⚙️ **Easy configuration** - Update content via JSON files, no code changes needed
|
||||
- 📱 **Fully responsive** - Looks great on all devices
|
||||
- 🖼️ **Immich integration** - Direct links to shared albums for portfolio and client galleries
|
||||
- 👥 **Multi-photographer support** - Configure multiple profiles for different photographers
|
||||
- 🐳 **Docker ready** - Easy deployment with Docker and Docker Compose
|
||||
- 🔄 **CI/CD ready** - Gitea Actions workflow included
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Local Development
|
||||
|
||||
1. Clone this repository
|
||||
2. Customize `config/site.json` with your information
|
||||
3. Add your images to `public/images/`
|
||||
4. Run with Docker:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
5. Visit `http://localhost:8080`
|
||||
|
||||
### Configuration
|
||||
|
||||
All site content is configured through `config/site.json`. Here's what you can customize:
|
||||
|
||||
#### Site Settings
|
||||
```json
|
||||
{
|
||||
"site": {
|
||||
"title": "Your Photography Name",
|
||||
"tagline": "Your tagline here",
|
||||
"description": "SEO description"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Social Media
|
||||
```json
|
||||
{
|
||||
"social": {
|
||||
"instagram": "your.handle",
|
||||
"facebook": "yourpage",
|
||||
"pinterest": "yourprofile",
|
||||
"tiktok": "yourhandle"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Immich Integration
|
||||
```json
|
||||
{
|
||||
"immich": {
|
||||
"baseUrl": "https://photos.yourdomain.com",
|
||||
"publicAlbumPrefix": "/share/"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Portfolio Categories
|
||||
```json
|
||||
{
|
||||
"portfolio": {
|
||||
"categories": [
|
||||
{
|
||||
"id": "seniors",
|
||||
"name": "Senior Portraits",
|
||||
"description": "Celebrate your milestone",
|
||||
"coverImage": "/images/portfolio/seniors-cover.jpg",
|
||||
"immichAlbumId": "your-immich-album-share-id"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Theme Customization
|
||||
```json
|
||||
{
|
||||
"theme": {
|
||||
"primaryColor": "#8B7355",
|
||||
"accentColor": "#D4C5B5",
|
||||
"textColor": "#2C2C2C",
|
||||
"backgroundColor": "#FDFBF9"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Portfolio Images
|
||||
|
||||
1. Create cover images for each portfolio category
|
||||
2. Place them in `public/images/portfolio/`
|
||||
3. Update the `coverImage` paths in `config/site.json`
|
||||
4. Get the share IDs from your Immich albums and add them to `immichAlbumId`
|
||||
|
||||
### Client Gallery Access
|
||||
|
||||
Clients can enter their gallery code (Immich share ID) on the website. They'll be redirected to their Immich shared album.
|
||||
|
||||
**Workflow:**
|
||||
1. Create a shared album in Immich for your client
|
||||
2. Give them the share ID as their "gallery code"
|
||||
3. They enter it on your website and are taken directly to their photos
|
||||
|
||||
## Deployment
|
||||
|
||||
### With Docker Compose
|
||||
|
||||
1. Copy files to your server:
|
||||
```bash
|
||||
scp -r . user@server:/opt/lisilou-portfolio/
|
||||
```
|
||||
|
||||
2. Create the external network:
|
||||
```bash
|
||||
docker network create web
|
||||
```
|
||||
|
||||
3. Start the container:
|
||||
```bash
|
||||
cd /opt/lisilou-portfolio
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### With Reverse Proxy (Traefik/Nginx)
|
||||
|
||||
If using Traefik, add labels to docker-compose.yml:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
portfolio:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.portfolio.rule=Host(`lisilou.com`)"
|
||||
- "traefik.http.routers.portfolio.tls=true"
|
||||
- "traefik.http.routers.portfolio.tls.certresolver=letsencrypt"
|
||||
```
|
||||
|
||||
### Gitea Actions CI/CD
|
||||
|
||||
1. Set up Gitea Actions on your Gitea instance
|
||||
2. Add these secrets to your repository:
|
||||
- `REGISTRY_USERNAME` - Container registry username
|
||||
- `REGISTRY_PASSWORD` - Container registry password
|
||||
- `DEPLOY_HOST` - Your server hostname
|
||||
- `DEPLOY_USER` - SSH username
|
||||
- `DEPLOY_KEY` - SSH private key
|
||||
|
||||
3. Update `.gitea/workflows/deploy.yml` with your registry URL
|
||||
|
||||
4. Push to main branch to trigger automatic deployment
|
||||
|
||||
## Multi-Photographer Support
|
||||
|
||||
To support multiple photographers:
|
||||
|
||||
1. Create additional config files (e.g., `config/photographer2.json`)
|
||||
2. Update `config/profiles.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"lisilou": {
|
||||
"enabled": true,
|
||||
"domain": "lisilou.com",
|
||||
"configFile": "site.json"
|
||||
},
|
||||
"photographer2": {
|
||||
"enabled": true,
|
||||
"domain": "photographer2.com",
|
||||
"configFile": "photographer2.json"
|
||||
}
|
||||
},
|
||||
"defaultProfile": "lisilou",
|
||||
"multiTenant": true
|
||||
}
|
||||
```
|
||||
|
||||
3. Add multi-tenant routing to nginx.conf (or use separate containers)
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
lisilou-portfolio/
|
||||
├── config/
|
||||
│ ├── site.json # Main configuration
|
||||
│ └── profiles.json # Multi-profile config
|
||||
├── public/
|
||||
│ └── images/
|
||||
│ ├── portfolio/ # Portfolio cover images
|
||||
│ ├── logo.png # Site logo
|
||||
│ └── favicon.ico # Favicon
|
||||
├── src/
|
||||
│ └── index.html # Main HTML file
|
||||
├── .gitea/
|
||||
│ └── workflows/
|
||||
│ └── deploy.yml # CI/CD workflow
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── nginx.conf
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Updating Content
|
||||
|
||||
### To update text/settings:
|
||||
1. Edit `config/site.json`
|
||||
2. The changes are applied immediately (no rebuild needed)
|
||||
|
||||
### To update images:
|
||||
1. Add new images to `public/images/`
|
||||
2. Update paths in `config/site.json`
|
||||
3. Changes are applied immediately
|
||||
|
||||
### To update code:
|
||||
1. Edit `src/index.html`
|
||||
2. Rebuild the Docker image:
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Or push to Git and let CI/CD handle it.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - Feel free to use and modify for your photography business!
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"profiles": {
|
||||
"lisilou": {
|
||||
"enabled": true,
|
||||
"domain": "lisilou.com",
|
||||
"configFile": "site.json"
|
||||
}
|
||||
},
|
||||
"defaultProfile": "lisilou",
|
||||
"multiTenant": false
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"site": {
|
||||
"title": "LisiLou Photography",
|
||||
"tagline": "Capturing life's beautiful moments",
|
||||
"description": "Senior portraits, engagement sessions, and milestone photography",
|
||||
"logo": "/images/logo.png",
|
||||
"favicon": "/images/favicon.ico"
|
||||
},
|
||||
"photographer": {
|
||||
"name": "LisiLou",
|
||||
"bio": "Passionate about capturing authentic moments and creating timeless memories.",
|
||||
"profileImage": "/images/profile.jpg"
|
||||
},
|
||||
"contact": {
|
||||
"email": "hello@lisilou.com",
|
||||
"phone": "",
|
||||
"bookingUrl": ""
|
||||
},
|
||||
"social": {
|
||||
"instagram": "lisilou.photography",
|
||||
"facebook": "",
|
||||
"pinterest": "",
|
||||
"tiktok": ""
|
||||
},
|
||||
"immich": {
|
||||
"baseUrl": "https://photography.lisilou.com",
|
||||
"publicAlbumPrefix": "/s/"
|
||||
},
|
||||
"portfolio": {
|
||||
"categories": [
|
||||
{
|
||||
"id": "seniors",
|
||||
"name": "Senior Portraits",
|
||||
"description": "Celebrate your milestone",
|
||||
"coverImage": "/images/portfolio/seniors-cover.jpg",
|
||||
"immichAlbumId": "fake-portraits"
|
||||
},
|
||||
{
|
||||
"id": "engagements",
|
||||
"name": "Engagements",
|
||||
"description": "Love stories captured",
|
||||
"coverImage": "/images/portfolio/engagements-cover.jpg",
|
||||
"immichAlbumId": "fake-engagements"
|
||||
},
|
||||
{
|
||||
"id": "families",
|
||||
"name": "Families",
|
||||
"description": "Moments that matter",
|
||||
"coverImage": "/images/portfolio/families-cover.jpg",
|
||||
"immichAlbumId": "family-portraits"
|
||||
}
|
||||
]
|
||||
},
|
||||
"clientAccess": {
|
||||
"enabled": true,
|
||||
"title": "Client Gallery Access",
|
||||
"description": "Enter your gallery code to view and download your photos",
|
||||
"placeholder": "Enter your gallery code"
|
||||
},
|
||||
"theme": {
|
||||
"primaryColor": "#8B7355",
|
||||
"accentColor": "#D4C5B5",
|
||||
"textColor": "#2C2C2C",
|
||||
"backgroundColor": "#FDFBF9",
|
||||
"fontDisplay": "Cormorant Garamond",
|
||||
"fontBody": "Nunito Sans"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
portfolio:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: lisilou-portfolio
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
# Mount config for easy updates without rebuilding
|
||||
- ./config:/usr/share/nginx/html/config:ro
|
||||
# Mount images for easy updates
|
||||
- ./public/images:/usr/share/nginx/html/images:ro
|
||||
environment:
|
||||
- TZ=America/Denver
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- web
|
||||
|
||||
# Optional: Watchtower for automatic updates
|
||||
# watchtower:
|
||||
# image: containrrr/watchtower
|
||||
# volumes:
|
||||
# - /var/run/docker.sock:/var/run/docker.sock
|
||||
# command: --interval 300 lisilou-portfolio
|
||||
|
||||
networks:
|
||||
web:
|
||||
external: true
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location ~* \.(css|js)$ {
|
||||
expires 1M;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# Config files should not be cached long
|
||||
location /config/ {
|
||||
expires 5m;
|
||||
add_header Cache-Control "public, must-revalidate";
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Proxy for Immich share pages (to avoid CORS issues)
|
||||
# Usage: /api/immich-proxy?url=https://photography.lisilou.com/s/albumId
|
||||
location /api/immich-proxy {
|
||||
internal;
|
||||
resolver 8.8.8.8 1.1.1.1 valid=300s;
|
||||
resolver_timeout 5s;
|
||||
|
||||
set $target_url $arg_url;
|
||||
proxy_pass $target_url;
|
||||
proxy_set_header Host $proxy_host;
|
||||
proxy_set_header Accept "text/html";
|
||||
proxy_ssl_server_name on;
|
||||
}
|
||||
|
||||
# Proxy endpoint for Immich share pages and API
|
||||
location ~ ^/api/fetch-immich/(.+)$ {
|
||||
resolver 8.8.8.8 1.1.1.1 valid=300s;
|
||||
resolver_timeout 5s;
|
||||
|
||||
set $immich_path $1;
|
||||
set $immich_host photography.lisilou.com;
|
||||
proxy_pass https://$immich_host/$immich_path$is_args$args;
|
||||
proxy_set_header Host $immich_host;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_protocols TLSv1.2 TLSv1.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
+956
@@ -0,0 +1,956 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title id="site-title">LisiLou Photography</title>
|
||||
<meta name="description" id="site-description" content="">
|
||||
<link rel="icon" type="image/x-icon" href="/images/favicon.ico">
|
||||
|
||||
<!-- Fonts -->
|
||||
<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=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400&family=Nunito+Sans:opsz,wght@6..12,300;6..12,400;6..12,500&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--color-primary: #8B7355;
|
||||
--color-accent: #D4C5B5;
|
||||
--color-text: #2C2C2C;
|
||||
--color-text-light: #6B6B6B;
|
||||
--color-bg: #FDFBF9;
|
||||
--color-bg-warm: #F7F3EF;
|
||||
--color-white: #FFFFFF;
|
||||
--font-display: 'Cormorant Garamond', serif;
|
||||
--font-body: 'Nunito Sans', sans-serif;
|
||||
--transition-smooth: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Elegant loading animation */
|
||||
.page-loader {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
transition: opacity 0.6s var(--transition-smooth), visibility 0.6s;
|
||||
}
|
||||
|
||||
.page-loader.hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.loader-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loader-logo {
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.5rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.3em;
|
||||
color: var(--color-primary);
|
||||
animation: fadeInUp 0.8s var(--transition-smooth);
|
||||
}
|
||||
|
||||
.loader-line {
|
||||
width: 60px;
|
||||
height: 1px;
|
||||
background: var(--color-primary);
|
||||
margin: 1.5rem auto;
|
||||
animation: expandLine 1.2s var(--transition-smooth) infinite;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes expandLine {
|
||||
0%, 100% { transform: scaleX(0.3); opacity: 0.3; }
|
||||
50% { transform: scaleX(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
padding: 1.5rem 3rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background 0.4s var(--transition-smooth), padding 0.4s var(--transition-smooth);
|
||||
}
|
||||
|
||||
header.scrolled {
|
||||
background: rgba(253, 251, 249, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 1rem 3rem;
|
||||
box-shadow: 0 1px 0 rgba(139, 115, 85, 0.1);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 2.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
nav a {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
nav a::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 1px;
|
||||
background: var(--color-primary);
|
||||
transition: width 0.3s var(--transition-smooth);
|
||||
}
|
||||
|
||||
nav a:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-toggle {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-toggle span {
|
||||
width: 24px;
|
||||
height: 1px;
|
||||
background: var(--color-text);
|
||||
transition: transform 0.3s, opacity 0.3s;
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, var(--color-bg-warm) 0%, var(--color-bg) 50%, var(--color-accent) 100%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.hero-pattern {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 80%, rgba(139, 115, 85, 0.03) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(212, 197, 181, 0.05) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
max-width: 800px;
|
||||
animation: heroFadeIn 1.2s var(--transition-smooth) 0.3s both;
|
||||
}
|
||||
|
||||
@keyframes heroFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(40px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-eyebrow {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.4em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(3rem, 8vw, 6rem);
|
||||
font-weight: 300;
|
||||
line-height: 1.1;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-title em {
|
||||
font-style: italic;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.hero-tagline {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.3rem;
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
color: var(--color-text-light);
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.hero-cta {
|
||||
display: inline-flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
padding: 1rem 2.5rem;
|
||||
border: 1px solid var(--color-primary);
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.4s var(--transition-smooth);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.btn-filled {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.btn-filled:hover {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Scroll indicator */
|
||||
.scroll-indicator {
|
||||
position: absolute;
|
||||
bottom: 3rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-text-light);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
animation: float 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.scroll-indicator svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke: var(--color-primary);
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateX(-50%) translateY(0); }
|
||||
50% { transform: translateX(-50%) translateY(8px); }
|
||||
}
|
||||
|
||||
/* Section styling */
|
||||
section {
|
||||
padding: 6rem 3rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
.section-eyebrow {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.4em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
font-weight: 300;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Portfolio Grid */
|
||||
#portfolio {
|
||||
background: var(--color-bg-warm);
|
||||
}
|
||||
|
||||
.portfolio-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.portfolio-card {
|
||||
position: relative;
|
||||
aspect-ratio: 4/5;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.portfolio-card img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.8s var(--transition-smooth), opacity 0.4s var(--transition-smooth);
|
||||
}
|
||||
|
||||
.portfolio-card:hover img {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.portfolio-card-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, rgba(44, 44, 44, 0.8) 0%, transparent 60%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: 2rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s var(--transition-smooth);
|
||||
}
|
||||
|
||||
.portfolio-card:hover .portfolio-card-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.portfolio-card-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-white);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.portfolio-card-desc {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* Client Access Section */
|
||||
#client-access {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.client-access-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-access-form {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.client-access-input {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
padding: 1rem 1.5rem;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid var(--color-accent);
|
||||
background: var(--color-white);
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.client-access-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.client-access-input::placeholder {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
/* Social Section */
|
||||
#connect {
|
||||
background: var(--color-bg-warm);
|
||||
}
|
||||
|
||||
.social-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-top: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.social-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
padding: 1rem 1.5rem;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.3s var(--transition-smooth);
|
||||
}
|
||||
|
||||
.social-link:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.social-link svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.footer-logo {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-light);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* Mobile Navigation */
|
||||
.mobile-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--color-bg);
|
||||
z-index: 99;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.4s, visibility 0.4s;
|
||||
}
|
||||
|
||||
.mobile-nav.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.mobile-nav a {
|
||||
font-family: var(--font-display);
|
||||
font-size: 2rem;
|
||||
font-weight: 300;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.mobile-nav a:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
header {
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
header.scrolled {
|
||||
padding: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mobile-nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 4rem 1.5rem;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.portfolio-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.client-access-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.client-access-input {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reveal animations */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: opacity 0.8s var(--transition-smooth), transform 0.8s var(--transition-smooth);
|
||||
}
|
||||
|
||||
.reveal.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Page Loader -->
|
||||
<div class="page-loader" id="loader">
|
||||
<div class="loader-content">
|
||||
<div class="loader-logo" id="loader-logo">LISILOU</div>
|
||||
<div class="loader-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<header id="header">
|
||||
<a href="#" class="logo" id="header-logo">LISILOU</a>
|
||||
<nav id="main-nav">
|
||||
<a href="#portfolio">Portfolio</a>
|
||||
<a href="#client-access">Client Access</a>
|
||||
<a href="#connect">Connect</a>
|
||||
</nav>
|
||||
<div class="nav-toggle" id="nav-toggle">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Mobile Navigation -->
|
||||
<div class="mobile-nav" id="mobile-nav">
|
||||
<a href="#portfolio" class="mobile-nav-link">Portfolio</a>
|
||||
<a href="#client-access" class="mobile-nav-link">Client Access</a>
|
||||
<a href="#connect" class="mobile-nav-link">Connect</a>
|
||||
</div>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="hero">
|
||||
<div class="hero-bg"></div>
|
||||
<div class="hero-pattern"></div>
|
||||
<div class="hero-content">
|
||||
<p class="hero-eyebrow" id="hero-eyebrow">Photography</p>
|
||||
<h1 class="hero-title" id="hero-title">LisiLou <em>Photography</em></h1>
|
||||
<p class="hero-tagline" id="hero-tagline">Capturing life's beautiful moments</p>
|
||||
<div class="hero-cta">
|
||||
<a href="#portfolio" class="btn btn-filled">View Portfolio</a>
|
||||
<a href="#client-access" class="btn">Client Access</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scroll-indicator">
|
||||
<span>Scroll</span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M12 5v14M19 12l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Portfolio Section -->
|
||||
<section id="portfolio">
|
||||
<div class="section-header reveal">
|
||||
<p class="section-eyebrow">The Work</p>
|
||||
<h2 class="section-title">Portfolio</h2>
|
||||
</div>
|
||||
<div class="portfolio-grid" id="portfolio-grid">
|
||||
<!-- Portfolio items will be dynamically inserted -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Client Access Section -->
|
||||
<section id="client-access">
|
||||
<div class="client-access-container reveal">
|
||||
<div class="section-header">
|
||||
<p class="section-eyebrow">Clients</p>
|
||||
<h2 class="section-title" id="client-title">Gallery Access</h2>
|
||||
</div>
|
||||
<p id="client-description">Enter your gallery code to view and download your photos</p>
|
||||
<form class="client-access-form" id="client-form">
|
||||
<input type="text" class="client-access-input" id="gallery-code" placeholder="Enter your gallery code" required>
|
||||
<button type="submit" class="btn btn-filled">View Gallery</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Connect Section -->
|
||||
<section id="connect">
|
||||
<div class="section-header reveal">
|
||||
<p class="section-eyebrow">Get in Touch</p>
|
||||
<h2 class="section-title">Let's Connect</h2>
|
||||
</div>
|
||||
<div class="social-links" id="social-links">
|
||||
<!-- Social links will be dynamically inserted -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer>
|
||||
<div class="footer-logo" id="footer-logo">LISILOU</div>
|
||||
<p class="footer-text">© <span id="current-year"></span> LisiLou Photography. All rights reserved.</p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Fetch all Immich album images via proxy
|
||||
async function getImmichAlbumImages(baseUrl, prefix, albumSlug) {
|
||||
try {
|
||||
// Step 1: Fetch the share page to get the share key from og:image
|
||||
const proxyUrl = `/api/fetch-immich${prefix}${albumSlug}`;
|
||||
const response = await fetch(proxyUrl);
|
||||
const html = await response.text();
|
||||
|
||||
// Extract share key from og:image URL
|
||||
const ogMatch = html.match(/<meta\s+property="og:image"\s+content="[^"]+\?key=([^"]+)"/);
|
||||
if (!ogMatch || !ogMatch[1]) {
|
||||
console.log(`Could not find share key for album: ${albumSlug}`);
|
||||
return [];
|
||||
}
|
||||
const shareKey = ogMatch[1];
|
||||
|
||||
// Step 2: Get shared link info
|
||||
const sharedLinkResponse = await fetch(`/api/fetch-immich/api/shared-links/me?key=${shareKey}`);
|
||||
const sharedLinkData = await sharedLinkResponse.json();
|
||||
|
||||
let assets = [];
|
||||
|
||||
// Handle both ALBUM and INDIVIDUAL share types
|
||||
if (sharedLinkData.type === 'ALBUM' && sharedLinkData.album?.id) {
|
||||
// For album shares, fetch the full album to get all assets
|
||||
const albumResponse = await fetch(`/api/fetch-immich/api/albums/${sharedLinkData.album.id}?key=${shareKey}`);
|
||||
const albumData = await albumResponse.json();
|
||||
assets = albumData.assets || [];
|
||||
} else if (sharedLinkData.assets && sharedLinkData.assets.length > 0) {
|
||||
// For individual asset shares, assets are directly in the response
|
||||
assets = sharedLinkData.assets;
|
||||
}
|
||||
|
||||
if (assets.length === 0) {
|
||||
console.log(`No assets found in: ${albumSlug}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Build thumbnail URLs for all assets
|
||||
const thumbnailUrls = assets.map(asset =>
|
||||
`${baseUrl}/api/assets/${asset.id}/thumbnail?key=${shareKey}`
|
||||
);
|
||||
|
||||
return thumbnailUrls;
|
||||
} catch (error) {
|
||||
console.log(`Could not fetch images for album: ${albumSlug}`, error);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Start carousel for a portfolio card
|
||||
function startCarousel(cardElement, images) {
|
||||
if (images.length <= 1) return;
|
||||
|
||||
let currentIndex = 0;
|
||||
const imgElement = cardElement.querySelector('img');
|
||||
|
||||
setInterval(() => {
|
||||
currentIndex = (currentIndex + 1) % images.length;
|
||||
imgElement.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
imgElement.src = images[currentIndex];
|
||||
imgElement.style.opacity = '1';
|
||||
}, 400);
|
||||
}, 4000); // Change image every 4 seconds
|
||||
}
|
||||
|
||||
// Configuration loader
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await fetch('/config/site.json');
|
||||
const config = await response.json();
|
||||
await applyConfig(config);
|
||||
} catch (error) {
|
||||
console.log('Using default configuration');
|
||||
}
|
||||
}
|
||||
|
||||
async function applyConfig(config) {
|
||||
// Store immich config globally first (needed for thumbnail fetching)
|
||||
window.immichConfig = config.immich || {};
|
||||
|
||||
// Apply theme colors
|
||||
if (config.theme) {
|
||||
const root = document.documentElement;
|
||||
if (config.theme.primaryColor) root.style.setProperty('--color-primary', config.theme.primaryColor);
|
||||
if (config.theme.accentColor) root.style.setProperty('--color-accent', config.theme.accentColor);
|
||||
if (config.theme.textColor) root.style.setProperty('--color-text', config.theme.textColor);
|
||||
if (config.theme.backgroundColor) root.style.setProperty('--color-bg', config.theme.backgroundColor);
|
||||
}
|
||||
|
||||
// Apply site content
|
||||
if (config.site) {
|
||||
document.title = config.site.title || document.title;
|
||||
const siteName = config.site.title?.replace(' Photography', '') || 'LISILOU';
|
||||
document.getElementById('loader-logo').textContent = siteName.toUpperCase();
|
||||
document.getElementById('header-logo').textContent = siteName.toUpperCase();
|
||||
document.getElementById('footer-logo').textContent = siteName.toUpperCase();
|
||||
document.getElementById('hero-title').innerHTML = `${siteName} <em>Photography</em>`;
|
||||
if (config.site.tagline) {
|
||||
document.getElementById('hero-tagline').textContent = config.site.tagline;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply portfolio categories
|
||||
if (config.portfolio?.categories) {
|
||||
const grid = document.getElementById('portfolio-grid');
|
||||
const baseUrl = window.immichConfig.baseUrl;
|
||||
const prefix = window.immichConfig.publicAlbumPrefix || '/s/';
|
||||
|
||||
// First render the cards with placeholder/fallback images
|
||||
grid.innerHTML = config.portfolio.categories.map(cat => `
|
||||
<div class="portfolio-card reveal" data-album-id="${cat.immichAlbumId || ''}" onclick="openPortfolio('${cat.id}', '${cat.immichAlbumId || ''}')">
|
||||
<img src="${cat.coverImage || '/images/placeholder.jpg'}" alt="${cat.name}" loading="lazy">
|
||||
<div class="portfolio-card-overlay">
|
||||
<h3 class="portfolio-card-title">${cat.name}</h3>
|
||||
<p class="portfolio-card-desc">${cat.description || ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
observeRevealElements();
|
||||
|
||||
// Then fetch album images and start carousels for each category
|
||||
config.portfolio.categories.forEach(async (cat, index) => {
|
||||
if (cat.immichAlbumId && baseUrl) {
|
||||
const images = await getImmichAlbumImages(baseUrl, prefix, cat.immichAlbumId);
|
||||
if (images.length > 0) {
|
||||
const card = grid.children[index];
|
||||
const img = card.querySelector('img');
|
||||
|
||||
// Set the first image
|
||||
img.src = images[0];
|
||||
|
||||
// Start the carousel if there are multiple images
|
||||
if (images.length > 1) {
|
||||
startCarousel(card, images);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Apply social links
|
||||
if (config.social) {
|
||||
const socialContainer = document.getElementById('social-links');
|
||||
const socialIcons = {
|
||||
instagram: `<svg viewBox="0 0 24 24"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/></svg>`,
|
||||
facebook: `<svg viewBox="0 0 24 24"><path d="M9 8h-3v4h3v12h5v-12h3.642l.358-4h-4v-1.667c0-.955.192-1.333 1.115-1.333h2.885v-5h-3.808c-3.596 0-5.192 1.583-5.192 4.615v3.385z"/></svg>`,
|
||||
pinterest: `<svg viewBox="0 0 24 24"><path d="M12 0c-6.627 0-12 5.372-12 12 0 5.084 3.163 9.426 7.627 11.174-.105-.949-.2-2.405.042-3.441.218-.937 1.407-5.965 1.407-5.965s-.359-.719-.359-1.782c0-1.668.967-2.914 2.171-2.914 1.023 0 1.518.769 1.518 1.69 0 1.029-.655 2.568-.994 3.995-.283 1.194.599 2.169 1.777 2.169 2.133 0 3.772-2.249 3.772-5.495 0-2.873-2.064-4.882-5.012-4.882-3.414 0-5.418 2.561-5.418 5.207 0 1.031.397 2.138.893 2.738.098.119.112.224.083.345l-.333 1.36c-.053.22-.174.267-.402.161-1.499-.698-2.436-2.889-2.436-4.649 0-3.785 2.75-7.262 7.929-7.262 4.163 0 7.398 2.967 7.398 6.931 0 4.136-2.607 7.464-6.227 7.464-1.216 0-2.359-.631-2.75-1.378l-.748 2.853c-.271 1.043-1.002 2.35-1.492 3.146 1.124.347 2.317.535 3.554.535 6.627 0 12-5.373 12-12 0-6.628-5.373-12-12-12z"/></svg>`,
|
||||
tiktok: `<svg viewBox="0 0 24 24"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>`
|
||||
};
|
||||
|
||||
let links = '';
|
||||
if (config.social.instagram) {
|
||||
links += `<a href="https://instagram.com/${config.social.instagram}" target="_blank" class="social-link">${socialIcons.instagram}<span>@${config.social.instagram}</span></a>`;
|
||||
}
|
||||
if (config.social.facebook) {
|
||||
links += `<a href="https://facebook.com/${config.social.facebook}" target="_blank" class="social-link">${socialIcons.facebook}<span>Facebook</span></a>`;
|
||||
}
|
||||
if (config.social.pinterest) {
|
||||
links += `<a href="https://pinterest.com/${config.social.pinterest}" target="_blank" class="social-link">${socialIcons.pinterest}<span>Pinterest</span></a>`;
|
||||
}
|
||||
if (config.social.tiktok) {
|
||||
links += `<a href="https://tiktok.com/@${config.social.tiktok}" target="_blank" class="social-link">${socialIcons.tiktok}<span>TikTok</span></a>`;
|
||||
}
|
||||
socialContainer.innerHTML = links;
|
||||
}
|
||||
|
||||
// Apply client access config
|
||||
if (config.clientAccess) {
|
||||
if (config.clientAccess.title) {
|
||||
document.getElementById('client-title').textContent = config.clientAccess.title;
|
||||
}
|
||||
if (config.clientAccess.description) {
|
||||
document.getElementById('client-description').textContent = config.clientAccess.description;
|
||||
}
|
||||
if (config.clientAccess.placeholder) {
|
||||
document.getElementById('gallery-code').placeholder = config.clientAccess.placeholder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Portfolio navigation
|
||||
function openPortfolio(categoryId, albumId) {
|
||||
if (albumId && window.immichConfig?.baseUrl) {
|
||||
window.open(`${window.immichConfig.baseUrl}${window.immichConfig.publicAlbumPrefix || '/share/'}${albumId}`, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
// Client gallery access
|
||||
document.getElementById('client-form').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const code = document.getElementById('gallery-code').value.trim();
|
||||
if (code && window.immichConfig?.baseUrl) {
|
||||
window.location.href = `${window.immichConfig.baseUrl}${window.immichConfig.publicAlbumPrefix || '/share/'}${code}`;
|
||||
}
|
||||
});
|
||||
|
||||
// Header scroll effect
|
||||
window.addEventListener('scroll', function() {
|
||||
const header = document.getElementById('header');
|
||||
if (window.scrollY > 50) {
|
||||
header.classList.add('scrolled');
|
||||
} else {
|
||||
header.classList.remove('scrolled');
|
||||
}
|
||||
});
|
||||
|
||||
// Mobile navigation
|
||||
const navToggle = document.getElementById('nav-toggle');
|
||||
const mobileNav = document.getElementById('mobile-nav');
|
||||
const mobileNavLinks = document.querySelectorAll('.mobile-nav-link');
|
||||
|
||||
navToggle.addEventListener('click', function() {
|
||||
mobileNav.classList.toggle('active');
|
||||
navToggle.classList.toggle('active');
|
||||
});
|
||||
|
||||
mobileNavLinks.forEach(link => {
|
||||
link.addEventListener('click', function() {
|
||||
mobileNav.classList.remove('active');
|
||||
navToggle.classList.remove('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Reveal animations on scroll
|
||||
function observeRevealElements() {
|
||||
const reveals = document.querySelectorAll('.reveal');
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('visible');
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1 });
|
||||
|
||||
reveals.forEach(el => observer.observe(el));
|
||||
}
|
||||
|
||||
// Page load
|
||||
window.addEventListener('load', function() {
|
||||
document.getElementById('current-year').textContent = new Date().getFullYear();
|
||||
loadConfig();
|
||||
|
||||
setTimeout(() => {
|
||||
document.getElementById('loader').classList.add('hidden');
|
||||
}, 1000);
|
||||
|
||||
observeRevealElements();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user