Author SHA1 Message Date
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
34 changed files with 164 additions and 687 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: 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 },
},
})),
},
},
];
+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}).`,
},
];
+2 -2
View File
@@ -13,7 +13,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;
@@ -45,7 +45,7 @@ const ogHeight = Number(og.attributes.height) || undefined;
const nav = [
{ href: '/portfolio', label: 'Portfolio' },
{ href: '/tjanster', label: 'Tjänster' },
{ href: '/posters', label: 'Posters' },
{ href: '/about', label: 'Om mig' },
{ href: '/contact', label: 'Kontakt' },
];
+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$/, '');
+2 -2
View File
@@ -6,14 +6,14 @@ import WaveBackground from '../components/WaveBackground.astro';
const links = [
{ href: '/', label: 'Startsidan' },
{ href: '/portfolio', label: 'Portfolio' },
{ href: '/tjanster', label: 'Tjänster och bokning' },
{ href: '/posters', label: 'Posters' },
{ href: '/contact', label: 'Kontakt' },
];
---
<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" />
-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>
+12 -16
View File
@@ -8,7 +8,7 @@ import { business, basedIn, genreList } from '../data/business';
<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="/"
>
@@ -86,16 +86,16 @@ 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">Vill du ha en bild på väggen?</h2>
<a href="/posters" 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 posters
</a>
</div>
</div>
</div>
<!-- Fakta om vem, vad, var och bokning: läsbar text för besökare, sökmotorer och AI-assistenter. -->
<!-- Fakta om vem, vad och var: 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">
@@ -116,18 +116,14 @@ import { business, basedIn, genreList } from '../data/business';
</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>
<h2 class="text-3xl md:text-4xl font-bold text-white">Posters och samarbeten</h2>
<p class="text-gray-300 text-lg leading-relaxed">
Fotografierna finns som posters: välj motiv, storlek och material. Vill du skapa något tillsammans,
eller synas genom reklam eller sponsring, hör av dig om ett samarbete.
</p>
<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>
<a href="/posters" class="inline-block px-6 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition">Se posters</a>
<a href="/contact" class="inline-block px-6 py-3 border border-white text-white font-bold rounded-full hover:bg-white/10 transition">Samarbeten</a>
</div>
</div>
</div>
+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',
'',
+11 -153
View File
@@ -90,23 +90,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,
fStop, shutterSpeed, iso, focalLength,
dlS: small, dlM: medium, dlL: large, srcset: jpg.srcSet.attribute,
assetId, statKey,
large, srcset: jpg.srcSet.attribute,
statKey,
};
}));
@@ -134,7 +131,7 @@ 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>
Vill du ha en bild på väggen? Se <a href="/posters" class="text-white underline underline-offset-4 hover:text-gray-300">posters</a>.</p>
</div>
<div class="flex flex-col items-center gap-6 mb-12">
@@ -157,17 +154,13 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
<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}
href={item.large}
class="gallery-item 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}
@@ -227,18 +220,9 @@ 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>
<a href="/posters" class="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">
Köp som poster
</a>
<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>
@@ -249,21 +233,6 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
</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 +245,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 +317,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,8 +328,6 @@ 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 || '';
+1 -2
View File
@@ -35,9 +35,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>.
+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>