Compare commits
@@ -8,15 +8,15 @@ A modern, responsive photography portfolio website built with Astro, Tailwind CS
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/public/3d-scroll/img1.jpg" width="250" alt="Portfolio Grid">
|
||||
<img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/src/assets/images/3d-scroll/img1.jpg" width="250" alt="Portfolio Grid">
|
||||
<br><em> More </em>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/public/3d-scroll/img2.jpg" width="250" alt="3D Scroll Effect">
|
||||
<img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/src/assets/images/3d-scroll/img2.jpg" width="250" alt="3D Scroll Effect">
|
||||
<br><em> At </em>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/public/3d-scroll/img3.jpg" width="250" alt="Hero Section">
|
||||
<img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/src/assets/images/3d-scroll/img3.jpg" width="250" alt="Hero Section">
|
||||
<br><em>Imagesbyolofsson.se</em>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -2,6 +2,15 @@ import { defineConfig } from 'astro/config';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
// Kanonisk adress: canonical, og:url, sitemap och JSON-LD byggs från denna.
|
||||
site: 'https://imagesbyolofsson.se',
|
||||
// En URL per sida: /about (aldrig /about/). Servern 301:ar /about/ -> /about.
|
||||
trailingSlash: 'never',
|
||||
build: {
|
||||
// about.astro -> dist/about.html (inte dist/about/index.html).
|
||||
// nginx/Caddy serverar /about via try_files $uri.html.
|
||||
format: 'file',
|
||||
},
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# deploy/caddy-site.snippet
|
||||
# Caddy-motsvarigheten till nginx.conf för imagesbyolofsson.se (statisk dist/ från `astro build`,
|
||||
# build.format "file": /about -> about.html, 404 -> 404.html).
|
||||
#
|
||||
# Användning i Caddyfile (Caddy >= 2.7):
|
||||
#
|
||||
# import /etc/caddy/snippets/caddy-site.snippet
|
||||
#
|
||||
# www.imagesbyolofsson.se {
|
||||
# import ibo_www_redirect
|
||||
# }
|
||||
# imagesbyolofsson.se {
|
||||
# # Specialregler (på Cleura: handle för /about, /contact, /posters -> kundportalen,
|
||||
# # se kundportalens deploy/cleura/caddy/Caddyfile) ska ligga FÖRE importen.
|
||||
# import ibo_site /srv/imagesbyolofsson/dist
|
||||
# }
|
||||
#
|
||||
# Argumentet är sökvägen till dist-katalogen på servern (justera till den riktiga).
|
||||
|
||||
(ibo_www_redirect) {
|
||||
redir https://imagesbyolofsson.se{uri} permanent
|
||||
}
|
||||
|
||||
(ibo_site) {
|
||||
root * {args[0]}
|
||||
encode zstd gzip
|
||||
|
||||
# --- Säkerhetsheaders ---
|
||||
# CSP: tillåter Meta Pixel (laddas först efter samtycke), anonym statistik via sendBeacon
|
||||
# till customer.imagesbyolofsson.se och Cloudflares egna skript (/cdn-cgi, ev. Web Analytics).
|
||||
# 'unsafe-inline' behövs för samtyckesskriptet och Astros inlinade skript/stilar.
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()"
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://connect.facebook.net https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://www.facebook.com https://*.facebook.com; font-src 'self' data:; connect-src 'self' https://customer.imagesbyolofsson.se https://www.facebook.com https://*.facebook.com https://connect.facebook.net https://cloudflareinsights.com; frame-src https://www.facebook.com; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests"
|
||||
-Server
|
||||
}
|
||||
|
||||
# --- Cache-Control (ömsesidigt uteslutande matchers) ---
|
||||
@ibo_immutable path /_astro/*
|
||||
header @ibo_immutable Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
@ibo_media {
|
||||
path *.jpg *.jpeg *.png *.webp *.avif *.gif *.svg *.ico *.woff *.woff2 *.ttf *.otf
|
||||
not path /_astro/*
|
||||
}
|
||||
header @ibo_media Cache-Control "public, max-age=2592000"
|
||||
|
||||
@ibo_textfiles path *.xml *.txt
|
||||
header @ibo_textfiles Cache-Control "public, max-age=3600"
|
||||
|
||||
@ibo_pages {
|
||||
not path /_astro/*
|
||||
not path *.jpg *.jpeg *.png *.webp *.avif *.gif *.svg *.ico *.woff *.woff2 *.ttf *.otf *.xml *.txt
|
||||
}
|
||||
header @ibo_pages Cache-Control "public, max-age=0, must-revalidate"
|
||||
|
||||
# --- 301:or ---
|
||||
# Gamla indexerade adresser -> portfolion.
|
||||
@ibo_old path /my-people /my-people/ /animales-and-nature /animales-and-nature/ /celebrations-with-family /celebrations-with-family/ /northen-lights /northen-lights/
|
||||
redir @ibo_old /portfolio permanent
|
||||
|
||||
# /index.html -> /
|
||||
redir /index.html / permanent
|
||||
|
||||
# /about.html -> /about (404-sidan hanteras separat nedan)
|
||||
@ibo_htmlext {
|
||||
path_regexp ibo_htmlext ^(/.+)\.html$
|
||||
not path /404.html
|
||||
}
|
||||
redir @ibo_htmlext {re.ibo_htmlext.1}{?query} permanent
|
||||
|
||||
# Avslutande snedstreck: /about/ -> /about
|
||||
@ibo_trailing path_regexp ibo_trailing ^(/.+)/$
|
||||
redir @ibo_trailing {re.ibo_trailing.1}{?query} permanent
|
||||
|
||||
# 404-sidan ska bara synas som svar på okända adresser, inte som egen URL med 200.
|
||||
@ibo_404page path /404 /404.html
|
||||
error @ibo_404page 404
|
||||
|
||||
# --- Statiska filer ---
|
||||
try_files {path} {path}.html
|
||||
file_server
|
||||
|
||||
# Riktig 404: 404.html med status 404 (inte startsidan med 200).
|
||||
handle_errors {
|
||||
@ibo_notfound expression {err.status_code} == 404
|
||||
handle @ibo_notfound {
|
||||
root * {args[0]}
|
||||
rewrite * /404.html
|
||||
file_server {
|
||||
status 404
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy/cleura/install-portfolio-sync.sh – installs the portfolio sync on the Cleura server (run as root from a
|
||||
# checkout of this repo). Needs /etc/portal/portfolio-sync.env (root 600) first; see portfolio-sync.sh for the keys.
|
||||
# Undo: systemctl disable --now portfolio-sync.timer && rm /etc/systemd/system/portfolio-sync.{service,timer}
|
||||
set -euo pipefail
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
ENVF=/etc/portal/portfolio-sync.env
|
||||
[ -f "$ENVF" ] || { echo "$ENVF is missing - copy IMMICH_URL, PORTFOLIO_TAG_ID, IMMICH_API_KEY and GIT_URL there first"; exit 2; }
|
||||
[ "$(stat -c %a "$ENVF")" = "600" ] || chmod 600 "$ENVF"
|
||||
install -m 755 -o root -g root "$HERE/portfolio-sync.sh" /opt/portal/site/portfolio-sync.sh
|
||||
install -m 644 -o root -g root "$HERE/portfolio-sync.service" /etc/systemd/system/portfolio-sync.service
|
||||
install -m 644 -o root -g root "$HERE/portfolio-sync.timer" /etc/systemd/system/portfolio-sync.timer
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now portfolio-sync.timer
|
||||
systemctl list-timers portfolio-sync.timer --no-pager
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Immich portfolio tag -> imagesbyolofsson-se repo -> live site (deploy/cleura/portfolio-sync.sh)
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/portal/site/portfolio-sync.sh
|
||||
TimeoutStartSec=45min
|
||||
Nice=10
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy/cleura/portfolio-sync.sh – Immich "portfolio" tag -> this repo -> imagesbyolofsson.se, on the Cleura server.
|
||||
#
|
||||
# Replaces /opt/immich-sync/run-sync.sh on Nurem (started by n8n over SSH) when imagesbyolofsson.se moved to Cleura
|
||||
# (Linus 2026-11-06: everything on the Cleura server). Same steps: fetch main, run scripts/portfolio-sync.mjs,
|
||||
# commit + push to Gitea main when photos were added or removed, and then rebuild the live site with
|
||||
# /opt/portal/site/build-live.sh (EXIF check included). Nothing is built when nothing changed.
|
||||
#
|
||||
# Installed by deploy/cleura/install-portfolio-sync.sh as /opt/portal/site/portfolio-sync.sh, run by
|
||||
# portfolio-sync.timer every 20 minutes. Secrets: /etc/portal/portfolio-sync.env (root 600) with
|
||||
# IMMICH_URL, PORTFOLIO_TAG_ID, IMMICH_API_KEY (read-only Immich key) and GIT_URL (Gitea URL that may push main,
|
||||
# with user:token@ in it, as on Nurem).
|
||||
#
|
||||
# The Gitea token never lands on disk outside that env file: the clone's remote URL is stored without it, git gets
|
||||
# it from a credential helper at fetch/push time, and the Node container (npm install runs third-party code) gets
|
||||
# an empty tmpfs over .git and only the Immich variables.
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
ENVF=${PORTFOLIO_SYNC_ENV:-/etc/portal/portfolio-sync.env}
|
||||
DIR=${PORTFOLIO_SYNC_DIR:-/opt/portal/site/sync/repo}
|
||||
NODE=node:22.22.0-alpine@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
|
||||
|
||||
exec 9>/run/lock/portfolio-sync.lock
|
||||
flock -n 9 || { echo "[sync] already running"; exit 0; }
|
||||
|
||||
set -a; . "$ENVF"; set +a
|
||||
export IMMICH_URL="${IMMICH_URL:-https://foto.imagesbyolofsson.se/api}"
|
||||
export PORTFOLIO_TAG_ID="${PORTFOLIO_TAG_ID:-67d16b6c-0dea-42ef-a289-2c9e57fa5c5a}"
|
||||
[ -n "${IMMICH_API_KEY:-}" ] || { echo "FATAL: IMMICH_API_KEY missing in $ENVF"; exit 2; }
|
||||
[ -n "${GIT_URL:-}" ] || { echo "FATAL: GIT_URL missing in $ENVF"; exit 2; }
|
||||
|
||||
# Split GIT_URL into the clean URL (stored in .git/config) and user/token (kept in this process only).
|
||||
read -r CLEAN_URL GIT_USER GIT_TOKEN < <(python3 - "$GIT_URL" <<'PY'
|
||||
import sys, urllib.parse as u
|
||||
p = u.urlsplit(sys.argv[1])
|
||||
host = p.hostname + (":%d" % p.port if p.port else "")
|
||||
print(u.urlunsplit((p.scheme, host, p.path, "", "")), u.unquote(p.username or "-"), u.unquote(p.password or "-"))
|
||||
PY
|
||||
)
|
||||
unset GIT_URL
|
||||
export GIT_USER GIT_TOKEN
|
||||
# shellcheck disable=SC2016 # $GIT_USER/$GIT_TOKEN are expanded by the helper's shell, not here.
|
||||
G=(git -c credential.helper= -c 'credential.helper=!f() { echo "username=$GIT_USER"; echo "password=$GIT_TOKEN"; }; f')
|
||||
|
||||
if [ ! -d "$DIR/.git" ]; then
|
||||
mkdir -p "$(dirname "$DIR")"
|
||||
"${G[@]}" clone -q "$CLEAN_URL" "$DIR"
|
||||
fi
|
||||
chmod 700 "$(dirname "$DIR")" "$DIR"
|
||||
git -C "$DIR" remote set-url origin "$CLEAN_URL"
|
||||
echo "[sync] fetch + reset main"
|
||||
"${G[@]}" -C "$DIR" fetch -q origin main
|
||||
git -C "$DIR" checkout -q -B main origin/main
|
||||
git -C "$DIR" reset -q --hard origin/main
|
||||
|
||||
echo "[sync] scripts/portfolio-sync.mjs"
|
||||
# portfolio-sync.mjs exits 1 when a single photo fails but still writes the others; those are committed below, and the
|
||||
# run ends with exit 1 so systemd shows that something needs a look (journalctl -u portfolio-sync).
|
||||
SYNC_RC=0
|
||||
docker run --rm --memory 3g --cpus 2 --network bridge \
|
||||
-e IMMICH_URL -e IMMICH_API_KEY -e PORTFOLIO_TAG_ID \
|
||||
-v "$DIR":/app --mount type=tmpfs,destination=/app/.git -w /app "$NODE" \
|
||||
sh -c 'npm install --no-audit --no-fund --loglevel=error >/dev/null && node scripts/portfolio-sync.mjs' || SYNC_RC=$?
|
||||
|
||||
if git -C "$DIR" status --porcelain -- src/assets/images/portfolio | grep -q .; then
|
||||
git -C "$DIR" add -A src/assets/images/portfolio
|
||||
git -C "$DIR" -c user.name="portfolio-sync (Cleura)" -c user.email="Admin@imagesbyolofsson.se" \
|
||||
commit -q -m "chore(portfolio): sync $(date -u +%Y-%m-%d)"
|
||||
"${G[@]}" -C "$DIR" push -q origin main
|
||||
echo "[sync] pushed $(git -C "$DIR" rev-parse --short HEAD), rebuilding the live site"
|
||||
/opt/portal/site/build-live.sh main
|
||||
else
|
||||
echo "[sync] no changes"
|
||||
fi
|
||||
if [ "$SYNC_RC" != 0 ]; then
|
||||
echo "[sync] WARNING: portfolio-sync.mjs exited $SYNC_RC - some photos failed (see the lines with '!' above)"
|
||||
exit "$SYNC_RC"
|
||||
fi
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Portfolio sync every 20 minutes (Immich has no event when a photo is tagged, so it polls)
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*:0/20
|
||||
RandomizedDelaySec=60
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,11 +1,20 @@
|
||||
map $sent_http_content_type $cache_control_strategy {
|
||||
default "public, max-age=2592000";
|
||||
~^text/html "no-store, must-revalidate";
|
||||
~^text/css "public, max-age=31536000, immutable";
|
||||
~^application/javascript "public, max-age=31536000, immutable";
|
||||
~^application/json "no-store, must-revalidate";
|
||||
~^image/ "public, max-age=2592000";
|
||||
~^font/ "public, max-age=31536000, immutable";
|
||||
# nginx för imagesbyolofsson.se (Docker-image: dist/ i /usr/share/nginx/html).
|
||||
# Bygget använder build.format "file": /about -> about.html, 404 -> 404.html.
|
||||
# Samma regler som Caddy-varianten i deploy/caddy-site.snippet.
|
||||
|
||||
# Cache-Control per sökväg (inte per Content-Type, så att /_astro/*.jpg också blir immutable).
|
||||
map $uri $ibo_cache_control {
|
||||
default "public, max-age=0, must-revalidate";
|
||||
~^/_astro/ "public, max-age=31536000, immutable";
|
||||
~*\.(?:jpe?g|png|webp|avif|gif|svg|ico|woff2?|ttf|otf)$ "public, max-age=2592000";
|
||||
~*\.(?:xml|txt)$ "public, max-age=3600";
|
||||
}
|
||||
|
||||
# www -> apex (om www-trafik når den här containern).
|
||||
server {
|
||||
listen 80;
|
||||
server_name www.imagesbyolofsson.se;
|
||||
return 301 https://imagesbyolofsson.se$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
@@ -14,33 +23,68 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Relativa Location-headers: bakom Cloudflare/proxy blir det aldrig http://host:80/...
|
||||
absolute_redirect off;
|
||||
server_tokens off;
|
||||
|
||||
# Säkerhetsheaders. OBS: inga add_header i location-blocken nedan, annars ärvs inte dessa.
|
||||
# CSP: tillåter Meta Pixel (connect.facebook.net/facebook.com, laddas först efter samtycke),
|
||||
# den anonyma statistiken (sendBeacon till customer.imagesbyolofsson.se) och Cloudflares
|
||||
# egna skript (/cdn-cgi, ev. Web Analytics). 'unsafe-inline' behövs för samtyckesskriptet
|
||||
# och Astros inlinade skript/stilar; byt mot hashar (Astro experimental.csp) i ett senare steg.
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
add_header Cache-Control $cache_control_strategy always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://connect.facebook.net https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://www.facebook.com https://*.facebook.com; font-src 'self' data:; connect-src 'self' https://customer.imagesbyolofsson.se https://www.facebook.com https://*.facebook.com https://connect.facebook.net https://cloudflareinsights.com; frame-src https://www.facebook.com; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests" always;
|
||||
add_header Cache-Control $ibo_cache_control always;
|
||||
|
||||
# Gamla indexerade adresser -> närmaste nya sida.
|
||||
location ~ ^/(?:my-people|animales-and-nature|celebrations-with-family|northen-lights)/?$ {
|
||||
return 301 /portfolio;
|
||||
}
|
||||
|
||||
# /index.html och /about.html -> rena URL:er (bara externa anrop; try_files nedan
|
||||
# serverar filerna internt utan att gå hit).
|
||||
location = /index.html {
|
||||
return 301 /;
|
||||
}
|
||||
location ~ ^(?<ibo_noext>/.+)\.html$ {
|
||||
return 301 $ibo_noext$is_args$args;
|
||||
}
|
||||
|
||||
# Inga dubbletter med avslutande snedstreck: /about/ -> /about.
|
||||
location ~ ^(?<ibo_noslash>/.+)/$ {
|
||||
return 301 $ibo_noslash$is_args$args;
|
||||
}
|
||||
|
||||
# Startsidan serveras direkt (ingen intern omdirigering till /index.html).
|
||||
location = / {
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
# Hashade Astro-filer: cachas ett år (immutable sätts via map ovan).
|
||||
location ^~ /_astro/ {
|
||||
access_log off;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Originalnedladdning (/download/original) proxas till Immich någon annanstans
|
||||
# (Cloudflare/Caddy). Finns den regeln i den här nginx: lägg den här ovanför.
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ $uri.html $uri/index.html =404;
|
||||
try_files $uri $uri.html =404;
|
||||
}
|
||||
|
||||
location /_astro/ {
|
||||
access_log off;
|
||||
expires 1y;
|
||||
}
|
||||
|
||||
location ~* \.(webp|avif|jpg|jpeg|png|gif|svg|ico|woff2?|ttf|otf|eot)$ {
|
||||
access_log off;
|
||||
expires 30d;
|
||||
# Riktig 404: 404.html med status 404 (inte startsidan med 200).
|
||||
error_page 404 /404.html;
|
||||
location = /404.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss image/svg+xml;
|
||||
|
||||
error_page 404 /404.html;
|
||||
location = /404.html {
|
||||
internal;
|
||||
}
|
||||
gzip_types text/plain text/css text/xml application/xml application/json application/javascript application/xml+rss image/svg+xml;
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 700 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 542 KiB |
@@ -0,0 +1,38 @@
|
||||
# robots.txt – https://imagesbyolofsson.se
|
||||
#
|
||||
# Policy (beslut av Linus 2026-10-27): ALLA botar är välkomna, även AI-crawlers
|
||||
# och träningsbotar. Innehållet får användas av AI-assistenter med hänvisning
|
||||
# till Images by Olofsson (se /llms.txt). Cloudflare-sidan (AI Crawl Control /
|
||||
# "Block AI bots" / hanterad robots.txt) ställs in separat så att den inte
|
||||
# blockerar eller skriver över detta.
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# AI-crawlers och träningsbotar: uttryckligen tillåtna.
|
||||
User-agent: GPTBot
|
||||
User-agent: OAI-SearchBot
|
||||
User-agent: ChatGPT-User
|
||||
User-agent: ClaudeBot
|
||||
User-agent: Claude-User
|
||||
User-agent: Claude-SearchBot
|
||||
User-agent: anthropic-ai
|
||||
User-agent: CCBot
|
||||
User-agent: Google-Extended
|
||||
User-agent: Applebot
|
||||
User-agent: Applebot-Extended
|
||||
User-agent: Bytespider
|
||||
User-agent: Amazonbot
|
||||
User-agent: meta-externalagent
|
||||
User-agent: meta-externalfetcher
|
||||
User-agent: FacebookBot
|
||||
User-agent: PerplexityBot
|
||||
User-agent: Perplexity-User
|
||||
User-agent: cohere-ai
|
||||
User-agent: Diffbot
|
||||
User-agent: YouBot
|
||||
User-agent: DuckAssistBot
|
||||
User-agent: MistralAI-User
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://imagesbyolofsson.se/sitemap-index.xml
|
||||
@@ -77,7 +77,9 @@ function destName(asset) {
|
||||
async function downloadAndResize(asset, destPath) {
|
||||
const res = await api("GET", `/assets/${asset.id}/original`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
await sharp(buf)
|
||||
// Pixel shift and the 15000 px Instagram squares can exceed sharp's default limit (268 MP). JPEGs are shrunk while
|
||||
// they are decoded, so memory stays low; the sync container still has a memory cap.
|
||||
await sharp(buf, { limitInputPixels: 1_000_000_000 })
|
||||
.rotate() // respektera EXIF-orientering
|
||||
.resize(MAX_DIM, MAX_DIM, { fit: "inside", withoutEnlargement: true })
|
||||
.withMetadata() // BEVARA EXIF (kritiskt för portfolions datum/kamera)
|
||||
|
||||
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 497 KiB |
|
After Width: | Height: | Size: 949 KiB |
|
After Width: | Height: | Size: 910 KiB |
|
After Width: | Height: | Size: 365 KiB |
|
After Width: | Height: | Size: 829 KiB |
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 383 KiB |
|
After Width: | Height: | Size: 451 KiB |
|
After Width: | Height: | Size: 938 KiB |
|
After Width: | Height: | Size: 667 KiB |
@@ -0,0 +1,46 @@
|
||||
---
|
||||
// src/components/SEO.astro
|
||||
// <title>, meta description, canonical, Open Graph och Twitter-kort för en sida.
|
||||
import { business } from '../data/business';
|
||||
import { normalizePath } from '../lib/url';
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
/** Sökväg utan avslutande snedstreck, t.ex. "/about". Annars härleds den från URL:en. */
|
||||
path?: string;
|
||||
/** Absolut URL till og:image (MainLayout tar fram en EXIF-fri JPG via getImage). */
|
||||
imageUrl: string;
|
||||
imageWidth?: number;
|
||||
imageHeight?: number;
|
||||
imageAlt: string;
|
||||
type?: 'website' | 'profile' | 'article';
|
||||
noindex?: boolean;
|
||||
}
|
||||
|
||||
const { title, description, path, imageUrl, imageWidth, imageHeight, imageAlt, type = 'website', noindex = false } = Astro.props;
|
||||
|
||||
const site = Astro.site ?? new URL(business.url);
|
||||
const canonical = new URL(normalizePath(path ?? Astro.url.pathname), site).toString();
|
||||
---
|
||||
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
{noindex ? <meta name="robots" content="noindex, follow" /> : <link rel="canonical" href={canonical} />}
|
||||
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:site_name" content={business.brand} />
|
||||
<meta property="og:locale" content="sv_SE" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:image" content={imageUrl} />
|
||||
{imageWidth && <meta property="og:image:width" content={String(imageWidth)} />}
|
||||
{imageHeight && <meta property="og:image:height" content={String(imageHeight)} />}
|
||||
<meta property="og:image:alt" content={imageAlt} />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={imageUrl} />
|
||||
<meta name="twitter:image:alt" content={imageAlt} />
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
// src/components/SchemaOrg.astro
|
||||
// JSON-LD (@graph): WebSite + Person (Linus) + ProfessionalService (Images by Olofsson)
|
||||
// + WebPage för aktuell sida (+ FAQPage och BreadcrumbList när det är relevant).
|
||||
// Allt hämtas från src/data/business.ts. null-värden utelämnas helt.
|
||||
import { business, absoluteUrl, genreList } from '../data/business';
|
||||
import type { FaqItem } from '../data/faq';
|
||||
import { normalizePath } from '../lib/url';
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
path?: string;
|
||||
pageType?: 'WebPage' | 'AboutPage' | 'ContactPage' | 'CollectionPage';
|
||||
/** Namn i brödsmulan för undersidor, t.ex. "Portfolio". */
|
||||
breadcrumb?: string;
|
||||
faq?: FaqItem[];
|
||||
/** Absolut URL till sidans huvudbild (og:image). */
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
const { title, description, path, pageType = 'WebPage', breadcrumb, faq, imageUrl } = Astro.props;
|
||||
|
||||
const site = (Astro.site ?? new URL(business.url)).toString().replace(/\/$/, '');
|
||||
const pagePath = normalizePath(path ?? Astro.url.pathname);
|
||||
const pageUrl = absoluteUrl(pagePath);
|
||||
|
||||
const ids = {
|
||||
website: `${site}/#website`,
|
||||
person: `${site}/#linus-olofsson`,
|
||||
business: `${site}/#images-by-olofsson`,
|
||||
webpage: `${pageUrl}#webpage`,
|
||||
breadcrumb: `${pageUrl}#breadcrumb`,
|
||||
};
|
||||
|
||||
// Tar bort null/undefined/tomma listor så att okända fakta aldrig hamnar i JSON-LD.
|
||||
function clean<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(clean).filter((v) => v !== null && v !== undefined) as unknown as T;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (v === null || v === undefined) continue;
|
||||
const c = clean(v);
|
||||
if (Array.isArray(c) && c.length === 0) continue;
|
||||
out[k] = c;
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Landet är känt (Sverige); ort och region läggs till när de fylls i business.ts.
|
||||
const address = {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: business.city,
|
||||
addressRegion: business.region,
|
||||
addressCountry: business.country.code,
|
||||
};
|
||||
|
||||
const graph: Record<string, unknown>[] = [
|
||||
{
|
||||
'@type': 'WebSite',
|
||||
'@id': ids.website,
|
||||
url: `${site}/`,
|
||||
name: business.brand,
|
||||
inLanguage: 'sv-SE',
|
||||
publisher: { '@id': ids.business },
|
||||
},
|
||||
{
|
||||
'@type': 'Person',
|
||||
'@id': ids.person,
|
||||
name: business.person.name,
|
||||
jobTitle: business.person.jobTitle,
|
||||
url: absoluteUrl('/about'),
|
||||
description: `${business.person.jobTitle} baserad i ${business.country.name}. ${business.recognitionThirdPerson}`,
|
||||
knowsAbout: business.genres.map((g) => g.name),
|
||||
worksFor: { '@id': ids.business },
|
||||
sameAs: [business.social.linkedin, business.social.lightroom],
|
||||
},
|
||||
{
|
||||
'@type': 'Organization',
|
||||
'@id': ids.business,
|
||||
name: business.brand,
|
||||
url: `${site}/`,
|
||||
description: `${business.person.name} fotograferar ${genreList()}.`,
|
||||
email: business.email,
|
||||
telephone: business.phone,
|
||||
image: imageUrl,
|
||||
address,
|
||||
founder: { '@id': ids.person },
|
||||
employee: { '@id': ids.person },
|
||||
sameAs: [business.social.instagram.url, business.social.facebook, business.social.flickr, business.social.lightroom],
|
||||
},
|
||||
];
|
||||
|
||||
const webpage: Record<string, unknown> = {
|
||||
'@type': faq?.length ? [pageType, 'FAQPage'] : pageType,
|
||||
'@id': ids.webpage,
|
||||
url: pageUrl,
|
||||
name: title,
|
||||
description,
|
||||
inLanguage: 'sv-SE',
|
||||
isPartOf: { '@id': ids.website },
|
||||
about: { '@id': ids.business },
|
||||
primaryImageOfPage: imageUrl ? { '@type': 'ImageObject', url: imageUrl } : null,
|
||||
breadcrumb: breadcrumb ? { '@id': ids.breadcrumb } : null,
|
||||
mainEntity: faq?.length
|
||||
? faq.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: { '@type': 'Answer', text: item.answer },
|
||||
}))
|
||||
: null,
|
||||
};
|
||||
graph.push(webpage);
|
||||
|
||||
if (breadcrumb) {
|
||||
graph.push({
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': ids.breadcrumb,
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Hem', item: `${site}/` },
|
||||
{ '@type': 'ListItem', position: 2, name: breadcrumb, item: pageUrl },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const json = JSON.stringify(clean({ '@context': 'https://schema.org', '@graph': graph })).replace(/</g, '\\u003c');
|
||||
---
|
||||
|
||||
<script type="application/ld+json" is:inline set:html={json} />
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
// src/components/T.astro – en text på svenska och engelska (Linus 2026-11-06: en svensk och en engelsk version,
|
||||
// SV/EN-knappen i menyn växlar). Båda finns i sidan; CSS i MainLayout visar den som hör till <html lang>.
|
||||
// <T sv="Om mig" en="About me" /> enkel text
|
||||
// <T><Fragment slot="sv">…</Fragment><Fragment slot="en">…</Fragment></T> text med länkar eller taggar
|
||||
export interface Props {
|
||||
sv?: string;
|
||||
en?: string;
|
||||
}
|
||||
const { sv, en } = Astro.props;
|
||||
---
|
||||
<span class="i18n-sv"><slot name="sv">{sv}</slot></span><span class="i18n-en" lang="en"><slot name="en">{en}</slot></span>
|
||||
@@ -1,13 +1,22 @@
|
||||
---
|
||||
// src/components/ThreeDScroll.astro
|
||||
// Bilderna ligger i src/assets så att Astro skalar om dem (AVIF/WebP/JPG, srcset)
|
||||
// och rensar bort EXIF (GPS, serienummer m.m.). Originalen publiceras inte.
|
||||
import { Picture } from 'astro:assets';
|
||||
import img1 from '../assets/images/3d-scroll/img1.jpg';
|
||||
import img2 from '../assets/images/3d-scroll/img2.jpg';
|
||||
import img3 from '../assets/images/3d-scroll/img3.jpg';
|
||||
import img4 from '../assets/images/3d-scroll/img4.jpg';
|
||||
import img5 from '../assets/images/3d-scroll/img5.jpg';
|
||||
|
||||
const images = [
|
||||
{ src: "/3d-scroll/img1.jpg", alt: "Architecture Dark" },
|
||||
{ src: "/3d-scroll/img2.jpg", alt: "Shadows Light" },
|
||||
{ src: "/3d-scroll/img3.jpg", alt: "Portrait High End" },
|
||||
{ src: "/3d-scroll/img4.jpg", alt: "Minimalist Landscape" },
|
||||
{ src: "/3d-scroll/img5.jpg", alt: "Foggy Forest" },
|
||||
{ src: img1, alt: 'Närbild av en gammal läderinbunden bok med slitna pappersark i varmt motljus' },
|
||||
{ src: img2, alt: 'Veteranbil med tända strålkastare på en stadsgata i kvällsljus' },
|
||||
{ src: img3, alt: 'Överblommade tistlar bland höga grässtrån i dämpade bruna toner' },
|
||||
{ src: img4, alt: 'Järnvägsspår som slingrar sig mellan träd i motljus, i sepiaton' },
|
||||
{ src: img5, alt: 'Svartvit bild av en man som bär ett litet barn bland såpbubblor i motljus' },
|
||||
];
|
||||
const last = images.length - 1;
|
||||
---
|
||||
|
||||
<div id="scroll-container" class="relative h-[400vh] w-full">
|
||||
@@ -20,9 +29,20 @@ const images = [
|
||||
class="gallery-item absolute transform-style-3d will-change-transform flex items-center justify-center"
|
||||
data-index={index}
|
||||
>
|
||||
<img
|
||||
src={img.src}
|
||||
{/* Första bilden: eager + hög prioritet. Den sista ligger överst innan man
|
||||
scrollat (alla bilder står på samma plats tills scriptet flyttar dem),
|
||||
så den laddas också direkt. Övriga laddas lat. */}
|
||||
<Picture
|
||||
src={img.src}
|
||||
alt={img.alt}
|
||||
widths={[640, 1024, 1600, 2048]}
|
||||
sizes="80vw"
|
||||
formats={['avif', 'webp']}
|
||||
fallbackFormat="jpg"
|
||||
quality={78}
|
||||
loading={index === 0 || index === last ? 'eager' : 'lazy'}
|
||||
fetchpriority={index === 0 ? 'high' : 'auto'}
|
||||
decoding="async"
|
||||
class="floating-animation max-w-[80vw] max-h-[70vh] w-auto h-auto object-contain rounded-lg shadow-[0_20px_60px_-15px_rgba(0,0,0,0.8)] border border-white/5 grayscale hover:grayscale-0 transition-all duration-700 ease-out"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// src/data/business.ts
|
||||
// ALLA fakta om Images by Olofsson på ett ställe. Sidor, JSON-LD, llms.txt och
|
||||
// sitemap läser härifrån – skriv aldrig in fakta direkt i en sida.
|
||||
//
|
||||
// Regel: det vi inte vet är `null`. Allt som läser filen hoppar över null-värden
|
||||
// (ingen "TODO", inga påhittade siffror syns på sajten). När Linus lämnar ett
|
||||
// värde: fyll i det här, så dyker det upp på /tjanster, i FAQ och i JSON-LD.
|
||||
|
||||
export type Genre = {
|
||||
/** Kort id, används som ankare på /tjanster (#portratt osv.) */
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type Business = {
|
||||
brand: string;
|
||||
url: string;
|
||||
person: {
|
||||
name: string;
|
||||
jobTitle: string;
|
||||
};
|
||||
email: string;
|
||||
/** Telefonnummer i format +46... – okänt */
|
||||
phone: string | null;
|
||||
country: { code: string; name: string };
|
||||
/** Ort Linus utgår från (hans publika Flickr- och LinkedIn-profil, 2026-10-27) */
|
||||
city: string | null;
|
||||
/** Län/region (LinkedIn, 2026-10-27) */
|
||||
region: string | null;
|
||||
/** Område han tar uppdrag i (fritext, t.ex. "Stockholm med omnejd") – okänt */
|
||||
serviceArea: string | null;
|
||||
/** Lägsta pris i SEK för en fotografering – okänt */
|
||||
priceFromSek: number | null;
|
||||
/** schema.org priceRange, t.ex. "$$" eller "1500–5000 kr" – okänt */
|
||||
priceRange: string | null;
|
||||
/** Paket och vad som ingår – okänt */
|
||||
packages: { name: string; includes: string[]; priceSek: number | null }[] | null;
|
||||
/** Typisk leveranstid, fritext (t.ex. "inom två veckor") – okänt */
|
||||
deliveryTime: string | null;
|
||||
/** Antal år som fotograf – okänt */
|
||||
yearsOfExperience: number | null;
|
||||
/** Kundomdömen – inga kända */
|
||||
testimonials: { name: string; text: string }[] | null;
|
||||
camera: string;
|
||||
lenses: string[];
|
||||
editing: string;
|
||||
/** Linus egen formulering: "featured by Adobe three times". Inga datum/detaljer. Jag-form för sidtext. */
|
||||
recognition: string;
|
||||
/** Samma fakta i tredje person (llms.txt, JSON-LD, meta descriptions). */
|
||||
recognitionThirdPerson: string;
|
||||
portal: {
|
||||
url: string;
|
||||
bookingUrl: string;
|
||||
};
|
||||
social: {
|
||||
instagram: { handle: string; url: string };
|
||||
facebook: string;
|
||||
flickr: string;
|
||||
lightroom: string;
|
||||
linkedin: string;
|
||||
github: string;
|
||||
};
|
||||
genres: Genre[];
|
||||
bookingSteps: { title: string; text: string }[];
|
||||
};
|
||||
|
||||
export const business: Business = {
|
||||
brand: 'Images by Olofsson',
|
||||
url: 'https://imagesbyolofsson.se',
|
||||
person: {
|
||||
name: 'Linus Olofsson',
|
||||
jobTitle: 'Fotograf',
|
||||
},
|
||||
email: 'Admin@imagesbyolofsson.se',
|
||||
phone: null,
|
||||
country: { code: 'SE', name: 'Sverige' },
|
||||
city: 'Örebro',
|
||||
region: 'Örebro län',
|
||||
serviceArea: null,
|
||||
priceFromSek: null,
|
||||
priceRange: null,
|
||||
packages: null,
|
||||
deliveryTime: null,
|
||||
yearsOfExperience: null,
|
||||
testimonials: null,
|
||||
camera: 'Sony A7R V',
|
||||
lenses: ['Sony FE 70-200mm f/4 G OSS', 'Sony FE 20mm f/1.8 G'],
|
||||
editing: 'Adobe Lightroom Classic',
|
||||
recognition: 'Mina bilder har lyfts fram av Adobe tre gånger.',
|
||||
recognitionThirdPerson: 'Hans bilder har lyfts fram av Adobe tre gånger.',
|
||||
portal: {
|
||||
url: 'https://customer.imagesbyolofsson.se',
|
||||
bookingUrl: 'https://customer.imagesbyolofsson.se/boka',
|
||||
},
|
||||
social: {
|
||||
instagram: { handle: '@imagesbyolofsson', url: 'https://www.instagram.com/imagesbyolofsson/' },
|
||||
facebook: 'https://www.facebook.com/imagesbyolofsson',
|
||||
flickr: 'https://www.flickr.com/photos/imagesbyolofsson/',
|
||||
lightroom: 'https://lightroom.adobe.com/u/linusolofsson1',
|
||||
linkedin: 'https://www.linkedin.com/in/linus-olofsson-/',
|
||||
github: 'https://github.com/LinusOlofsson-maker',
|
||||
},
|
||||
// Bara genrer som redan finns i sajtens texter och gamla sidnamn
|
||||
// (/my-people, /animales-and-nature, /celebrations-with-family, /northen-lights).
|
||||
genres: [
|
||||
{
|
||||
id: 'portratt',
|
||||
name: 'Porträtt och människor',
|
||||
description: 'Porträtt och ögonblick med människor – uttryck som berättar något utan ord.',
|
||||
},
|
||||
{
|
||||
id: 'natur',
|
||||
name: 'Natur och djur',
|
||||
description: 'Skog, landskap och djur – naturens stillhet och de små detaljerna i den.',
|
||||
},
|
||||
{
|
||||
id: 'stad',
|
||||
name: 'Stad och arkitektur',
|
||||
description: 'Gator, byggnader, strukturer och kontraster – stadens energi och dess tomma stunder.',
|
||||
},
|
||||
{
|
||||
id: 'makro',
|
||||
name: 'Makro och detaljer',
|
||||
description: 'Mönster, texturer och små detaljer som de flesta går förbi.',
|
||||
},
|
||||
{
|
||||
id: 'norrsken',
|
||||
name: 'Norrsken',
|
||||
description: 'Norrskenets ljus och färger över himlen.',
|
||||
},
|
||||
{
|
||||
id: 'fest',
|
||||
name: 'Fester och familjehögtider',
|
||||
description: 'Bilder från fester och högtider tillsammans med familj och vänner.',
|
||||
},
|
||||
],
|
||||
bookingSteps: [
|
||||
{
|
||||
title: 'Skicka en förfrågan',
|
||||
text: 'Berätta vad du vill ha fotograferat, när och var – via kundportalen.',
|
||||
},
|
||||
{
|
||||
title: 'Få en offert',
|
||||
text: 'Jag återkommer med ett förslag och en offert för just ditt uppdrag.',
|
||||
},
|
||||
{
|
||||
title: 'Bokning',
|
||||
text: 'När du har godkänt offerten bokar vi in fotograferingen.',
|
||||
},
|
||||
{
|
||||
title: 'Leverans i ett privat galleri',
|
||||
text: 'Du får bilderna i ett eget, privat galleri i kundportalen.',
|
||||
},
|
||||
{
|
||||
title: 'Originalfilerna efter betalning',
|
||||
text: 'När betalningen är klar kan du ladda ner originalfilerna i full upplösning.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Genrer som läsbar lista: "a, b, c samt d". */
|
||||
export function genreList(lowercase = true): string {
|
||||
const names = business.genres.map((g) => (lowercase ? g.name.toLowerCase() : g.name));
|
||||
if (names.length <= 1) return names.join('');
|
||||
// "samt" före sista ledet, eftersom genrenamnen själva innehåller "och".
|
||||
return `${names.slice(0, -1).join(', ')} samt ${names[names.length - 1]}`;
|
||||
}
|
||||
|
||||
/** "Sverige", eller "Ort, Sverige" när orten är känd. */
|
||||
export function basedIn(): string {
|
||||
return [business.city, business.country.name].filter(Boolean).join(', ');
|
||||
}
|
||||
|
||||
/** Formaterat pris ("1 500 kr") eller null. */
|
||||
export function formatSek(value: number | null): string | null {
|
||||
if (value == null) return null;
|
||||
return `${value.toLocaleString('sv-SE')} kr`;
|
||||
}
|
||||
|
||||
export function absoluteUrl(path: string): string {
|
||||
return new URL(path, business.url).toString();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// src/data/faq.ts
|
||||
// Typ för vanliga frågor (FAQPage-JSON-LD). Frågorna om bokning, offert och gallerier togs bort 2026-11-06:
|
||||
// Linus erbjuder inga tjänster, bara posters och samarbeten.
|
||||
|
||||
export type FaqItem = { question: string; answer: string };
|
||||
@@ -0,0 +1,616 @@
|
||||
// src/data/portfolio-meta.ts
|
||||
// DATAKROK för riktiga alt-texter (och ev. titel/kategori) per portfoliobild.
|
||||
// Ifylld 2026-10-27: alt-texter skrivna med visionsmodellen gemma-4-31b-it-fp8 (Cleura)
|
||||
// på nedskalade kopior, granskade manuellt mot bilderna. Saknas en post använder sidan
|
||||
// en neutral svensk reservtext byggd på datum, aldrig kameramodell eller "undefined".
|
||||
//
|
||||
// Nyckel = bildens filnamn utan ändelse, exakt som i src/assets/images/portfolio/,
|
||||
// t.ex. "immich-02533d10-07ca-4a39-8b8a-6648fe2ffcef-20260905-DSC07734"
|
||||
// eller "20250510-WJ9E1439-Enhanced-NR".
|
||||
//
|
||||
// Exempel:
|
||||
// "immich-02533d10-...-20260905-DSC07734": {
|
||||
// alt: "Norrsken i grönt och lila över en mörk skogssiluett",
|
||||
// title: "Norrsken i september",
|
||||
// category: "Norrsken",
|
||||
// },
|
||||
|
||||
export type PortfolioMeta = {
|
||||
/** Beskriv vad bilden visar, på svenska, ca 8–16 ord. */
|
||||
alt?: string;
|
||||
/** Kort bildtitel (visas i lightboxen om den finns). */
|
||||
title?: string;
|
||||
/** Helst ett av namnen i business.genres, t.ex. "Natur och djur". */
|
||||
category?: string;
|
||||
};
|
||||
|
||||
export const portfolioMeta: Record<string, PortfolioMeta> = {
|
||||
"20240804-20241104-IMG_3861": {
|
||||
alt: "En person sedd bakifrån under lövverk mot ett dimmigt fält och en lada i skymningen",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"20240819-Screenshot_2024-08-19-12-12-33-62_92460851df6f172a4592fca41cc2d2e6": {
|
||||
alt: "Närbild av ett gulsvart bi med orange pollen på benet mot en mörk bakgrund",
|
||||
category: "Makro och detaljer",
|
||||
},
|
||||
"20240901-20240901-_J9E8565": {
|
||||
alt: "En slingrande grusväg genom en mörk skog med höga björkar i dämpat ljus",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"20240901-20240901-_J9E8586": {
|
||||
alt: "Ett djuprött löv i centrum mot en mörk och suddig grön bakgrund i dämpat ljus",
|
||||
category: "Makro och detaljer",
|
||||
},
|
||||
"20240901-_J9E8446": {
|
||||
alt: "Vita fröställningar i ett fält av torrt gräs med varma färger och mörk bakgrund",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"20240903-20240903-WJ9E8891": {
|
||||
alt: "Kurviga järnvägsspår i sepiafärgat ljus omgivna av mörka träd och en byggnad i bakgrunden",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"20240914-20240914-WJ9E0318": {
|
||||
alt: "Silhuett av en person med långt hår mot en gyllene solnedgång över öppna fält",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"20240921-WJ9E1119": {
|
||||
alt: "En man med rödbrunt lockigt hår och skägg i mörka kläder i en dämpad stadsmiljö",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"20241021-IMG_9545-Enhanced-NR": {
|
||||
alt: "Närbild på torra bruna ekblad omgivna av gula och orangea höstfärger med mörk vinjettering",
|
||||
category: "Makro och detaljer",
|
||||
},
|
||||
"20241101-_MG_0023-Enhanced-NR": {
|
||||
alt: "Ett brunt höstlöv med glittrande vattendroppar mot en svart bakgrund i dramatiskt ljus",
|
||||
category: "Makro och detaljer",
|
||||
},
|
||||
"20241111-_MG_9832": {
|
||||
alt: "En mörk stadsgata med byggnader under en lila skymningshimmel och en lysande måne",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"20241117-_J9E0597": {
|
||||
alt: "En brun häst med grimma i profil mot en gyllene solnedgång och suddiga träd",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"20241117-_J9E0620": {
|
||||
alt: "Närbild av en vit häst med brunt läderträns i ett varmt och mörkt motljus",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"20241117-_J9E0621": {
|
||||
alt: "En vit häst med brun grimma i ett mjukt ljus vid en solnedgång",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"20241124-_MG_1066-Enhanced-NR": {
|
||||
alt: "En närbild av vita, glittrande iskristaller mot en mörk och suddig bakgrund",
|
||||
category: "Makro och detaljer",
|
||||
},
|
||||
"20250217-_J9E5859-Enhanced-NR": {
|
||||
alt: "Torkade fröställningar i mörkt, stämningsfullt ljus med dova bruna och lila färger",
|
||||
category: "Makro och detaljer",
|
||||
},
|
||||
"20250217-_J9E5864-Enhanced-NR": {
|
||||
alt: "En rödbrun torkad blomställning i mjukt ljus mot en mörk suddig bakgrund",
|
||||
category: "Makro och detaljer",
|
||||
},
|
||||
"20250217-_J9E5897": {
|
||||
alt: "Gyllene ax av torkat gräs i varmt ljus mot en mörkblå suddig bakgrund",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"20250315-WJ9E1852": {
|
||||
alt: "Ett barn i grön vinterjacka och mörk mössa håller i något i dämpat utomhusljus",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"20250315-WJ9E1919": {
|
||||
alt: "En man i mörkblå mössa och dunjacka i dämpat ljus mot en suddig bakgrund",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"20250315-WJ9E1967-Enhanced-NR": {
|
||||
alt: "En kvinna i vit mössa och ett barn i grön jacka blåser såpbubblor utomhus",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"20250330-_MG_1223-Enhanced-NR": {
|
||||
alt: "En person håller en svart kamera mot en suddig bakgrund av gula höstlöv",
|
||||
category: "Makro och detaljer",
|
||||
},
|
||||
"20250413-WJ9E0212": {
|
||||
alt: "En man kör en vagn dragen av två hästar på en stadsgata i sepia",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"20250510-WJ9E1439-Enhanced-NR": {
|
||||
alt: "Ett barn i randig mössa och grön jacka håller en röd spade i en sandlåda",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"20250510-WJ9E1492-Enhanced-NR": {
|
||||
alt: "En kvinna med glasögon och brun handske håller en grön mugg i dämpat ljus",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"20250510-WJ9E1546-Enhanced-NR": {
|
||||
alt: "Ett barn med blont hår och blå ögon i mörkblå fleecejacka med fingret vid munnen",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"20250510-WJ9E1598-Enhanced-NR": {
|
||||
alt: "Två vuxna och ett barn går hand i hand över en parkering med bilar",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"20250510-WJ9E1673-Enhanced-NR": {
|
||||
alt: "Ett barn i grön väst håller i ratten på ett rött fordon med en man bakom",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"20250510-WJ9E1806-Enhanced-NR": {
|
||||
alt: "En man håller ett barn vid ett stängsel i en grön utomhusmiljö med röda byggnader",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"20250510-WJ9E1817-Enhanced-NR": {
|
||||
alt: "En brun höna med röd kam i profil mot en suddig grön bakgrund",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"20250513-WJ9E0143-Enhanced-NR": {
|
||||
alt: "En blå klassisk bil på en stadsgata i varmt kvällsljus med mörka skuggor",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"20250513-WJ9E0211-Enhanced-NR": {
|
||||
alt: "Gula och röda hus vid ett stilla vatten med speglingar i varmt kvällsljus",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"20250726-20250726-WJ9E0854": {
|
||||
alt: "En ryttare i mörk rustning på en vit häst framför en ljus byggnad och publik",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"20250726-20250726-WJ9E0979": {
|
||||
alt: "En man i blå och gul rustning med ett svärd rider en brun häst utomhus",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"20250802-WJ9E1325": {
|
||||
alt: "En humla och en vit fjäril på lila blommor i ett mjukt gyllene ljus",
|
||||
category: "Makro och detaljer",
|
||||
},
|
||||
"20250802-WJ9E1390": {
|
||||
alt: "En vit fjäril med svarta fläckar på vingarna vilar på en lila blomstängel",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"20250802-WJ9E1396": {
|
||||
alt: "En vit fjäril med svarta prickar vilar på en lila blomma i starkt solljus",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"20250806-IMG_5565": {
|
||||
alt: "En orange och svart humla på en lila klotformad blomma mot grön bakgrund",
|
||||
category: "Makro och detaljer",
|
||||
},
|
||||
"20251017-WJ9E0289": {
|
||||
alt: "En tom träbänk i en höstskog omgiven av gula löv och varmt ljus",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"20251227-WJ9E0356": {
|
||||
alt: "Frostiga växter och en tunn gren i varmt motljus från en sol i skogen",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"20251227-WJ9E0361": {
|
||||
alt: "Frostig växt i motljus med en gyllene sol mot en mörk och suddig skog",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"20251227-WJ9E0362": {
|
||||
alt: "Varmt solljus strömmar genom träden bakom ett trästaket i en mörk skog",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"20251227-WJ9E0388": {
|
||||
alt: "Gyllene solljus som lyser upp frostiga torra löv och grässtrån på en mark",
|
||||
category: "Makro och detaljer",
|
||||
},
|
||||
"20251227-WJ9E0434": {
|
||||
alt: "En stadsgata i varmt gyllene ljus med suddiga byggnader och träd vid solnedgången",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"20251227-WJ9E0450": {
|
||||
alt: "Gyllene solljus över en tom stadsgata med silhuetter av träd och en husfasad",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"20251230-IMG20251230141046": {
|
||||
alt: "Snöig gränd med stenhus prydd med stora röda julkulor och små gröna granar i krukor",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"20251231-IMG20251231113121": {
|
||||
alt: "Gyllene solljus över en snöig kullerstensgata omgiven av färgglada hus i en stad",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"20251231-IMG20251231113417": {
|
||||
alt: "En smal kullerstensgata mellan en vit husfasad och en hög stenmur under en molnig himmel",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"20251231-WJ9E0465": {
|
||||
alt: "En mörk lykta i förgrunden med en suddig ljus byggnad och spiror i bakgrunden",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"20251231-WJ9E0490": {
|
||||
alt: "Snöklädda granris med små varma och kalla ljuspunkter i en mörk vintermiljö",
|
||||
category: "Makro och detaljer",
|
||||
},
|
||||
"20251231-WJ9E0495": {
|
||||
alt: "Snötäckta barr med små varma lampor mot en mörk bakgrund med suddiga orangea ljus",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"20251231-WJ9E0512": {
|
||||
alt: "En smal kullerstensgata i en stad med gamla hus, snöfläckar och svala, mörka färger",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"20251231-WJ9E0552": {
|
||||
alt: "Smal stadsgata med ett högt torn, en julgran och silhuetter av människor i varmt ljus",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"20260102-IMG20260102132702": {
|
||||
alt: "Vy genom en mörk stenvägg mot ett snöigt landskap och en gyllene himmel",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"20260102-IMG20260102141620": {
|
||||
alt: "Grå stenmur med trägallerier och ett torn med rött tak under ett snöfall",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"20260102-IMG20260102142048": {
|
||||
alt: "En trägång längs en grå stenmur under ett snöfall med kala grenar i förgrunden",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"immich-02533d10-07ca-4a39-8b8a-6648fe2ffcef-20260905-DSC07734": {
|
||||
alt: "Två barn springer och skrattar framför ett hus med rött plåttak och högt gräs",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-04342446-23b4-4600-a93e-352d32b6eac1-20260905-DSC07933": {
|
||||
alt: "Två barn leker med en grön vattenpump och en hink på en solig gräsmatta",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-0617ce88-3f9c-4b5b-bec4-1a30cfd65602-20260905-DSC07895": {
|
||||
alt: "Ett blont barn i grön tröja vid en grön vattenpump i varmt gyllene ljus",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-07328282-e321-4216-8710-ee87dc65496e-20260905-DSC07730": {
|
||||
alt: "Ett leende barn i blå t-shirt står på en grön gräsmatta omgiven av gröna träd",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-07578a7d-3d6e-4dbe-8fec-0accff45258c-20260920-DSC08719-2": {
|
||||
alt: "En gulsvart humla samlar pollen från en mörkröd blomma mot en suddig grön bakgrund",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-0be0c122-4e81-4e92-b229-4736e66b9c70-20260920-DSC08717-2": {
|
||||
alt: "En svartgul humla på en djupröd blomma omgiven av gröna stjälkar i varmt solljus",
|
||||
category: "Makro och detaljer",
|
||||
},
|
||||
"immich-0d7ccea4-22c2-4208-b487-ec09eb88158c-20260704-DSC03171": {
|
||||
alt: "En man och en kvinna med solglasögon poserar bredvid en vit limousine i en stad",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-0f559874-9212-45e1-8860-a9ebfe6d95c0-20260704-DSC03081": {
|
||||
alt: "En man i vit skjorta och mörka byxor vid en vit limousine under gröna träd",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-10a42a42-e7af-4f02-a0ca-d07a960bd094-20260605-_J9E5983": {
|
||||
alt: "Närbild på en svartvit hund med bruna ögon bland suddiga rosa blommor och grönska",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-146b119f-7a18-47ca-b255-38242adc290e-20260621-Kopia_av_IMG_7877": {
|
||||
alt: "En röd ekorre på en grov trädgren med mjukt ljus och suddig bakgrund",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-17c30f0a-3dea-425d-9fbe-336cc1ad88e2-20260621-Kopia_av_IMG_8045": {
|
||||
alt: "En rödbrun ekorre på en trädgren omgiven av ljusgröna blad i ett ljust skogsparti",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-18db801d-5db9-49d0-83c0-cda75832be93-20260920-DSC08726": {
|
||||
alt: "En gulsvart humla i flykt mot en mörkröd blomma med orange centrum i grönska",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-1ab2b2fe-dea6-4aa4-81c9-73fb8d4918c3-20240816-WJ9E37214520240816": {
|
||||
alt: "En man och ett barn i solljus med suddiga gröna träd och färgglada ljusreflexer",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-1ccacf42-3f11-46c6-a6ac-4e38c6ff96b5-20250413-WJ9E0212": {
|
||||
alt: "Två hästar drar en vit vagn med människor längs en solig gata i en stad",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"immich-1dc2a516-5026-4f97-861d-3e450acb0948-20260606-_J9E6089": {
|
||||
alt: "Orange och svart fjäril som vilar på vita blommor i mörklila bladverk",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-1e4b55d1-10c0-4431-bcbf-0b6207208e1c-20260905-DSC07775": {
|
||||
alt: "En man i vit jacka hjälper ett barn med blå hjälm och röd cykel utomhus",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-23f04539-f8eb-47f2-8d39-3d3e548e0f9b-20260905-DSC07842": {
|
||||
alt: "Ett leende barn med blont hår i en grön pikétröja utomhus i mjukt ljus",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-27924d4a-0c2e-4421-b8cd-067b857549ba-20240912-20240912-WJ9E9866": {
|
||||
alt: "En bäck med vitt skummande vatten, mossiga stenar och torra löv under en mörk trädstam",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-2be918bc-db83-4024-a066-bf300e2bebc5-20240912-20240912-WJ9E9861": {
|
||||
alt: "Rinnande vatten över gröna mossiga stenar omgivna av gräs och torra bruna löv",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-2db21106-a3be-4f2b-82a7-eb69eb0038eb-20240912-20240912-WJ9E0194": {
|
||||
alt: "Ett rödbrunt höstlöv som vilar på en mörk texturerad yta mot en mörkblå bakgrund",
|
||||
category: "Makro och detaljer",
|
||||
},
|
||||
"immich-302dfa7d-fe44-45a4-9dd7-6e2d0ceaf39d-20260905-DSC07999": {
|
||||
alt: "En kvinna med guldglasögon och blont hår i en denimjacka mot en suddig bakgrund",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-30952208-1c57-48b0-8917-8176ee374efe-20260905-DSC07908": {
|
||||
alt: "Ett barn i grön tröja vattnar lila blommor i en trädgård bredvid en ljus bod",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-30a4d915-e794-40d0-8517-73c385a5d9c9-20260621-squareblur_1781332089807_7": {
|
||||
alt: "En person i mörk mantel sedd bakifrån genom lövverk mot ett grönt fält",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-3539cda6-7495-4a51-b2d5-61d528fece0f-20260621-Kopia_av_IMG_7804": {
|
||||
alt: "En brun ekorre med fluffig svans och vitt bröst mot en ljus bakgrund",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-38146653-9b12-4333-a4f7-b72b86c58f87-20250416-WJ9E0243": {
|
||||
alt: "En beige skoter parkerad på en kullerstensgata i en stad i varmt, gyllene ljus",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"immich-39b95c2c-eeb7-4334-8771-47e726ca00a3-20240816-WJ9E37204620240816": {
|
||||
alt: "En man och ett barn i naturligt ljus med en suddig grön bakgrund av träd",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-3b58d401-1b27-4c89-8094-c5025e24b160-20260905-DSC07838": {
|
||||
alt: "En man i ljus skjorta kramar ett barn med blå hjälm bland färgglada blommor",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-3d77f859-13be-462b-92be-767aa1f74ee6-20260920-DSC08731": {
|
||||
alt: "Ett svartgult bi på en mörkröd blomma med suddig bakgrund i varma färger",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-3eccb3dc-707f-4f8d-837b-d0db61358e31-20260905-DSC07598": {
|
||||
alt: "En man med solglasögon och ljus skjorta står bredvid en röd bil mot grön bakgrund",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-415532f5-deec-43d5-8cb5-a47f4f0abfbc-20260606-_J9E6061": {
|
||||
alt: "En gulsvart humla i flykt mot rosa blomklasar mot en ljusblå himmel",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-46430956-07b8-4306-86a2-daf98daab847-20241018-20241018-IMG_8965": {
|
||||
alt: "Gammal stenbyggnad med runt torn och tända lyktor reflekteras i mörkt vatten under grå himmel",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"immich-490a149a-df25-4b81-96fd-3dc2fee7bc24-20241018-20241018-IMG_8926": {
|
||||
alt: "En mörk stenbro med valv över vatten med blå toner och orangea reflektioner",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"immich-4abbfa4b-6eba-466d-88f4-57c2e867df82-20260920-DSC08912-2": {
|
||||
alt: "En röd blomma i fokus mot en mörk bakgrund med varma gyllene ljuscirklar",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-4bcab51e-f51f-460d-8df7-7096e70a76bb-20260905-DSC07811": {
|
||||
alt: "Ett leende barn med blå hjälm på en röd cykel i en suddig utomhusmiljö",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-4f60de4c-4e1f-445c-afd9-f911e1acf995-20260605-_J9E5968": {
|
||||
alt: "En svartvit prickig hund i profil omgiven av suddigt grönt gräs i naturligt ljus",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-4f8a9f96-16bb-4275-887b-7958a34addab-20250413-WJ9E0196": {
|
||||
alt: "En gammal astronomisk klocka med gyllene detaljer i blått och rött mot mörk bakgrund",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"immich-50bce1ec-f162-4fa8-957c-4819689f70c4-20260905-DSC07617": {
|
||||
alt: "Ett barn med blont hår håller en vuxens hand utomhus bredvid en grön gräsmatta",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-50e9e443-c475-4da0-b438-1a6b3f740a8c-20250414-WJ9E0143-Enhanced-NR": {
|
||||
alt: "Människor med röda paraplyer i en solig stad med blå och beige arkitektur",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"immich-54d22882-3bdc-4bd5-b4d5-7dd2e5d71656-20260905-DSC07825": {
|
||||
alt: "En man i ljus skjorta håller ett barn i gröna kläder på en gräsmatta vid klippor",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-590158f5-f459-4c16-b6dd-b4354c0ab512-20260824-DSC07025__2_": {
|
||||
alt: "Närbild av ett hjortdjur med lurviga, basttäckta horn och mörk päls mot svart bakgrund",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-5a60699d-bf05-4c18-bb43-5969af8f0106-20260905-DSC07930": {
|
||||
alt: "Ett barn i grön tröja leker med en grön vattenpump på en solig gräsmatta",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-5f79107b-ffbe-4e32-8b9d-b37689b3352f-20260905-DSC07639": {
|
||||
alt: "En man, en kvinna och två barn vid en träbyggnad med gula blommor utomhus",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-61040612-f637-4bdb-893c-901171857f83-20260621-squareblur_1781332134108_13": {
|
||||
alt: "Röd boj vid en vit träbom i stilla vatten med näckrosblad och vass i solnedgången",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-638a305e-2d06-4d4e-9166-5f3a80c906a2-20260905-DSC07710": {
|
||||
alt: "Ett barn i blå tröja med brandbilar står i en grönskande miljö framför en man",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-69aa5b22-f80d-4cb5-b022-e77132560985-20260606-_J9E6105": {
|
||||
alt: "En orange och svart fjäril på vita blommor bland mörklila blad i starkt solljus",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-6ee56b75-ff51-4817-98c8-0fb3c4485931-20250415-WJ9E1163-Enhanced-NR": {
|
||||
alt: "En stenbro full av människor över en flod mellan röda tak och torn",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"immich-6f8ae1a4-fc2d-478d-ba2e-8452db0a08f0-20240912-20240912-WJ9E9894": {
|
||||
alt: "En liten bäck med mjukt vatten som flyter över mörka stenar och grönska",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-71cea45b-73c3-4e9a-9810-c0e070b4f5d0-20240914-20240914-WJ9E0284": {
|
||||
alt: "En person i jeansjacka sedd bakifrån i ett solrosfält i solnedgångens varma ljus",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-73a61049-65e8-47ed-9904-220bf316e351-20260605-_J9E5985": {
|
||||
alt: "Närbild av en svartvit hund i en grön trädgård med suddiga rosa blommor i förgrunden",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-7c1fc70b-c766-4c97-ba30-7fbd321ac702-20260905-DSC07953": {
|
||||
alt: "Ett barn med blont hår skrattar i en blå t-shirt med brandbilar framför trävägg",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-7c32da7b-9a9a-4675-a813-4fa3d026bcf5-20260905-DSC07665": {
|
||||
alt: "Ett leende barn i blå t-shirt i en dörröppning med suddiga lila blommor i förgrunden",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-8113f930-24bf-4920-adc6-734e397bd3e5-20260905-DSC07800": {
|
||||
alt: "Ett barn med blå hjälm på en röd cykel framför ett beige hus i dagsljus",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-81d60de8-8a13-43e1-b549-ced73e326368-20250415-WJ9E1324": {
|
||||
alt: "Mörkt fönster med utsikt mot en molnig himmel och skuggspel på en grå vägg",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"immich-827bde98-bbf1-4f1f-bd38-4ffffb8c6f03-20260920-DSC08725-2": {
|
||||
alt: "En gulsvart humla på en mörkröd blomma med suddig grön och röd bakgrund",
|
||||
category: "Makro och detaljer",
|
||||
},
|
||||
"immich-87fbfcab-81f8-4ccf-a80e-e097e9a0614d-20250415-WJ9E1448": {
|
||||
alt: "En kvinna med vindpustat brunt hår och mörkblå jacka mot en bakgrund av vita blommor",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-9ad43795-2df0-4ca4-8a69-f8f99954b7e8-20260605-_J9E5944": {
|
||||
alt: "En svartvit hund med lockig päls sitter på grönt gräs bredvid en vit fotboll",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-a5086bb9-3868-442c-9715-f3d626ec0123-20260605-_J9E6018": {
|
||||
alt: "Klara vattendroppar på ett mörkgrönt blad med tydliga ådror mot en svart bakgrund",
|
||||
category: "Makro och detaljer",
|
||||
},
|
||||
"immich-a5129e40-445b-4fcf-a5c5-3a5202590f27-20260920-DSC08720-2": {
|
||||
alt: "En gulsvart humla på en mörkröd blomma omgiven av gröna blad i dagsljus",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-a6fa98f0-ba73-42c2-91a6-07709e195015-20260905-DSC07869": {
|
||||
alt: "Två barn sedda bakifrån bredvid en vuxen och en vit flätad korg i varmt ljus",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-a71dc5cd-ede5-4ae2-94cb-07f06d074846-20260606-_J9E6073": {
|
||||
alt: "Orange och svart fjäril på en ljusgrå träbit med blå färgfläckar mot suddig grön bakgrund",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-a8bd05fb-864a-4c87-8a6c-7cbfcc047d3d-20250415-WJ9E1249-Enhanced-NR": {
|
||||
alt: "En person med långt brunt hår som blåser i vinden, sedd bakifrån i dämpat ljus",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-ac8360da-9fbd-45e0-b740-d091356037fe-20241018-20241018-IMG_8978": {
|
||||
alt: "Ett runt stentorn och en gul byggnad vid ett vatten med varma kvällsljus",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"immich-b241db80-28b8-4430-b08b-c3ce589a85a3-20260621-Kopia_av_IMG_8714": {
|
||||
alt: "En rödbrun ekorre på en grov gren med suddiga gröna barr i bakgrunden",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-b2780899-4269-44be-8dd3-fed23d3e89d3-20260621-squareblur_1781332107423_9": {
|
||||
alt: "En blå vintagebil på en mörk stadsgata med varma gula ljusreflektioner i asfalten",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"immich-b2f52935-d52a-46f1-9262-78a7b1e19a90-20260905-DSC07604": {
|
||||
alt: "Ett barn i grön tröja och ljus keps framför en suddig röd bil",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-b3c19299-0e5d-4261-a98a-1ed08f01a0a3-20260905-DSC07702": {
|
||||
alt: "Ett barn i grön tröja sparkar på en fotboll på en gräsmatta framför ett staket",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-b7ddab61-cbfc-4c5f-9354-b56bab0b38a1-20241018-20241018-IMG_8925-2": {
|
||||
alt: "Orangea höstlöv hänger över mörkt vatten med tydliga speglingar och en stenvägg i bakgrunden",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-b99369cb-622e-4f31-9a9f-a33bc4e0030a-20260605-_J9E5916": {
|
||||
alt: "En mörk hund med lockiga öron tittar uppåt mot en mörkgrön suddig bakgrund",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-bbf62d80-754f-4243-a71b-a5abe1e0340c-20260905-DSC07890": {
|
||||
alt: "Ett barn i grön pikétröja pumpar vatten ur en gammal grön handpump i ett varmt motljus",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-c3ab7ee4-8a84-42a4-a3e9-662469569b51-20260605-_J9E5888": {
|
||||
alt: "En person håller om en svart och vit hund med lockig päls i dämpat inomhusljus",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-c6489154-53d1-47e2-8572-521dd851137a-20260905-DSC07823": {
|
||||
alt: "En man i ljus skjorta lyfter ett barn på en gräsmatta bredvid en stor sten",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-cbbd628e-bf5a-4a9e-85e0-d5229680f492-20260905-DSC07657": {
|
||||
alt: "Ett barn i blå t-shirt står i dörröppningen till en vit träbyggnad med mörkt inre",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-d058e797-861a-4cd3-86a1-63c616bf248f-20260905-DSC07711": {
|
||||
alt: "Ett barn i blå tröja går mot en man i vit jacka i en grön trädgård",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-d2d2eb01-597b-47ed-8055-f193a629c472-20260605-_J9E5860": {
|
||||
alt: "En springande svartvit hund med fladdrande öron på en grön gräsmatta mot mörk bakgrund",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-d34b0499-d2c0-40fd-848a-8e06329b7bf5-20260905-DSC07607": {
|
||||
alt: "En kvinna och ett barn går hand i hand framför ett hus i varmt ljus",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-d8cb2d51-2ead-4cf0-9215-a59b07f26f6d-20260905-DSC07704": {
|
||||
alt: "Ett barn i grön tröja och blå jeans sparkar en fotboll på gräs framför en stenvägg",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-db3ffd50-7e6a-4319-8815-b388e52e9aa9-20260905-DSC07896": {
|
||||
alt: "Ett barn i grön tröja pumpar vatten ur en mörk handpump på en grön gräsmatta",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-ddb13056-990c-48dc-ab92-e03baadb7a44-20260905-DSC07647": {
|
||||
alt: "En kvinna i blå kläder håller i händerna på ett barn utomhus på grön gräsmatta",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-debf1a27-f337-4fc8-a556-c8b08ad98404-20260621-squareblur_1781332127640_12": {
|
||||
alt: "En tom träbänk i en höstskog omgiven av gula och orangea löv",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-e20af65c-c05b-4585-81b4-dcad6b52e450-20240914-20240914-WJ9E0319": {
|
||||
alt: "En person i motljus framför ett öppet landskap under en gyllene solnedgång",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-e25d4130-b3d3-4c63-8c82-e0750efa41b8-20260621-squareblur_1781332028758_1": {
|
||||
alt: "Mörka stentrappor med orangea löv som leder upp från en spegelblank vattenyta",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"immich-e3a7d5bc-9ee1-4d29-8673-65922736295f-20260920-DSC08813": {
|
||||
alt: "En svartgul humla i flykt mot en lila blomma omgiven av mörkgröna blad",
|
||||
category: "Makro och detaljer",
|
||||
},
|
||||
"immich-e7048f3d-da0d-432e-b833-8e766df5708b-20240914-20240914-WJ9E0285": {
|
||||
alt: "En person med långt hår sedd bakifrån i ett solrosfält i varmt kvällsljus",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-f1f02aac-7ef7-4cc0-818a-e06e0ecdf7f7-20241018-20241018-IMG_9028": {
|
||||
alt: "En stenbro med valv som reflekteras i mörkt vatten under ett varmt gyllene ljus",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"immich-f2d03140-50d5-4e94-94e8-425fb605a110-20260905-DSC07904": {
|
||||
alt: "Ett barn i grön tröja pumpar vatten ur en grön handpump på en gräsmatta",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-f836d606-e331-4277-9500-b9906305544e-20260621-Kopia_av_IMG_8222": {
|
||||
alt: "En rödbrun ekorre sitter på marken mot en grov grå trädstam i dagsljus",
|
||||
category: "Natur och djur",
|
||||
},
|
||||
"immich-f8943f55-4cba-4521-b9a5-4e735f31a336-20241018-20241018-IMG_9057": {
|
||||
alt: "Stadsmiljö i skymning med upplysta hus och färgglada ljusreflektioner i ett mörkt vatten",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"immich-f9dd008c-3445-4394-b4f4-329b0034fd2c-20241018-20241018-IMG_8996": {
|
||||
alt: "En mörk stenbro över vatten med röda höstlöv och en byggnad i bakgrunden",
|
||||
category: "Stad och arkitektur",
|
||||
},
|
||||
"immich-faee21c0-1609-4a35-aab5-0b5d68c4cab4-20260905-DSC07618": {
|
||||
alt: "Ett leende barn i blå tröja håller en vuxens hand mot en grön bakgrund",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-fbb8346a-5992-4136-a099-596c06bbde30-20260905-DSC07723": {
|
||||
alt: "En man i ljus jacka och två barn springer över en grön gräsmatta under träd",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
"immich-fed1e776-2242-468a-9800-8a8901df2be0-20250413-WJ9E0284": {
|
||||
alt: "En kvinna i blå jeansjacka med en kamera i en park med gula tulpaner",
|
||||
category: "Porträtt och människor",
|
||||
},
|
||||
};
|
||||
@@ -1,60 +1,181 @@
|
||||
---
|
||||
import "../styles/global.css";
|
||||
const { title } = Astro.props;
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import SEO from '../components/SEO.astro';
|
||||
import SchemaOrg from '../components/SchemaOrg.astro';
|
||||
import T from '../components/T.astro';
|
||||
import type { FaqItem } from '../data/faq';
|
||||
import { business } from '../data/business';
|
||||
import defaultOgImage from '../assets/images/3d-scroll/img5.jpg';
|
||||
|
||||
export interface Props {
|
||||
/** Unik svensk titel för sidan. */
|
||||
title: string;
|
||||
/** Unik svensk meta description, ca 120–160 tecken. */
|
||||
description: string;
|
||||
/** Kanonisk sökväg, t.ex. "/portfolio". Utelämnas = härleds från URL:en. */
|
||||
path?: string;
|
||||
image?: ImageMetadata;
|
||||
imageAlt?: string;
|
||||
pageType?: 'WebPage' | 'AboutPage' | 'ContactPage' | 'CollectionPage';
|
||||
/** Namn i brödsmulan (BreadcrumbList) för undersidor. */
|
||||
breadcrumb?: string;
|
||||
faq?: FaqItem[];
|
||||
noindex?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
path,
|
||||
image = defaultOgImage,
|
||||
imageAlt = 'Svartvit bild av en man som bär ett litet barn bland såpbubblor i motljus',
|
||||
pageType,
|
||||
breadcrumb,
|
||||
faq,
|
||||
noindex = false,
|
||||
} = Astro.props;
|
||||
|
||||
// og:image: ny JPG via Astros bildtjänst (EXIF/GPS rensas bort), absolut URL.
|
||||
const site = Astro.site ?? new URL(business.url);
|
||||
const og = await getImage({ src: image, width: 1200, format: 'jpg', quality: 82 });
|
||||
const ogImageUrl = new URL(og.src, site).toString();
|
||||
const ogWidth = Number(og.attributes.width) || undefined;
|
||||
const ogHeight = Number(og.attributes.height) || undefined;
|
||||
|
||||
// Samma meny på alla sidor (Linus 2026-11-06): Portfolio, Posters, Om mig, Kontakt.
|
||||
const nav = [
|
||||
{ href: '/portfolio', sv: 'Portfolio', en: 'Portfolio' },
|
||||
{ href: '/posters', sv: 'Posters', en: 'Posters' },
|
||||
{ href: '/about', sv: 'Om mig', en: 'About me' },
|
||||
{ href: '/contact', sv: 'Kontakt', en: 'Contact' },
|
||||
];
|
||||
const langBtn = 'rounded-full px-2.5 py-1 text-xs font-semibold transition-colors text-gray-400 hover:text-white aria-pressed:bg-white aria-pressed:text-neutral-900';
|
||||
---
|
||||
|
||||
<html lang="sv" class="scroll-smooth"> <head>
|
||||
<!doctype html>
|
||||
<html lang="sv" class="scroll-smooth">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#171717" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/favicon-512.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon-180.png" />
|
||||
<title>{title}</title>
|
||||
<link rel="sitemap" type="application/xml" href="/sitemap-index.xml" />
|
||||
<!-- Språket väljs innan sidan ritas (ingen blinkning): ?lang=, sedan besökarens val (samma nyckel som kundportalen),
|
||||
sedan webbläsaren. Svenska/nordiska webbläsare får svenska. -->
|
||||
<script is:inline>
|
||||
(function () {
|
||||
var lang = null;
|
||||
try { var q = new URLSearchParams(location.search).get('lang'); if (q === 'sv' || q === 'en') lang = q; } catch (e) {}
|
||||
if (!lang) { try { var s = localStorage.getItem('ibo_lang'); if (s === 'sv' || s === 'en') lang = s; } catch (e) {} }
|
||||
if (!lang) {
|
||||
var b = ((navigator.languages && navigator.languages[0]) || navigator.language || 'sv').toLowerCase();
|
||||
lang = /^(sv|nb|no|da|nn)/.test(b) ? 'sv' : 'en';
|
||||
}
|
||||
document.documentElement.lang = lang;
|
||||
})();
|
||||
</script>
|
||||
<style is:global>
|
||||
html[lang="en"] .i18n-sv { display: none !important; }
|
||||
html:not([lang="en"]) .i18n-en { display: none !important; }
|
||||
</style>
|
||||
<SEO
|
||||
title={title}
|
||||
description={description}
|
||||
path={path}
|
||||
imageUrl={ogImageUrl}
|
||||
imageWidth={ogWidth}
|
||||
imageHeight={ogHeight}
|
||||
imageAlt={imageAlt}
|
||||
noindex={noindex}
|
||||
/>
|
||||
{!noindex && (
|
||||
<SchemaOrg
|
||||
title={title}
|
||||
description={description}
|
||||
path={path}
|
||||
pageType={pageType}
|
||||
breadcrumb={breadcrumb}
|
||||
faq={faq}
|
||||
imageUrl={ogImageUrl}
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body class="bg-neutral-900 text-white font-sans antialiased min-h-screen flex flex-col">
|
||||
|
||||
<nav class="fixed top-0 w-full z-50 border-b border-white/10 py-6 bg-neutral-900/80 backdrop-blur-md transition-all duration-300">
|
||||
<div class="container mx-auto px-4 flex justify-between items-center">
|
||||
|
||||
<a href="/" class="text-2xl font-bold tracking-wider uppercase hover:text-gray-300 transition relative z-50">
|
||||
|
||||
<nav class="fixed top-0 w-full z-50 border-b border-white/10 py-4 md:py-6 bg-neutral-900/80 backdrop-blur-md transition-all duration-300">
|
||||
<div class="container mx-auto px-4 flex flex-wrap gap-y-2 justify-between items-center">
|
||||
|
||||
<a href="/" class="text-lg md:text-2xl font-bold tracking-wider uppercase hover:text-gray-300 transition relative z-50">
|
||||
Images by Olofsson
|
||||
</a>
|
||||
|
||||
<ul class="flex space-x-6 text-sm font-medium text-gray-400">
|
||||
<li><a href="/portfolio" class="hover:text-white transition">Portfolio</a></li>
|
||||
<li><a href="/about" class="hover:text-white transition">About</a></li>
|
||||
<li><a href="/contact" class="hover:text-white transition">Contact</a></li>
|
||||
</ul>
|
||||
<div class="flex items-center gap-4 md:gap-6">
|
||||
<ul class="flex space-x-4 md:space-x-6 text-sm font-medium text-gray-300">
|
||||
{nav.map((item) => (
|
||||
<li><a href={item.href} class="hover:text-white transition"><T sv={item.sv} en={item.en} /></a></li>
|
||||
))}
|
||||
</ul>
|
||||
<div class="inline-flex items-center rounded-full border border-neutral-700 p-0.5" role="group" aria-label="Språk / Language">
|
||||
<button type="button" data-lang-set="sv" class={langBtn} aria-label="Svenska">SV</button>
|
||||
<button type="button" data-lang-set="en" class={langBtn} aria-label="English">EN</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="flex-grow pt-24">
|
||||
<slot />
|
||||
<main class="flex-grow pt-28 md:pt-24">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-neutral-800 py-8 text-center text-gray-500 text-sm bg-neutral-900 relative z-40">
|
||||
<footer class="border-t border-neutral-800 py-8 text-center text-gray-400 text-sm bg-neutral-900 relative z-40">
|
||||
<p class="mb-2">
|
||||
Crafting digital experiences for
|
||||
<a href="https://lyxlimo.se/" target="_blank" rel="noopener noreferrer" data-glow-link="green" class="text-gray-400 hover:text-white transition-colors">lyxlimo.se</a>
|
||||
&
|
||||
<a href="https://karleksguiden.se/" target="_blank" rel="noopener noreferrer" data-glow-link="magenta" class="text-gray-400 hover:text-white transition-colors">karleksguiden.se</a>
|
||||
{business.brand} – <T sv={`fotograf ${business.person.name}, Sverige`} en={`photographer ${business.person.name}, Sweden`} />
|
||||
· <a href={`mailto:${business.email}`} class="text-gray-300 hover:text-white transition-colors">{business.email}</a>
|
||||
· <a href={business.social.instagram.url} target="_blank" rel="noopener noreferrer" class="text-gray-300 hover:text-white transition-colors">Instagram</a>
|
||||
</p>
|
||||
<p>© {new Date().getFullYear()} Images by Olofsson. All rights reserved. · <a href="/privacy" class="text-gray-400 hover:text-white transition-colors">Privacy</a> · <button type="button" data-consent-open class="text-gray-400 hover:text-white transition-colors">Cookie settings</button></p>
|
||||
<p>© {new Date().getFullYear()} {business.brand}. <T sv="Alla rättigheter förbehållna." en="All rights reserved." /> · <a href="/privacy" class="text-gray-300 hover:text-white transition-colors"><T sv="Integritetspolicy" en="Privacy policy" /></a> · <button type="button" data-consent-open class="text-gray-300 hover:text-white transition-colors"><T sv="Cookie-inställningar" en="Cookie settings" /></button></p>
|
||||
</footer>
|
||||
|
||||
<!-- Cookie consent: Meta Pixel laddas forst efter "Accept" -->
|
||||
<!-- Cookie consent: Meta Pixel laddas först efter "Godkänn" -->
|
||||
<div data-consent-banner hidden class="fixed bottom-4 inset-x-4 md:left-auto md:right-6 md:max-w-md z-[60] bg-neutral-900/95 backdrop-blur-md border border-white/10 rounded-2xl p-5 shadow-2xl text-sm text-gray-300">
|
||||
<p class="mb-4">
|
||||
I use the Meta Pixel to see how visitors from Instagram and Facebook use this site. It sets cookies and sends data to Meta.
|
||||
<a href="/privacy" class="text-white underline hover:text-gray-300">Read more</a>
|
||||
<T sv="Jag använder Meta Pixel för att se hur besökare från Instagram och Facebook använder sajten. Den sätter cookies och skickar data till Meta."
|
||||
en="I use the Meta Pixel to see how visitors from Instagram and Facebook use the site. It sets cookies and sends data to Meta." />
|
||||
<a href="/privacy" class="text-white underline hover:text-gray-300"><T sv="Läs mer" en="Read more" /></a>
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button type="button" data-consent="denied" class="px-4 py-2 rounded-full border border-white/20 text-gray-300 hover:text-white hover:border-white/40 transition-colors">Decline</button>
|
||||
<button type="button" data-consent="granted" class="px-4 py-2 rounded-full bg-white text-neutral-900 font-medium hover:bg-gray-200 transition-colors">Accept</button>
|
||||
<button type="button" data-consent="denied" class="px-4 py-2 rounded-full border border-white/20 text-gray-300 hover:text-white hover:border-white/40 transition-colors"><T sv="Avböj" en="Decline" /></button>
|
||||
<button type="button" data-consent="granted" class="px-4 py-2 rounded-full bg-white text-neutral-900 font-medium hover:bg-gray-200 transition-colors"><T sv="Godkänn" en="Accept" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
// SV/EN-knappen: sparar valet (samma nyckel som kundportalen), byter <html lang> och meddelar sidans skript.
|
||||
(function () {
|
||||
function show(lang) {
|
||||
document.documentElement.lang = lang;
|
||||
document.querySelectorAll('[data-lang-set]').forEach(function (b) {
|
||||
b.setAttribute('aria-pressed', b.getAttribute('data-lang-set') === lang ? 'true' : 'false');
|
||||
});
|
||||
document.querySelectorAll('[data-aria-sv]').forEach(function (el) {
|
||||
el.setAttribute('aria-label', el.getAttribute(lang === 'en' ? 'data-aria-en' : 'data-aria-sv'));
|
||||
});
|
||||
document.dispatchEvent(new CustomEvent('ibo-lang', { detail: lang }));
|
||||
}
|
||||
show(document.documentElement.lang === 'en' ? 'en' : 'sv');
|
||||
document.querySelectorAll('[data-lang-set]').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
var lang = b.getAttribute('data-lang-set');
|
||||
try { localStorage.setItem('ibo_lang', lang); } catch (e) {}
|
||||
show(lang);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
(function () {
|
||||
var KEY = 'ibo-consent';
|
||||
var banner = document.querySelector('[data-consent-banner]');
|
||||
@@ -78,7 +199,7 @@ const { title } = Astro.props;
|
||||
/* End Meta Pixel Code */
|
||||
}
|
||||
|
||||
// Aterkallat samtycke: ta bort Metas forsta-parts-cookies.
|
||||
// Återkallat samtycke: ta bort Metas förstapartscookies.
|
||||
function clearMetaCookies() {
|
||||
['_fbp', '_fbc'].forEach(function (name) {
|
||||
document.cookie = name + '=; Max-Age=0; path=/';
|
||||
@@ -108,4 +229,4 @@ const { title } = Astro.props;
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// src/lib/pages.ts
|
||||
// Sajtens riktiga sidor, härledda från src/pages/*.astro (404 och _-filer exkluderas).
|
||||
// Nya sidor hamnar automatiskt i sitemap.
|
||||
const pageFiles = import.meta.glob('../pages/*.astro');
|
||||
|
||||
// Sidor på samma domän som kundportalen (samma Cleura-server) visar, så att Om mig-texten,
|
||||
// samarbeten och posters redigeras på ett ställe i admin (Linus 2026-11-06). Caddy skickar dem
|
||||
// till portalen; enskilda posters listas i portalens egen /posters/sitemap.xml.
|
||||
export const PORTAL_PAGES = ['/about', '/contact', '/posters'] as const;
|
||||
|
||||
export function sitePaths(): string[] {
|
||||
const own = Object.keys(pageFiles)
|
||||
.map((file) => file.replace(/^\.\.\/pages\//, '').replace(/\.astro$/, ''))
|
||||
.filter((name) => name !== '404' && !name.startsWith('_') && !name.startsWith('['))
|
||||
.map((name) => (name === 'index' ? '/' : `/${name}`));
|
||||
return [...new Set([...own, ...PORTAL_PAGES])]
|
||||
.sort((a, b) => (a === '/' ? -1 : b === '/' ? 1 : a.localeCompare(b)));
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// src/lib/url.ts
|
||||
// Gör om en byggsökväg till kanonisk form: "/", "/about", "/portfolio".
|
||||
// Klarar både build.format "file" (/about.html) och "directory" (/about/).
|
||||
export function normalizePath(p: string): string {
|
||||
let out = p.replace(/\/index\.html$/, '/').replace(/\.html$/, '');
|
||||
if (out.length > 1) out = out.replace(/\/+$/, '');
|
||||
return out || '/';
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
// src/pages/404.astro -> dist/404.html (servern svarar med status 404)
|
||||
import MainLayout from '../layouts/MainLayout.astro';
|
||||
import WaveBackground from '../components/WaveBackground.astro';
|
||||
import T from '../components/T.astro';
|
||||
|
||||
const links = [
|
||||
{ href: '/', sv: 'Startsidan', en: 'Home' },
|
||||
{ href: '/portfolio', sv: 'Portfolio', en: 'Portfolio' },
|
||||
{ href: '/posters', sv: 'Posters', en: 'Posters' },
|
||||
{ href: '/contact', sv: 'Kontakt', en: 'Contact' },
|
||||
];
|
||||
---
|
||||
|
||||
<MainLayout
|
||||
title="Sidan hittades inte | Images by Olofsson"
|
||||
description="Sidan du letade efter finns inte. Gå till startsidan, portfolion eller posters av fotograf Linus Olofsson."
|
||||
noindex
|
||||
>
|
||||
<WaveBackground opacity="opacity-60" />
|
||||
|
||||
<div class="container mx-auto px-4 py-20 md:py-32 relative z-10 text-center">
|
||||
<p class="text-sm text-gray-400 uppercase tracking-widest mb-4"><T sv="Fel 404" en="Error 404" /></p>
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-6"><T sv="Sidan hittades inte" en="Page not found" /></h1>
|
||||
<p class="text-gray-300 text-lg max-w-xl mx-auto mb-10">
|
||||
<T sv="Sidan du letade efter finns inte längre, eller så har adressen ändrats. Här kan du hitta tillbaka:" en="The page you were looking for no longer exists, or its address has changed. Find your way back here:" />
|
||||
</p>
|
||||
<ul class="flex flex-wrap justify-center gap-4">
|
||||
{links.map((l) => (
|
||||
<li>
|
||||
<a href={l.href} class="inline-block px-6 py-3 rounded-full border border-white/20 text-white hover:bg-white hover:text-black transition-colors"><T sv={l.sv} en={l.en} /></a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</MainLayout>
|
||||
@@ -1,127 +0,0 @@
|
||||
---
|
||||
// src/pages/about.astro
|
||||
import MainLayout from '../layouts/MainLayout.astro';
|
||||
import { Image } from 'astro:assets';
|
||||
import profileImg from '../assets/images/about/profile.jpg';
|
||||
import WaveBackground from '../components/WaveBackground.astro';
|
||||
---
|
||||
|
||||
<MainLayout title="About | Images by Olofsson">
|
||||
|
||||
<WaveBackground opacity="opacity-80" />
|
||||
|
||||
<div class="container mx-auto px-4 py-12 md:py-20 relative z-10">
|
||||
|
||||
<div data-trail-box class="bg-neutral-900/60 backdrop-blur-md border border-white/10 rounded-3xl p-8 md:p-12 shadow-2xl mb-20">
|
||||
|
||||
<div class="flex flex-col lg:flex-row items-center gap-12 lg:gap-20">
|
||||
|
||||
<div class="w-full lg:w-1/2 relative group">
|
||||
<div class="absolute inset-0 bg-gradient-to-tr from-neutral-900/50 to-transparent rounded-2xl z-10"></div>
|
||||
<div class="relative rounded-2xl overflow-hidden shadow-2xl border border-white/10 transform transition duration-500 group-hover:scale-[1.01]">
|
||||
<Image
|
||||
src={profileImg}
|
||||
alt="Linus Olofsson Photographer"
|
||||
width={800}
|
||||
height={1000}
|
||||
class="w-full h-auto object-cover grayscale hover:grayscale-0 transition-all duration-700 ease-in-out"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full lg:w-1/2 space-y-6">
|
||||
<h1 class="text-4xl md:text-5xl font-bold leading-tight">
|
||||
Dynamic moments, <br/>
|
||||
<span class="text-transparent bg-clip-text bg-gradient-to-r from-white to-gray-500">Frozen in high resolution.</span>
|
||||
</h1>
|
||||
|
||||
<div class="space-y-4 text-gray-300 text-lg leading-relaxed">
|
||||
<p>
|
||||
Hi — I’m Linus Olofsson.
|
||||
Cloud specialist by day, passionate photographer by night.
|
||||
</p>
|
||||
<p>
|
||||
Based in Sweden, my journey began with a simple curiosity for how light shapes the way we perceive the world. Over time, that curiosity evolved into an obsession with capturing the subtle details most people overlook — textures, structures, contrasts and moments that flicker by unnoticed.
|
||||
</p>
|
||||
<p>
|
||||
I find beauty in the small things: a stone, a tree, an empty street, a quiet forest path, or the expression of a stranger passing by.
|
||||
</p>
|
||||
<p>
|
||||
Whether it’s the intricate patterns of a macro shot, the vast silence of nature, or the energy of a city, my goal is
|
||||
always the same — to create images that convey emotion and tell a story without words.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<p class="font-handwriting text-2xl text-white">Linus Olofsson</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-4 pt-6">
|
||||
<a href="/contact" class="inline-block bg-white text-black px-8 py-3 rounded-full font-bold hover:bg-gray-200 transition-colors shadow-lg">
|
||||
Get in touch
|
||||
</a>
|
||||
<a href="/portfolio" class="inline-block border border-white text-white px-8 py-3 rounded-full font-bold hover:bg-white/10 transition-colors">
|
||||
View the world through my eyes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-white/10 pt-16">
|
||||
<h2 class="text-3xl font-bold mb-10 text-center text-white">What's in my bag?</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
|
||||
<div data-trail-box class="gear-card">
|
||||
<div class="icon-box">
|
||||
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2 text-white">Camera Body</h3>
|
||||
<p class="text-gray-400">Sony A7RV</p>
|
||||
<p class="text-gray-500 text-sm mt-2">A trusty companion that has been with me on countless adventures.</p>
|
||||
</div>
|
||||
|
||||
<div data-trail-box class="gear-card">
|
||||
<div class="icon-box">
|
||||
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2 text-white">Lenses</h3>
|
||||
<ul class="text-gray-400 text-sm space-y-1">
|
||||
<li>Sony FE 70-200mm f/4 G OSS</li>
|
||||
<li>Sony FE 20mm f/1.8 G</li>
|
||||
<li>and a couple of extra batteries</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div data-trail-box class="gear-card">
|
||||
<div class="icon-box">
|
||||
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2 text-white">Editing</h3>
|
||||
<p class="text-gray-400">Adobe Lightroom Classic</p>
|
||||
<p class="text-gray-500 text-sm mt-2">Where the raw captures come to life. I focus on natural tones and contrast.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</MainLayout>
|
||||
|
||||
<style>
|
||||
@reference "../styles/global.css";
|
||||
|
||||
.font-handwriting {
|
||||
font-family: 'Brush Script MT', cursive;
|
||||
}
|
||||
|
||||
.gear-card {
|
||||
/* Jag la även till lite backdrop-blur på korten här nere för enhetlighet */
|
||||
@apply bg-neutral-900/80 backdrop-blur-sm p-8 rounded-xl border border-white/5 hover:border-white/20 transition-all duration-300 flex flex-col items-center text-center;
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
@apply w-16 h-16 bg-neutral-800 rounded-full flex items-center justify-center mb-6 text-white shadow-lg border border-white/5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,128 +0,0 @@
|
||||
---
|
||||
// src/pages/contact.astro
|
||||
import MainLayout from '../layouts/MainLayout.astro';
|
||||
import WaveBackground from '../components/WaveBackground.astro';
|
||||
---
|
||||
|
||||
<MainLayout title="Contact | Images by Olofsson">
|
||||
<div>
|
||||
<WaveBackground opacity="opacity-80" theme="purple" />
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 py-12 md:py-20 relative z-10">
|
||||
<div class="container mx-auto px-4 py-12 md:py-20">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-12 lg:gap-20 max-w-6xl mx-auto items-start">
|
||||
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h1 class="text-4xl md:text-5xl font-bold mb-6">Get in touch</h1>
|
||||
<p class="text-gray-400 text-lg leading-relaxed">
|
||||
Do you have questions about any of my photos, collaborations, or just want to say hi?
|
||||
Connect with me on social media or send me a direct email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-white font-semibold text-xl mb-4">You can also find me on...</h2>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
|
||||
<a href="https://www.instagram.com/imagesbyolofsson/" target="_blank" rel="noopener noreferrer" class="social-card group" aria-label="Instagram">
|
||||
<svg class="w-8 h-8 group-hover:text-pink-500 transition-colors" viewBox="0 0 24 24" fill="currentColor"><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>
|
||||
</a>
|
||||
|
||||
<a href="https://www.flickr.com/photos/imagesbyolofsson/" target="_blank" rel="noopener noreferrer" class="social-card group" aria-label="Flickr">
|
||||
<svg class="w-8 h-8 group-hover:text-blue-500 transition-colors" viewBox="0 0 24 24" fill="currentColor"><path d="M24 4v16h-24v-16h24zm-16.146 11.908c1.652 0 2.992-1.339 2.992-2.991 0-1.652-1.34-2.991-2.992-2.991-1.651 0-2.99 1.339-2.99 2.991 0 1.652 1.339 2.991 2.99 2.991zm8.008 0c1.652 0 2.992-1.339 2.992-2.991 0-1.652-1.34-2.991-2.992-2.991-1.652 0-2.992 1.339-2.992 2.991 0 1.652 1.34 2.991 2.992 2.991z"/></svg>
|
||||
</a>
|
||||
|
||||
<a href="https://lightroom.adobe.com/u/linusolofsson1" target="_blank" rel="noopener noreferrer" class="social-card group" aria-label="Adobe Lightroom">
|
||||
<svg class="w-8 h-8 group-hover:text-cyan-400 transition-colors" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M4 4v16h11v-4H8V4H4zm13 4c-2.21 0-4 1.79-4 4v8h4v-7c0-1.1.9-2 2-2v-3h-2z"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/LinusOlofsson-maker" target="_blank" rel="noopener noreferrer" class="social-card group" aria-label="Github">
|
||||
<svg class="w-8 h-8 group-hover:text-purple-500 transition-colors" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
</a>
|
||||
|
||||
<a href="https://www.linkedin.com/in/linus-olofsson-/" target="_blank" rel="noopener noreferrer" class="social-card group" aria-label="LinkedIn">
|
||||
<svg class="w-8 h-8 group-hover:text-blue-400 transition-colors" viewBox="0 0 24 24" fill="currentColor"><path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/></svg>
|
||||
</a>
|
||||
|
||||
<a href="mailto:Admin@imagesbyolofsson.se" class="social-card group" aria-label="Email">
|
||||
<svg class="w-8 h-8 group-hover:text-green-500 transition-colors" viewBox="0 0 24 24" fill="currentColor"><path d="M0 3v18h24v-18h-24zm6.623 7.929l-4.623 5.712v-11.174l4.623 5.462zm5.377 2.738l-5.354-6.324-3.791 3.214 9.145 10.829 9.145-10.829-3.791-3.214-5.354 6.324zm5.377-2.738l4.623-5.462v11.174l-4.623-5.712z"/></svg>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative group">
|
||||
<div class="absolute -inset-1 bg-gradient-to-r from-purple-600 to-pink-600 rounded-2xl blur opacity-25 group-hover:opacity-50 transition duration-1000 group-hover:duration-200"></div>
|
||||
|
||||
<div data-trail-box class="relative bg-neutral-900 ring-1 ring-white/10 rounded-xl p-8 md:p-12 text-center space-y-10">
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold text-white mb-2">Waiting to hear from you</h3>
|
||||
<p class="text-gray-400">Feel free to reach out for inquiries or just a chat.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="inline-flex items-center justify-center p-4 bg-white/5 rounded-full mb-4 group-hover:scale-110 transition-transform duration-300">
|
||||
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 uppercase tracking-widest mb-2">Email me at</p>
|
||||
<button id="email-btn" class="text-xl md:text-3xl font-bold text-white hover:text-purple-400 transition-colors break-all" data-email="Admin@imagesbyolofsson.se">
|
||||
Admin@imagesbyolofsson.se
|
||||
</button>
|
||||
<p id="copy-feedback" class="h-4 text-xs text-green-400 mt-2 opacity-0 transition-opacity">Copied to clipboard!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<p class="text-sm text-gray-500 uppercase tracking-widest mb-4">Or DM me on Instagram</p>
|
||||
<a href="https://www.instagram.com/imagesbyolofsson/" target="_blank" class="inline-flex items-center gap-2 text-xl font-bold text-white hover:text-pink-500 transition-colors">
|
||||
@imagesbyolofsson
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
|
||||
<style>
|
||||
@reference "../styles/global.css";
|
||||
|
||||
.social-card {
|
||||
@apply flex items-center justify-center p-4 rounded-xl bg-neutral-800 border border-white/5 text-gray-400 transition-all duration-300;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.social-card:hover {
|
||||
@apply bg-neutral-700 border-white/20 transform -translate-y-1 shadow-lg shadow-white/5;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const emailBtn = document.getElementById('email-btn');
|
||||
const feedback = document.getElementById('copy-feedback');
|
||||
|
||||
if (emailBtn && feedback) {
|
||||
emailBtn.addEventListener('click', () => {
|
||||
const email = emailBtn.getAttribute('data-email');
|
||||
if (email) {
|
||||
navigator.clipboard.writeText(email).then(() => {
|
||||
feedback.style.opacity = '1';
|
||||
setTimeout(() => {
|
||||
feedback.style.opacity = '0';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -3,9 +3,23 @@
|
||||
import MainLayout from '../layouts/MainLayout.astro';
|
||||
import ThreeDScroll from '../components/ThreeDScroll.astro';
|
||||
import WaveBackground from '../components/WaveBackground.astro';
|
||||
import T from '../components/T.astro';
|
||||
import { business, basedIn, genreList } from '../data/business';
|
||||
|
||||
const footerButtons = [
|
||||
{ href: '/posters', sv: 'Posters', en: 'Posters' },
|
||||
{ href: '/contact', sv: 'Samarbeten', en: 'Collaborations' },
|
||||
{ href: '/about', sv: 'Mer om mig', en: 'More about me' },
|
||||
{ href: '/portfolio', sv: 'Portfolio', en: 'Portfolio' },
|
||||
{ href: '/contact', sv: 'Kontakt', en: 'Contact' },
|
||||
];
|
||||
---
|
||||
|
||||
<MainLayout title="Home | Images by Olofsson">
|
||||
<MainLayout
|
||||
title="Images by Olofsson – fotograf Linus Olofsson i Sverige"
|
||||
description={`Fotograf Linus Olofsson (Images by Olofsson) i ${basedIn()}: porträtt, natur och djur, stad, makro, norrsken och familjefester. Fotografierna finns som posters.`}
|
||||
path="/"
|
||||
>
|
||||
|
||||
<div id="wave-wrapper" class="fixed inset-0 z-0 pointer-events-none transition-opacity duration-1000 ease-in-out" style="opacity: 0.4;">
|
||||
<WaveBackground opacity="opacity-100" theme="cyan" />
|
||||
@@ -38,7 +52,8 @@ import WaveBackground from '../components/WaveBackground.astro';
|
||||
|
||||
</div>
|
||||
|
||||
<p id="scroll-arrow" class="text-white/70 mt-6 text-lg font-light tracking-widest uppercase transition-opacity duration-300">↓</p>
|
||||
<p class="text-white/80 mt-4 text-base md:text-lg tracking-widest uppercase"><T sv={`Fotograf ${business.person.name} · ${basedIn()}`} en={`Photographer ${business.person.name} · Örebro, Sweden`} /></p>
|
||||
<p id="scroll-arrow" aria-hidden="true" class="text-white/70 mt-6 text-lg font-light tracking-widest uppercase transition-opacity duration-300">↓</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -52,14 +67,14 @@ import WaveBackground from '../components/WaveBackground.astro';
|
||||
<div class="flex flex-col md:flex-row gap-12 md:gap-24 items-center text-center">
|
||||
|
||||
<div data-trail-box class="flex flex-col items-center px-6 py-4 rounded-2xl">
|
||||
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Looking for inspiration?</h2>
|
||||
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6"><T sv="Letar du inspiration?" en="Looking for inspiration?" /></h2>
|
||||
<a href="/portfolio" class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105">
|
||||
My Portfolio
|
||||
<T sv="Se min portfolio" en="See my portfolio" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row md:flex-col gap-6 items-center justify-center">
|
||||
<a href="https://www.instagram.com/imagesbyolofsson/" target="_blank" rel="noopener noreferrer" class="circle-btn group" aria-label="Instagram">
|
||||
<a href={business.social.instagram.url} target="_blank" rel="noopener noreferrer" class="circle-btn group" aria-label="Instagram">
|
||||
<svg class="outline-ring" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="48" />
|
||||
</svg>
|
||||
@@ -69,7 +84,7 @@ import WaveBackground from '../components/WaveBackground.astro';
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
<a href="https://lightroom.adobe.com/u/linusolofsson1" target="_blank" rel="noopener noreferrer" class="circle-btn group" aria-label="Lightroom">
|
||||
<a href={business.social.lightroom} target="_blank" rel="noopener noreferrer" class="circle-btn group" aria-label="Adobe Lightroom">
|
||||
<svg class="outline-ring" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="48" />
|
||||
</svg>
|
||||
@@ -80,15 +95,106 @@ import WaveBackground from '../components/WaveBackground.astro';
|
||||
</div>
|
||||
|
||||
<div data-trail-box class="flex flex-col items-center px-6 py-4 rounded-2xl">
|
||||
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Ready to collaborate?</h2>
|
||||
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6"><T sv="Redo att samarbeta?" en="Ready to collaborate?" /></h2>
|
||||
<a href="/contact" class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105">
|
||||
Contact me
|
||||
<T sv="Kontakta mig" en="Contact me" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Länkar längst ner (Linus 2026-11-06): bara knappar, avlånga och raka, ingen text och inga textlänkar. -->
|
||||
<nav id="lankar" aria-label="Mer på sajten" data-aria-sv="Mer på sajten" data-aria-en="More on the site" class="relative z-30 bg-neutral-900 border-t border-white/10 py-12 md:py-16">
|
||||
<ul class="container mx-auto px-4 flex flex-wrap justify-center gap-3 md:gap-4">
|
||||
{footerButtons.map((b) => (
|
||||
<li><a href={b.href} class="inline-flex min-w-[11rem] items-center justify-center rounded-md border border-white/20 bg-white/[0.03] px-8 py-3 text-xs md:text-sm font-semibold uppercase tracking-[0.2em] text-white transition-colors hover:border-white hover:bg-white hover:text-neutral-900"><T sv={b.sv} en={b.en} /></a></li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
// SVG Längd
|
||||
const path = document.getElementById('outline-path') as unknown as SVGPathElement;
|
||||
if (path) {
|
||||
const length = path.getTotalLength();
|
||||
path.closest('svg')?.style.setProperty('--length', length.toString());
|
||||
}
|
||||
|
||||
const heroText = document.getElementById('hero-text');
|
||||
const textWrapper = document.getElementById('text-wrapper');
|
||||
const arrow = document.getElementById('scroll-arrow');
|
||||
const smokyPath = document.querySelector('.smoky-path') as SVGElement;
|
||||
const waveWrapper = document.getElementById('wave-wrapper'); // Hämta bakgrunden
|
||||
|
||||
// LOGIK FÖR GRADVIS FADE BASERAT PÅ SCROLL
|
||||
window.addEventListener('scroll', () => {
|
||||
const scrollY = window.scrollY;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if (arrow) {
|
||||
arrow.style.opacity = scrollY > 50 ? '0' : '1';
|
||||
}
|
||||
|
||||
if (heroText && !footerIsVisible) {
|
||||
const fadeProgress = Math.min(scrollY / (windowHeight * 3), 1);
|
||||
heroText.style.opacity = fadeProgress.toString();
|
||||
}
|
||||
|
||||
if (scrollY > 2500) {
|
||||
if (smokyPath) smokyPath.style.animationDuration = '4s';
|
||||
} else {
|
||||
if (smokyPath) smokyPath.style.animationDuration = '6s';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// FOOTER INTERAKTION
|
||||
const footer = document.getElementById('footer-section');
|
||||
const imagesContainer = document.getElementById('images-container');
|
||||
let footerIsVisible = false;
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
footerIsVisible = entry.isIntersecting;
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
// -- FOOTER SYNS --
|
||||
if (imagesContainer) imagesContainer.style.opacity = '0';
|
||||
if (heroText) {
|
||||
heroText.style.opacity = '1';
|
||||
heroText.style.transform = 'translateY(-25vh)';
|
||||
}
|
||||
|
||||
// Tänd Brackets
|
||||
if (textWrapper) textWrapper.classList.add('show-brackets');
|
||||
|
||||
// HÖJ OPACITY TILL 90% (Hårdkodat via style)
|
||||
if (waveWrapper) {
|
||||
waveWrapper.style.opacity = '0.9';
|
||||
}
|
||||
|
||||
} else {
|
||||
// -- FOOTER SYNS INTE --
|
||||
if (imagesContainer) imagesContainer.style.opacity = '1';
|
||||
if (heroText) {
|
||||
heroText.style.transform = 'translateY(0)';
|
||||
}
|
||||
|
||||
// Släck Brackets
|
||||
if (textWrapper) textWrapper.classList.remove('show-brackets');
|
||||
|
||||
// ÅTERSTÄLL OPACITY TILL 40% (Hårdkodat via style)
|
||||
if (waveWrapper) {
|
||||
waveWrapper.style.opacity = '0.4';
|
||||
}
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1 });
|
||||
|
||||
if (footer) observer.observe(footer);
|
||||
</script>
|
||||
|
||||
</MainLayout>
|
||||
|
||||
<style>
|
||||
@@ -213,99 +319,3 @@ import WaveBackground from '../components/WaveBackground.astro';
|
||||
.outline-ring circle { fill: none; stroke: white; stroke-width: 2; stroke-dasharray: 302; stroke-dashoffset: 302; transition: stroke-dashoffset 0.6s cubic-bezier(0.25, 1, 0.5, 1); opacity: 0.8; }
|
||||
.circle-btn:hover .outline-ring circle { stroke-dashoffset: 0; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// SVG Längd
|
||||
const path = document.getElementById('outline-path') as unknown as SVGPathElement;
|
||||
if (path) {
|
||||
const length = path.getTotalLength();
|
||||
path.closest('svg')?.style.setProperty('--length', length.toString());
|
||||
}
|
||||
|
||||
const heroText = document.getElementById('hero-text');
|
||||
const textWrapper = document.getElementById('text-wrapper');
|
||||
const arrow = document.getElementById('scroll-arrow');
|
||||
const smokyPath = document.querySelector('.smoky-path') as SVGElement;
|
||||
const waveWrapper = document.getElementById('wave-wrapper'); // Hämta bakgrunden
|
||||
|
||||
// LOGIK FÖR GRADVIS FADE BASERAT PÅ SCROLL
|
||||
window.addEventListener('scroll', () => {
|
||||
const scrollY = window.scrollY;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if (arrow) {
|
||||
arrow.style.opacity = scrollY > 50 ? '0' : '1';
|
||||
}
|
||||
|
||||
if (heroText && !footerIsVisible) {
|
||||
const fadeProgress = Math.min(scrollY / (windowHeight * 3), 1);
|
||||
heroText.style.opacity = fadeProgress.toString();
|
||||
}
|
||||
|
||||
if (scrollY > 2500) {
|
||||
if (smokyPath) smokyPath.style.animationDuration = '4s';
|
||||
} else {
|
||||
if (smokyPath) smokyPath.style.animationDuration = '6s';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// FOOTER INTERAKTION
|
||||
const footer = document.getElementById('footer-section');
|
||||
const imagesContainer = document.getElementById('images-container');
|
||||
let footerIsVisible = false;
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
footerIsVisible = entry.isIntersecting;
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
// -- FOOTER SYNS --
|
||||
if (imagesContainer) imagesContainer.style.opacity = '0';
|
||||
if (heroText) {
|
||||
heroText.style.opacity = '1';
|
||||
heroText.style.transform = 'translateY(-25vh)';
|
||||
}
|
||||
|
||||
// Tänd Brackets
|
||||
if (textWrapper) textWrapper.classList.add('show-brackets');
|
||||
|
||||
// HÖJ OPACITY TILL 90% (Hårdkodat via style)
|
||||
if (waveWrapper) {
|
||||
waveWrapper.style.opacity = '0.9';
|
||||
}
|
||||
|
||||
} else {
|
||||
// -- FOOTER SYNS INTE --
|
||||
if (imagesContainer) imagesContainer.style.opacity = '1';
|
||||
if (heroText) {
|
||||
heroText.style.transform = 'translateY(0)';
|
||||
}
|
||||
|
||||
// Släck Brackets
|
||||
if (textWrapper) textWrapper.classList.remove('show-brackets');
|
||||
|
||||
// ÅTERSTÄLL OPACITY TILL 40% (Hårdkodat via style)
|
||||
if (waveWrapper) {
|
||||
waveWrapper.style.opacity = '0.4';
|
||||
}
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1 });
|
||||
|
||||
if (footer) observer.observe(footer);
|
||||
|
||||
// FOOTER-LÄNKAR HOVER: ändra logo-glow när användare hovrar lyxlimo/karleksguiden-länkar
|
||||
const glowLinks = document.querySelectorAll<HTMLElement>('[data-glow-link]');
|
||||
glowLinks.forEach(link => {
|
||||
link.addEventListener('mouseenter', () => {
|
||||
if (!textWrapper) return;
|
||||
const tint = link.getAttribute('data-glow-link');
|
||||
if (tint) textWrapper.setAttribute('data-logo-tint', tint);
|
||||
});
|
||||
link.addEventListener('mouseleave', () => {
|
||||
if (!textWrapper) return;
|
||||
textWrapper.removeAttribute('data-logo-tint');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
// src/pages/llms.txt.ts -> /llms.txt (https://llmstxt.org/)
|
||||
// Byggs från src/data/business.ts så att fakta bara finns på ett ställe.
|
||||
// Okända fakta (null) skrivs som "lämnas på förfrågan", aldrig som gissningar.
|
||||
import type { APIRoute } from 'astro';
|
||||
import { business, basedIn, genreList } from '../data/business';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
const base = site ?? new URL(business.url);
|
||||
const url = (p: string) => new URL(p, base).toString();
|
||||
|
||||
const lines: (string | null)[] = [
|
||||
`# ${business.brand}`,
|
||||
'',
|
||||
`> ${business.brand} är namnet som ${business.person.jobTitle.toLowerCase()}en ${business.person.name} fotograferar under. Han är baserad i ${basedIn()} och fotograferar ${genreList()}. ${business.recognitionThirdPerson}`,
|
||||
'',
|
||||
'## Fakta',
|
||||
'',
|
||||
`- Fotograf: ${business.person.name}`,
|
||||
`- Namn/varumärke: ${business.brand}`,
|
||||
`- Baserad i: ${basedIn()}`,
|
||||
business.phone ? `- Telefon: ${business.phone}` : null,
|
||||
`- E-post: ${business.email}`,
|
||||
`- Instagram: ${business.social.instagram.handle} (${business.social.instagram.url})`,
|
||||
`- Facebook: ${business.social.facebook}`,
|
||||
`- Kamera: ${business.camera}`,
|
||||
`- Redigering: ${business.editing}`,
|
||||
'',
|
||||
'## Sidor',
|
||||
'',
|
||||
`- [Startsida](${url('/')}): vem ${business.person.name} är och vad han fotograferar`,
|
||||
`- [Portfolio](${url('/portfolio')}): urval av fotografier`,
|
||||
`- [Om mig](${url('/about')}): ${business.person.name}, bakgrund och utrustning`,
|
||||
`- [Posters](${url('/posters')}): fotografier som posters, välj storlek och material`,
|
||||
`- [Kontakt och samarbeten](${url('/contact')}): samarbeten, reklam och sponsring`,
|
||||
'',
|
||||
'## Profiler',
|
||||
'',
|
||||
`- [Instagram](${business.social.instagram.url})`,
|
||||
`- [Flickr](${business.social.flickr})`,
|
||||
`- [Adobe Lightroom](${business.social.lightroom})`,
|
||||
`- [LinkedIn](${business.social.linkedin})`,
|
||||
'',
|
||||
'## Användning av innehållet',
|
||||
'',
|
||||
`AI-assistenter och sökmotorer får läsa och använda innehållet på den här sajten, inklusive för träning, med hänvisning till ${business.brand} (${url('/')}). Fotografierna är upphovsrättsskyddade av ${business.person.name}.`,
|
||||
'',
|
||||
];
|
||||
|
||||
const body = lines.filter((l): l is string => l !== null).join('\n');
|
||||
return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
|
||||
};
|
||||
@@ -1,31 +1,33 @@
|
||||
---
|
||||
// src/pages/portfolio.astro
|
||||
import MainLayout from '../layouts/MainLayout.astro';
|
||||
import { Image } from 'astro:assets';
|
||||
import { Image, getImage } from 'astro:assets';
|
||||
import exifr from 'exifr';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import WaveBackground from '../components/WaveBackground.astro';
|
||||
import T from '../components/T.astro';
|
||||
import { business, genreList } from '../data/business';
|
||||
import { portfolioMeta } from '../data/portfolio-meta';
|
||||
|
||||
// --- SERVER-SIDE LOGIC ---
|
||||
// SERVER-SIDE LOGIC (byggtid)
|
||||
|
||||
// 1. Hämta alla bilder
|
||||
const allImageFiles = import.meta.glob('../assets/images/portfolio/*.{jpeg,jpg,png,webp,JPG,JPEG,PNG}', { eager: true });
|
||||
// -s (800px) och -m (1600px) ar NEDLADDNINGSVARIANTER, inte egna galleribilder.
|
||||
// Utan filtret visas varje bild tre ganger.
|
||||
const imageFiles = Object.fromEntries(
|
||||
Object.entries(allImageFiles).filter(([pathKey]) => !/-[sm]\.(jpe?g|png|webp)$/i.test(pathKey))
|
||||
// 1. Hämta alla galleribilder.
|
||||
// -s (800px) och -m (1600px) i mappen är gamla nedladdningsvarianter med EXIF kvar.
|
||||
// De importeras INTE längre (då hamnar de inte i dist/); nedladdningsstorlekarna
|
||||
// tas i stället fram av Astros bildtjänst nedan, vilket också rensar bort EXIF
|
||||
// (GPS, kamerans serienummer, Artist m.m.).
|
||||
const imageFiles = import.meta.glob(
|
||||
[
|
||||
'../assets/images/portfolio/*.{jpeg,jpg,png,webp,JPG,JPEG,PNG}',
|
||||
'!../assets/images/portfolio/*-{s,m}.{jpeg,jpg,png,webp,JPG,JPEG,PNG}',
|
||||
],
|
||||
{ eager: true }
|
||||
);
|
||||
|
||||
// Karta assetId -> { s, m } med Astros hashade URL:er, sa klienten kan lanka
|
||||
// till ratt nedladdningsvariant. Large ar galleribilden sjalv, original gar via proxy.
|
||||
const downloadVariants: Record<string, Record<string, string>> = {};
|
||||
for (const [pathKey, file] of Object.entries(allImageFiles) as [string, any][]) {
|
||||
const m = pathKey.match(/immich-([0-9a-f-]{36})-.*-([sm])\.(?:jpe?g|png|webp)$/i);
|
||||
if (!m) continue;
|
||||
const id = m[1].toLowerCase();
|
||||
(downloadVariants[id] ||= {})[m[2].toLowerCase()] = file.default.src;
|
||||
}
|
||||
// OBS: läs aldrig .src/.width/.height direkt på de importerade bilderna här.
|
||||
// Då räknar Astro originalet som "använt" och kopierar originalfilen (med EXIF)
|
||||
// till dist/. Gå alltid via <Image>/getImage.
|
||||
|
||||
function formatShutterSpeed(exposureTime: number) {
|
||||
if (!exposureTime) return null;
|
||||
@@ -34,40 +36,78 @@ function formatShutterSpeed(exposureTime: number) {
|
||||
return `1/${fraction}`;
|
||||
}
|
||||
|
||||
// Datum ur filnamnet: "immich-<id>-20260905-..." eller "20250510-...".
|
||||
function dateFromFilename(name: string): Date | null {
|
||||
const m = name.match(/(?:^|-)((?:19|20)\d{2})(\d{2})(\d{2})-/);
|
||||
if (!m) return null;
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
const widthOf = (descriptor: string) => parseInt(descriptor, 10) || 0;
|
||||
|
||||
const galleryItems = await Promise.all(Object.entries(imageFiles).map(async ([relativePath, file]: [string, any]) => {
|
||||
let exifData: any = {};
|
||||
let dateTaken = null;
|
||||
let exifData: any = {};
|
||||
let dateTaken: Date | null = null;
|
||||
const absolutePath = path.resolve('./src/pages', relativePath);
|
||||
const fileName = path.basename(relativePath);
|
||||
// Stabil nyckel = filnamn utan ändelse. Används för alt-texter (portfolio-meta.ts) och statistik.
|
||||
const key = fileName.replace(/\.[^.]+$/, '');
|
||||
|
||||
try {
|
||||
const fileBuffer = fs.readFileSync(absolutePath);
|
||||
exifData = await exifr.parse(fileBuffer, [
|
||||
'ISO', 'Model', 'FNumber', 'ExposureTime', 'FocalLength', 'DateTimeOriginal', 'CreateDate'
|
||||
]);
|
||||
dateTaken = exifData?.DateTimeOriginal || exifData?.CreateDate;
|
||||
if (!dateTaken) dateTaken = new Date();
|
||||
const exifDate = exifData?.DateTimeOriginal || exifData?.CreateDate;
|
||||
if (exifDate) dateTaken = new Date(exifDate);
|
||||
} catch (e) {
|
||||
console.error(`Kunde inte läsa EXIF för ${relativePath}`);
|
||||
dateTaken = new Date();
|
||||
}
|
||||
if (!dateTaken || isNaN(dateTaken.getTime())) dateTaken = dateFromFilename(fileName);
|
||||
const hasDate = !!dateTaken;
|
||||
const dateObj = dateTaken ?? new Date();
|
||||
|
||||
const dateObj = new Date(dateTaken);
|
||||
const year = dateObj.getFullYear().toString();
|
||||
const month = dateObj.toLocaleString('sv-SE', { month: 'long' }).toLowerCase();
|
||||
const monthIndex = dateObj.getMonth();
|
||||
|
||||
const fullDate = dateObj.toLocaleDateString('sv-SE', {
|
||||
day: 'numeric', month: 'long', year: 'numeric'
|
||||
});
|
||||
|
||||
const fullDate = hasDate
|
||||
? dateObj.toLocaleDateString('sv-SE', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: '';
|
||||
const fullDateEn = hasDate
|
||||
? dateObj.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: '';
|
||||
|
||||
const shutterSpeed = formatShutterSpeed(exifData?.ExposureTime);
|
||||
const focalLength = exifData?.FocalLength ? `${Math.round(exifData.FocalLength)}mm` : null;
|
||||
const fStop = exifData?.FNumber ? `f/${exifData.FNumber}` : null;
|
||||
const iso = exifData?.ISO ? `ISO ${exifData.ISO}` : null;
|
||||
|
||||
// Alt-text: riktig text från portfolio-meta.ts om den finns, annars en neutral
|
||||
// svensk reservtext. Aldrig kameramodell, aldrig "undefined".
|
||||
const meta = portfolioMeta[key] ?? {};
|
||||
const fallbackAlt = [
|
||||
meta.category ? `${meta.category}:` : null,
|
||||
`fotografi av ${business.person.name}`,
|
||||
fullDate ? `taget ${fullDate}` : null,
|
||||
].filter(Boolean).join(' ');
|
||||
const alt = meta.alt?.trim() || fallbackAlt.charAt(0).toUpperCase() + fallbackAlt.slice(1);
|
||||
|
||||
// Lightbox: EXIF-fria JPG:er i 800/1600/3000 px (Astro hoppar över bredder större än originalet).
|
||||
// Ingen nedladdning längre (Linus 2026-11-06): bilderna säljs som posters i stället.
|
||||
const jpg = await getImage({ src: file.default, format: 'jpg', quality: 88, width: 800, widths: [800, 1600, 3000] });
|
||||
const variants = [...jpg.srcSet.values].sort((a, b) => widthOf(a.descriptor ?? '') - widthOf(b.descriptor ?? ''));
|
||||
const large = variants[variants.length - 1]?.url ?? jpg.src;
|
||||
|
||||
const statKey = ('portfolio/' + key.replace(/[^\w\-.]/g, '_')).slice(0, 150);
|
||||
|
||||
return {
|
||||
...file, year, month, monthIndex, fullDate, model: exifData?.Model,
|
||||
fStop, shutterSpeed, iso, focalLength
|
||||
img: file.default, key, alt, title: meta.title ?? null,
|
||||
year, month, monthIndex, fullDate, fullDateEn, model: exifData?.Model,
|
||||
fStop, shutterSpeed, iso, focalLength,
|
||||
large, srcset: jpg.srcSet.attribute,
|
||||
statKey,
|
||||
};
|
||||
}));
|
||||
|
||||
@@ -75,10 +115,17 @@ const uniqueYears = [...new Set(galleryItems.map((item: any) => item.year))].sor
|
||||
const allMonths = galleryItems.map((item: any) => ({ name: item.month, index: item.monthIndex }));
|
||||
const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).values()]
|
||||
.sort((a: any, b: any) => a.index - b.index)
|
||||
.map((item: any) => item.name);
|
||||
.map((item: any) => ({ sv: item.name, en: new Date(2000, item.index, 1).toLocaleString('en-GB', { month: 'long' }) }));
|
||||
const genresEn = 'portraits and people, nature and animals, city and architecture, macro and details, northern lights, and celebrations and family occasions';
|
||||
---
|
||||
|
||||
<MainLayout title="Portfolio | Images by Olofsson">
|
||||
<MainLayout
|
||||
title="Portfolio – fotografier av Linus Olofsson | Images by Olofsson"
|
||||
description={`Portfolio med fotografier av Linus Olofsson: ${genreList()}. Filtrera på år och månad och öppna bilderna i större format.`}
|
||||
path="/portfolio"
|
||||
pageType="CollectionPage"
|
||||
breadcrumb="Portfolio"
|
||||
>
|
||||
|
||||
<div>
|
||||
<WaveBackground opacity="opacity-55" />
|
||||
@@ -88,57 +135,76 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
|
||||
|
||||
<div class="text-center mb-12">
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-4">Portfolio</h1>
|
||||
<p class="text-gray-400">A curated selection of my work across different styles.</p>
|
||||
<p class="text-gray-300 max-w-2xl mx-auto"><T sv={`Ett urval av mina bilder i olika stilar – ${genreList()}.`} en={`A selection of my photos in different styles – ${genresEn}.`} /></p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-6 mb-12">
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<button class="filter-btn active" data-filter="all" data-type="year">Alla år</button>
|
||||
<button type="button" class="filter-btn active" data-filter="all" data-type="year"><T sv="Alla år" en="All years" /></button>
|
||||
{uniqueYears.map(year => (
|
||||
<button class="filter-btn" data-filter={year} data-type="year">{year}</button>
|
||||
<button type="button" class="filter-btn" data-filter={year} data-type="year">{year}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<button class="filter-btn active" data-filter="all" data-type="month">Alla månader</button>
|
||||
<button type="button" class="filter-btn active" data-filter="all" data-type="month"><T sv="Alla månader" en="All months" /></button>
|
||||
{uniqueMonths.map(month => (
|
||||
<button class="filter-btn capitalize" data-filter={month} data-type="month">{month}</button>
|
||||
<button type="button" class="filter-btn capitalize" data-filter={month.sv} data-type="month"><T sv={month.sv} en={month.en} /></button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="sr-only"><T sv="Bilder" en="Photos" /></h2>
|
||||
<div class="columns-1 md:columns-2 lg:columns-3 gap-6 space-y-6" id="gallery-grid">
|
||||
{galleryItems.map((item: any) => (
|
||||
<div
|
||||
class="gallery-item break-inside-avoid relative group overflow-hidden rounded-lg cursor-pointer bg-neutral-800 shadow-md"
|
||||
{galleryItems.map((item: any, index: number) => (
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={item.fullDate ? `Visa bilden från ${item.fullDate}` : 'Visa bilden'}
|
||||
data-aria-sv={item.fullDate ? `Visa bilden från ${item.fullDate}` : 'Visa bilden'}
|
||||
data-aria-en={item.fullDateEn ? `View the photo from ${item.fullDateEn}` : 'View the photo'}
|
||||
class="gallery-item protected block break-inside-avoid relative group overflow-hidden rounded-lg cursor-pointer bg-neutral-800 shadow-md"
|
||||
data-year={item.year}
|
||||
data-month={item.month}
|
||||
data-src={item.default.src}
|
||||
data-src={item.large}
|
||||
data-srcset={item.srcset}
|
||||
data-key={item.key}
|
||||
data-stat={item.statKey}
|
||||
data-alt={item.alt}
|
||||
data-title={item.title}
|
||||
data-model={item.model}
|
||||
data-iso={item.iso}
|
||||
data-fstop={item.fStop}
|
||||
data-shutter={item.shutterSpeed}
|
||||
data-focal={item.focalLength}
|
||||
data-date={item.fullDate}
|
||||
data-date-en={item.fullDateEn}
|
||||
>
|
||||
<Image
|
||||
src={item.default}
|
||||
alt={`Foto taget med ${item.model}`}
|
||||
width={800}
|
||||
class="w-full h-auto object-cover transform transition duration-700 group-hover:scale-105"
|
||||
<Image
|
||||
src={item.img}
|
||||
alt={item.alt}
|
||||
width={800}
|
||||
widths={[480, 800]}
|
||||
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
|
||||
loading={index < 3 ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
class="pointer-events-none select-none w-full h-auto object-cover transform transition duration-700 group-hover:scale-105"
|
||||
/>
|
||||
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent opacity-0 group-hover:opacity-100 transition duration-300 flex flex-col justify-end p-6 pointer-events-none">
|
||||
<p class="text-white font-bold text-lg">{item.fullDate}</p>
|
||||
<p class="text-xs text-gray-400 uppercase tracking-widest mt-1">Visa bild</p>
|
||||
<!-- Genomskinligt lager över bilden (som på Posters): högerklick och långt tryck träffar inte bilden. -->
|
||||
<div class="absolute inset-0" aria-hidden="true"></div>
|
||||
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition duration-300 flex flex-col justify-end p-6 pointer-events-none">
|
||||
{item.fullDate && <p class="text-white font-bold text-lg"><T sv={item.fullDate} en={item.fullDateEn} /></p>}
|
||||
<p class="text-xs text-gray-300 uppercase tracking-widest mt-1"><T sv="Visa bild" en="View photo" /></p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div id="no-results" class="hidden text-center text-gray-500 mt-20">
|
||||
<p class="text-xl">Inga bilder hittades för denna tidpunkt.</p>
|
||||
<button id="reset-btn" class="mt-4 text-white underline hover:text-gray-300">Visa alla bilder</button>
|
||||
<div id="no-results" class="hidden text-center text-gray-400 mt-20">
|
||||
<p class="text-xl"><T sv="Inga bilder hittades för denna tidpunkt." en="No photos found for this time." /></p>
|
||||
<button type="button" id="reset-btn" class="mt-4 text-white underline hover:text-gray-300"><T sv="Visa alla bilder" en="Show all photos" /></button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -146,72 +212,250 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
|
||||
<div id="lightbox" class="fixed inset-0 z-50 hidden bg-black/95 backdrop-blur-md flex items-center justify-center opacity-0 transition-opacity duration-300 p-2 md:p-8">
|
||||
<div class="bg-neutral-900 w-full max-w-6xl h-[85vh] md:h-[90vh] flex flex-col rounded-xl overflow-hidden shadow-2xl relative border border-white/10">
|
||||
|
||||
<button id="lb-close" class="absolute top-4 right-4 z-50 bg-black/50 hover:bg-black/80 text-white p-2 rounded-full transition-colors">
|
||||
<button type="button" id="lb-close" aria-label="Stäng" data-aria-sv="Stäng" data-aria-en="Close" class="absolute top-4 right-4 z-50 bg-black/50 hover:bg-black/80 text-white p-2 rounded-full transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
|
||||
<div class="flex-1 relative bg-black flex items-center justify-center overflow-hidden group">
|
||||
<img id="lb-img" src="" alt="Full size" class="max-h-full max-w-full w-full h-full object-contain p-2 md:p-4" />
|
||||
<button id="lb-prev" class="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 bg-black/40 hover:bg-black/70 text-white p-3 rounded-full transition-all">
|
||||
<div class="protected flex-1 relative bg-black flex items-center justify-center overflow-hidden group">
|
||||
<img id="lb-img" src="" alt="" sizes="100vw" draggable="false" class="pointer-events-none select-none max-h-full max-w-full w-full h-full object-contain p-2 md:p-4" />
|
||||
<div class="absolute inset-0" aria-hidden="true"></div>
|
||||
<button type="button" id="lb-prev" aria-label="Föregående bild" data-aria-sv="Föregående bild" data-aria-en="Previous photo" class="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 bg-black/40 hover:bg-black/70 text-white p-3 rounded-full transition-all">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
|
||||
</button>
|
||||
<button id="lb-next" class="absolute right-2 md:right-4 top-1/2 -translate-y-1/2 bg-black/40 hover:bg-black/70 text-white p-3 rounded-full transition-all">
|
||||
<button type="button" id="lb-next" aria-label="Nästa bild" data-aria-sv="Nästa bild" data-aria-en="Next photo" class="absolute right-2 md:right-4 top-1/2 -translate-y-1/2 bg-black/40 hover:bg-black/70 text-white p-3 rounded-full transition-all">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-800 p-4 md:p-6 shrink-0 border-t border-white/5 flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div class="text-left">
|
||||
<h3 id="lb-date" class="text-white font-bold text-lg md:text-xl capitalize"></h3>
|
||||
<p id="lb-date" class="text-white font-bold text-lg md:text-xl"></p>
|
||||
<p id="lb-model" class="text-gray-400 text-sm font-medium tracking-wide mt-1"></p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-4 md:gap-6">
|
||||
<div class="relative">
|
||||
<button id="lb-download" class="group inline-flex items-center gap-2 px-6 py-2.5 bg-neutral-700/80 hover:bg-neutral-600/80 text-gray-200 hover:text-white font-semibold text-xs uppercase tracking-widest rounded-full border border-black/60 shadow-lg shadow-black/30 ring-1 ring-white/5 transition-all duration-200 transform hover:scale-105 active:scale-95 active:duration-75">
|
||||
<svg id="lb-dl-arrow" xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 transition-colors duration-200" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 19h16"/></svg>
|
||||
Download
|
||||
</button>
|
||||
<div id="lb-sizes" class="hidden absolute bottom-full mb-3 left-0 md:left-auto md:right-0 min-w-[260px] bg-neutral-900/95 backdrop-blur-md border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden origin-bottom opacity-0 translate-y-2 transition-all duration-200 ease-out">
|
||||
<a data-size="s" class="dl-opt group flex items-center justify-between px-5 py-3.5 text-sm text-gray-200 hover:bg-black/40 hover:text-white cursor-pointer transition-colors border-b border-white/5"><span class="font-medium uppercase tracking-widest text-xs">Small</span><span class="text-gray-500 text-xs font-mono group-hover:text-gray-300 transition-colors">800 px</span></a>
|
||||
<a data-size="m" class="dl-opt group flex items-center justify-between px-5 py-3.5 text-sm text-gray-200 hover:bg-black/40 hover:text-white cursor-pointer transition-colors border-b border-white/5"><span class="font-medium uppercase tracking-widest text-xs">Medium</span><span class="text-gray-500 text-xs font-mono group-hover:text-gray-300 transition-colors">1600 px</span></a>
|
||||
<a data-size="l" class="dl-opt group flex items-center justify-between px-5 py-3.5 text-sm text-gray-200 hover:bg-black/40 hover:text-white cursor-pointer transition-colors border-b border-white/5"><span class="font-medium uppercase tracking-widest text-xs">Large</span><span class="text-gray-500 text-xs font-mono group-hover:text-gray-300 transition-colors">3000 px</span></a>
|
||||
<a data-size="orig" class="dl-opt group flex items-center justify-between px-5 py-3.5 text-sm text-gray-200 hover:bg-black/40 hover:text-white cursor-pointer transition-colors "><span class="font-medium uppercase tracking-widest text-xs">Original<span class="block text-[10px] text-gray-500 normal-case tracking-normal mt-0.5">Kräver lösenord</span></span><span class="text-gray-500 text-xs font-mono group-hover:text-gray-300 transition-colors">full storlek</span></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3 md:gap-6 text-xs md:text-sm font-mono text-gray-300">
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-500 uppercase text-[10px] font-bold">Focal</span><span id="lb-focal">--</span></div>
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-500 uppercase text-[10px] font-bold">Aperture</span><span id="lb-fstop">--</span></div>
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-500 uppercase text-[10px] font-bold">Shutter</span><span id="lb-shutter">--</span></div>
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-500 uppercase text-[10px] font-bold">ISO</span><span id="lb-iso">--</span></div>
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold"><T sv="Brännvidd" en="Focal length" /></span><span id="lb-focal">--</span></div>
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold"><T sv="Bländare" en="Aperture" /></span><span id="lb-fstop">--</span></div>
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold"><T sv="Slutartid" en="Shutter speed" /></span><span id="lb-shutter">--</span></div>
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold">ISO</span><span id="lb-iso">--</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="application/json" id="dl-variants" set:html={JSON.stringify(downloadVariants)}></script>
|
||||
|
||||
<!-- Losenordsruta for ORIGINAL. Originalen ligger inte i repot (27-70 MB/st)
|
||||
utan hamtas fran Immich via /download/original, som kraver losenord. -->
|
||||
<div id="pw-modal" class="hidden fixed inset-0 z-[60] bg-black/85 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="bg-neutral-900 border border-white/10 rounded-xl shadow-2xl p-7 w-full max-w-sm">
|
||||
<h4 class="text-white font-bold text-lg tracking-tight mb-1">Ladda ner original</h4>
|
||||
<p class="text-gray-400 text-sm mb-4">Originalfilen är i full upplösning och kräver lösenord.</p>
|
||||
<input id="pw-input" type="password" placeholder="Lösenord" autocomplete="current-password"
|
||||
class="w-full bg-black/60 border border-white/20 rounded-lg text-white px-4 py-2.5 mb-2 focus:outline-none focus:border-white/60 focus:ring-1 focus:ring-white/20 transition" />
|
||||
<p id="pw-error" class="hidden text-red-400 text-xs tracking-wide mb-2">Fel lösenord — försök igen.</p>
|
||||
<div class="flex gap-2 justify-end mt-2">
|
||||
<button id="pw-cancel" class="px-5 py-2.5 text-xs uppercase tracking-widest text-gray-400 hover:text-white rounded-full transition-colors">Avbryt</button>
|
||||
<button id="pw-ok" class="px-5 py-2.5 text-xs uppercase tracking-widest bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105">Ladda ner</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
// --- ANONYM STATISTIK ---
|
||||
// Räknare per dag och bild i kundportalen: ingen cookie, ingen IP, inget id.
|
||||
const STATS_URL = 'https://customer.imagesbyolofsson.se/api/stats/hit';
|
||||
// Bildens statistiknyckel räknas ut vid bygget (data-stat) och är densamma som
|
||||
// tidigare ("portfolio/<filnamn utan ändelse>"), så historiken fortsätter.
|
||||
function statsItem(item?: HTMLElement): string {
|
||||
return item?.dataset.stat || 'portfolio/okand';
|
||||
}
|
||||
function stat(kind: 'view' | 'open', item: string) {
|
||||
try {
|
||||
const body = new Blob([JSON.stringify({ kind, item })], { type: 'text/plain' });
|
||||
navigator.sendBeacon?.(STATS_URL, body);
|
||||
} catch { /* statistik får aldrig störa sidan */ }
|
||||
}
|
||||
stat('view', 'page/portfolio');
|
||||
|
||||
// --- FILTER LOGIK ---
|
||||
// MULTI-select: flera år + månader samtidigt. 'all' = samtligt.
|
||||
let selectedYears = new Set(['all']);
|
||||
let selectedMonths = new Set(['all']);
|
||||
|
||||
const items = Array.from(document.querySelectorAll('.gallery-item')) as HTMLElement[];
|
||||
const buttons = document.querySelectorAll('.filter-btn');
|
||||
const noResults = document.getElementById('no-results');
|
||||
const resetBtn = document.getElementById('reset-btn');
|
||||
|
||||
function filterImages() {
|
||||
let visibleCount = 0;
|
||||
items.forEach((el) => {
|
||||
const itemYear = el.dataset.year;
|
||||
const itemMonth = el.dataset.month;
|
||||
const matchYear = selectedYears.has('all') || selectedYears.has(itemYear);
|
||||
const matchMonth = selectedMonths.has('all') || selectedMonths.has(itemMonth);
|
||||
|
||||
if (matchYear && matchMonth) {
|
||||
el.style.display = 'block';
|
||||
el.classList.add('visible-item');
|
||||
if(el.style.opacity === '0') {
|
||||
el.animate([{ opacity: 0, transform: 'scale(0.95)' }, { opacity: 1, transform: 'scale(1)' }], { duration: 300, easing: 'ease-out' });
|
||||
}
|
||||
visibleCount++;
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
el.classList.remove('visible-item');
|
||||
}
|
||||
});
|
||||
if (noResults) noResults.style.display = visibleCount === 0 ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// --- LIGHTBOX LOGIK ---
|
||||
const lightbox = document.getElementById('lightbox');
|
||||
const lbImg = document.getElementById('lb-img') as HTMLImageElement;
|
||||
const lbDate = document.getElementById('lb-date');
|
||||
const lbModel = document.getElementById('lb-model');
|
||||
const lbFocal = document.getElementById('lb-focal');
|
||||
const lbFstop = document.getElementById('lb-fstop');
|
||||
const lbShutter = document.getElementById('lb-shutter');
|
||||
const lbIso = document.getElementById('lb-iso');
|
||||
const closeBtn = document.getElementById('lb-close');
|
||||
const nextBtn = document.getElementById('lb-next');
|
||||
const prevBtn = document.getElementById('lb-prev');
|
||||
|
||||
let currentImageIndex = 0;
|
||||
let visibleItems: HTMLElement[] = [];
|
||||
|
||||
function updateVisibleItems() {
|
||||
visibleItems = items.filter(item => item.style.display !== 'none');
|
||||
}
|
||||
|
||||
function openLightbox(index: number) {
|
||||
updateVisibleItems();
|
||||
if (visibleItems.length === 0) return;
|
||||
currentImageIndex = index;
|
||||
updateLightboxContent();
|
||||
|
||||
lightbox?.classList.remove('hidden');
|
||||
setTimeout(() => { lightbox?.classList.remove('opacity-0'); }, 10);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
lightbox?.classList.add('opacity-0');
|
||||
setTimeout(() => {
|
||||
lightbox?.classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function updateLightboxContent() {
|
||||
const item = visibleItems[currentImageIndex];
|
||||
if (!item) return;
|
||||
// srcset först så att webbläsaren väljer 800/1600/3000 px efter skärmen.
|
||||
lbImg.srcset = item.dataset.srcset || '';
|
||||
lbImg.src = item.dataset.src || '';
|
||||
lbImg.alt = item.dataset.alt || '';
|
||||
stat('open', statsItem(item));
|
||||
|
||||
const en = document.documentElement.lang === 'en';
|
||||
if(lbDate) lbDate.innerText = (en ? item.dataset.dateEn : item.dataset.date) || '';
|
||||
if(lbModel) lbModel.innerText = item.dataset.model || '';
|
||||
if(lbFocal) lbFocal.innerText = item.dataset.focal || '--';
|
||||
if(lbFstop) lbFstop.innerText = item.dataset.fstop || '--';
|
||||
if(lbShutter) lbShutter.innerText = item.dataset.shutter ? item.dataset.shutter + 's' : '--';
|
||||
if(lbIso) lbIso.innerText = item.dataset.iso || '--';
|
||||
}
|
||||
|
||||
function showNext() {
|
||||
currentImageIndex = (currentImageIndex + 1) % visibleItems.length;
|
||||
updateLightboxContent();
|
||||
}
|
||||
|
||||
function showPrev() {
|
||||
currentImageIndex = (currentImageIndex - 1 + visibleItems.length) % visibleItems.length;
|
||||
updateLightboxContent();
|
||||
}
|
||||
|
||||
// Skydd som på Posters: ingen högerklick-meny, ingen dragning, inget långt tryck på bilderna.
|
||||
document.addEventListener('contextmenu', (e) => {
|
||||
if ((e.target as HTMLElement).closest('.protected')) e.preventDefault();
|
||||
});
|
||||
document.addEventListener('dragstart', (e) => {
|
||||
if ((e.target as HTMLElement).closest('.protected')) e.preventDefault();
|
||||
});
|
||||
// Byter besökaren språk med ljuslådan öppen följer datumet med.
|
||||
document.addEventListener('ibo-lang', () => {
|
||||
if (!lightbox?.classList.contains('hidden')) updateLightboxContent();
|
||||
});
|
||||
|
||||
// Event Listeners
|
||||
items.forEach((item) => {
|
||||
item.addEventListener('keydown', (e) => {
|
||||
const key = (e as KeyboardEvent).key;
|
||||
if (key !== 'Enter' && key !== ' ') return;
|
||||
e.preventDefault();
|
||||
updateVisibleItems();
|
||||
const index = visibleItems.indexOf(item);
|
||||
if (index !== -1) openLightbox(index);
|
||||
});
|
||||
item.classList.add('visible-item');
|
||||
item.addEventListener('click', (e) => {
|
||||
// Länken pekar på den stora bilden (crawlbar/öppningsbar i ny flik),
|
||||
// men ett vanligt klick öppnar lightboxen.
|
||||
const me = e as MouseEvent;
|
||||
if (me.metaKey || me.ctrlKey || me.shiftKey || me.button === 1) return;
|
||||
e.preventDefault();
|
||||
updateVisibleItems();
|
||||
const index = visibleItems.indexOf(item);
|
||||
if (index !== -1) openLightbox(index);
|
||||
});
|
||||
});
|
||||
|
||||
closeBtn?.addEventListener('click', closeLightbox);
|
||||
nextBtn?.addEventListener('click', (e) => { e.stopPropagation(); showNext(); });
|
||||
prevBtn?.addEventListener('click', (e) => { e.stopPropagation(); showPrev(); });
|
||||
|
||||
lightbox?.addEventListener('click', (e) => {
|
||||
if (e.target === lightbox) closeLightbox();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (lightbox?.classList.contains('hidden')) return;
|
||||
if (e.key === 'Escape') closeLightbox();
|
||||
if (e.key === 'ArrowRight') showNext();
|
||||
if (e.key === 'ArrowLeft') showPrev();
|
||||
});
|
||||
|
||||
// Filter Listeners
|
||||
buttons.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const target = btn as HTMLElement;
|
||||
const type = target.dataset.type;
|
||||
const value = target.dataset.filter;
|
||||
if (!type || !value) return;
|
||||
|
||||
const sel = type === 'year' ? selectedYears : selectedMonths;
|
||||
if (value === 'all') { sel.clear(); sel.add('all'); }
|
||||
else {
|
||||
sel.delete('all');
|
||||
sel.has(value) ? sel.delete(value) : sel.add(value);
|
||||
if (sel.size === 0) sel.add('all');
|
||||
}
|
||||
document.querySelectorAll(`.filter-btn[data-type="${type}"]`).forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.filter === 'all' ? sel.has('all') : sel.has(b.dataset.filter));
|
||||
});
|
||||
filterImages();
|
||||
});
|
||||
});
|
||||
|
||||
resetBtn?.addEventListener('click', () => {
|
||||
selectedYears = new Set(['all']);
|
||||
selectedMonths = new Set(['all']);
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
document.querySelector('.filter-btn[data-filter="all"][data-type="year"]')?.classList.add('active');
|
||||
document.querySelector('.filter-btn[data-filter="all"][data-type="month"]')?.classList.add('active');
|
||||
filterImages();
|
||||
});
|
||||
</script>
|
||||
</MainLayout>
|
||||
|
||||
<style>
|
||||
@reference "../styles/global.css";
|
||||
|
||||
.protected, .protected img {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
@apply px-4 py-1.5 rounded-full border border-white/10 bg-neutral-800/50 text-sm text-gray-400 transition hover:bg-white hover:text-black hover:border-white;
|
||||
}
|
||||
@@ -220,271 +464,3 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
|
||||
@apply bg-white text-black border-white font-medium shadow-[0_0_15px_rgba(255,255,255,0.3)];
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// --- FILTER LOGIK ---
|
||||
// MULTI-select: flera ar + manader samtidigt. 'all' = samtligt.
|
||||
let selectedYears = new Set(['all']);
|
||||
let selectedMonths = new Set(['all']);
|
||||
|
||||
const items = Array.from(document.querySelectorAll('.gallery-item')) as HTMLElement[];
|
||||
const buttons = document.querySelectorAll('.filter-btn');
|
||||
const noResults = document.getElementById('no-results');
|
||||
const resetBtn = document.getElementById('reset-btn');
|
||||
|
||||
function filterImages() {
|
||||
let visibleCount = 0;
|
||||
items.forEach((el) => {
|
||||
const itemYear = el.dataset.year;
|
||||
const itemMonth = el.dataset.month;
|
||||
const matchYear = selectedYears.has('all') || selectedYears.has(itemYear);
|
||||
const matchMonth = selectedMonths.has('all') || selectedMonths.has(itemMonth);
|
||||
|
||||
if (matchYear && matchMonth) {
|
||||
el.style.display = 'block';
|
||||
el.classList.add('visible-item');
|
||||
if(el.style.opacity === '0') {
|
||||
el.animate([{ opacity: 0, transform: 'scale(0.95)' }, { opacity: 1, transform: 'scale(1)' }], { duration: 300, easing: 'ease-out' });
|
||||
}
|
||||
visibleCount++;
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
el.classList.remove('visible-item');
|
||||
}
|
||||
});
|
||||
if (noResults) noResults.style.display = visibleCount === 0 ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// --- LIGHTBOX LOGIK ---
|
||||
const lightbox = document.getElementById('lightbox');
|
||||
const lbImg = document.getElementById('lb-img') as HTMLImageElement;
|
||||
const lbDate = document.getElementById('lb-date');
|
||||
const lbModel = document.getElementById('lb-model');
|
||||
const lbFocal = document.getElementById('lb-focal');
|
||||
const lbFstop = document.getElementById('lb-fstop');
|
||||
const lbShutter = document.getElementById('lb-shutter');
|
||||
const lbIso = document.getElementById('lb-iso');
|
||||
const closeBtn = document.getElementById('lb-close');
|
||||
const nextBtn = document.getElementById('lb-next');
|
||||
const prevBtn = document.getElementById('lb-prev');
|
||||
|
||||
let currentImageIndex = 0;
|
||||
let visibleItems: HTMLElement[] = [];
|
||||
|
||||
function updateVisibleItems() {
|
||||
visibleItems = items.filter(item => item.style.display !== 'none');
|
||||
}
|
||||
|
||||
function openLightbox(index: number) {
|
||||
updateVisibleItems();
|
||||
if (visibleItems.length === 0) return;
|
||||
currentImageIndex = index;
|
||||
updateLightboxContent();
|
||||
|
||||
lightbox?.classList.remove('hidden');
|
||||
setTimeout(() => { lightbox?.classList.remove('opacity-0'); }, 10);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
// --- NEDLADDNING (Small/Medium/Large ur repot, Original via proxy) ---
|
||||
const dlBtn = document.getElementById('lb-download');
|
||||
const dlMenu = document.getElementById('lb-sizes');
|
||||
const pwModal = document.getElementById('pw-modal');
|
||||
const pwInput = document.getElementById('pw-input') as HTMLInputElement;
|
||||
const pwError = document.getElementById('pw-error');
|
||||
|
||||
// Astro hashar filnamnen, men assetId:t finns kvar i dem:
|
||||
// /_astro/immich-<assetId>-<datum>-<namn>.<hash>.jpg
|
||||
function assetIdFor(src: string): string | null {
|
||||
const m = src.match(/immich-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-/i);
|
||||
return m ? m[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
// Large ar filen galleriet redan visar. -s/-m ligger bredvid i samma katalog,
|
||||
// men med egen Astro-hash - darfor laser vi dem ur en genererad karta.
|
||||
const VARIANTS: Record<string, Record<string, string>> = JSON.parse(
|
||||
document.getElementById('dl-variants')?.textContent || '{}'
|
||||
);
|
||||
|
||||
function urlForSize(size: string): string | null {
|
||||
const item = visibleItems[currentImageIndex];
|
||||
if (!item) return null;
|
||||
const large = item.dataset.src || '';
|
||||
if (size === 'l') return large;
|
||||
const id = assetIdFor(large);
|
||||
if (!id) return null;
|
||||
if (size === 'orig') return `/download/original?asset=${id}`;
|
||||
return VARIANTS[id]?.[size] || null;
|
||||
}
|
||||
|
||||
function startDownload(url: string, filnamn?: string) {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filnamn || '';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
const dlArrow = document.getElementById('lb-dl-arrow');
|
||||
|
||||
function visaMeny(visa: boolean) {
|
||||
if (!dlMenu) return;
|
||||
// Pilen gar gra medan menyn ar oppen och tillbaka till vanligt nar den stangs
|
||||
// (bade vid val av storlek och nar man klickar bort menyn).
|
||||
dlArrow?.classList.toggle('text-gray-500', visa);
|
||||
if (visa) {
|
||||
dlMenu.classList.remove('hidden');
|
||||
requestAnimationFrame(() => dlMenu.classList.remove('opacity-0', 'translate-y-2'));
|
||||
} else {
|
||||
dlMenu.classList.add('opacity-0', 'translate-y-2');
|
||||
setTimeout(() => dlMenu.classList.add('hidden'), 180);
|
||||
}
|
||||
}
|
||||
|
||||
dlBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
visaMeny(dlMenu?.classList.contains('hidden') ?? true);
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!dlMenu || dlMenu.classList.contains('hidden')) return;
|
||||
if (!dlMenu.contains(e.target as Node) && !dlBtn?.contains(e.target as Node)) visaMeny(false);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.dl-opt').forEach((opt) => {
|
||||
opt.addEventListener('click', () => {
|
||||
const size = (opt as HTMLElement).dataset.size || 'l';
|
||||
visaMeny(false);
|
||||
const url = urlForSize(size);
|
||||
if (!url) { alert('Den storleken saknas for den har bilden.'); return; }
|
||||
if (size === 'orig') {
|
||||
// Losenordet sparas bara for fliken, aldrig i localStorage.
|
||||
const sparat = sessionStorage.getItem('dlpw');
|
||||
if (sparat) { startDownload(`${url}&pw=${encodeURIComponent(sparat)}`); return; }
|
||||
pwError?.classList.add('hidden');
|
||||
if (pwInput) pwInput.value = '';
|
||||
pwModal?.classList.remove('hidden');
|
||||
setTimeout(() => pwInput?.focus(), 50);
|
||||
} else {
|
||||
startDownload(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function forsokOriginal() {
|
||||
const pw = pwInput?.value || '';
|
||||
if (!pw) return;
|
||||
const url = urlForSize('orig');
|
||||
if (!url) return;
|
||||
// HEAD forst: da far vi 401 utan att dra igang en 70 MB-nedladdning.
|
||||
try {
|
||||
const r = await fetch(`${url}&pw=${encodeURIComponent(pw)}`, { method: 'HEAD' });
|
||||
if (r.status === 401) { pwError?.classList.remove('hidden'); return; }
|
||||
if (!r.ok) { alert('Kunde inte hamta originalet just nu.'); return; }
|
||||
} catch { /* natverksfel -> forsok anda nedan */ }
|
||||
sessionStorage.setItem('dlpw', pw);
|
||||
pwModal?.classList.add('hidden');
|
||||
startDownload(`${url}&pw=${encodeURIComponent(pw)}`);
|
||||
}
|
||||
|
||||
document.getElementById('pw-ok')?.addEventListener('click', forsokOriginal);
|
||||
pwInput?.addEventListener('keydown', (e) => { if ((e as KeyboardEvent).key === 'Enter') forsokOriginal(); });
|
||||
document.getElementById('pw-cancel')?.addEventListener('click', () => pwModal?.classList.add('hidden'));
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e as KeyboardEvent).key === 'Escape' && !dlMenu?.classList.contains('hidden')) {
|
||||
e.stopImmediatePropagation();
|
||||
visaMeny(false);
|
||||
}
|
||||
}, true);
|
||||
|
||||
function closeLightbox() {
|
||||
lightbox?.classList.add('opacity-0');
|
||||
setTimeout(() => {
|
||||
lightbox?.classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function updateLightboxContent() {
|
||||
const item = visibleItems[currentImageIndex];
|
||||
if (!item) return;
|
||||
visaMeny(false);
|
||||
|
||||
lbImg.src = item.dataset.src || '';
|
||||
|
||||
if(lbDate) lbDate.innerText = item.dataset.date || '';
|
||||
if(lbModel) lbModel.innerText = item.dataset.model || '';
|
||||
if(lbFocal) lbFocal.innerText = item.dataset.focal || '--';
|
||||
if(lbFstop) lbFstop.innerText = item.dataset.fstop || '--';
|
||||
if(lbShutter) lbShutter.innerText = item.dataset.shutter ? item.dataset.shutter + 's' : '--';
|
||||
if(lbIso) lbIso.innerText = item.dataset.iso || '--';
|
||||
}
|
||||
|
||||
function showNext() {
|
||||
currentImageIndex = (currentImageIndex + 1) % visibleItems.length;
|
||||
updateLightboxContent();
|
||||
}
|
||||
|
||||
function showPrev() {
|
||||
currentImageIndex = (currentImageIndex - 1 + visibleItems.length) % visibleItems.length;
|
||||
updateLightboxContent();
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
items.forEach((item) => {
|
||||
item.classList.add('visible-item');
|
||||
item.addEventListener('click', () => {
|
||||
updateVisibleItems();
|
||||
const index = visibleItems.indexOf(item);
|
||||
if (index !== -1) openLightbox(index);
|
||||
});
|
||||
});
|
||||
|
||||
closeBtn?.addEventListener('click', closeLightbox);
|
||||
nextBtn?.addEventListener('click', (e) => { e.stopPropagation(); showNext(); });
|
||||
prevBtn?.addEventListener('click', (e) => { e.stopPropagation(); showPrev(); });
|
||||
|
||||
lightbox?.addEventListener('click', (e) => {
|
||||
if (e.target === lightbox) closeLightbox();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (lightbox?.classList.contains('hidden')) return;
|
||||
if (e.key === 'Escape') closeLightbox();
|
||||
if (e.key === 'ArrowRight') showNext();
|
||||
if (e.key === 'ArrowLeft') showPrev();
|
||||
});
|
||||
|
||||
// Filter Listeners
|
||||
buttons.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const type = target.dataset.type;
|
||||
const value = target.dataset.filter;
|
||||
if (!type || !value) return;
|
||||
|
||||
const sel = type === 'year' ? selectedYears : selectedMonths;
|
||||
if (value === 'all') { sel.clear(); sel.add('all'); }
|
||||
else {
|
||||
sel.delete('all');
|
||||
sel.has(value) ? sel.delete(value) : sel.add(value);
|
||||
if (sel.size === 0) sel.add('all');
|
||||
}
|
||||
document.querySelectorAll(`.filter-btn[data-type="${type}"]`).forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.filter === 'all' ? sel.has('all') : sel.has(b.dataset.filter));
|
||||
});
|
||||
filterImages();
|
||||
});
|
||||
});
|
||||
|
||||
resetBtn?.addEventListener('click', () => {
|
||||
selectedYears = new Set(['all']);
|
||||
selectedMonths = new Set(['all']);
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
document.querySelector('.filter-btn[data-filter="all"][data-type="year"]')?.classList.add('active');
|
||||
document.querySelector('.filter-btn[data-filter="all"][data-type="month"]')?.classList.add('active');
|
||||
filterImages();
|
||||
});
|
||||
</script>
|
||||
@@ -2,69 +2,166 @@
|
||||
// src/pages/privacy.astro
|
||||
import MainLayout from '../layouts/MainLayout.astro';
|
||||
import WaveBackground from '../components/WaveBackground.astro';
|
||||
import T from '../components/T.astro';
|
||||
import { business } from '../data/business';
|
||||
|
||||
const link = 'text-white underline hover:text-gray-300';
|
||||
---
|
||||
|
||||
<MainLayout title="Privacy Policy | Images by Olofsson">
|
||||
<MainLayout
|
||||
title="Integritetspolicy | Images by Olofsson"
|
||||
description="Hur imagesbyolofsson.se hanterar personuppgifter: Meta Pixel endast med samtycke, anonym bildstatistik utan cookies och dina rättigheter enligt GDPR."
|
||||
path="/privacy"
|
||||
breadcrumb="Integritetspolicy"
|
||||
>
|
||||
|
||||
<WaveBackground opacity="opacity-80" />
|
||||
|
||||
<div class="container mx-auto px-4 py-12 md:py-20 relative z-10">
|
||||
<div data-trail-box class="bg-neutral-900/60 backdrop-blur-md border border-white/10 rounded-3xl p-8 md:p-12 shadow-2xl mb-20 max-w-3xl mx-auto">
|
||||
|
||||
<h1 class="text-4xl md:text-5xl font-bold leading-tight mb-2">Privacy Policy</h1>
|
||||
<p class="text-gray-500 mb-10">Last updated: 25 October 2026</p>
|
||||
<h1 class="text-4xl md:text-5xl font-bold leading-tight mb-2"><T sv="Integritetspolicy" en="Privacy policy" /></h1>
|
||||
<p class="text-gray-400 mb-10"><T sv="Senast uppdaterad: 6 november 2026" en="Last updated: 6 November 2026" /></p>
|
||||
|
||||
<div class="space-y-8 text-gray-300 text-lg leading-relaxed">
|
||||
<div class="i18n-sv space-y-8 text-gray-300 text-lg leading-relaxed">
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">Vem som är ansvarig</h2>
|
||||
<p>
|
||||
{business.brand} drivs av {business.person.name}, {business.country.name}. Frågor om integritet:
|
||||
<a href={`mailto:${business.email}`} class={link}>{business.email}</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">Den här webbplatsen</h2>
|
||||
<ul class="list-disc pl-6 space-y-2">
|
||||
<li>Det finns inga användarkonton och inga kontaktformulär på den här webbplatsen. Beställningar av posters och samarbeten sköts i den separata kundportalen.</li>
|
||||
<li>Om du mejlar mig används din adress och ditt meddelande bara för att svara dig och delas inte med någon.</li>
|
||||
<li>
|
||||
Webbplatsen levereras via Cloudflare, som behandlar tekniska uppgifter som din IP-adress för att leverera
|
||||
och skydda webbplatsen. Se <a href="https://www.cloudflare.com/privacypolicy/" target="_blank" rel="noopener noreferrer" class={link}>Cloudflares integritetspolicy</a>.
|
||||
</li>
|
||||
<li>Om du syns på någon av mina bilder och vill att den tas bort, mejla mig så tar jag bort den.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">Meta Pixel (bara med ditt samtycke)</h2>
|
||||
<p>
|
||||
Om du klickar på ”Godkänn” i cookierutan laddar webbplatsen Meta Pixel från Meta Platforms Ireland Ltd.
|
||||
Jag använder den för att se hur besökare som kommer från mitt Instagram och Facebook använder webbplatsen.
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2">
|
||||
<li>Den sätter cookies (till exempel <code>_fbp</code>) och skickar uppgifter till Meta, som vilka sidor du besöker, din IP-adress och information om din webbläsare.</li>
|
||||
<li>Meta kan koppla detta till ditt Facebook- eller Instagramkonto och kan föra över uppgifter utanför EU. Se
|
||||
<a href="https://www.facebook.com/privacy/policy/" target="_blank" rel="noopener noreferrer" class={link}>Metas integritetspolicy</a>.</li>
|
||||
<li>Om du klickar på ”Avböj” eller inte gör något laddas pixeln aldrig.</li>
|
||||
<li>Du kan ändra ditt val när som helst via ”Cookie-inställningar” längst ner på varje sida. Ditt val sparas i din egen webbläsare.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">Anonym statistik för portfolion</h2>
|
||||
<p>
|
||||
För att se vilka bilder som uppskattas räknar portfolion hur många gånger varje bild öppnas, per dag.
|
||||
Bara antalet sparas, på min egen server i Sverige. Inga cookies, ingen IP-adress, ingen webbläsarinformation och inget
|
||||
som kan identifiera dig. Det sker utan samtycke eftersom inga uppgifter om dig samlas in.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">App för säkerhetskopiering av bilder (Google)</h2>
|
||||
<p>
|
||||
”townhouse-backup” är ett privat verktyg som bara jag använder för att säkerhetskopiera mina egna bilder till mitt eget Google-konto.
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2">
|
||||
<li>Google Drive: verktyget kommer bara åt filer som det själv har skapat (en säkerhetskopia av mitt bildarkiv).</li>
|
||||
<li>Google Foto: verktyget laddar bara upp mina bilder och lägger dem i album som det själv har skapat; det kan inte se något annat i biblioteket.</li>
|
||||
<li>Inga uppgifter om någon annan samlas in. Ingenting säljs, delas med tredje part, används för reklam eller används för att träna AI-modeller.</li>
|
||||
<li>
|
||||
Verktygets användning av information från Googles API:er följer
|
||||
<a href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank" rel="noopener noreferrer" class={link}>Google API Services User Data Policy</a>,
|
||||
inklusive kraven på begränsad användning (Limited Use).
|
||||
</li>
|
||||
</ul>
|
||||
<p lang="en" class="text-base text-gray-400">
|
||||
In English: townhouse-backup's use of information received from Google APIs adheres to the
|
||||
<a href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank" rel="noopener noreferrer" class={link}>Google API Services User Data Policy</a>,
|
||||
including the Limited Use requirements.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">Dina rättigheter</h2>
|
||||
<p>
|
||||
Enligt GDPR kan du fråga vilka personuppgifter jag har om dig och få dem rättade eller raderade – mejla mig bara.
|
||||
Du kan också klaga hos Integritetsskyddsmyndigheten
|
||||
(<a href="https://www.imy.se/" target="_blank" rel="noopener noreferrer" class={link}>IMY</a>).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="i18n-en space-y-8 text-gray-300 text-lg leading-relaxed" lang="en">
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">Who is responsible</h2>
|
||||
<p>
|
||||
Images by Olofsson is run by Linus Olofsson, Sweden. Questions about privacy:
|
||||
<a href="mailto:Admin@imagesbyolofsson.se" class="text-white underline hover:text-gray-300">Admin@imagesbyolofsson.se</a>.
|
||||
{business.brand} is run by {business.person.name}, Sweden. Questions about privacy:
|
||||
<a href={`mailto:${business.email}`} class={link}>{business.email}</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">This website</h2>
|
||||
<ul class="list-disc pl-6 space-y-2">
|
||||
<li>There are no user accounts and no contact forms.</li>
|
||||
<li>If you email me, your address and message are used only to answer you and are not shared with anyone.</li>
|
||||
<li>Password-protected galleries keep the password only in your open browser tab; it is not stored after you close it.</li>
|
||||
<li>There are no user accounts and no contact forms on this website. Poster orders and collaborations are handled in the separate customer portal.</li>
|
||||
<li>If you e-mail me, your address and message are only used to answer you and are not shared with anyone.</li>
|
||||
<li>
|
||||
The site is delivered through Cloudflare, which processes technical data such as your IP address to deliver
|
||||
and protect the site. See <a href="https://www.cloudflare.com/privacypolicy/" target="_blank" rel="noopener noreferrer" class="text-white underline hover:text-gray-300">Cloudflare's privacy policy</a>.
|
||||
The website is delivered through Cloudflare, which processes technical data such as your IP address to deliver
|
||||
and protect the website. See <a href="https://www.cloudflare.com/privacypolicy/" target="_blank" rel="noopener noreferrer" class={link}>Cloudflare's privacy policy</a>.
|
||||
</li>
|
||||
<li>If you appear in one of my photos and want it removed, email me and I will take it down.</li>
|
||||
<li>If you appear in one of my photos and want it removed, e-mail me and I will remove it.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">Meta Pixel (only with your consent)</h2>
|
||||
<p>
|
||||
If you click "Accept" in the cookie box, the site loads the Meta Pixel from Meta Platforms Ireland Ltd.
|
||||
I use it to see how visitors coming from my Instagram and Facebook use the site.
|
||||
If you click "Accept" in the cookie box, the website loads the Meta Pixel from Meta Platforms Ireland Ltd.
|
||||
I use it to see how visitors coming from my Instagram and Facebook use the website.
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2">
|
||||
<li>It sets cookies (such as <code>_fbp</code>) and sends Meta information like the pages you visit, your IP address and browser details.</li>
|
||||
<li>It sets cookies (for example <code>_fbp</code>) and sends data to Meta, such as the pages you visit, your IP address and information about your browser.</li>
|
||||
<li>Meta may link this to your Facebook or Instagram account and may transfer data outside the EU. See
|
||||
<a href="https://www.facebook.com/privacy/policy/" target="_blank" rel="noopener noreferrer" class="text-white underline hover:text-gray-300">Meta's privacy policy</a>.</li>
|
||||
<a href="https://www.facebook.com/privacy/policy/" target="_blank" rel="noopener noreferrer" class={link}>Meta's privacy policy</a>.</li>
|
||||
<li>If you click "Decline" or do nothing, the pixel is never loaded.</li>
|
||||
<li>You can change your choice at any time via "Cookie settings" at the bottom of every page. Your choice is stored in your own browser.</li>
|
||||
<li>You can change your choice at any time through "Cookie settings" at the bottom of every page. Your choice is stored in your own browser.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">Anonymous statistics for the portfolio</h2>
|
||||
<p>
|
||||
To see which photos are appreciated, the portfolio counts how many times each photo is opened, per day.
|
||||
Only the count is stored, on my own server in Sweden. No cookies, no IP address, no browser information and nothing
|
||||
that can identify you. This happens without consent because no data about you is collected.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">Photo backup app (Google)</h2>
|
||||
<p>
|
||||
"townhouse-backup" is a private tool used only by me to back up my own photos to my own Google account.
|
||||
"townhouse-backup" is a private tool that only I use to back up my own photos to my own Google account.
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2">
|
||||
<li>Google Drive: it only accesses files it has created itself (a backup copy of my photo archive).</li>
|
||||
<li>Google Photos: it only uploads my photos and adds them to albums it created; it cannot see anything else in the library.</li>
|
||||
<li>No data from anyone else is collected. Nothing is sold, shared with third parties, used for advertising or used to train AI models.</li>
|
||||
<li>Google Drive: the tool only accesses files it has created itself (a backup of my photo archive).</li>
|
||||
<li>Google Photos: the tool only uploads my photos and puts them in albums it has created itself; it cannot see anything else in the library.</li>
|
||||
<li>No data about anyone else is collected. Nothing is sold, shared with third parties, used for advertising or used to train AI models.</li>
|
||||
<li>
|
||||
Its use of information received from Google APIs adheres to the
|
||||
<a href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank" rel="noopener noreferrer" class="text-white underline hover:text-gray-300">Google API Services User Data Policy</a>,
|
||||
townhouse-backup's use of information received from Google APIs adheres to the
|
||||
<a href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank" rel="noopener noreferrer" class={link}>Google API Services User Data Policy</a>,
|
||||
including the Limited Use requirements.
|
||||
</li>
|
||||
</ul>
|
||||
@@ -73,13 +170,14 @@ import WaveBackground from '../components/WaveBackground.astro';
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">Your rights</h2>
|
||||
<p>
|
||||
Under the GDPR you can ask what personal data I hold about you, and have it corrected or deleted — just email me.
|
||||
Under the GDPR you can ask what personal data I hold about you and have it corrected or deleted – just e-mail me.
|
||||
You can also complain to the Swedish Authority for Privacy Protection
|
||||
(<a href="https://www.imy.se/" target="_blank" rel="noopener noreferrer" class="text-white underline hover:text-gray-300">IMY</a>).
|
||||
(<a href="https://www.imy.se/" target="_blank" rel="noopener noreferrer" class={link}>IMY</a>).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// src/pages/sitemap-0.xml.ts -> /sitemap-0.xml
|
||||
// Listar sajtens riktiga sidor (inte 404). Ingen <lastmod>: vi har inget
|
||||
// pålitligt ändringsdatum per sida, och ett påhittat datum gör mer skada än nytta.
|
||||
import type { APIRoute } from 'astro';
|
||||
import { sitePaths } from '../lib/pages';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
const escapeXml = (s: string) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
const base = site ?? new URL('https://imagesbyolofsson.se');
|
||||
const urls = sitePaths()
|
||||
.map((p) => ` <url><loc>${escapeXml(new URL(p, base).toString())}</loc></url>`)
|
||||
.join('\n');
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls}
|
||||
</urlset>
|
||||
`;
|
||||
return new Response(body, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } });
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
// src/pages/sitemap-index.xml.ts -> /sitemap-index.xml
|
||||
// Egen sitemap utan @astrojs/sitemap (inga nya beroenden).
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
const base = site ?? new URL('https://imagesbyolofsson.se');
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<sitemap><loc>${new URL('/sitemap-0.xml', base).toString()}</loc></sitemap>
|
||||
<sitemap><loc>${new URL('/posters/sitemap.xml', base).toString()}</loc></sitemap>
|
||||
</sitemapindex>
|
||||
`;
|
||||
return new Response(body, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } });
|
||||
};
|
||||