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:
2026-01-17 10:35:55 -07:00
commit 4420dd51ac
13 changed files with 1625 additions and 0 deletions
+12
View File
@@ -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)"
]
}
}
+68
View File
@@ -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
View File
@@ -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
+71
View File
@@ -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
View File
@@ -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;"]
+234
View File
@@ -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!
+11
View File
@@ -0,0 +1,11 @@
{
"profiles": {
"lisilou": {
"enabled": true,
"domain": "lisilou.com",
"configFile": "site.json"
}
},
"defaultProfile": "lisilou",
"multiTenant": false
}
+68
View File
@@ -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"
}
}
+37
View File
@@ -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
View File
@@ -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;
}
}
}
View File
View File
+956
View File
@@ -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">&copy; <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>