Author SHA1 Message Date
Linus OlofssonandClaude Opus 5.5 63baa35b92 Start page, portfolio, privacy and 404: Linus's list of 2026-11-06, and Swedish/English on every page
- Start page: the text block ("Fotograf Linus Olofsson ..." and "Posters och samarbeten ...") is gone; the buttons
  Posters, Samarbeten, Mer om mig, Portfolio and Kontakt sit at the bottom as long, straight buttons (no round pills, no
  text links). The second scroll box is Linus's own from May again (Redo att samarbeta? -> Kontakta mig) instead of
  "Vill du ha en bild på väggen?".
- Portfolio: "Vill du ha en bild på väggen? Se posters." and every "Köp som poster" button are gone. Photos are
  protected like on Posters: no link to the large file (nothing to open in a new tab), a transparent layer over every
  photo, no context menu, no drag, no long-press save.
- SV/EN: SV/EN switch in the menu; every visible text in both languages (menu, footer, cookie box, start page,
  portfolio with dates and months, privacy page, 404). The choice uses the portal's key (ibo_lang), so all pages on
  imagesbyolofsson.se follow the same choice; it is set before the page is drawn.
- Privacy: the statistics count openings only (there are no downloads any more).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 06:22:52 +01:00
imagesbyolofsson 63a4b3a848 Merge pull request 'Meny: Portfolio, Posters, Om mig, Kontakt – bort med Tjänster och all bokningstext' (#4) from inga-tjanster-2026-11-06 into main
Reviewed-on: #4
2026-11-06 04:42:11 +00:00
Linus OlofssonandClaude Opus 5.5 c760e11c4c No services: /tjanster, booking steps, offers, galleries and downloads removed everywhere
Linus 2026-11-06: he offers no services; the site has posters and collaborations only. /tjanster went live with the
move to Cleura (it came with the SEO pull request #1) and was redirected to the start page in Caddy the same morning.

- tjanster.astro deleted; Tjänster out of the menu and the 404 page.
- Start page: "Vill du boka en fotografering?" -> "Vill du ha en bild på väggen?" (Se posters); "Så bokar du" ->
  "Posters och samarbeten"; description without "Boka via kundportalen".
- Portfolio intro points to the posters; privacy page: orders of posters and collaborations instead of bookings.
- JSON-LD: Organization instead of ProfessionalService, no ReserveAction, offer catalog, price range or service area.
- llms.txt: no prices, booking, booking steps, services page or booking FAQ; faq.ts keeps only the type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 05:31:20 +01:00
imagesbyolofsson d841cf98b5 chore(portfolio): sync 2026-11-06 2026-11-06 04:23:58 +00:00
imagesbyolofsson 5a5b2b42d2 Merge pull request 'Portfoliosynk: 13 bilder som fastnat sedan 29/9 kommer med; en trasig bild stoppar inte de andra' (#3) from portfoliosynk-fix-2026-11-06 into main
Reviewed-on: #3
2026-11-06 04:22:38 +00:00
Linus OlofssonandClaude Opus 5.5 24a59c6e93 Portfolio sync: a photo that fails no longer stops the others; sharp accepts pixel-shift and 15000 px squares
Seen on the first run on Cleura 2026-11-06: 13 photos tagged since 2026-09-29 were never synced. portfolio-sync.mjs
sets exit code 1 when one photo fails (DSC00342_PSMS16-square.jpg: "Input image exceeds pixel limit", sharp's default
268 MP) and set -e then stopped before commit/push - on Nurem as well. Now the photos that worked are committed and
built, the run still ends with exit 1 so systemd shows it, and sharp's limit is 1 000 MP (JPEGs shrink while decoding;
the container gets 3 GB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 05:18:14 +01:00
imagesbyolofsson 8c9afb65c3 Merge pull request 'En domän: portfolion utan nedladdning, Om mig/Kontakt/Posters från portalen, portfoliosynk på Cleura' (#2) from ett-domannamn-2026-11-06 into main
Reviewed-on: #2
2026-11-06 04:07:25 +00:00
Linus OlofssonandClaude Opus 5.5 dc2bda8c07 Portfolio sync: Gitea token stays out of .git/config and out of the Node container
Security review: GIT_URL (user:token@) was stored as the clone's remote URL, and the repo, .git included, was mounted
into the container that runs npm install. Now: umask 077 + 700 directories, the remote is the clean URL, git gets the
token from a credential helper at fetch/push time, and the container sees an empty tmpfs over .git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:45:09 +01:00
Linus OlofssonandClaude Opus 5.5 6bb56e6035 Portfolio sync runs on the Cleura server (systemd timer every 20 min) instead of n8n -> Nurem
deploy/cleura/portfolio-sync.sh: same steps as /opt/immich-sync/run-sync.sh on Nurem (fetch main, portfolio-sync.mjs,
commit + push when photos changed) and then build-live.sh main on Cleura; flock against overlapping runs, the sync
runs in the pinned Node container. install-portfolio-sync.sh puts it in place; secrets stay in
/etc/portal/portfolio-sync.env (root 600). The snippet comment no longer mentions the removed /download/original.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:43:33 +01:00
Linus OlofssonandClaude Opus 5.5 1b24607e02 Portfolio without downloads; Om mig, Kontakt and Posters come from the portal on the same domain
Linus 2026-11-06: everything public lives on imagesbyolofsson.se (Cleura), the customer
subdomain is only for ordering and the logged-in portal; photos are sold as posters
instead of being downloadable.

- Portfolio: download menu (S/M/L), the original password dialog and /download/original
  are gone; the lightbox has "Köp som poster" -> /posters instead.
- about.astro and contact.astro removed: Caddy on Cleura sends /about, /contact and
  /posters to the portal, so the About text and collaborations are edited once in admin.
  They stay in sitemap-0.xml (PORTAL_PAGES); the sitemap index lists /posters/sitemap.xml.
- Menu and 404 page link Posters; privacy, robots.txt and llms.txt no longer mention
  password downloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:08:04 +01:00
imagesbyolofsson 85bd49b0c3 Merge pull request 'SEO & AEO: everything from the PDF review (robots, sitemap, 404, meta/OG, JSON-LD, Swedish text, /tjanster, EXIF clean, alt texts, Admin@, Örebro)' (#1) from seo-aeo-2026-10-27 into main
Reviewed-on: #1
2026-10-27 23:45:31 +00:00
Linus OlofssonandClaude Opus 5.5 7207e351d4 Portfolio alt texts: what each photo shows (147/147, Swedish, no names/places/camera), written with Cleura gemma-4 vision and checked by eye
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvE3C43Uwtv2vtnoFBLDw7
2026-10-27 23:27:17 +01:00
Linus OlofssonandClaude Opus 5.5 03a70efcb6 Verified facts: Örebro / Örebro län (his public Flickr + LinkedIn) and the Facebook page in sameAs and llms.txt
Phone, prices, packages, delivery time and years stay null until Linus confirms them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvE3C43Uwtv2vtnoFBLDw7
2026-10-27 23:14:55 +01:00
Linus OlofssonandClaude Opus 5.5 15560445ce Contact e-mail: Admin@imagesbyolofsson.se everywhere (Linus 2026-10-27), as on the old contact page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvE3C43Uwtv2vtnoFBLDw7
2026-10-27 23:08:40 +01:00
Linus OlofssonandClaude Opus 5.5 1e921dfe37 fix(images): stop publishing EXIF/GPS originals, responsive images, crawlable gallery
- Homepage images: <Picture> from src/assets (AVIF/WebP/JPG srcset, width/
  height, first image eager + fetchpriority high, rest lazy), Swedish alt
  texts. (The duplicate EXIF-laden copies in public/3d-scroll were deleted
  in 30e385e; README links now point at src/assets.)
- Portfolio: originals are no longer referenced (no .src access), so Astro
  does not copy them to dist/. Small/Medium/Large downloads and the lightbox
  srcset are EXIF-free JPGs from getImage. Old -s/-m files are no longer
  imported.
- Alt texts: Swedish fallback from date (EXIF or filename), never camera
  model or "undefined"; portfolio-meta.ts overrides per image.
- Gallery items are <a href> to the large image (lightbox on click);
  download options are buttons; no empty H3, no H4 for the password box.
- Stats beacon unchanged; item keys computed at build time (same format).
- Script moved inside the layout so it is no longer rendered after </html>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvE3C43Uwtv2vtnoFBLDw7
2026-10-27 19:13:24 +01:00
Linus OlofssonandClaude Opus 5.5 4f1b468f75 feat(server): redirects, real 404, caching and security headers (nginx + Caddy)
- www -> apex 301, old URLs (/my-people, /animales-and-nature,
  /celebrations-with-family, /northen-lights) -> /portfolio 301,
  trailing slash and .html -> clean URL 301.
- try_files $uri $uri.html, otherwise 404.html with status 404.
- /_astro/*: public, max-age=31536000, immutable (path-based map, no
  duplicate Cache-Control headers); HTML must-revalidate.
- HSTS, Referrer-Policy, Permissions-Policy, X-Frame-Options, CSP that
  allows the Meta Pixel and the stats beacon to customer.imagesbyolofsson.se.
- deploy/caddy-site.snippet: the same rules as importable Caddy snippets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvE3C43Uwtv2vtnoFBLDw7
2026-10-27 19:13:07 +01:00
Linus OlofssonandClaude Opus 5.5 206d31d9c2 feat(content): Swedish copy, new /tjanster page with booking flow and FAQ
- New /tjanster: genres from existing copy, where/price (contact for offer
  while unknown), booking flow via customer portal, delivery in a private
  gallery, originals after payment, FAQ (also emitted as FAQPage).
- Homepage: tagline + text section on who/what/where/how to book.
- About: Swedish, photographer framing instead of "by day/by night",
  Adobe line. Contact: Swedish, plain mailto info@, booking CTA, fixed
  unclosed div. Privacy: Swedish (Google Limited Use line kept in English).
- Unique Swedish title and description per page; scripts moved inside the
  layout (no <script> after </html>).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvE3C43Uwtv2vtnoFBLDw7
2026-10-27 19:13:07 +01:00
Linus OlofssonandClaude Opus 5.5 f452d6d237 feat(seo): site config, sitemap, robots.txt, llms.txt and a real 404 page
- astro.config: site, trailingSlash "never", build.format "file".
- /sitemap-index.xml + /sitemap-0.xml as prerendered endpoints (no new
  dependency); pages are derived from src/pages/*.astro, 404 excluded.
- robots.txt: all bots allowed, AI crawlers/training bots explicitly
  allowed (Linus's decision), /download/ disallowed, sitemap line.
- /llms.txt generated from business.ts, incl. usage-with-attribution note.
- 404.astro in Swedish with links back, noindex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvE3C43Uwtv2vtnoFBLDw7
2026-10-27 19:13:07 +01:00
Linus OlofssonandClaude Opus 5.5 30e385ec7f feat(seo): facts in src/data/business.ts, SEO + JSON-LD components in layout
- business.ts: single source of truth; unknown facts (city, area, prices,
  packages, phone, delivery time, experience, testimonials) are null and are
  skipped everywhere. faq.ts builds answers only from known facts.
- portfolio-meta.ts: data hook for real per-image alt texts.
- SEO.astro: title, description, canonical, Open Graph, Twitter, og:image
  (EXIF-free JPG via getImage, absolute URL).
- SchemaOrg.astro: @graph with WebSite, Person, ProfessionalService
  (sameAs, ReserveAction to the customer portal, offer catalog without
  prices), WebPage, optional FAQPage and BreadcrumbList.
- MainLayout: lang=sv, Swedish nav (adds Tjänster), consent banner and
  footer in Swedish, lyxlimo/karleksguiden line removed, info@ address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvE3C43Uwtv2vtnoFBLDw7
2026-10-27 19:13:07 +01:00
Claude (for Linus) ce5676f552 Anonymous portfolio statistics (opens/downloads per image, cookieless) + privacy text
Counts go to customer.imagesbyolofsson.se/api/stats/hit via sendBeacon. No cookie, IP or id stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:41:59 +01:00
47 changed files with 2233 additions and 808 deletions
+3 -3
View File
@@ -8,15 +8,15 @@ A modern, responsive photography portfolio website built with Astro, Tailwind CS
<table> <table>
<tr> <tr>
<td align="center"> <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> <br><em> More </em>
</td> </td>
<td align="center"> <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> <br><em> At </em>
</td> </td>
<td align="center"> <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> <br><em>Imagesbyolofsson.se</em>
</td> </td>
</tr> </tr>
+9
View File
@@ -2,6 +2,15 @@ import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
export default defineConfig({ 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: { vite: {
plugins: [tailwindcss()], plugins: [tailwindcss()],
}, },
+99
View File
@@ -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
}
}
}
}
+15
View File
@@ -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
+11
View File
@@ -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
+78
View File
@@ -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
+10
View File
@@ -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
+69 -25
View File
@@ -1,11 +1,20 @@
map $sent_http_content_type $cache_control_strategy { # nginx för imagesbyolofsson.se (Docker-image: dist/ i /usr/share/nginx/html).
default "public, max-age=2592000"; # Bygget använder build.format "file": /about -> about.html, 404 -> 404.html.
~^text/html "no-store, must-revalidate"; # Samma regler som Caddy-varianten i deploy/caddy-site.snippet.
~^text/css "public, max-age=31536000, immutable";
~^application/javascript "public, max-age=31536000, immutable"; # Cache-Control per sökväg (inte per Content-Type, så att /_astro/*.jpg också blir immutable).
~^application/json "no-store, must-revalidate"; map $uri $ibo_cache_control {
~^image/ "public, max-age=2592000"; default "public, max-age=0, must-revalidate";
~^font/ "public, max-age=31536000, immutable"; ~^/_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 { server {
@@ -14,33 +23,68 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
add_header Cache-Control $cache_control_strategy 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 / { location / {
try_files $uri $uri/ $uri.html $uri/index.html =404; try_files $uri $uri.html =404;
} }
location /_astro/ { # Riktig 404: 404.html med status 404 (inte startsidan med 200).
access_log off; error_page 404 /404.html;
expires 1y; location = /404.html {
} internal;
location ~* \.(webp|avif|jpg|jpeg|png|gif|svg|ico|woff2?|ttf|otf|eot)$ {
access_log off;
expires 30d;
} }
gzip on; gzip on;
gzip_vary on; gzip_vary on;
gzip_min_length 1024; gzip_min_length 1024;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss image/svg+xml; gzip_types text/plain text/css text/xml application/xml application/json application/javascript application/xml+rss image/svg+xml;
error_page 404 /404.html;
location = /404.html {
internal;
}
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 542 KiB

+38
View File
@@ -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
+3 -1
View File
@@ -77,7 +77,9 @@ function destName(asset) {
async function downloadAndResize(asset, destPath) { async function downloadAndResize(asset, destPath) {
const res = await api("GET", `/assets/${asset.id}/original`); const res = await api("GET", `/assets/${asset.id}/original`);
const buf = Buffer.from(await res.arrayBuffer()); 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 .rotate() // respektera EXIF-orientering
.resize(MAX_DIM, MAX_DIM, { fit: "inside", withoutEnlargement: true }) .resize(MAX_DIM, MAX_DIM, { fit: "inside", withoutEnlargement: true })
.withMetadata() // BEVARA EXIF (kritiskt för portfolions datum/kamera) .withMetadata() // BEVARA EXIF (kritiskt för portfolions datum/kamera)
Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 949 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 KiB

+46
View File
@@ -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} />
+133
View File
@@ -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} />
+12
View File
@@ -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>
+26 -6
View File
@@ -1,13 +1,22 @@
--- ---
// src/components/ThreeDScroll.astro // 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 = [ const images = [
{ src: "/3d-scroll/img1.jpg", alt: "Architecture Dark" }, { src: img1, alt: 'Närbild av en gammal läderinbunden bok med slitna pappersark i varmt motljus' },
{ src: "/3d-scroll/img2.jpg", alt: "Shadows Light" }, { src: img2, alt: 'Veteranbil med tända strålkastare på en stadsgata i kvällsljus' },
{ src: "/3d-scroll/img3.jpg", alt: "Portrait High End" }, { src: img3, alt: 'Överblommade tistlar bland höga grässtrån i dämpade bruna toner' },
{ src: "/3d-scroll/img4.jpg", alt: "Minimalist Landscape" }, { src: img4, alt: 'Järnvägsspår som slingrar sig mellan träd i motljus, i sepiaton' },
{ src: "/3d-scroll/img5.jpg", alt: "Foggy Forest" }, { 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"> <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" class="gallery-item absolute transform-style-3d will-change-transform flex items-center justify-center"
data-index={index} data-index={index}
> >
<img {/* 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} src={img.src}
alt={img.alt} 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" 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> </div>
+183
View File
@@ -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();
}
+5
View File
@@ -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 };
+616
View File
@@ -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",
},
};
+145 -24
View File
@@ -1,60 +1,181 @@
--- ---
import "../styles/global.css"; 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 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="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/favicon-512.png" /> <link rel="icon" type="image/png" sizes="512x512" href="/favicon-512.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon-180.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> </head>
<body class="bg-neutral-900 text-white font-sans antialiased min-h-screen flex flex-col"> <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"> <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 justify-between items-center"> <div class="container mx-auto px-4 flex flex-wrap gap-y-2 justify-between items-center">
<a href="/" class="text-2xl font-bold tracking-wider uppercase hover:text-gray-300 transition relative z-50"> <a href="/" class="text-lg md:text-2xl font-bold tracking-wider uppercase hover:text-gray-300 transition relative z-50">
Images by Olofsson Images by Olofsson
</a> </a>
<ul class="flex space-x-6 text-sm font-medium text-gray-400"> <div class="flex items-center gap-4 md:gap-6">
<li><a href="/portfolio" class="hover:text-white transition">Portfolio</a></li> <ul class="flex space-x-4 md:space-x-6 text-sm font-medium text-gray-300">
<li><a href="/about" class="hover:text-white transition">About</a></li> {nav.map((item) => (
<li><a href="/contact" class="hover:text-white transition">Contact</a></li> <li><a href={item.href} class="hover:text-white transition"><T sv={item.sv} en={item.en} /></a></li>
))}
</ul> </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> </div>
</nav> </nav>
<main class="flex-grow pt-24"> <main class="flex-grow pt-28 md:pt-24">
<slot /> <slot />
</main> </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"> <p class="mb-2">
Crafting digital experiences for {business.brand} – <T sv={`fotograf ${business.person.name}, Sverige`} en={`photographer ${business.person.name}, Sweden`} />
<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> &middot; <a href={`mailto:${business.email}`} class="text-gray-300 hover:text-white transition-colors">{business.email}</a>
&amp; &middot; <a href={business.social.instagram.url} target="_blank" rel="noopener noreferrer" class="text-gray-300 hover:text-white transition-colors">Instagram</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>
</p> </p>
<p>&copy; {new Date().getFullYear()} Images by Olofsson. All rights reserved. &middot; <a href="/privacy" class="text-gray-400 hover:text-white transition-colors">Privacy</a> &middot; <button type="button" data-consent-open class="text-gray-400 hover:text-white transition-colors">Cookie settings</button></p> <p>&copy; {new Date().getFullYear()} {business.brand}. <T sv="Alla rättigheter förbehållna." en="All rights reserved." /> &middot; <a href="/privacy" class="text-gray-300 hover:text-white transition-colors"><T sv="Integritetspolicy" en="Privacy policy" /></a> &middot; <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> </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"> <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"> <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. <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."
<a href="/privacy" class="text-white underline hover:text-gray-300">Read more</a> 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> </p>
<div class="flex gap-3 justify-end"> <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="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">Accept</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>
</div> </div>
<script is:inline> <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 () { (function () {
var KEY = 'ibo-consent'; var KEY = 'ibo-consent';
var banner = document.querySelector('[data-consent-banner]'); var banner = document.querySelector('[data-consent-banner]');
@@ -78,7 +199,7 @@ const { title } = Astro.props;
/* End Meta Pixel Code */ /* End Meta Pixel Code */
} }
// Aterkallat samtycke: ta bort Metas forsta-parts-cookies. // Återkallat samtycke: ta bort Metas förstapartscookies.
function clearMetaCookies() { function clearMetaCookies() {
['_fbp', '_fbc'].forEach(function (name) { ['_fbp', '_fbc'].forEach(function (name) {
document.cookie = name + '=; Max-Age=0; path=/'; document.cookie = name + '=; Max-Age=0; path=/';
+18
View File
@@ -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)));
}
+8
View File
@@ -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 || '/';
}
+36
View File
@@ -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>
-127
View File
@@ -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>
-128
View File
@@ -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>
+114 -104
View File
@@ -3,9 +3,23 @@
import MainLayout from '../layouts/MainLayout.astro'; import MainLayout from '../layouts/MainLayout.astro';
import ThreeDScroll from '../components/ThreeDScroll.astro'; import ThreeDScroll from '../components/ThreeDScroll.astro';
import WaveBackground from '../components/WaveBackground.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;"> <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" /> <WaveBackground opacity="opacity-100" theme="cyan" />
@@ -38,7 +52,8 @@ import WaveBackground from '../components/WaveBackground.astro';
</div> </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>
</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 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"> <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"> <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> </a>
</div> </div>
<div class="flex flex-row md:flex-col gap-6 items-center justify-center"> <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"> <svg class="outline-ring" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="48" /> <circle cx="50" cy="50" r="48" />
</svg> </svg>
@@ -69,7 +84,7 @@ import WaveBackground from '../components/WaveBackground.astro';
</svg> </svg>
</div> </div>
</a> </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"> <svg class="outline-ring" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="48" /> <circle cx="50" cy="50" r="48" />
</svg> </svg>
@@ -80,15 +95,106 @@ import WaveBackground from '../components/WaveBackground.astro';
</div> </div>
<div data-trail-box class="flex flex-col items-center px-6 py-4 rounded-2xl"> <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"> <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> </a>
</div> </div>
</div> </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> </MainLayout>
<style> <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; } .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; } .circle-btn:hover .outline-ring circle { stroke-dashoffset: 0; }
</style> </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>
+53
View File
@@ -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' } });
};
+192 -216
View File
@@ -1,31 +1,33 @@
--- ---
// src/pages/portfolio.astro // src/pages/portfolio.astro
import MainLayout from '../layouts/MainLayout.astro'; import MainLayout from '../layouts/MainLayout.astro';
import { Image } from 'astro:assets'; import { Image, getImage } from 'astro:assets';
import exifr from 'exifr'; import exifr from 'exifr';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import WaveBackground from '../components/WaveBackground.astro'; 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 // 1. Hämta alla galleribilder.
const allImageFiles = import.meta.glob('../assets/images/portfolio/*.{jpeg,jpg,png,webp,JPG,JPEG,PNG}', { eager: true }); // -s (800px) och -m (1600px) i mappen är gamla nedladdningsvarianter med EXIF kvar.
// -s (800px) och -m (1600px) ar NEDLADDNINGSVARIANTER, inte egna galleribilder. // De importeras INTE längre (då hamnar de inte i dist/); nedladdningsstorlekarna
// Utan filtret visas varje bild tre ganger. // tas i stället fram av Astros bildtjänst nedan, vilket också rensar bort EXIF
const imageFiles = Object.fromEntries( // (GPS, kamerans serienummer, Artist m.m.).
Object.entries(allImageFiles).filter(([pathKey]) => !/-[sm]\.(jpe?g|png|webp)$/i.test(pathKey)) 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 // OBS: läs aldrig .src/.width/.height direkt på de importerade bilderna här.
// till ratt nedladdningsvariant. Large ar galleribilden sjalv, original gar via proxy. // Då räknar Astro originalet som "använt" och kopierar originalfilen (med EXIF)
const downloadVariants: Record<string, Record<string, string>> = {}; // till dist/. Gå alltid via <Image>/getImage.
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;
}
function formatShutterSpeed(exposureTime: number) { function formatShutterSpeed(exposureTime: number) {
if (!exposureTime) return null; if (!exposureTime) return null;
@@ -34,40 +36,78 @@ function formatShutterSpeed(exposureTime: number) {
return `1/${fraction}`; 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]) => { const galleryItems = await Promise.all(Object.entries(imageFiles).map(async ([relativePath, file]: [string, any]) => {
let exifData: any = {}; let exifData: any = {};
let dateTaken = null; let dateTaken: Date | null = null;
const absolutePath = path.resolve('./src/pages', relativePath); 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 { try {
const fileBuffer = fs.readFileSync(absolutePath); const fileBuffer = fs.readFileSync(absolutePath);
exifData = await exifr.parse(fileBuffer, [ exifData = await exifr.parse(fileBuffer, [
'ISO', 'Model', 'FNumber', 'ExposureTime', 'FocalLength', 'DateTimeOriginal', 'CreateDate' 'ISO', 'Model', 'FNumber', 'ExposureTime', 'FocalLength', 'DateTimeOriginal', 'CreateDate'
]); ]);
dateTaken = exifData?.DateTimeOriginal || exifData?.CreateDate; const exifDate = exifData?.DateTimeOriginal || exifData?.CreateDate;
if (!dateTaken) dateTaken = new Date(); if (exifDate) dateTaken = new Date(exifDate);
} catch (e) { } catch (e) {
console.error(`Kunde inte läsa EXIF för ${relativePath}`); 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 year = dateObj.getFullYear().toString();
const month = dateObj.toLocaleString('sv-SE', { month: 'long' }).toLowerCase(); const month = dateObj.toLocaleString('sv-SE', { month: 'long' }).toLowerCase();
const monthIndex = dateObj.getMonth(); const monthIndex = dateObj.getMonth();
const fullDate = dateObj.toLocaleDateString('sv-SE', { const fullDate = hasDate
day: 'numeric', month: 'long', year: 'numeric' ? 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 shutterSpeed = formatShutterSpeed(exifData?.ExposureTime);
const focalLength = exifData?.FocalLength ? `${Math.round(exifData.FocalLength)}mm` : null; const focalLength = exifData?.FocalLength ? `${Math.round(exifData.FocalLength)}mm` : null;
const fStop = exifData?.FNumber ? `f/${exifData.FNumber}` : null; const fStop = exifData?.FNumber ? `f/${exifData.FNumber}` : null;
const iso = exifData?.ISO ? `ISO ${exifData.ISO}` : 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 { return {
...file, year, month, monthIndex, fullDate, model: exifData?.Model, img: file.default, key, alt, title: meta.title ?? null,
fStop, shutterSpeed, iso, focalLength 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 allMonths = galleryItems.map((item: any) => ({ name: item.month, index: item.monthIndex }));
const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).values()] const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).values()]
.sort((a: any, b: any) => a.index - b.index) .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> <div>
<WaveBackground opacity="opacity-55" /> <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"> <div class="text-center mb-12">
<h1 class="text-4xl md:text-6xl font-bold mb-4">Portfolio</h1> <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>
<div class="flex flex-col items-center gap-6 mb-12"> <div class="flex flex-col items-center gap-6 mb-12">
<div class="flex flex-wrap justify-center gap-2"> <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 => ( {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>
<div class="flex flex-wrap justify-center gap-2"> <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 => ( {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>
</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"> <div class="columns-1 md:columns-2 lg:columns-3 gap-6 space-y-6" id="gallery-grid">
{galleryItems.map((item: any) => ( {galleryItems.map((item: any, index: number) => (
<div <div
class="gallery-item break-inside-avoid relative group overflow-hidden rounded-lg cursor-pointer bg-neutral-800 shadow-md" 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-year={item.year}
data-month={item.month} 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-model={item.model}
data-iso={item.iso} data-iso={item.iso}
data-fstop={item.fStop} data-fstop={item.fStop}
data-shutter={item.shutterSpeed} data-shutter={item.shutterSpeed}
data-focal={item.focalLength} data-focal={item.focalLength}
data-date={item.fullDate} data-date={item.fullDate}
data-date-en={item.fullDateEn}
> >
<Image <Image
src={item.default} src={item.img}
alt={`Foto taget med ${item.model}`} alt={item.alt}
width={800} width={800}
class="w-full h-auto object-cover transform transition duration-700 group-hover:scale-105" 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"
/> />
<!-- 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 transition duration-300 flex flex-col justify-end p-6 pointer-events-none"> <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">
<p class="text-white font-bold text-lg">{item.fullDate}</p> {item.fullDate && <p class="text-white font-bold text-lg"><T sv={item.fullDate} en={item.fullDateEn} /></p>}
<p class="text-xs text-gray-400 uppercase tracking-widest mt-1">Visa bild</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>
))} ))}
</div> </div>
<div id="no-results" class="hidden text-center text-gray-500 mt-20"> <div id="no-results" class="hidden text-center text-gray-400 mt-20">
<p class="text-xl">Inga bilder hittades för denna tidpunkt.</p> <p class="text-xl"><T sv="Inga bilder hittades för denna tidpunkt." en="No photos found for this time." /></p>
<button id="reset-btn" class="mt-4 text-white underline hover:text-gray-300">Visa alla bilder</button> <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>
</div> </div>
@@ -146,84 +212,59 @@ 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 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"> <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> <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> </button>
<div class="flex-1 relative bg-black flex items-center justify-center overflow-hidden group"> <div class="protected 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" /> <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" />
<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="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> <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>
<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> <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> </button>
</div> </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="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"> <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> <p id="lb-model" class="text-gray-400 text-sm font-medium tracking-wide mt-1"></p>
</div> </div>
<div class="flex flex-wrap items-center gap-4 md:gap-6"> <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-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-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-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-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-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-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-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">ISO</span><span id="lb-iso">--</span></div>
</div> </div>
</div> </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> </div>
</MainLayout>
<style> <script>
@reference "../styles/global.css"; // --- ANONYM STATISTIK ---
// Räknare per dag och bild i kundportalen: ingen cookie, ingen IP, inget id.
.filter-btn { const STATS_URL = 'https://customer.imagesbyolofsson.se/api/stats/hit';
@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; // 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) {
.filter-btn.active { try {
@apply bg-white text-black border-white font-medium shadow-[0_0_15px_rgba(255,255,255,0.3)]; const body = new Blob([JSON.stringify({ kind, item })], { type: 'text/plain' });
navigator.sendBeacon?.(STATS_URL, body);
} catch { /* statistik får aldrig störa sidan */ }
} }
</style> stat('view', 'page/portfolio');
<script>
// --- FILTER LOGIK --- // --- FILTER LOGIK ---
// MULTI-select: flera ar + manader samtidigt. 'all' = samtligt. // MULTI-select: flera år + månader samtidigt. 'all' = samtligt.
let selectedYears = new Set(['all']); let selectedYears = new Set(['all']);
let selectedMonths = new Set(['all']); let selectedMonths = new Set(['all']);
@@ -286,119 +327,6 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
document.body.style.overflow = 'hidden'; 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() { function closeLightbox() {
lightbox?.classList.add('opacity-0'); lightbox?.classList.add('opacity-0');
setTimeout(() => { setTimeout(() => {
@@ -410,11 +338,14 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
function updateLightboxContent() { function updateLightboxContent() {
const item = visibleItems[currentImageIndex]; const item = visibleItems[currentImageIndex];
if (!item) return; if (!item) return;
visaMeny(false); // 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.src = item.dataset.src || '';
lbImg.alt = item.dataset.alt || '';
stat('open', statsItem(item));
if(lbDate) lbDate.innerText = item.dataset.date || ''; 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(lbModel) lbModel.innerText = item.dataset.model || '';
if(lbFocal) lbFocal.innerText = item.dataset.focal || '--'; if(lbFocal) lbFocal.innerText = item.dataset.focal || '--';
if(lbFstop) lbFstop.innerText = item.dataset.fstop || '--'; if(lbFstop) lbFstop.innerText = item.dataset.fstop || '--';
@@ -432,10 +363,35 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
updateLightboxContent(); 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 // Event Listeners
items.forEach((item) => { 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.classList.add('visible-item');
item.addEventListener('click', () => { 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(); updateVisibleItems();
const index = visibleItems.indexOf(item); const index = visibleItems.indexOf(item);
if (index !== -1) openLightbox(index); if (index !== -1) openLightbox(index);
@@ -460,7 +416,7 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
// Filter Listeners // Filter Listeners
buttons.forEach(btn => { buttons.forEach(btn => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
const target = e.target as HTMLElement; const target = btn as HTMLElement;
const type = target.dataset.type; const type = target.dataset.type;
const value = target.dataset.filter; const value = target.dataset.filter;
if (!type || !value) return; if (!type || !value) return;
@@ -487,4 +443,24 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
document.querySelector('.filter-btn[data-filter="all"][data-type="month"]')?.classList.add('active'); document.querySelector('.filter-btn[data-filter="all"][data-type="month"]')?.classList.add('active');
filterImages(); filterImages();
}); });
</script> </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;
}
.filter-btn.active {
@apply bg-white text-black border-white font-medium shadow-[0_0_15px_rgba(255,255,255,0.3)];
}
</style>
+123 -25
View File
@@ -2,69 +2,166 @@
// src/pages/privacy.astro // src/pages/privacy.astro
import MainLayout from '../layouts/MainLayout.astro'; import MainLayout from '../layouts/MainLayout.astro';
import WaveBackground from '../components/WaveBackground.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" /> <WaveBackground opacity="opacity-80" />
<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 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"> <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> <h1 class="text-4xl md:text-5xl font-bold leading-tight mb-2"><T sv="Integritetspolicy" en="Privacy policy" /></h1>
<p class="text-gray-500 mb-10">Last updated: 25 October 2026</p> <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"> <section class="space-y-3">
<h2 class="text-2xl font-bold text-white">Who is responsible</h2> <h2 class="text-2xl font-bold text-white">Who is responsible</h2>
<p> <p>
Images by Olofsson is run by Linus Olofsson, Sweden. Questions about privacy: {business.brand} is run by {business.person.name}, Sweden. Questions about privacy:
<a href="mailto:Admin@imagesbyolofsson.se" class="text-white underline hover:text-gray-300">Admin@imagesbyolofsson.se</a>. <a href={`mailto:${business.email}`} class={link}>{business.email}</a>.
</p> </p>
</section> </section>
<section class="space-y-3"> <section class="space-y-3">
<h2 class="text-2xl font-bold text-white">This website</h2> <h2 class="text-2xl font-bold text-white">This website</h2>
<ul class="list-disc pl-6 space-y-2"> <ul class="list-disc pl-6 space-y-2">
<li>There are no user accounts and no contact forms.</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 email me, your address and message are used only to answer you and are not shared with anyone.</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>Password-protected galleries keep the password only in your open browser tab; it is not stored after you close it.</li>
<li> <li>
The site is delivered through Cloudflare, which processes technical data such as your IP address to deliver The website 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>. 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>
<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> </ul>
</section> </section>
<section class="space-y-3"> <section class="space-y-3">
<h2 class="text-2xl font-bold text-white">Meta Pixel (only with your consent)</h2> <h2 class="text-2xl font-bold text-white">Meta Pixel (only with your consent)</h2>
<p> <p>
If you click "Accept" in the cookie box, the site loads the Meta Pixel from Meta Platforms Ireland Ltd. 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 site. I use it to see how visitors coming from my Instagram and Facebook use the website.
</p> </p>
<ul class="list-disc pl-6 space-y-2"> <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 <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>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> </ul>
</section> </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"> <section class="space-y-3">
<h2 class="text-2xl font-bold text-white">Photo backup app (Google)</h2> <h2 class="text-2xl font-bold text-white">Photo backup app (Google)</h2>
<p> <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> </p>
<ul class="list-disc pl-6 space-y-2"> <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 Drive: the tool only accesses files it has created itself (a backup 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>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 from anyone else is collected. Nothing is sold, shared with third parties, used for advertising or used to train AI models.</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> <li>
Its use of information received from Google APIs adheres to the 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="text-white underline hover:text-gray-300">Google API Services User Data Policy</a>, <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. including the Limited Use requirements.
</li> </li>
</ul> </ul>
@@ -73,13 +170,14 @@ import WaveBackground from '../components/WaveBackground.astro';
<section class="space-y-3"> <section class="space-y-3">
<h2 class="text-2xl font-bold text-white">Your rights</h2> <h2 class="text-2xl font-bold text-white">Your rights</h2>
<p> <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 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> </p>
</section> </section>
</div> </div>
</div> </div>
</div> </div>
+23
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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' } });
};
+16
View File
@@ -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' } });
};