12 Commits
Author SHA1 Message Date
imagesbyolofsson e499ae533e chore(portfolio): sync 2026-11-13 2026-11-13 18:00:33 +00:00
imagesbyolofsson 69cb075618 Merge pull request 'Linus lista 2026-11-06: startsidans text bort, knappar längst ner, Portfolio utan Köp som poster + bildskydd, SV/EN överallt' (#5) from startsida-utan-text-2026-11-06 into main 2026-11-06 05:36:07 +00:00
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
36 changed files with 404 additions and 765 deletions
+2 -2
View File
@@ -10,8 +10,8 @@
# import ibo_www_redirect
# }
# imagesbyolofsson.se {
# # Befintliga specialregler (t.ex. handle /download/original* -> Immich-proxyn)
# # ska ligga FÖRE importen, så att de vinner över try_files/file_server.
# # 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
# }
#
+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
-3
View File
@@ -8,8 +8,6 @@
User-agent: *
Allow: /
# Lösenordsskyddad nedladdning av originalfiler – inget att indexera.
Disallow: /download/
# AI-crawlers och träningsbotar: uttryckligen tillåtna.
User-agent: GPTBot
@@ -36,6 +34,5 @@ User-agent: YouBot
User-agent: DuckAssistBot
User-agent: MistralAI-User
Allow: /
Disallow: /download/
Sitemap: https://imagesbyolofsson.se/sitemap-index.xml
+3 -1
View File
@@ -77,7 +77,9 @@ function destName(asset) {
async function downloadAndResize(asset, destPath) {
const res = await api("GET", `/assets/${asset.id}/original`);
const buf = Buffer.from(await res.arrayBuffer());
await sharp(buf)
// Pixel shift and the 15000 px Instagram squares can exceed sharp's default limit (268 MP). JPEGs are shrunk while
// they are decoded, so memory stays low; the sync container still has a memory cap.
await sharp(buf, { limitInputPixels: 1_000_000_000 })
.rotate() // respektera EXIF-orientering
.resize(MAX_DIM, MAX_DIM, { fit: "inside", withoutEnlargement: true })
.withMetadata() // BEVARA EXIF (kritiskt för portfolions datum/kamera)
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: 714 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 KiB

+2 -24
View File
@@ -12,7 +12,7 @@ export interface Props {
description: string;
path?: string;
pageType?: 'WebPage' | 'AboutPage' | 'ContactPage' | 'CollectionPage';
/** Namn i brödsmulan för undersidor, t.ex. "Tjänster". */
/** Namn i brödsmulan för undersidor, t.ex. "Portfolio". */
breadcrumb?: string;
faq?: FaqItem[];
/** Absolut URL till sidans huvudbild (og:image). */
@@ -80,7 +80,7 @@ const graph: Record<string, unknown>[] = [
sameAs: [business.social.linkedin, business.social.lightroom],
},
{
'@type': 'ProfessionalService',
'@type': 'Organization',
'@id': ids.business,
name: business.brand,
url: `${site}/`,
@@ -89,31 +89,9 @@ const graph: Record<string, unknown>[] = [
telephone: business.phone,
image: imageUrl,
address,
areaServed: business.serviceArea,
priceRange: business.priceRange,
founder: { '@id': ids.person },
employee: { '@id': ids.person },
sameAs: [business.social.instagram.url, business.social.facebook, business.social.flickr, business.social.lightroom],
potentialAction: {
'@type': 'ReserveAction',
name: 'Skicka en bokningsförfrågan',
target: business.portal.bookingUrl,
},
hasOfferCatalog: {
'@type': 'OfferCatalog',
name: 'Fotografering',
itemListElement: business.genres.map((g) => ({
'@type': 'Offer',
priceCurrency: business.priceFromSek != null ? 'SEK' : null,
price: business.priceFromSek,
itemOffered: {
'@type': 'Service',
name: g.name,
description: g.description,
provider: { '@id': ids.business },
},
})),
},
},
];
+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>
+2 -60
View File
@@ -1,63 +1,5 @@
// src/data/faq.ts
// Vanliga frågor för /tjanster. Samma text används på sidan och i FAQPage-JSON-LD.
// Svaren byggs ENBART av fakta i business.ts. Okända fakta (null) ger ett
// "kontakta mig"-svar i stället för en gissning.
import { business, basedIn, formatSek, genreList } from './business';
// 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 };
function areaAnswer(): string {
if (business.serviceArea) {
return `Jag utgår från ${basedIn()} och tar uppdrag i ${business.serviceArea}. Hör av dig om du är osäker på om jag kan komma till dig.`;
}
return `Jag är baserad i ${basedIn()}. Kontakta mig så stämmer vi av om jag kan fotografera där du befinner dig.`;
}
function priceAnswer(): string {
const from = formatSek(business.priceFromSek);
if (from) {
return `En fotografering kostar från ${from}. Det slutliga priset beror på uppdraget och står i offerten du får efter din förfrågan.`;
}
return 'Priset beror på uppdraget. Kontakta mig eller skicka en förfrågan i kundportalen, så får du en offert för just det du vill ha fotograferat.';
}
function deliveryAnswer(): string {
const base = 'Du får bilderna i ett privat galleri i kundportalen. När betalningen är klar kan du ladda ner originalfilerna i full upplösning.';
if (business.deliveryTime) return `${base} Normal leveranstid är ${business.deliveryTime}.`;
return base;
}
export const faq: FaqItem[] = [
{
question: 'Vad fotograferar du?',
answer: `Jag fotograferar ${genreList()}.`,
},
{
question: 'Var finns du och var kan du fotografera?',
answer: areaAnswer(),
},
{
question: 'Vad kostar en fotografering?',
answer: priceAnswer(),
},
{
question: 'Hur bokar jag en fotografering?',
answer: `Skicka en förfrågan i kundportalen (${business.portal.bookingUrl}). Jag återkommer med en offert, och när du har godkänt den bokar vi in fotograferingen.`,
},
{
question: 'Hur får jag mina bilder?',
answer: deliveryAnswer(),
},
{
question: 'Vilken utrustning använder du?',
answer: `Jag fotograferar med en ${business.camera} och redigerar i ${business.editing}.`,
},
{
question: 'Har dina bilder uppmärksammats?',
answer: business.recognition,
},
{
question: 'Hur kontaktar jag dig?',
answer: `Mejla ${business.email} eller skicka ett meddelande på Instagram (${business.social.instagram.handle}).`,
},
];
+66 -16
View File
@@ -4,6 +4,7 @@ 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';
@@ -13,7 +14,7 @@ export interface Props {
title: string;
/** Unik svensk meta description, ca 120–160 tecken. */
description: string;
/** Kanonisk sökväg, t.ex. "/tjanster". Utelämnas = härleds från URL:en. */
/** Kanonisk sökväg, t.ex. "/portfolio". Utelämnas = härleds från URL:en. */
path?: string;
image?: ImageMetadata;
imageAlt?: string;
@@ -43,12 +44,14 @@ 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', label: 'Portfolio' },
{ href: '/tjanster', label: 'Tjänster' },
{ href: '/about', label: 'Om mig' },
{ href: '/contact', label: 'Kontakt' },
{ 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';
---
<!doctype html>
@@ -61,6 +64,24 @@ const nav = [
<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="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}
@@ -92,11 +113,17 @@ const nav = [
Images by Olofsson
</a>
<ul class="flex space-x-4 md:space-x-6 text-sm font-medium text-gray-300">
{nav.map((item) => (
<li><a href={item.href} class="hover:text-white transition">{item.label}</a></li>
))}
</ul>
<div class="flex items-center gap-4 md:gap-6">
<ul class="flex space-x-4 md:space-x-6 text-sm font-medium text-gray-300">
{nav.map((item) => (
<li><a href={item.href} class="hover:text-white transition"><T sv={item.sv} en={item.en} /></a></li>
))}
</ul>
<div class="inline-flex items-center rounded-full border border-neutral-700 p-0.5" role="group" aria-label="Språk / Language">
<button type="button" data-lang-set="sv" class={langBtn} aria-label="Svenska">SV</button>
<button type="button" data-lang-set="en" class={langBtn} aria-label="English">EN</button>
</div>
</div>
</div>
</nav>
@@ -106,26 +133,49 @@ const nav = [
<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">
{business.brand} – {business.person.jobTitle.toLowerCase()} {business.person.name}, {business.country.name}
{business.brand} – <T sv={`fotograf ${business.person.name}, Sverige`} en={`photographer ${business.person.name}, Sweden`} />
&middot; <a href={`mailto:${business.email}`} class="text-gray-300 hover:text-white transition-colors">{business.email}</a>
&middot; <a href={business.social.instagram.url} target="_blank" rel="noopener noreferrer" class="text-gray-300 hover:text-white transition-colors">Instagram</a>
</p>
<p>&copy; {new Date().getFullYear()} {business.brand}. Alla rättigheter förbehållna. &middot; <a href="/privacy" class="text-gray-300 hover:text-white transition-colors">Integritetspolicy</a> &middot; <button type="button" data-consent-open class="text-gray-300 hover:text-white transition-colors">Cookie-inställningar</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>
<!-- Cookie consent: Meta Pixel laddas först efter "Godkänn" -->
<div data-consent-banner hidden class="fixed bottom-4 inset-x-4 md:left-auto md:right-6 md:max-w-md z-[60] bg-neutral-900/95 backdrop-blur-md border border-white/10 rounded-2xl p-5 shadow-2xl text-sm text-gray-300">
<p class="mb-4">
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">Läs mer</a>
<T sv="Jag använder Meta Pixel för att se hur besökare från Instagram och Facebook använder sajten. Den sätter cookies och skickar data till Meta."
en="I use the Meta Pixel to see how visitors from Instagram and Facebook use the site. It sets cookies and sends data to Meta." />
<a href="/privacy" class="text-white underline hover:text-gray-300"><T sv="Läs mer" en="Read more" /></a>
</p>
<div class="flex gap-3 justify-end">
<button type="button" data-consent="denied" class="px-4 py-2 rounded-full border border-white/20 text-gray-300 hover:text-white hover:border-white/40 transition-colors">Avböj</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">Godkänn</button>
<button type="button" data-consent="denied" class="px-4 py-2 rounded-full border border-white/20 text-gray-300 hover:text-white hover:border-white/40 transition-colors"><T sv="Avböj" en="Decline" /></button>
<button type="button" data-consent="granted" class="px-4 py-2 rounded-full bg-white text-neutral-900 font-medium hover:bg-gray-200 transition-colors"><T sv="Godkänn" en="Accept" /></button>
</div>
</div>
<script is:inline>
// SV/EN-knappen: sparar valet (samma nyckel som kundportalen), byter <html lang> och meddelar sidans skript.
(function () {
function show(lang) {
document.documentElement.lang = lang;
document.querySelectorAll('[data-lang-set]').forEach(function (b) {
b.setAttribute('aria-pressed', b.getAttribute('data-lang-set') === lang ? 'true' : 'false');
});
document.querySelectorAll('[data-aria-sv]').forEach(function (el) {
el.setAttribute('aria-label', el.getAttribute(lang === 'en' ? 'data-aria-en' : 'data-aria-sv'));
});
document.dispatchEvent(new CustomEvent('ibo-lang', { detail: lang }));
}
show(document.documentElement.lang === 'en' ? 'en' : 'sv');
document.querySelectorAll('[data-lang-set]').forEach(function (b) {
b.addEventListener('click', function () {
var lang = b.getAttribute('data-lang-set');
try { localStorage.setItem('ibo_lang', lang); } catch (e) {}
show(lang);
});
});
})();
(function () {
var KEY = 'ibo-consent';
var banner = document.querySelector('[data-consent-banner]');
+8 -2
View File
@@ -3,10 +3,16 @@
// 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[] {
return Object.keys(pageFiles)
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}`))
.map((name) => (name === 'index' ? '/' : `/${name}`));
return [...new Set([...own, ...PORTAL_PAGES])]
.sort((a, b) => (a === '/' ? -1 : b === '/' ? 1 : a.localeCompare(b)));
}
+1 -1
View File
@@ -1,5 +1,5 @@
// src/lib/url.ts
// Gör om en byggsökväg till kanonisk form: "/", "/about", "/tjanster".
// 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$/, '');
+10 -9
View File
@@ -2,32 +2,33 @@
// 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: '/', label: 'Startsidan' },
{ href: '/portfolio', label: 'Portfolio' },
{ href: '/tjanster', label: 'Tjänster och bokning' },
{ href: '/contact', label: 'Kontakt' },
{ 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 tjänsterna hos fotograf Linus 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">Fel 404</p>
<h1 class="text-4xl md:text-6xl font-bold mb-6">Sidan hittades inte</h1>
<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">
Sidan du letade efter finns inte längre, eller så har adressen ändrats. Här kan du hitta tillbaka:
<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">{l.label}</a>
<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>
-137
View File
@@ -1,137 +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';
import { business, basedIn } from '../data/business';
---
<MainLayout
title="Om Linus Olofsson – fotograf | Images by Olofsson"
description={`Linus Olofsson är fotografen bakom Images by Olofsson, baserad i ${basedIn()}. ${business.recognitionThirdPerson} Läs om hans bildspråk och utrustning.`}
path="/about"
image={profileImg}
imageAlt="Porträtt av fotografen Linus Olofsson"
pageType="AboutPage"
breadcrumb="Om mig"
>
<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="Porträtt av fotografen Linus Olofsson"
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">
Levande ögonblick, <br/>
<span class="text-transparent bg-clip-text bg-gradient-to-r from-white to-gray-400">fångade i hög upplösning.</span>
</h1>
<div class="space-y-4 text-gray-300 text-lg leading-relaxed">
<p>
Hej, jag heter {business.person.name} och är fotografen bakom {business.brand}.
</p>
<p>
Jag är baserad i {basedIn()}. Allt började med en enkel nyfikenhet på hur ljuset formar sättet vi ser världen på. Med tiden blev nyfikenheten en besatthet av att fånga detaljerna som de flesta missar – texturer, strukturer, kontraster och ögonblick som passerar obemärkt förbi.
</p>
<p>
Jag hittar skönheten i det lilla: en sten, ett träd, en tom gata, en stilla skogsstig eller uttrycket hos en främling som går förbi.
</p>
<p>
Oavsett om det är de invecklade mönstren i en makrobild, naturens stora tystnad eller stadens energi är målet alltid detsamma – att skapa bilder som förmedlar känslor och berättar en historia utan ord.
</p>
<p>{business.recognition}</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">
Kontakta mig
</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">
Se världen genom mina ögon
</a>
<a href="/tjanster" class="inline-block border border-white/40 text-white px-8 py-3 rounded-full font-bold hover:bg-white/10 transition-colors">
Tjänster och bokning
</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">Vad har jag i kameraväskan?</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">Kamerahus</h3>
<p class="text-gray-300">{business.camera}</p>
<p class="text-gray-400 text-sm mt-2">En pålitlig följeslagare som har varit med mig på otaliga äventyr.</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">Objektiv</h3>
<ul class="text-gray-300 text-sm space-y-1">
{business.lenses.map((lens) => <li>{lens}</li>)}
<li>och ett par extra batterier</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">Redigering</h3>
<p class="text-gray-300">{business.editing}</p>
<p class="text-gray-400 text-sm mt-2">Här får råfilerna liv. Jag fokuserar på naturliga toner och kontrast.</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>
-141
View File
@@ -1,141 +0,0 @@
---
// src/pages/contact.astro
import MainLayout from '../layouts/MainLayout.astro';
import WaveBackground from '../components/WaveBackground.astro';
import { business } from '../data/business';
---
<MainLayout
title="Kontakt och bokning | Images by Olofsson"
description={`Kontakta fotograf Linus Olofsson på ${business.email} eller Instagram ${business.social.instagram.handle}, eller skicka en bokningsförfrågan via kundportalen.`}
path="/contact"
pageType="ContactPage"
breadcrumb="Kontakt"
>
<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="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">Kontakta mig</h1>
<p class="text-gray-300 text-lg leading-relaxed">
Har du frågor om någon av mina bilder, vill du samarbeta eller bara säga hej?
Hör av dig via sociala medier eller mejla mig direkt.
</p>
<p class="text-gray-300 text-lg leading-relaxed mt-4">
Vill du boka en fotografering? Skicka en förfrågan i kundportalen så återkommer jag med en offert.
Läs mer om hur det går till under <a href="/tjanster" class="text-white underline underline-offset-4 hover:text-gray-300">Tjänster</a>.
</p>
<a href={business.portal.bookingUrl} class="inline-block mt-6 px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition">Skicka en bokningsförfrågan</a>
</div>
<div>
<h2 class="text-white font-semibold text-xl mb-4">Du hittar mig också på</h2>
<div class="grid grid-cols-3 gap-4">
<a href={business.social.instagram.url} 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={business.social.flickr} 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={business.social.lightroom} 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={business.social.github} 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={business.social.linkedin} 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:${business.email}`} class="social-card group" aria-label="E-post">
<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>
<h2 class="text-2xl font-bold text-white mb-2">Jag ser fram emot att höra av dig</h2>
<p class="text-gray-300">Hör gärna av dig med frågor, förfrågningar eller bara för att prata.</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-400 uppercase tracking-widest mb-2">Mejla mig på</p>
<a href={`mailto:${business.email}`} class="text-xl md:text-3xl font-bold text-white hover:text-purple-400 transition-colors break-all">
{business.email}
</a>
<div>
<button type="button" id="email-btn" class="mt-3 text-xs uppercase tracking-widest text-gray-400 hover:text-white transition-colors" data-email={business.email}>Kopiera adressen</button>
</div>
<p id="copy-feedback" class="h-4 text-xs text-green-400 mt-2 opacity-0 transition-opacity" aria-live="polite">Kopierad!</p>
</div>
</div>
<div class="pt-2">
<p class="text-sm text-gray-400 uppercase tracking-widest mb-4">Eller skriv till mig på Instagram</p>
<a href={business.social.instagram.url} target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 text-xl font-bold text-white hover:text-pink-500 transition-colors">
{business.social.instagram.handle}
</a>
</div>
</div>
</div>
</div>
</div>
<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>
</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>
+24 -44
View File
@@ -3,12 +3,21 @@
import MainLayout from '../layouts/MainLayout.astro';
import ThreeDScroll from '../components/ThreeDScroll.astro';
import WaveBackground from '../components/WaveBackground.astro';
import T from '../components/T.astro';
import { business, basedIn, genreList } from '../data/business';
const footerButtons = [
{ href: '/posters', sv: 'Posters', en: 'Posters' },
{ href: '/contact', sv: 'Samarbeten', en: 'Collaborations' },
{ href: '/about', sv: 'Mer om mig', en: 'More about me' },
{ href: '/portfolio', sv: 'Portfolio', en: 'Portfolio' },
{ href: '/contact', sv: 'Kontakt', en: 'Contact' },
];
---
<MainLayout
title="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. Boka via kundportalen.`}
description={`Fotograf Linus Olofsson (Images by Olofsson) i ${basedIn()}: porträtt, natur och djur, stad, makro, norrsken och familjefester. Fotografierna finns som posters.`}
path="/"
>
@@ -43,7 +52,7 @@ import { business, basedIn, genreList } from '../data/business';
</div>
<p class="text-white/80 mt-4 text-base md:text-lg tracking-widest uppercase">Fotograf {business.person.name} &middot; {basedIn()}</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>
@@ -58,9 +67,9 @@ import { business, basedIn, genreList } from '../data/business';
<div class="flex flex-col md:flex-row gap-12 md:gap-24 items-center text-center">
<div data-trail-box class="flex flex-col items-center px-6 py-4 rounded-2xl">
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Letar du 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">
Se min portfolio
<T sv="Se min portfolio" en="See my portfolio" />
</a>
</div>
@@ -86,52 +95,23 @@ import { business, basedIn, genreList } from '../data/business';
</div>
<div data-trail-box class="flex flex-col items-center px-6 py-4 rounded-2xl">
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Vill du boka en fotografering?</h2>
<a href="/tjanster" class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105">
Tjänster och bokning
<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">
<T sv="Kontakta mig" en="Contact me" />
</a>
</div>
</div>
</div>
<!-- Fakta om vem, vad, var och bokning: läsbar text för besökare, sökmotorer och AI-assistenter. -->
<section id="om-images-by-olofsson" class="relative z-30 bg-neutral-900 border-t border-white/10 py-16 md:py-24">
<div class="container mx-auto px-4 max-w-5xl grid gap-12 md:grid-cols-2">
<div class="space-y-5 text-gray-300 text-lg leading-relaxed">
<h2 class="text-3xl md:text-4xl font-bold text-white">Fotograf {business.person.name}</h2>
<p>
Jag heter {business.person.name} och fotograferar under namnet {business.brand}.
Jag är baserad i {basedIn()} och fotograferar {genreList()}.
</p>
<p>{business.recognition}</p>
<p>
Jag fotograferar med en {business.camera} och redigerar i {business.editing}, med fokus på naturliga toner och kontrast.
</p>
<p class="flex flex-wrap gap-x-6 gap-y-2 text-base">
<a href="/about" class="text-white underline underline-offset-4 hover:text-gray-300">Mer om mig</a>
<a href="/portfolio" class="text-white underline underline-offset-4 hover:text-gray-300">Portfolio</a>
<a href={`mailto:${business.email}`} class="text-white underline underline-offset-4 hover:text-gray-300">{business.email}</a>
</p>
</div>
<div class="space-y-5">
<h2 class="text-3xl md:text-4xl font-bold text-white">Så bokar du</h2>
<ol class="space-y-4 text-gray-300">
{business.bookingSteps.map((step, i) => (
<li class="flex gap-4">
<span class="shrink-0 w-8 h-8 rounded-full border border-white/30 flex items-center justify-center text-sm font-bold text-white">{i + 1}</span>
<span><strong class="text-white">{step.title}.</strong> {step.text}</span>
</li>
))}
</ol>
<div class="flex flex-wrap gap-4 pt-2">
<a href={business.portal.bookingUrl} class="inline-block px-6 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition">Skicka en förfrågan</a>
<a href="/tjanster" class="inline-block px-6 py-3 border border-white text-white font-bold rounded-full hover:bg-white/10 transition">Vanliga frågor</a>
</div>
</div>
</div>
</section>
<!-- 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
+3 -14
View File
@@ -2,15 +2,13 @@
// 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, formatSek, genreList } from '../data/business';
import { faq } from '../data/faq';
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 price = formatSek(business.priceFromSek);
const lines: (string | null)[] = [
`# ${business.brand}`,
@@ -22,29 +20,20 @@ export const GET: APIRoute = ({ site }) => {
`- Fotograf: ${business.person.name}`,
`- Namn/varumärke: ${business.brand}`,
`- Baserad i: ${basedIn()}`,
business.serviceArea ? `- Tar uppdrag i: ${business.serviceArea}` : '- Upptagningsområde: stäms av vid förfrågan',
price ? `- Pris från: ${price}` : '- Priser: lämnas som offert efter förfrågan',
business.deliveryTime ? `- Leveranstid: ${business.deliveryTime}` : null,
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}`,
`- Bokning: ${business.portal.bookingUrl}`,
`- Bokningsgång: ${business.bookingSteps.map((s) => s.title.toLowerCase()).join(' → ')}`,
'',
'## Sidor',
'',
`- [Startsida](${url('/')}): vem ${business.person.name} är och vad han fotograferar`,
`- [Tjänster och vanliga frågor](${url('/tjanster')}): vad du kan boka, hur bokningen går till och svar på vanliga frågor`,
`- [Portfolio](${url('/portfolio')}): urval av fotografier`,
`- [Om mig](${url('/about')}): ${business.person.name}, bakgrund och utrustning`,
`- [Kontakt](${url('/contact')}): e-post, Instagram och bokning`,
'',
'## Vanliga frågor',
'',
...faq.map((f) => `- ${f.question} ${f.answer}`),
`- [Posters](${url('/posters')}): fotografier som posters, välj storlek och material`,
`- [Kontakt och samarbeten](${url('/contact')}): samarbeten, reklam och sponsring`,
'',
'## Profiler',
'',
+74 -178
View File
@@ -6,6 +6,7 @@ import exifr from 'exifr';
import fs from 'node:fs';
import path from 'node:path';
import WaveBackground from '../components/WaveBackground.astro';
import T from '../components/T.astro';
import { business, genreList } from '../data/business';
import { portfolioMeta } from '../data/portfolio-meta';
@@ -74,6 +75,9 @@ const galleryItems = await Promise.all(Object.entries(imageFiles).map(async ([re
const fullDate = hasDate
? dateObj.toLocaleDateString('sv-SE', { day: 'numeric', month: 'long', year: 'numeric' })
: '';
const fullDateEn = hasDate
? dateObj.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })
: '';
const shutterSpeed = formatShutterSpeed(exifData?.ExposureTime);
const focalLength = exifData?.FocalLength ? `${Math.round(exifData.FocalLength)}mm` : null;
@@ -90,23 +94,20 @@ const galleryItems = await Promise.all(Object.entries(imageFiles).map(async ([re
].filter(Boolean).join(' ');
const alt = meta.alt?.trim() || fallbackAlt.charAt(0).toUpperCase() + fallbackAlt.slice(1);
// Nedladdning/lightbox: nya, EXIF-fria JPG:er i 800/1600/3000 px
// (Astro hoppar över bredder större än originalet).
// 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 small = variants[0]?.url ?? jpg.src;
const large = variants[variants.length - 1]?.url ?? jpg.src;
const medium = variants.find((v) => widthOf(v.descriptor ?? '') === 1600)?.url ?? large;
const assetId = key.match(/^immich-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-/i)?.[1]?.toLowerCase() ?? null;
const statKey = ('portfolio/' + key.replace(/[^\w\-.]/g, '_')).slice(0, 150);
return {
img: file.default, key, alt, title: meta.title ?? null,
year, month, monthIndex, fullDate, model: exifData?.Model,
year, month, monthIndex, fullDate, fullDateEn, model: exifData?.Model,
fStop, shutterSpeed, iso, focalLength,
dlS: small, dlM: medium, dlL: large, srcset: jpg.srcSet.attribute,
assetId, statKey,
large, srcset: jpg.srcSet.attribute,
statKey,
};
}));
@@ -114,7 +115,8 @@ const uniqueYears = [...new Set(galleryItems.map((item: any) => item.year))].sor
const allMonths = galleryItems.map((item: any) => ({ name: item.month, index: item.monthIndex }));
const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).values()]
.sort((a: any, b: any) => a.index - b.index)
.map((item: any) => item.name);
.map((item: any) => ({ sv: item.name, en: new Date(2000, item.index, 1).toLocaleString('en-GB', { month: 'long' }) }));
const genresEn = 'portraits and people, nature and animals, city and architecture, macro and details, northern lights, and celebrations and family occasions';
---
<MainLayout
@@ -133,41 +135,40 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
<div class="text-center mb-12">
<h1 class="text-4xl md:text-6xl font-bold mb-4">Portfolio</h1>
<p class="text-gray-300 max-w-2xl mx-auto">Ett urval av mina bilder i olika stilar – {genreList()}.
Vill du boka en fotografering? Läs om <a href="/tjanster" class="text-white underline underline-offset-4 hover:text-gray-300">tjänster och bokning</a>.</p>
<p class="text-gray-300 max-w-2xl mx-auto"><T sv={`Ett urval av mina bilder i olika stilar – ${genreList()}.`} en={`A selection of my photos in different styles – ${genresEn}.`} /></p>
</div>
<div class="flex flex-col items-center gap-6 mb-12">
<div class="flex flex-wrap justify-center gap-2">
<button type="button" class="filter-btn active" data-filter="all" data-type="year">Alla år</button>
<button type="button" class="filter-btn active" data-filter="all" data-type="year"><T sv="Alla år" en="All years" /></button>
{uniqueYears.map(year => (
<button type="button" class="filter-btn" data-filter={year} data-type="year">{year}</button>
))}
</div>
<div class="flex flex-wrap justify-center gap-2">
<button type="button" class="filter-btn active" data-filter="all" data-type="month">Alla månader</button>
<button type="button" class="filter-btn active" data-filter="all" data-type="month"><T sv="Alla månader" en="All months" /></button>
{uniqueMonths.map(month => (
<button type="button" class="filter-btn capitalize" data-filter={month} data-type="month">{month}</button>
<button type="button" class="filter-btn capitalize" data-filter={month.sv} data-type="month"><T sv={month.sv} en={month.en} /></button>
))}
</div>
</div>
<h2 class="sr-only">Bilder</h2>
<h2 class="sr-only"><T sv="Bilder" en="Photos" /></h2>
<div class="columns-1 md:columns-2 lg:columns-3 gap-6 space-y-6" id="gallery-grid">
{galleryItems.map((item: any, index: number) => (
<a
href={item.dlL}
class="gallery-item block break-inside-avoid relative group overflow-hidden rounded-lg cursor-pointer bg-neutral-800 shadow-md"
<div
role="button"
tabindex="0"
aria-label={item.fullDate ? `Visa bilden från ${item.fullDate}` : 'Visa bilden'}
data-aria-sv={item.fullDate ? `Visa bilden från ${item.fullDate}` : 'Visa bilden'}
data-aria-en={item.fullDateEn ? `View the photo from ${item.fullDateEn}` : 'View the photo'}
class="gallery-item protected block break-inside-avoid relative group overflow-hidden rounded-lg cursor-pointer bg-neutral-800 shadow-md"
data-year={item.year}
data-month={item.month}
data-src={item.dlL}
data-src={item.large}
data-srcset={item.srcset}
data-dl-s={item.dlS}
data-dl-m={item.dlM}
data-dl-l={item.dlL}
data-key={item.key}
data-asset={item.assetId}
data-stat={item.statKey}
data-alt={item.alt}
data-title={item.title}
@@ -177,6 +178,7 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
data-shutter={item.shutterSpeed}
data-focal={item.focalLength}
data-date={item.fullDate}
data-date-en={item.fullDateEn}
>
<Image
src={item.img}
@@ -186,20 +188,23 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
loading={index < 3 ? 'eager' : 'lazy'}
decoding="async"
class="w-full h-auto object-cover transform transition duration-700 group-hover:scale-105"
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 group-focus-visible:opacity-100 transition duration-300 flex flex-col justify-end p-6 pointer-events-none">
{(item.title || item.fullDate) && <p class="text-white font-bold text-lg">{item.title ?? item.fullDate}</p>}
<p class="text-xs text-gray-300 uppercase tracking-widest mt-1">Visa bild</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-300 uppercase tracking-widest mt-1"><T sv="Visa bild" en="View photo" /></p>
</div>
</a>
</div>
))}
</div>
<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>
<button type="button" id="reset-btn" class="mt-4 text-white underline hover:text-gray-300">Visa alla bilder</button>
<p class="text-xl"><T sv="Inga bilder hittades för denna tidpunkt." en="No photos found for this time." /></p>
<button type="button" id="reset-btn" class="mt-4 text-white underline hover:text-gray-300"><T sv="Visa alla bilder" en="Show all photos" /></button>
</div>
</div>
@@ -207,16 +212,17 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
<div id="lightbox" class="fixed inset-0 z-50 hidden bg-black/95 backdrop-blur-md flex items-center justify-center opacity-0 transition-opacity duration-300 p-2 md:p-8">
<div class="bg-neutral-900 w-full max-w-6xl h-[85vh] md:h-[90vh] flex flex-col rounded-xl overflow-hidden shadow-2xl relative border border-white/10">
<button type="button" id="lb-close" aria-label="Stäng" class="absolute top-4 right-4 z-50 bg-black/50 hover:bg-black/80 text-white p-2 rounded-full transition-colors">
<button type="button" id="lb-close" aria-label="Stäng" data-aria-sv="Stäng" data-aria-en="Close" class="absolute top-4 right-4 z-50 bg-black/50 hover:bg-black/80 text-white p-2 rounded-full transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
<div class="flex-1 relative bg-black flex items-center justify-center overflow-hidden group">
<img id="lb-img" src="" alt="" sizes="100vw" class="max-h-full max-w-full w-full h-full object-contain p-2 md:p-4" />
<button type="button" id="lb-prev" aria-label="Föregående bild" class="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 bg-black/40 hover:bg-black/70 text-white p-3 rounded-full transition-all">
<div class="protected flex-1 relative bg-black flex items-center justify-center overflow-hidden group">
<img id="lb-img" src="" alt="" sizes="100vw" draggable="false" class="pointer-events-none select-none max-h-full max-w-full w-full h-full object-contain p-2 md:p-4" />
<div class="absolute inset-0" aria-hidden="true"></div>
<button type="button" id="lb-prev" aria-label="Föregående bild" data-aria-sv="Föregående bild" data-aria-en="Previous photo" class="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 bg-black/40 hover:bg-black/70 text-white p-3 rounded-full transition-all">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button>
<button type="button" id="lb-next" aria-label="Nästa bild" class="absolute right-2 md:right-4 top-1/2 -translate-y-1/2 bg-black/40 hover:bg-black/70 text-white p-3 rounded-full transition-all">
<button type="button" id="lb-next" aria-label="Nästa bild" data-aria-sv="Nästa bild" data-aria-en="Next photo" class="absolute right-2 md:right-4 top-1/2 -translate-y-1/2 bg-black/40 hover:bg-black/70 text-white p-3 rounded-full transition-all">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>
</button>
</div>
@@ -227,43 +233,16 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
<p id="lb-model" class="text-gray-400 text-sm font-medium tracking-wide mt-1"></p>
</div>
<div class="flex flex-wrap items-center gap-4 md:gap-6">
<div class="relative">
<button type="button" id="lb-download" aria-haspopup="true" aria-expanded="false" 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>
Ladda ner
</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">
<button type="button" data-size="s" class="dl-opt w-full text-left 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">Liten</span><span class="text-gray-400 text-xs font-mono group-hover:text-gray-300 transition-colors">800 px</span></button>
<button type="button" data-size="m" class="dl-opt w-full text-left 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">Mellan</span><span class="text-gray-400 text-xs font-mono group-hover:text-gray-300 transition-colors">1600 px</span></button>
<button type="button" data-size="l" class="dl-opt w-full text-left 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">Stor</span><span class="text-gray-400 text-xs font-mono group-hover:text-gray-300 transition-colors">3000 px</span></button>
<button type="button" data-size="orig" class="dl-opt w-full text-left 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-400 normal-case tracking-normal mt-0.5">Kräver lösenord</span></span><span class="text-gray-400 text-xs font-mono group-hover:text-gray-300 transition-colors">full storlek</span></button>
</div>
</div>
<div class="flex flex-wrap gap-3 md:gap-6 text-xs md:text-sm font-mono text-gray-300">
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold">Brännvidd</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">Bländare</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">Slutartid</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="Brännvidd" en="Focal length" /></span><span id="lb-focal">--</span></div>
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold"><T sv="Bländare" en="Aperture" /></span><span id="lb-fstop">--</span></div>
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold"><T sv="Slutartid" en="Shutter speed" /></span><span id="lb-shutter">--</span></div>
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold">ISO</span><span id="lb-iso">--</span></div>
</div>
</div>
</div>
</div>
<!-- Lösenordsruta för ORIGINAL. Originalen ligger inte i repot (27–70 MB/st)
utan hämtas från Immich via /download/original, som kräver lösenord. -->
<div id="pw-modal" role="dialog" aria-modal="true" aria-labelledby="pw-title" 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">
<p id="pw-title" class="text-white font-bold text-lg tracking-tight mb-1">Ladda ner original</p>
<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" aria-label="Lösenord" 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 type="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 type="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>
@@ -276,7 +255,7 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
function statsItem(item?: HTMLElement): string {
return item?.dataset.stat || 'portfolio/okand';
}
function stat(kind: 'view' | 'open' | 'download', item: string) {
function stat(kind: 'view' | 'open', item: string) {
try {
const body = new Blob([JSON.stringify({ kind, item })], { type: 'text/plain' });
navigator.sendBeacon?.(STATS_URL, body);
@@ -348,115 +327,6 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
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');
// Small/Medium/Large är EXIF-fria JPG:er som Astro tar fram vid bygget
// (data-dl-s/m/l). Original hämtas från Immich via proxyn och kräver lösenord.
function urlForSize(size: string): string | null {
const item = visibleItems[currentImageIndex];
if (!item) return null;
if (size === 's') return item.dataset.dlS || null;
if (size === 'm') return item.dataset.dlM || null;
if (size === 'l') return item.dataset.dlL || null;
if (size === 'orig') return item.dataset.asset ? `/download/original?asset=${item.dataset.asset}` : null;
return null;
}
function fileNameFor(size: string): string {
const item = visibleItems[currentImageIndex];
const key = (item?.dataset.key || 'bild').replace(/[^\w\-]/g, '_');
return `imagesbyolofsson-${key}-${size}.jpg`;
}
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 går grå medan menyn är öppen och tillbaka till vanligt när den stängs
// (både vid val av storlek och när man klickar bort menyn).
dlArrow?.classList.toggle('text-gray-500', visa);
dlBtn?.setAttribute('aria-expanded', visa ? 'true' : 'false');
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 för den här bilden.'); return; }
stat('download', statsItem(visibleItems[currentImageIndex]));
if (size === 'orig') {
// Lösenordet sparas bara för 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, fileNameFor(size));
}
});
});
async function forsokOriginal() {
const pw = pwInput?.value || '';
if (!pw) return;
const url = urlForSize('orig');
if (!url) return;
// HEAD först: då får vi 401 utan att dra igång 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 hämta originalet just nu.'); return; }
} catch { /* nätverksfel -> försök ändå nedan */ }
sessionStorage.setItem('dlpw', pw);
pwModal?.classList.add('hidden');
startDownload(`${url}&pw=${encodeURIComponent(pw)}`);
}
document.getElementById('pw-ok')?.addEventListener('click', forsokOriginal);
pwInput?.addEventListener('keydown', (e) => { if ((e as KeyboardEvent).key === 'Enter') forsokOriginal(); });
document.getElementById('pw-cancel')?.addEventListener('click', () => pwModal?.classList.add('hidden'));
document.addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Escape' && !dlMenu?.classList.contains('hidden')) {
e.stopImmediatePropagation();
visaMeny(false);
}
}, true);
function closeLightbox() {
lightbox?.classList.add('opacity-0');
setTimeout(() => {
@@ -468,15 +338,14 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
function updateLightboxContent() {
const item = visibleItems[currentImageIndex];
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.alt = item.dataset.alt || '';
stat('open', statsItem(item));
if(lbDate) lbDate.innerText = [item.dataset.title, item.dataset.date].filter(Boolean).join(' – ');
const en = document.documentElement.lang === 'en';
if(lbDate) lbDate.innerText = (en ? item.dataset.dateEn : item.dataset.date) || '';
if(lbModel) lbModel.innerText = item.dataset.model || '';
if(lbFocal) lbFocal.innerText = item.dataset.focal || '--';
if(lbFstop) lbFstop.innerText = item.dataset.fstop || '--';
@@ -494,8 +363,28 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
updateLightboxContent();
}
// Skydd som på Posters: ingen högerklick-meny, ingen dragning, inget långt tryck på bilderna.
document.addEventListener('contextmenu', (e) => {
if ((e.target as HTMLElement).closest('.protected')) e.preventDefault();
});
document.addEventListener('dragstart', (e) => {
if ((e.target as HTMLElement).closest('.protected')) e.preventDefault();
});
// Byter besökaren språk med ljuslådan öppen följer datumet med.
document.addEventListener('ibo-lang', () => {
if (!lightbox?.classList.contains('hidden')) updateLightboxContent();
});
// Event Listeners
items.forEach((item) => {
item.addEventListener('keydown', (e) => {
const key = (e as KeyboardEvent).key;
if (key !== 'Enter' && key !== ' ') return;
e.preventDefault();
updateVisibleItems();
const index = visibleItems.indexOf(item);
if (index !== -1) openLightbox(index);
});
item.classList.add('visible-item');
item.addEventListener('click', (e) => {
// Länken pekar på den stora bilden (crawlbar/öppningsbar i ny flik),
@@ -527,7 +416,7 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
// Filter Listeners
buttons.forEach(btn => {
btn.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const target = btn as HTMLElement;
const type = target.dataset.type;
const value = target.dataset.filter;
if (!type || !value) return;
@@ -560,6 +449,13 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
<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;
}
+82 -6
View File
@@ -2,6 +2,7 @@
// src/pages/privacy.astro
import MainLayout from '../layouts/MainLayout.astro';
import WaveBackground from '../components/WaveBackground.astro';
import T from '../components/T.astro';
import { business } from '../data/business';
const link = 'text-white underline hover:text-gray-300';
@@ -19,10 +20,10 @@ const link = 'text-white underline hover:text-gray-300';
<div class="container mx-auto px-4 py-12 md:py-20 relative z-10">
<div data-trail-box class="bg-neutral-900/60 backdrop-blur-md border border-white/10 rounded-3xl p-8 md:p-12 shadow-2xl mb-20 max-w-3xl mx-auto">
<h1 class="text-4xl md:text-5xl font-bold leading-tight mb-2">Integritetspolicy</h1>
<p class="text-gray-400 mb-10">Senast uppdaterad: 27 oktober 2026</p>
<h1 class="text-4xl md:text-5xl font-bold leading-tight mb-2"><T sv="Integritetspolicy" en="Privacy policy" /></h1>
<p class="text-gray-400 mb-10"><T sv="Senast uppdaterad: 6 november 2026" en="Last updated: 6 November 2026" /></p>
<div class="space-y-8 text-gray-300 text-lg leading-relaxed">
<div class="i18n-sv space-y-8 text-gray-300 text-lg leading-relaxed">
<section class="space-y-3">
<h2 class="text-2xl font-bold text-white">Vem som är ansvarig</h2>
@@ -35,9 +36,8 @@ const link = 'text-white underline hover:text-gray-300';
<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. Bokningar görs i den separata kundportalen.</li>
<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>Lösenordsskyddade nedladdningar sparar lösenordet bara i din öppna webbläsarflik; det finns inte kvar när du stänger den.</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>.
@@ -64,7 +64,7 @@ const link = 'text-white underline hover:text-gray-300';
<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 eller laddas ner, per dag.
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>
@@ -102,6 +102,82 @@ const link = 'text-white underline hover:text-gray-300';
</section>
</div>
<div class="i18n-en space-y-8 text-gray-300 text-lg leading-relaxed" lang="en">
<section class="space-y-3">
<h2 class="text-2xl font-bold text-white">Who is responsible</h2>
<p>
{business.brand} is run by {business.person.name}, Sweden. Questions about privacy:
<a href={`mailto:${business.email}`} class={link}>{business.email}</a>.
</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-bold text-white">This website</h2>
<ul class="list-disc pl-6 space-y-2">
<li>There are no user accounts and no contact forms on this website. Poster orders and collaborations are handled in the separate customer portal.</li>
<li>If you e-mail me, your address and message are only used to answer you and are not shared with anyone.</li>
<li>
The website is delivered through Cloudflare, which processes technical data such as your IP address to deliver
and protect the website. See <a href="https://www.cloudflare.com/privacypolicy/" target="_blank" rel="noopener noreferrer" class={link}>Cloudflare's privacy policy</a>.
</li>
<li>If you appear in one of my photos and want it removed, e-mail me and I will remove it.</li>
</ul>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-bold text-white">Meta Pixel (only with your consent)</h2>
<p>
If you click "Accept" in the cookie box, the website loads the Meta Pixel from Meta Platforms Ireland Ltd.
I use it to see how visitors coming from my Instagram and Facebook use the website.
</p>
<ul class="list-disc pl-6 space-y-2">
<li>It sets cookies (for example <code>_fbp</code>) and sends data to Meta, such as the pages you visit, your IP address and information about your browser.</li>
<li>Meta may link this to your Facebook or Instagram account and may transfer data outside the EU. See
<a href="https://www.facebook.com/privacy/policy/" target="_blank" rel="noopener noreferrer" class={link}>Meta's privacy policy</a>.</li>
<li>If you click "Decline" or do nothing, the pixel is never loaded.</li>
<li>You can change your choice at any time through "Cookie settings" at the bottom of every page. Your choice is stored in your own browser.</li>
</ul>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-bold text-white">Anonymous statistics for the portfolio</h2>
<p>
To see which photos are appreciated, the portfolio counts how many times each photo is opened, per day.
Only the count is stored, on my own server in Sweden. No cookies, no IP address, no browser information and nothing
that can identify you. This happens without consent because no data about you is collected.
</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-bold text-white">Photo backup app (Google)</h2>
<p>
"townhouse-backup" is a private tool that only I use to back up my own photos to my own Google account.
</p>
<ul class="list-disc pl-6 space-y-2">
<li>Google Drive: the tool only accesses files it has created itself (a backup of my photo archive).</li>
<li>Google Photos: the tool only uploads my photos and puts them in albums it has created itself; it cannot see anything else in the library.</li>
<li>No data about anyone else is collected. Nothing is sold, shared with third parties, used for advertising or used to train AI models.</li>
<li>
townhouse-backup's use of information received from Google APIs adheres to the
<a href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank" rel="noopener noreferrer" class={link}>Google API Services User Data Policy</a>,
including the Limited Use requirements.
</li>
</ul>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-bold text-white">Your rights</h2>
<p>
Under the GDPR you can ask what personal data I hold about you and have it corrected or deleted – just e-mail me.
You can also complain to the Swedish Authority for Privacy Protection
(<a href="https://www.imy.se/" target="_blank" rel="noopener noreferrer" class={link}>IMY</a>).
</p>
</section>
</div>
</div>
</div>
+1
View File
@@ -9,6 +9,7 @@ export const GET: APIRoute = ({ site }) => {
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' } });
-127
View File
@@ -1,127 +0,0 @@
---
// src/pages/tjanster.astro
// Vad Linus fotograferar, för vem, var, pris, leverans och hur man bokar + FAQ.
// Allt från src/data/business.ts och src/data/faq.ts – okända fakta visas inte.
import MainLayout from '../layouts/MainLayout.astro';
import WaveBackground from '../components/WaveBackground.astro';
import { business, basedIn, formatSek } from '../data/business';
import { faq } from '../data/faq';
import heroImg from '../assets/images/3d-scroll/img2.jpg';
const priceFrom = formatSek(business.priceFromSek);
const packages = business.packages ?? [];
const portalHost = new URL(business.portal.url).host;
---
<MainLayout
title="Tjänster och bokning – fotografering | Images by Olofsson"
description={`Fotograf Linus Olofsson i ${basedIn()}: porträtt, natur, stad, makro, norrsken och familjefester. Så bokar du via kundportalen, leverans i privat galleri och vanliga frågor.`}
path="/tjanster"
image={heroImg}
imageAlt="Veteranbil med tända strålkastare på en stadsgata i kvällsljus"
breadcrumb="Tjänster"
faq={faq}
>
<WaveBackground opacity="opacity-70" />
<div class="container mx-auto px-4 py-12 md:py-20 relative z-10 max-w-5xl">
<header class="text-center mb-16">
<h1 class="text-4xl md:text-6xl font-bold mb-6">Tjänster och bokning</h1>
<p class="text-gray-300 text-lg md:text-xl leading-relaxed max-w-3xl mx-auto">
Jag heter {business.person.name} och är fotografen bakom {business.brand}, baserad i {basedIn()}.
Här ser du vad jag fotograferar, hur en bokning går till och hur du får dina bilder.
</p>
<div class="flex flex-wrap justify-center gap-4 mt-8">
<a href={business.portal.bookingUrl} class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition">Skicka en förfrågan</a>
<a href="#vanliga-fragor" class="inline-block px-8 py-3 border border-white text-white font-bold rounded-full hover:bg-white/10 transition">Vanliga frågor</a>
</div>
</header>
<section aria-labelledby="vad" class="mb-20">
<h2 id="vad" class="text-3xl font-bold text-white mb-8">Det här fotograferar jag</h2>
<ul class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{business.genres.map((g) => (
<li id={g.id} data-trail-box class="bg-neutral-900/80 backdrop-blur-sm p-6 rounded-xl border border-white/10">
<h3 class="text-xl font-bold text-white mb-2">{g.name}</h3>
<p class="text-gray-300">{g.description}</p>
</li>
))}
</ul>
<p class="text-gray-300 mt-6">
Se exempel i min <a href="/portfolio" class="text-white underline underline-offset-4 hover:text-gray-300">portfolio</a>.
Har du något annat i tankarna? Beskriv det i din förfrågan, så återkommer jag med ett förslag.
</p>
</section>
<section aria-labelledby="var-pris" class="mb-20 grid gap-6 md:grid-cols-2">
<div data-trail-box class="bg-neutral-900/80 backdrop-blur-sm p-6 md:p-8 rounded-xl border border-white/10">
<h2 id="var-pris" class="text-2xl font-bold text-white mb-3">Var</h2>
{business.serviceArea ? (
<p class="text-gray-300">Jag utgår från {basedIn()} och tar uppdrag i {business.serviceArea}.</p>
) : (
<p class="text-gray-300">Jag är baserad i {basedIn()}. Kontakta mig så stämmer vi av om jag kan fotografera där du befinner dig.</p>
)}
</div>
<div data-trail-box class="bg-neutral-900/80 backdrop-blur-sm p-6 md:p-8 rounded-xl border border-white/10">
<h2 class="text-2xl font-bold text-white mb-3">Pris</h2>
{priceFrom ? (
<p class="text-gray-300">Fotografering från <strong class="text-white">{priceFrom}</strong>. Det slutliga priset står i offerten.</p>
) : (
<p class="text-gray-300">Priset beror på uppdraget. Kontakta mig eller skicka en förfrågan, så får du en offert för just det du vill ha fotograferat.</p>
)}
{packages.length > 0 && (
<ul class="mt-4 space-y-3">
{packages.map((p) => (
<li class="text-gray-300">
<strong class="text-white">{p.name}</strong>
{formatSek(p.priceSek) && <span> – {formatSek(p.priceSek)}</span>}
{p.includes.length > 0 && <span class="block text-sm text-gray-400">{p.includes.join(', ')}</span>}
</li>
))}
</ul>
)}
</div>
</section>
<section aria-labelledby="boka" class="mb-20">
<h2 id="boka" class="text-3xl font-bold text-white mb-8">Så går en bokning till</h2>
<ol class="space-y-4">
{business.bookingSteps.map((step, i) => (
<li class="flex gap-4 bg-neutral-900/70 backdrop-blur-sm p-5 rounded-xl border border-white/10">
<span class="shrink-0 w-10 h-10 rounded-full border border-white/30 flex items-center justify-center font-bold text-white">{i + 1}</span>
<div>
<h3 class="text-lg font-bold text-white">{step.title}</h3>
<p class="text-gray-300">{step.text}</p>
</div>
</li>
))}
</ol>
<p class="text-gray-300 mt-6">
Leverans: bilderna levereras i ett privat galleri i kundportalen
({portalHost}).{business.deliveryTime && ` Normal leveranstid är ${business.deliveryTime}.`}
</p>
<a href={business.portal.bookingUrl} class="inline-block mt-6 px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition">Skicka en förfrågan i kundportalen</a>
</section>
<section aria-labelledby="vanliga-fragor" class="mb-12">
<h2 id="vanliga-fragor" class="text-3xl font-bold text-white mb-8">Vanliga frågor</h2>
<div class="space-y-4">
{faq.map((item) => (
<details class="group bg-neutral-900/80 backdrop-blur-sm rounded-xl border border-white/10 p-5 open:border-white/20">
<summary class="cursor-pointer list-none flex justify-between items-center gap-4">
<h3 class="text-lg font-bold text-white">{item.question}</h3>
<span aria-hidden="true" class="text-gray-400 transition-transform group-open:rotate-45 text-2xl leading-none">+</span>
</summary>
<p class="text-gray-300 mt-3">{item.answer}</p>
</details>
))}
</div>
<p class="text-gray-300 mt-8">
Fler frågor? Mejla <a href={`mailto:${business.email}`} class="text-white underline underline-offset-4 hover:text-gray-300">{business.email}</a>
eller skriv på Instagram <a href={business.social.instagram.url} target="_blank" rel="noopener noreferrer" class="text-white underline underline-offset-4 hover:text-gray-300">{business.social.instagram.handle}</a>.
</p>
</section>
</div>
</MainLayout>