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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:41:59 +01:00
29 changed files with 2395 additions and 609 deletions
+3 -3
View File
@@ -8,15 +8,15 @@ A modern, responsive photography portfolio website built with Astro, Tailwind CS
<table> <table>
<tr> <tr>
<td align="center"> <td align="center">
<img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/public/3d-scroll/img1.jpg" width="250" alt="Portfolio Grid"> <img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/src/assets/images/3d-scroll/img1.jpg" width="250" alt="Portfolio Grid">
<br><em> More </em> <br><em> More </em>
</td> </td>
<td align="center"> <td align="center">
<img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/public/3d-scroll/img2.jpg" width="250" alt="3D Scroll Effect"> <img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/src/assets/images/3d-scroll/img2.jpg" width="250" alt="3D Scroll Effect">
<br><em> At </em> <br><em> At </em>
</td> </td>
<td align="center"> <td align="center">
<img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/public/3d-scroll/img3.jpg" width="250" alt="Hero Section"> <img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/src/assets/images/3d-scroll/img3.jpg" width="250" alt="Hero Section">
<br><em>Imagesbyolofsson.se</em> <br><em>Imagesbyolofsson.se</em>
</td> </td>
</tr> </tr>
+9
View File
@@ -2,6 +2,15 @@ import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
export default defineConfig({ export default defineConfig({
// Kanonisk adress: canonical, og:url, sitemap och JSON-LD byggs från denna.
site: 'https://imagesbyolofsson.se',
// En URL per sida: /about (aldrig /about/). Servern 301:ar /about/ -> /about.
trailingSlash: 'never',
build: {
// about.astro -> dist/about.html (inte dist/about/index.html).
// nginx/Caddy serverar /about via try_files $uri.html.
format: 'file',
},
vite: { vite: {
plugins: [tailwindcss()], plugins: [tailwindcss()],
}, },
+99
View File
@@ -0,0 +1,99 @@
# deploy/caddy-site.snippet
# Caddy-motsvarigheten till nginx.conf för imagesbyolofsson.se (statisk dist/ från `astro build`,
# build.format "file": /about -> about.html, 404 -> 404.html).
#
# Användning i Caddyfile (Caddy >= 2.7):
#
# import /etc/caddy/snippets/caddy-site.snippet
#
# www.imagesbyolofsson.se {
# import ibo_www_redirect
# }
# imagesbyolofsson.se {
# # Befintliga specialregler (t.ex. handle /download/original* -> Immich-proxyn)
# # ska ligga FÖRE importen, så att de vinner över try_files/file_server.
# import ibo_site /srv/imagesbyolofsson/dist
# }
#
# Argumentet är sökvägen till dist-katalogen på servern (justera till den riktiga).
(ibo_www_redirect) {
redir https://imagesbyolofsson.se{uri} permanent
}
(ibo_site) {
root * {args[0]}
encode zstd gzip
# --- Säkerhetsheaders ---
# CSP: tillåter Meta Pixel (laddas först efter samtycke), anonym statistik via sendBeacon
# till customer.imagesbyolofsson.se och Cloudflares egna skript (/cdn-cgi, ev. Web Analytics).
# 'unsafe-inline' behövs för samtyckesskriptet och Astros inlinade skript/stilar.
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()"
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://connect.facebook.net https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://www.facebook.com https://*.facebook.com; font-src 'self' data:; connect-src 'self' https://customer.imagesbyolofsson.se https://www.facebook.com https://*.facebook.com https://connect.facebook.net https://cloudflareinsights.com; frame-src https://www.facebook.com; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests"
-Server
}
# --- Cache-Control (ömsesidigt uteslutande matchers) ---
@ibo_immutable path /_astro/*
header @ibo_immutable Cache-Control "public, max-age=31536000, immutable"
@ibo_media {
path *.jpg *.jpeg *.png *.webp *.avif *.gif *.svg *.ico *.woff *.woff2 *.ttf *.otf
not path /_astro/*
}
header @ibo_media Cache-Control "public, max-age=2592000"
@ibo_textfiles path *.xml *.txt
header @ibo_textfiles Cache-Control "public, max-age=3600"
@ibo_pages {
not path /_astro/*
not path *.jpg *.jpeg *.png *.webp *.avif *.gif *.svg *.ico *.woff *.woff2 *.ttf *.otf *.xml *.txt
}
header @ibo_pages Cache-Control "public, max-age=0, must-revalidate"
# --- 301:or ---
# Gamla indexerade adresser -> portfolion.
@ibo_old path /my-people /my-people/ /animales-and-nature /animales-and-nature/ /celebrations-with-family /celebrations-with-family/ /northen-lights /northen-lights/
redir @ibo_old /portfolio permanent
# /index.html -> /
redir /index.html / permanent
# /about.html -> /about (404-sidan hanteras separat nedan)
@ibo_htmlext {
path_regexp ibo_htmlext ^(/.+)\.html$
not path /404.html
}
redir @ibo_htmlext {re.ibo_htmlext.1}{?query} permanent
# Avslutande snedstreck: /about/ -> /about
@ibo_trailing path_regexp ibo_trailing ^(/.+)/$
redir @ibo_trailing {re.ibo_trailing.1}{?query} permanent
# 404-sidan ska bara synas som svar på okända adresser, inte som egen URL med 200.
@ibo_404page path /404 /404.html
error @ibo_404page 404
# --- Statiska filer ---
try_files {path} {path}.html
file_server
# Riktig 404: 404.html med status 404 (inte startsidan med 200).
handle_errors {
@ibo_notfound expression {err.status_code} == 404
handle @ibo_notfound {
root * {args[0]}
rewrite * /404.html
file_server {
status 404
}
}
}
}
+69 -25
View File
@@ -1,11 +1,20 @@
map $sent_http_content_type $cache_control_strategy { # nginx för imagesbyolofsson.se (Docker-image: dist/ i /usr/share/nginx/html).
default "public, max-age=2592000"; # Bygget använder build.format "file": /about -> about.html, 404 -> 404.html.
~^text/html "no-store, must-revalidate"; # Samma regler som Caddy-varianten i deploy/caddy-site.snippet.
~^text/css "public, max-age=31536000, immutable";
~^application/javascript "public, max-age=31536000, immutable"; # Cache-Control per sökväg (inte per Content-Type, så att /_astro/*.jpg också blir immutable).
~^application/json "no-store, must-revalidate"; map $uri $ibo_cache_control {
~^image/ "public, max-age=2592000"; default "public, max-age=0, must-revalidate";
~^font/ "public, max-age=31536000, immutable"; ~^/_astro/ "public, max-age=31536000, immutable";
~*\.(?:jpe?g|png|webp|avif|gif|svg|ico|woff2?|ttf|otf)$ "public, max-age=2592000";
~*\.(?:xml|txt)$ "public, max-age=3600";
}
# www -> apex (om www-trafik når den här containern).
server {
listen 80;
server_name www.imagesbyolofsson.se;
return 301 https://imagesbyolofsson.se$request_uri;
} }
server { server {
@@ -14,33 +23,68 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Relativa Location-headers: bakom Cloudflare/proxy blir det aldrig http://host:80/...
absolute_redirect off;
server_tokens off;
# Säkerhetsheaders. OBS: inga add_header i location-blocken nedan, annars ärvs inte dessa.
# CSP: tillåter Meta Pixel (connect.facebook.net/facebook.com, laddas först efter samtycke),
# den anonyma statistiken (sendBeacon till customer.imagesbyolofsson.se) och Cloudflares
# egna skript (/cdn-cgi, ev. Web Analytics). 'unsafe-inline' behövs för samtyckesskriptet
# och Astros inlinade skript/stilar; byt mot hashar (Astro experimental.csp) i ett senare steg.
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
add_header Cache-Control $cache_control_strategy always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://connect.facebook.net https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://www.facebook.com https://*.facebook.com; font-src 'self' data:; connect-src 'self' https://customer.imagesbyolofsson.se https://www.facebook.com https://*.facebook.com https://connect.facebook.net https://cloudflareinsights.com; frame-src https://www.facebook.com; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests" always;
add_header Cache-Control $ibo_cache_control always;
# Gamla indexerade adresser -> närmaste nya sida.
location ~ ^/(?:my-people|animales-and-nature|celebrations-with-family|northen-lights)/?$ {
return 301 /portfolio;
}
# /index.html och /about.html -> rena URL:er (bara externa anrop; try_files nedan
# serverar filerna internt utan att gå hit).
location = /index.html {
return 301 /;
}
location ~ ^(?<ibo_noext>/.+)\.html$ {
return 301 $ibo_noext$is_args$args;
}
# Inga dubbletter med avslutande snedstreck: /about/ -> /about.
location ~ ^(?<ibo_noslash>/.+)/$ {
return 301 $ibo_noslash$is_args$args;
}
# Startsidan serveras direkt (ingen intern omdirigering till /index.html).
location = / {
try_files /index.html =404;
}
# Hashade Astro-filer: cachas ett år (immutable sätts via map ovan).
location ^~ /_astro/ {
access_log off;
try_files $uri =404;
}
# Originalnedladdning (/download/original) proxas till Immich någon annanstans
# (Cloudflare/Caddy). Finns den regeln i den här nginx: lägg den här ovanför.
location / { location / {
try_files $uri $uri/ $uri.html $uri/index.html =404; try_files $uri $uri.html =404;
} }
location /_astro/ { # Riktig 404: 404.html med status 404 (inte startsidan med 200).
access_log off; error_page 404 /404.html;
expires 1y; location = /404.html {
} internal;
location ~* \.(webp|avif|jpg|jpeg|png|gif|svg|ico|woff2?|ttf|otf|eot)$ {
access_log off;
expires 30d;
} }
gzip on; gzip on;
gzip_vary on; gzip_vary on;
gzip_min_length 1024; gzip_min_length 1024;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss image/svg+xml; gzip_types text/plain text/css text/xml application/xml application/json application/javascript application/xml+rss image/svg+xml;
error_page 404 /404.html;
location = /404.html {
internal;
}
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 542 KiB

+41
View File
@@ -0,0 +1,41 @@
# robots.txt – https://imagesbyolofsson.se
#
# Policy (beslut av Linus 2026-10-27): ALLA botar är välkomna, även AI-crawlers
# och träningsbotar. Innehållet får användas av AI-assistenter med hänvisning
# till Images by Olofsson (se /llms.txt). Cloudflare-sidan (AI Crawl Control /
# "Block AI bots" / hanterad robots.txt) ställs in separat så att den inte
# blockerar eller skriver över detta.
User-agent: *
Allow: /
# Lösenordsskyddad nedladdning av originalfiler – inget att indexera.
Disallow: /download/
# AI-crawlers och träningsbotar: uttryckligen tillåtna.
User-agent: GPTBot
User-agent: OAI-SearchBot
User-agent: ChatGPT-User
User-agent: ClaudeBot
User-agent: Claude-User
User-agent: Claude-SearchBot
User-agent: anthropic-ai
User-agent: CCBot
User-agent: Google-Extended
User-agent: Applebot
User-agent: Applebot-Extended
User-agent: Bytespider
User-agent: Amazonbot
User-agent: meta-externalagent
User-agent: meta-externalfetcher
User-agent: FacebookBot
User-agent: PerplexityBot
User-agent: Perplexity-User
User-agent: cohere-ai
User-agent: Diffbot
User-agent: YouBot
User-agent: DuckAssistBot
User-agent: MistralAI-User
Allow: /
Disallow: /download/
Sitemap: https://imagesbyolofsson.se/sitemap-index.xml
+46
View File
@@ -0,0 +1,46 @@
---
// src/components/SEO.astro
// <title>, meta description, canonical, Open Graph och Twitter-kort för en sida.
import { business } from '../data/business';
import { normalizePath } from '../lib/url';
export interface Props {
title: string;
description: string;
/** Sökväg utan avslutande snedstreck, t.ex. "/about". Annars härleds den från URL:en. */
path?: string;
/** Absolut URL till og:image (MainLayout tar fram en EXIF-fri JPG via getImage). */
imageUrl: string;
imageWidth?: number;
imageHeight?: number;
imageAlt: string;
type?: 'website' | 'profile' | 'article';
noindex?: boolean;
}
const { title, description, path, imageUrl, imageWidth, imageHeight, imageAlt, type = 'website', noindex = false } = Astro.props;
const site = Astro.site ?? new URL(business.url);
const canonical = new URL(normalizePath(path ?? Astro.url.pathname), site).toString();
---
<title>{title}</title>
<meta name="description" content={description} />
{noindex ? <meta name="robots" content="noindex, follow" /> : <link rel="canonical" href={canonical} />}
<meta property="og:type" content={type} />
<meta property="og:site_name" content={business.brand} />
<meta property="og:locale" content="sv_SE" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={imageUrl} />
{imageWidth && <meta property="og:image:width" content={String(imageWidth)} />}
{imageHeight && <meta property="og:image:height" content={String(imageHeight)} />}
<meta property="og:image:alt" content={imageAlt} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={imageUrl} />
<meta name="twitter:image:alt" content={imageAlt} />
+155
View File
@@ -0,0 +1,155 @@
---
// src/components/SchemaOrg.astro
// JSON-LD (@graph): WebSite + Person (Linus) + ProfessionalService (Images by Olofsson)
// + WebPage för aktuell sida (+ FAQPage och BreadcrumbList när det är relevant).
// Allt hämtas från src/data/business.ts. null-värden utelämnas helt.
import { business, absoluteUrl, genreList } from '../data/business';
import type { FaqItem } from '../data/faq';
import { normalizePath } from '../lib/url';
export interface Props {
title: string;
description: string;
path?: string;
pageType?: 'WebPage' | 'AboutPage' | 'ContactPage' | 'CollectionPage';
/** Namn i brödsmulan för undersidor, t.ex. "Tjänster". */
breadcrumb?: string;
faq?: FaqItem[];
/** Absolut URL till sidans huvudbild (og:image). */
imageUrl?: string;
}
const { title, description, path, pageType = 'WebPage', breadcrumb, faq, imageUrl } = Astro.props;
const site = (Astro.site ?? new URL(business.url)).toString().replace(/\/$/, '');
const pagePath = normalizePath(path ?? Astro.url.pathname);
const pageUrl = absoluteUrl(pagePath);
const ids = {
website: `${site}/#website`,
person: `${site}/#linus-olofsson`,
business: `${site}/#images-by-olofsson`,
webpage: `${pageUrl}#webpage`,
breadcrumb: `${pageUrl}#breadcrumb`,
};
// Tar bort null/undefined/tomma listor så att okända fakta aldrig hamnar i JSON-LD.
function clean<T>(value: T): T {
if (Array.isArray(value)) {
return value.map(clean).filter((v) => v !== null && v !== undefined) as unknown as T;
}
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (v === null || v === undefined) continue;
const c = clean(v);
if (Array.isArray(c) && c.length === 0) continue;
out[k] = c;
}
return out as T;
}
return value;
}
// Landet är känt (Sverige); ort och region läggs till när de fylls i business.ts.
const address = {
'@type': 'PostalAddress',
addressLocality: business.city,
addressRegion: business.region,
addressCountry: business.country.code,
};
const graph: Record<string, unknown>[] = [
{
'@type': 'WebSite',
'@id': ids.website,
url: `${site}/`,
name: business.brand,
inLanguage: 'sv-SE',
publisher: { '@id': ids.business },
},
{
'@type': 'Person',
'@id': ids.person,
name: business.person.name,
jobTitle: business.person.jobTitle,
url: absoluteUrl('/about'),
description: `${business.person.jobTitle} baserad i ${business.country.name}. ${business.recognitionThirdPerson}`,
knowsAbout: business.genres.map((g) => g.name),
worksFor: { '@id': ids.business },
sameAs: [business.social.linkedin, business.social.lightroom],
},
{
'@type': 'ProfessionalService',
'@id': ids.business,
name: business.brand,
url: `${site}/`,
description: `${business.person.name} fotograferar ${genreList()}.`,
email: business.email,
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 },
},
})),
},
},
];
const webpage: Record<string, unknown> = {
'@type': faq?.length ? [pageType, 'FAQPage'] : pageType,
'@id': ids.webpage,
url: pageUrl,
name: title,
description,
inLanguage: 'sv-SE',
isPartOf: { '@id': ids.website },
about: { '@id': ids.business },
primaryImageOfPage: imageUrl ? { '@type': 'ImageObject', url: imageUrl } : null,
breadcrumb: breadcrumb ? { '@id': ids.breadcrumb } : null,
mainEntity: faq?.length
? faq.map((item) => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: { '@type': 'Answer', text: item.answer },
}))
: null,
};
graph.push(webpage);
if (breadcrumb) {
graph.push({
'@type': 'BreadcrumbList',
'@id': ids.breadcrumb,
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Hem', item: `${site}/` },
{ '@type': 'ListItem', position: 2, name: breadcrumb, item: pageUrl },
],
});
}
const json = JSON.stringify(clean({ '@context': 'https://schema.org', '@graph': graph })).replace(/</g, '\\u003c');
---
<script type="application/ld+json" is:inline set:html={json} />
+26 -6
View File
@@ -1,13 +1,22 @@
--- ---
// src/components/ThreeDScroll.astro // src/components/ThreeDScroll.astro
// Bilderna ligger i src/assets så att Astro skalar om dem (AVIF/WebP/JPG, srcset)
// och rensar bort EXIF (GPS, serienummer m.m.). Originalen publiceras inte.
import { Picture } from 'astro:assets';
import img1 from '../assets/images/3d-scroll/img1.jpg';
import img2 from '../assets/images/3d-scroll/img2.jpg';
import img3 from '../assets/images/3d-scroll/img3.jpg';
import img4 from '../assets/images/3d-scroll/img4.jpg';
import img5 from '../assets/images/3d-scroll/img5.jpg';
const images = [ const images = [
{ src: "/3d-scroll/img1.jpg", alt: "Architecture Dark" }, { src: img1, alt: 'Närbild av en gammal läderinbunden bok med slitna pappersark i varmt motljus' },
{ src: "/3d-scroll/img2.jpg", alt: "Shadows Light" }, { src: img2, alt: 'Veteranbil med tända strålkastare på en stadsgata i kvällsljus' },
{ src: "/3d-scroll/img3.jpg", alt: "Portrait High End" }, { src: img3, alt: 'Överblommade tistlar bland höga grässtrån i dämpade bruna toner' },
{ src: "/3d-scroll/img4.jpg", alt: "Minimalist Landscape" }, { src: img4, alt: 'Järnvägsspår som slingrar sig mellan träd i motljus, i sepiaton' },
{ src: "/3d-scroll/img5.jpg", alt: "Foggy Forest" }, { src: img5, alt: 'Svartvit bild av en man som bär ett litet barn bland såpbubblor i motljus' },
]; ];
const last = images.length - 1;
--- ---
<div id="scroll-container" class="relative h-[400vh] w-full"> <div id="scroll-container" class="relative h-[400vh] w-full">
@@ -20,9 +29,20 @@ const images = [
class="gallery-item absolute transform-style-3d will-change-transform flex items-center justify-center" class="gallery-item absolute transform-style-3d will-change-transform flex items-center justify-center"
data-index={index} data-index={index}
> >
<img {/* Första bilden: eager + hög prioritet. Den sista ligger överst innan man
scrollat (alla bilder står på samma plats tills scriptet flyttar dem),
så den laddas också direkt. Övriga laddas lat. */}
<Picture
src={img.src} src={img.src}
alt={img.alt} alt={img.alt}
widths={[640, 1024, 1600, 2048]}
sizes="80vw"
formats={['avif', 'webp']}
fallbackFormat="jpg"
quality={78}
loading={index === 0 || index === last ? 'eager' : 'lazy'}
fetchpriority={index === 0 ? 'high' : 'auto'}
decoding="async"
class="floating-animation max-w-[80vw] max-h-[70vh] w-auto h-auto object-contain rounded-lg shadow-[0_20px_60px_-15px_rgba(0,0,0,0.8)] border border-white/5 grayscale hover:grayscale-0 transition-all duration-700 ease-out" class="floating-animation max-w-[80vw] max-h-[70vh] w-auto h-auto object-contain rounded-lg shadow-[0_20px_60px_-15px_rgba(0,0,0,0.8)] border border-white/5 grayscale hover:grayscale-0 transition-all duration-700 ease-out"
/> />
</div> </div>
+183
View File
@@ -0,0 +1,183 @@
// src/data/business.ts
// ALLA fakta om Images by Olofsson på ett ställe. Sidor, JSON-LD, llms.txt och
// sitemap läser härifrån – skriv aldrig in fakta direkt i en sida.
//
// Regel: det vi inte vet är `null`. Allt som läser filen hoppar över null-värden
// (ingen "TODO", inga påhittade siffror syns på sajten). När Linus lämnar ett
// värde: fyll i det här, så dyker det upp på /tjanster, i FAQ och i JSON-LD.
export type Genre = {
/** Kort id, används som ankare på /tjanster (#portratt osv.) */
id: string;
name: string;
description: string;
};
export type Business = {
brand: string;
url: string;
person: {
name: string;
jobTitle: string;
};
email: string;
/** Telefonnummer i format +46... – okänt */
phone: string | null;
country: { code: string; name: string };
/** Ort Linus utgår från (hans publika Flickr- och LinkedIn-profil, 2026-10-27) */
city: string | null;
/** Län/region (LinkedIn, 2026-10-27) */
region: string | null;
/** Område han tar uppdrag i (fritext, t.ex. "Stockholm med omnejd") – okänt */
serviceArea: string | null;
/** Lägsta pris i SEK för en fotografering – okänt */
priceFromSek: number | null;
/** schema.org priceRange, t.ex. "$$" eller "1500–5000 kr" – okänt */
priceRange: string | null;
/** Paket och vad som ingår – okänt */
packages: { name: string; includes: string[]; priceSek: number | null }[] | null;
/** Typisk leveranstid, fritext (t.ex. "inom två veckor") – okänt */
deliveryTime: string | null;
/** Antal år som fotograf – okänt */
yearsOfExperience: number | null;
/** Kundomdömen – inga kända */
testimonials: { name: string; text: string }[] | null;
camera: string;
lenses: string[];
editing: string;
/** Linus egen formulering: "featured by Adobe three times". Inga datum/detaljer. Jag-form för sidtext. */
recognition: string;
/** Samma fakta i tredje person (llms.txt, JSON-LD, meta descriptions). */
recognitionThirdPerson: string;
portal: {
url: string;
bookingUrl: string;
};
social: {
instagram: { handle: string; url: string };
facebook: string;
flickr: string;
lightroom: string;
linkedin: string;
github: string;
};
genres: Genre[];
bookingSteps: { title: string; text: string }[];
};
export const business: Business = {
brand: 'Images by Olofsson',
url: 'https://imagesbyolofsson.se',
person: {
name: 'Linus Olofsson',
jobTitle: 'Fotograf',
},
email: 'Admin@imagesbyolofsson.se',
phone: null,
country: { code: 'SE', name: 'Sverige' },
city: 'Örebro',
region: 'Örebro län',
serviceArea: null,
priceFromSek: null,
priceRange: null,
packages: null,
deliveryTime: null,
yearsOfExperience: null,
testimonials: null,
camera: 'Sony A7R V',
lenses: ['Sony FE 70-200mm f/4 G OSS', 'Sony FE 20mm f/1.8 G'],
editing: 'Adobe Lightroom Classic',
recognition: 'Mina bilder har lyfts fram av Adobe tre gånger.',
recognitionThirdPerson: 'Hans bilder har lyfts fram av Adobe tre gånger.',
portal: {
url: 'https://customer.imagesbyolofsson.se',
bookingUrl: 'https://customer.imagesbyolofsson.se/boka',
},
social: {
instagram: { handle: '@imagesbyolofsson', url: 'https://www.instagram.com/imagesbyolofsson/' },
facebook: 'https://www.facebook.com/imagesbyolofsson',
flickr: 'https://www.flickr.com/photos/imagesbyolofsson/',
lightroom: 'https://lightroom.adobe.com/u/linusolofsson1',
linkedin: 'https://www.linkedin.com/in/linus-olofsson-/',
github: 'https://github.com/LinusOlofsson-maker',
},
// Bara genrer som redan finns i sajtens texter och gamla sidnamn
// (/my-people, /animales-and-nature, /celebrations-with-family, /northen-lights).
genres: [
{
id: 'portratt',
name: 'Porträtt och människor',
description: 'Porträtt och ögonblick med människor – uttryck som berättar något utan ord.',
},
{
id: 'natur',
name: 'Natur och djur',
description: 'Skog, landskap och djur – naturens stillhet och de små detaljerna i den.',
},
{
id: 'stad',
name: 'Stad och arkitektur',
description: 'Gator, byggnader, strukturer och kontraster – stadens energi och dess tomma stunder.',
},
{
id: 'makro',
name: 'Makro och detaljer',
description: 'Mönster, texturer och små detaljer som de flesta går förbi.',
},
{
id: 'norrsken',
name: 'Norrsken',
description: 'Norrskenets ljus och färger över himlen.',
},
{
id: 'fest',
name: 'Fester och familjehögtider',
description: 'Bilder från fester och högtider tillsammans med familj och vänner.',
},
],
bookingSteps: [
{
title: 'Skicka en förfrågan',
text: 'Berätta vad du vill ha fotograferat, när och var – via kundportalen.',
},
{
title: 'Få en offert',
text: 'Jag återkommer med ett förslag och en offert för just ditt uppdrag.',
},
{
title: 'Bokning',
text: 'När du har godkänt offerten bokar vi in fotograferingen.',
},
{
title: 'Leverans i ett privat galleri',
text: 'Du får bilderna i ett eget, privat galleri i kundportalen.',
},
{
title: 'Originalfilerna efter betalning',
text: 'När betalningen är klar kan du ladda ner originalfilerna i full upplösning.',
},
],
};
/** Genrer som läsbar lista: "a, b, c samt d". */
export function genreList(lowercase = true): string {
const names = business.genres.map((g) => (lowercase ? g.name.toLowerCase() : g.name));
if (names.length <= 1) return names.join('');
// "samt" före sista ledet, eftersom genrenamnen själva innehåller "och".
return `${names.slice(0, -1).join(', ')} samt ${names[names.length - 1]}`;
}
/** "Sverige", eller "Ort, Sverige" när orten är känd. */
export function basedIn(): string {
return [business.city, business.country.name].filter(Boolean).join(', ');
}
/** Formaterat pris ("1 500 kr") eller null. */
export function formatSek(value: number | null): string | null {
if (value == null) return null;
return `${value.toLocaleString('sv-SE')} kr`;
}
export function absoluteUrl(path: string): string {
return new URL(path, business.url).toString();
}
+63
View File
@@ -0,0 +1,63 @@
// 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';
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}).`,
},
];
+616
View File
@@ -0,0 +1,616 @@
// src/data/portfolio-meta.ts
// DATAKROK för riktiga alt-texter (och ev. titel/kategori) per portfoliobild.
// Ifylld 2026-10-27: alt-texter skrivna med visionsmodellen gemma-4-31b-it-fp8 (Cleura)
// på nedskalade kopior, granskade manuellt mot bilderna. Saknas en post använder sidan
// en neutral svensk reservtext byggd på datum, aldrig kameramodell eller "undefined".
//
// Nyckel = bildens filnamn utan ändelse, exakt som i src/assets/images/portfolio/,
// t.ex. "immich-02533d10-07ca-4a39-8b8a-6648fe2ffcef-20260905-DSC07734"
// eller "20250510-WJ9E1439-Enhanced-NR".
//
// Exempel:
// "immich-02533d10-...-20260905-DSC07734": {
// alt: "Norrsken i grönt och lila över en mörk skogssiluett",
// title: "Norrsken i september",
// category: "Norrsken",
// },
export type PortfolioMeta = {
/** Beskriv vad bilden visar, på svenska, ca 8–16 ord. */
alt?: string;
/** Kort bildtitel (visas i lightboxen om den finns). */
title?: string;
/** Helst ett av namnen i business.genres, t.ex. "Natur och djur". */
category?: string;
};
export const portfolioMeta: Record<string, PortfolioMeta> = {
"20240804-20241104-IMG_3861": {
alt: "En person sedd bakifrån under lövverk mot ett dimmigt fält och en lada i skymningen",
category: "Porträtt och människor",
},
"20240819-Screenshot_2024-08-19-12-12-33-62_92460851df6f172a4592fca41cc2d2e6": {
alt: "Närbild av ett gulsvart bi med orange pollen på benet mot en mörk bakgrund",
category: "Makro och detaljer",
},
"20240901-20240901-_J9E8565": {
alt: "En slingrande grusväg genom en mörk skog med höga björkar i dämpat ljus",
category: "Natur och djur",
},
"20240901-20240901-_J9E8586": {
alt: "Ett djuprött löv i centrum mot en mörk och suddig grön bakgrund i dämpat ljus",
category: "Makro och detaljer",
},
"20240901-_J9E8446": {
alt: "Vita fröställningar i ett fält av torrt gräs med varma färger och mörk bakgrund",
category: "Natur och djur",
},
"20240903-20240903-WJ9E8891": {
alt: "Kurviga järnvägsspår i sepiafärgat ljus omgivna av mörka träd och en byggnad i bakgrunden",
category: "Stad och arkitektur",
},
"20240914-20240914-WJ9E0318": {
alt: "Silhuett av en person med långt hår mot en gyllene solnedgång över öppna fält",
category: "Porträtt och människor",
},
"20240921-WJ9E1119": {
alt: "En man med rödbrunt lockigt hår och skägg i mörka kläder i en dämpad stadsmiljö",
category: "Porträtt och människor",
},
"20241021-IMG_9545-Enhanced-NR": {
alt: "Närbild på torra bruna ekblad omgivna av gula och orangea höstfärger med mörk vinjettering",
category: "Makro och detaljer",
},
"20241101-_MG_0023-Enhanced-NR": {
alt: "Ett brunt höstlöv med glittrande vattendroppar mot en svart bakgrund i dramatiskt ljus",
category: "Makro och detaljer",
},
"20241111-_MG_9832": {
alt: "En mörk stadsgata med byggnader under en lila skymningshimmel och en lysande måne",
category: "Stad och arkitektur",
},
"20241117-_J9E0597": {
alt: "En brun häst med grimma i profil mot en gyllene solnedgång och suddiga träd",
category: "Natur och djur",
},
"20241117-_J9E0620": {
alt: "Närbild av en vit häst med brunt läderträns i ett varmt och mörkt motljus",
category: "Natur och djur",
},
"20241117-_J9E0621": {
alt: "En vit häst med brun grimma i ett mjukt ljus vid en solnedgång",
category: "Natur och djur",
},
"20241124-_MG_1066-Enhanced-NR": {
alt: "En närbild av vita, glittrande iskristaller mot en mörk och suddig bakgrund",
category: "Makro och detaljer",
},
"20250217-_J9E5859-Enhanced-NR": {
alt: "Torkade fröställningar i mörkt, stämningsfullt ljus med dova bruna och lila färger",
category: "Makro och detaljer",
},
"20250217-_J9E5864-Enhanced-NR": {
alt: "En rödbrun torkad blomställning i mjukt ljus mot en mörk suddig bakgrund",
category: "Makro och detaljer",
},
"20250217-_J9E5897": {
alt: "Gyllene ax av torkat gräs i varmt ljus mot en mörkblå suddig bakgrund",
category: "Natur och djur",
},
"20250315-WJ9E1852": {
alt: "Ett barn i grön vinterjacka och mörk mössa håller i något i dämpat utomhusljus",
category: "Porträtt och människor",
},
"20250315-WJ9E1919": {
alt: "En man i mörkblå mössa och dunjacka i dämpat ljus mot en suddig bakgrund",
category: "Porträtt och människor",
},
"20250315-WJ9E1967-Enhanced-NR": {
alt: "En kvinna i vit mössa och ett barn i grön jacka blåser såpbubblor utomhus",
category: "Porträtt och människor",
},
"20250330-_MG_1223-Enhanced-NR": {
alt: "En person håller en svart kamera mot en suddig bakgrund av gula höstlöv",
category: "Makro och detaljer",
},
"20250413-WJ9E0212": {
alt: "En man kör en vagn dragen av två hästar på en stadsgata i sepia",
category: "Stad och arkitektur",
},
"20250510-WJ9E1439-Enhanced-NR": {
alt: "Ett barn i randig mössa och grön jacka håller en röd spade i en sandlåda",
category: "Porträtt och människor",
},
"20250510-WJ9E1492-Enhanced-NR": {
alt: "En kvinna med glasögon och brun handske håller en grön mugg i dämpat ljus",
category: "Porträtt och människor",
},
"20250510-WJ9E1546-Enhanced-NR": {
alt: "Ett barn med blont hår och blå ögon i mörkblå fleecejacka med fingret vid munnen",
category: "Porträtt och människor",
},
"20250510-WJ9E1598-Enhanced-NR": {
alt: "Två vuxna och ett barn går hand i hand över en parkering med bilar",
category: "Porträtt och människor",
},
"20250510-WJ9E1673-Enhanced-NR": {
alt: "Ett barn i grön väst håller i ratten på ett rött fordon med en man bakom",
category: "Porträtt och människor",
},
"20250510-WJ9E1806-Enhanced-NR": {
alt: "En man håller ett barn vid ett stängsel i en grön utomhusmiljö med röda byggnader",
category: "Porträtt och människor",
},
"20250510-WJ9E1817-Enhanced-NR": {
alt: "En brun höna med röd kam i profil mot en suddig grön bakgrund",
category: "Natur och djur",
},
"20250513-WJ9E0143-Enhanced-NR": {
alt: "En blå klassisk bil på en stadsgata i varmt kvällsljus med mörka skuggor",
category: "Stad och arkitektur",
},
"20250513-WJ9E0211-Enhanced-NR": {
alt: "Gula och röda hus vid ett stilla vatten med speglingar i varmt kvällsljus",
category: "Stad och arkitektur",
},
"20250726-20250726-WJ9E0854": {
alt: "En ryttare i mörk rustning på en vit häst framför en ljus byggnad och publik",
category: "Porträtt och människor",
},
"20250726-20250726-WJ9E0979": {
alt: "En man i blå och gul rustning med ett svärd rider en brun häst utomhus",
category: "Porträtt och människor",
},
"20250802-WJ9E1325": {
alt: "En humla och en vit fjäril på lila blommor i ett mjukt gyllene ljus",
category: "Makro och detaljer",
},
"20250802-WJ9E1390": {
alt: "En vit fjäril med svarta fläckar på vingarna vilar på en lila blomstängel",
category: "Natur och djur",
},
"20250802-WJ9E1396": {
alt: "En vit fjäril med svarta prickar vilar på en lila blomma i starkt solljus",
category: "Natur och djur",
},
"20250806-IMG_5565": {
alt: "En orange och svart humla på en lila klotformad blomma mot grön bakgrund",
category: "Makro och detaljer",
},
"20251017-WJ9E0289": {
alt: "En tom träbänk i en höstskog omgiven av gula löv och varmt ljus",
category: "Natur och djur",
},
"20251227-WJ9E0356": {
alt: "Frostiga växter och en tunn gren i varmt motljus från en sol i skogen",
category: "Natur och djur",
},
"20251227-WJ9E0361": {
alt: "Frostig växt i motljus med en gyllene sol mot en mörk och suddig skog",
category: "Natur och djur",
},
"20251227-WJ9E0362": {
alt: "Varmt solljus strömmar genom träden bakom ett trästaket i en mörk skog",
category: "Natur och djur",
},
"20251227-WJ9E0388": {
alt: "Gyllene solljus som lyser upp frostiga torra löv och grässtrån på en mark",
category: "Makro och detaljer",
},
"20251227-WJ9E0434": {
alt: "En stadsgata i varmt gyllene ljus med suddiga byggnader och träd vid solnedgången",
category: "Stad och arkitektur",
},
"20251227-WJ9E0450": {
alt: "Gyllene solljus över en tom stadsgata med silhuetter av träd och en husfasad",
category: "Stad och arkitektur",
},
"20251230-IMG20251230141046": {
alt: "Snöig gränd med stenhus prydd med stora röda julkulor och små gröna granar i krukor",
category: "Stad och arkitektur",
},
"20251231-IMG20251231113121": {
alt: "Gyllene solljus över en snöig kullerstensgata omgiven av färgglada hus i en stad",
category: "Stad och arkitektur",
},
"20251231-IMG20251231113417": {
alt: "En smal kullerstensgata mellan en vit husfasad och en hög stenmur under en molnig himmel",
category: "Stad och arkitektur",
},
"20251231-WJ9E0465": {
alt: "En mörk lykta i förgrunden med en suddig ljus byggnad och spiror i bakgrunden",
category: "Stad och arkitektur",
},
"20251231-WJ9E0490": {
alt: "Snöklädda granris med små varma och kalla ljuspunkter i en mörk vintermiljö",
category: "Makro och detaljer",
},
"20251231-WJ9E0495": {
alt: "Snötäckta barr med små varma lampor mot en mörk bakgrund med suddiga orangea ljus",
category: "Natur och djur",
},
"20251231-WJ9E0512": {
alt: "En smal kullerstensgata i en stad med gamla hus, snöfläckar och svala, mörka färger",
category: "Stad och arkitektur",
},
"20251231-WJ9E0552": {
alt: "Smal stadsgata med ett högt torn, en julgran och silhuetter av människor i varmt ljus",
category: "Stad och arkitektur",
},
"20260102-IMG20260102132702": {
alt: "Vy genom en mörk stenvägg mot ett snöigt landskap och en gyllene himmel",
category: "Stad och arkitektur",
},
"20260102-IMG20260102141620": {
alt: "Grå stenmur med trägallerier och ett torn med rött tak under ett snöfall",
category: "Stad och arkitektur",
},
"20260102-IMG20260102142048": {
alt: "En trägång längs en grå stenmur under ett snöfall med kala grenar i förgrunden",
category: "Stad och arkitektur",
},
"immich-02533d10-07ca-4a39-8b8a-6648fe2ffcef-20260905-DSC07734": {
alt: "Två barn springer och skrattar framför ett hus med rött plåttak och högt gräs",
category: "Porträtt och människor",
},
"immich-04342446-23b4-4600-a93e-352d32b6eac1-20260905-DSC07933": {
alt: "Två barn leker med en grön vattenpump och en hink på en solig gräsmatta",
category: "Porträtt och människor",
},
"immich-0617ce88-3f9c-4b5b-bec4-1a30cfd65602-20260905-DSC07895": {
alt: "Ett blont barn i grön tröja vid en grön vattenpump i varmt gyllene ljus",
category: "Porträtt och människor",
},
"immich-07328282-e321-4216-8710-ee87dc65496e-20260905-DSC07730": {
alt: "Ett leende barn i blå t-shirt står på en grön gräsmatta omgiven av gröna träd",
category: "Porträtt och människor",
},
"immich-07578a7d-3d6e-4dbe-8fec-0accff45258c-20260920-DSC08719-2": {
alt: "En gulsvart humla samlar pollen från en mörkröd blomma mot en suddig grön bakgrund",
category: "Natur och djur",
},
"immich-0be0c122-4e81-4e92-b229-4736e66b9c70-20260920-DSC08717-2": {
alt: "En svartgul humla på en djupröd blomma omgiven av gröna stjälkar i varmt solljus",
category: "Makro och detaljer",
},
"immich-0d7ccea4-22c2-4208-b487-ec09eb88158c-20260704-DSC03171": {
alt: "En man och en kvinna med solglasögon poserar bredvid en vit limousine i en stad",
category: "Porträtt och människor",
},
"immich-0f559874-9212-45e1-8860-a9ebfe6d95c0-20260704-DSC03081": {
alt: "En man i vit skjorta och mörka byxor vid en vit limousine under gröna träd",
category: "Porträtt och människor",
},
"immich-10a42a42-e7af-4f02-a0ca-d07a960bd094-20260605-_J9E5983": {
alt: "Närbild på en svartvit hund med bruna ögon bland suddiga rosa blommor och grönska",
category: "Natur och djur",
},
"immich-146b119f-7a18-47ca-b255-38242adc290e-20260621-Kopia_av_IMG_7877": {
alt: "En röd ekorre på en grov trädgren med mjukt ljus och suddig bakgrund",
category: "Natur och djur",
},
"immich-17c30f0a-3dea-425d-9fbe-336cc1ad88e2-20260621-Kopia_av_IMG_8045": {
alt: "En rödbrun ekorre på en trädgren omgiven av ljusgröna blad i ett ljust skogsparti",
category: "Natur och djur",
},
"immich-18db801d-5db9-49d0-83c0-cda75832be93-20260920-DSC08726": {
alt: "En gulsvart humla i flykt mot en mörkröd blomma med orange centrum i grönska",
category: "Natur och djur",
},
"immich-1ab2b2fe-dea6-4aa4-81c9-73fb8d4918c3-20240816-WJ9E37214520240816": {
alt: "En man och ett barn i solljus med suddiga gröna träd och färgglada ljusreflexer",
category: "Porträtt och människor",
},
"immich-1ccacf42-3f11-46c6-a6ac-4e38c6ff96b5-20250413-WJ9E0212": {
alt: "Två hästar drar en vit vagn med människor längs en solig gata i en stad",
category: "Stad och arkitektur",
},
"immich-1dc2a516-5026-4f97-861d-3e450acb0948-20260606-_J9E6089": {
alt: "Orange och svart fjäril som vilar på vita blommor i mörklila bladverk",
category: "Natur och djur",
},
"immich-1e4b55d1-10c0-4431-bcbf-0b6207208e1c-20260905-DSC07775": {
alt: "En man i vit jacka hjälper ett barn med blå hjälm och röd cykel utomhus",
category: "Porträtt och människor",
},
"immich-23f04539-f8eb-47f2-8d39-3d3e548e0f9b-20260905-DSC07842": {
alt: "Ett leende barn med blont hår i en grön pikétröja utomhus i mjukt ljus",
category: "Porträtt och människor",
},
"immich-27924d4a-0c2e-4421-b8cd-067b857549ba-20240912-20240912-WJ9E9866": {
alt: "En bäck med vitt skummande vatten, mossiga stenar och torra löv under en mörk trädstam",
category: "Natur och djur",
},
"immich-2be918bc-db83-4024-a066-bf300e2bebc5-20240912-20240912-WJ9E9861": {
alt: "Rinnande vatten över gröna mossiga stenar omgivna av gräs och torra bruna löv",
category: "Natur och djur",
},
"immich-2db21106-a3be-4f2b-82a7-eb69eb0038eb-20240912-20240912-WJ9E0194": {
alt: "Ett rödbrunt höstlöv som vilar på en mörk texturerad yta mot en mörkblå bakgrund",
category: "Makro och detaljer",
},
"immich-302dfa7d-fe44-45a4-9dd7-6e2d0ceaf39d-20260905-DSC07999": {
alt: "En kvinna med guldglasögon och blont hår i en denimjacka mot en suddig bakgrund",
category: "Porträtt och människor",
},
"immich-30952208-1c57-48b0-8917-8176ee374efe-20260905-DSC07908": {
alt: "Ett barn i grön tröja vattnar lila blommor i en trädgård bredvid en ljus bod",
category: "Porträtt och människor",
},
"immich-30a4d915-e794-40d0-8517-73c385a5d9c9-20260621-squareblur_1781332089807_7": {
alt: "En person i mörk mantel sedd bakifrån genom lövverk mot ett grönt fält",
category: "Porträtt och människor",
},
"immich-3539cda6-7495-4a51-b2d5-61d528fece0f-20260621-Kopia_av_IMG_7804": {
alt: "En brun ekorre med fluffig svans och vitt bröst mot en ljus bakgrund",
category: "Natur och djur",
},
"immich-38146653-9b12-4333-a4f7-b72b86c58f87-20250416-WJ9E0243": {
alt: "En beige skoter parkerad på en kullerstensgata i en stad i varmt, gyllene ljus",
category: "Stad och arkitektur",
},
"immich-39b95c2c-eeb7-4334-8771-47e726ca00a3-20240816-WJ9E37204620240816": {
alt: "En man och ett barn i naturligt ljus med en suddig grön bakgrund av träd",
category: "Porträtt och människor",
},
"immich-3b58d401-1b27-4c89-8094-c5025e24b160-20260905-DSC07838": {
alt: "En man i ljus skjorta kramar ett barn med blå hjälm bland färgglada blommor",
category: "Porträtt och människor",
},
"immich-3d77f859-13be-462b-92be-767aa1f74ee6-20260920-DSC08731": {
alt: "Ett svartgult bi på en mörkröd blomma med suddig bakgrund i varma färger",
category: "Natur och djur",
},
"immich-3eccb3dc-707f-4f8d-837b-d0db61358e31-20260905-DSC07598": {
alt: "En man med solglasögon och ljus skjorta står bredvid en röd bil mot grön bakgrund",
category: "Porträtt och människor",
},
"immich-415532f5-deec-43d5-8cb5-a47f4f0abfbc-20260606-_J9E6061": {
alt: "En gulsvart humla i flykt mot rosa blomklasar mot en ljusblå himmel",
category: "Natur och djur",
},
"immich-46430956-07b8-4306-86a2-daf98daab847-20241018-20241018-IMG_8965": {
alt: "Gammal stenbyggnad med runt torn och tända lyktor reflekteras i mörkt vatten under grå himmel",
category: "Stad och arkitektur",
},
"immich-490a149a-df25-4b81-96fd-3dc2fee7bc24-20241018-20241018-IMG_8926": {
alt: "En mörk stenbro med valv över vatten med blå toner och orangea reflektioner",
category: "Stad och arkitektur",
},
"immich-4abbfa4b-6eba-466d-88f4-57c2e867df82-20260920-DSC08912-2": {
alt: "En röd blomma i fokus mot en mörk bakgrund med varma gyllene ljuscirklar",
category: "Natur och djur",
},
"immich-4bcab51e-f51f-460d-8df7-7096e70a76bb-20260905-DSC07811": {
alt: "Ett leende barn med blå hjälm på en röd cykel i en suddig utomhusmiljö",
category: "Porträtt och människor",
},
"immich-4f60de4c-4e1f-445c-afd9-f911e1acf995-20260605-_J9E5968": {
alt: "En svartvit prickig hund i profil omgiven av suddigt grönt gräs i naturligt ljus",
category: "Natur och djur",
},
"immich-4f8a9f96-16bb-4275-887b-7958a34addab-20250413-WJ9E0196": {
alt: "En gammal astronomisk klocka med gyllene detaljer i blått och rött mot mörk bakgrund",
category: "Stad och arkitektur",
},
"immich-50bce1ec-f162-4fa8-957c-4819689f70c4-20260905-DSC07617": {
alt: "Ett barn med blont hår håller en vuxens hand utomhus bredvid en grön gräsmatta",
category: "Porträtt och människor",
},
"immich-50e9e443-c475-4da0-b438-1a6b3f740a8c-20250414-WJ9E0143-Enhanced-NR": {
alt: "Människor med röda paraplyer i en solig stad med blå och beige arkitektur",
category: "Stad och arkitektur",
},
"immich-54d22882-3bdc-4bd5-b4d5-7dd2e5d71656-20260905-DSC07825": {
alt: "En man i ljus skjorta håller ett barn i gröna kläder på en gräsmatta vid klippor",
category: "Porträtt och människor",
},
"immich-590158f5-f459-4c16-b6dd-b4354c0ab512-20260824-DSC07025__2_": {
alt: "Närbild av ett hjortdjur med lurviga, basttäckta horn och mörk päls mot svart bakgrund",
category: "Natur och djur",
},
"immich-5a60699d-bf05-4c18-bb43-5969af8f0106-20260905-DSC07930": {
alt: "Ett barn i grön tröja leker med en grön vattenpump på en solig gräsmatta",
category: "Porträtt och människor",
},
"immich-5f79107b-ffbe-4e32-8b9d-b37689b3352f-20260905-DSC07639": {
alt: "En man, en kvinna och två barn vid en träbyggnad med gula blommor utomhus",
category: "Porträtt och människor",
},
"immich-61040612-f637-4bdb-893c-901171857f83-20260621-squareblur_1781332134108_13": {
alt: "Röd boj vid en vit träbom i stilla vatten med näckrosblad och vass i solnedgången",
category: "Natur och djur",
},
"immich-638a305e-2d06-4d4e-9166-5f3a80c906a2-20260905-DSC07710": {
alt: "Ett barn i blå tröja med brandbilar står i en grönskande miljö framför en man",
category: "Porträtt och människor",
},
"immich-69aa5b22-f80d-4cb5-b022-e77132560985-20260606-_J9E6105": {
alt: "En orange och svart fjäril på vita blommor bland mörklila blad i starkt solljus",
category: "Natur och djur",
},
"immich-6ee56b75-ff51-4817-98c8-0fb3c4485931-20250415-WJ9E1163-Enhanced-NR": {
alt: "En stenbro full av människor över en flod mellan röda tak och torn",
category: "Stad och arkitektur",
},
"immich-6f8ae1a4-fc2d-478d-ba2e-8452db0a08f0-20240912-20240912-WJ9E9894": {
alt: "En liten bäck med mjukt vatten som flyter över mörka stenar och grönska",
category: "Natur och djur",
},
"immich-71cea45b-73c3-4e9a-9810-c0e070b4f5d0-20240914-20240914-WJ9E0284": {
alt: "En person i jeansjacka sedd bakifrån i ett solrosfält i solnedgångens varma ljus",
category: "Porträtt och människor",
},
"immich-73a61049-65e8-47ed-9904-220bf316e351-20260605-_J9E5985": {
alt: "Närbild av en svartvit hund i en grön trädgård med suddiga rosa blommor i förgrunden",
category: "Natur och djur",
},
"immich-7c1fc70b-c766-4c97-ba30-7fbd321ac702-20260905-DSC07953": {
alt: "Ett barn med blont hår skrattar i en blå t-shirt med brandbilar framför trävägg",
category: "Porträtt och människor",
},
"immich-7c32da7b-9a9a-4675-a813-4fa3d026bcf5-20260905-DSC07665": {
alt: "Ett leende barn i blå t-shirt i en dörröppning med suddiga lila blommor i förgrunden",
category: "Porträtt och människor",
},
"immich-8113f930-24bf-4920-adc6-734e397bd3e5-20260905-DSC07800": {
alt: "Ett barn med blå hjälm på en röd cykel framför ett beige hus i dagsljus",
category: "Porträtt och människor",
},
"immich-81d60de8-8a13-43e1-b549-ced73e326368-20250415-WJ9E1324": {
alt: "Mörkt fönster med utsikt mot en molnig himmel och skuggspel på en grå vägg",
category: "Stad och arkitektur",
},
"immich-827bde98-bbf1-4f1f-bd38-4ffffb8c6f03-20260920-DSC08725-2": {
alt: "En gulsvart humla på en mörkröd blomma med suddig grön och röd bakgrund",
category: "Makro och detaljer",
},
"immich-87fbfcab-81f8-4ccf-a80e-e097e9a0614d-20250415-WJ9E1448": {
alt: "En kvinna med vindpustat brunt hår och mörkblå jacka mot en bakgrund av vita blommor",
category: "Porträtt och människor",
},
"immich-9ad43795-2df0-4ca4-8a69-f8f99954b7e8-20260605-_J9E5944": {
alt: "En svartvit hund med lockig päls sitter på grönt gräs bredvid en vit fotboll",
category: "Natur och djur",
},
"immich-a5086bb9-3868-442c-9715-f3d626ec0123-20260605-_J9E6018": {
alt: "Klara vattendroppar på ett mörkgrönt blad med tydliga ådror mot en svart bakgrund",
category: "Makro och detaljer",
},
"immich-a5129e40-445b-4fcf-a5c5-3a5202590f27-20260920-DSC08720-2": {
alt: "En gulsvart humla på en mörkröd blomma omgiven av gröna blad i dagsljus",
category: "Natur och djur",
},
"immich-a6fa98f0-ba73-42c2-91a6-07709e195015-20260905-DSC07869": {
alt: "Två barn sedda bakifrån bredvid en vuxen och en vit flätad korg i varmt ljus",
category: "Porträtt och människor",
},
"immich-a71dc5cd-ede5-4ae2-94cb-07f06d074846-20260606-_J9E6073": {
alt: "Orange och svart fjäril på en ljusgrå träbit med blå färgfläckar mot suddig grön bakgrund",
category: "Natur och djur",
},
"immich-a8bd05fb-864a-4c87-8a6c-7cbfcc047d3d-20250415-WJ9E1249-Enhanced-NR": {
alt: "En person med långt brunt hår som blåser i vinden, sedd bakifrån i dämpat ljus",
category: "Porträtt och människor",
},
"immich-ac8360da-9fbd-45e0-b740-d091356037fe-20241018-20241018-IMG_8978": {
alt: "Ett runt stentorn och en gul byggnad vid ett vatten med varma kvällsljus",
category: "Stad och arkitektur",
},
"immich-b241db80-28b8-4430-b08b-c3ce589a85a3-20260621-Kopia_av_IMG_8714": {
alt: "En rödbrun ekorre på en grov gren med suddiga gröna barr i bakgrunden",
category: "Natur och djur",
},
"immich-b2780899-4269-44be-8dd3-fed23d3e89d3-20260621-squareblur_1781332107423_9": {
alt: "En blå vintagebil på en mörk stadsgata med varma gula ljusreflektioner i asfalten",
category: "Stad och arkitektur",
},
"immich-b2f52935-d52a-46f1-9262-78a7b1e19a90-20260905-DSC07604": {
alt: "Ett barn i grön tröja och ljus keps framför en suddig röd bil",
category: "Porträtt och människor",
},
"immich-b3c19299-0e5d-4261-a98a-1ed08f01a0a3-20260905-DSC07702": {
alt: "Ett barn i grön tröja sparkar på en fotboll på en gräsmatta framför ett staket",
category: "Porträtt och människor",
},
"immich-b7ddab61-cbfc-4c5f-9354-b56bab0b38a1-20241018-20241018-IMG_8925-2": {
alt: "Orangea höstlöv hänger över mörkt vatten med tydliga speglingar och en stenvägg i bakgrunden",
category: "Natur och djur",
},
"immich-b99369cb-622e-4f31-9a9f-a33bc4e0030a-20260605-_J9E5916": {
alt: "En mörk hund med lockiga öron tittar uppåt mot en mörkgrön suddig bakgrund",
category: "Natur och djur",
},
"immich-bbf62d80-754f-4243-a71b-a5abe1e0340c-20260905-DSC07890": {
alt: "Ett barn i grön pikétröja pumpar vatten ur en gammal grön handpump i ett varmt motljus",
category: "Porträtt och människor",
},
"immich-c3ab7ee4-8a84-42a4-a3e9-662469569b51-20260605-_J9E5888": {
alt: "En person håller om en svart och vit hund med lockig päls i dämpat inomhusljus",
category: "Natur och djur",
},
"immich-c6489154-53d1-47e2-8572-521dd851137a-20260905-DSC07823": {
alt: "En man i ljus skjorta lyfter ett barn på en gräsmatta bredvid en stor sten",
category: "Porträtt och människor",
},
"immich-cbbd628e-bf5a-4a9e-85e0-d5229680f492-20260905-DSC07657": {
alt: "Ett barn i blå t-shirt står i dörröppningen till en vit träbyggnad med mörkt inre",
category: "Porträtt och människor",
},
"immich-d058e797-861a-4cd3-86a1-63c616bf248f-20260905-DSC07711": {
alt: "Ett barn i blå tröja går mot en man i vit jacka i en grön trädgård",
category: "Porträtt och människor",
},
"immich-d2d2eb01-597b-47ed-8055-f193a629c472-20260605-_J9E5860": {
alt: "En springande svartvit hund med fladdrande öron på en grön gräsmatta mot mörk bakgrund",
category: "Natur och djur",
},
"immich-d34b0499-d2c0-40fd-848a-8e06329b7bf5-20260905-DSC07607": {
alt: "En kvinna och ett barn går hand i hand framför ett hus i varmt ljus",
category: "Porträtt och människor",
},
"immich-d8cb2d51-2ead-4cf0-9215-a59b07f26f6d-20260905-DSC07704": {
alt: "Ett barn i grön tröja och blå jeans sparkar en fotboll på gräs framför en stenvägg",
category: "Porträtt och människor",
},
"immich-db3ffd50-7e6a-4319-8815-b388e52e9aa9-20260905-DSC07896": {
alt: "Ett barn i grön tröja pumpar vatten ur en mörk handpump på en grön gräsmatta",
category: "Porträtt och människor",
},
"immich-ddb13056-990c-48dc-ab92-e03baadb7a44-20260905-DSC07647": {
alt: "En kvinna i blå kläder håller i händerna på ett barn utomhus på grön gräsmatta",
category: "Porträtt och människor",
},
"immich-debf1a27-f337-4fc8-a556-c8b08ad98404-20260621-squareblur_1781332127640_12": {
alt: "En tom träbänk i en höstskog omgiven av gula och orangea löv",
category: "Natur och djur",
},
"immich-e20af65c-c05b-4585-81b4-dcad6b52e450-20240914-20240914-WJ9E0319": {
alt: "En person i motljus framför ett öppet landskap under en gyllene solnedgång",
category: "Porträtt och människor",
},
"immich-e25d4130-b3d3-4c63-8c82-e0750efa41b8-20260621-squareblur_1781332028758_1": {
alt: "Mörka stentrappor med orangea löv som leder upp från en spegelblank vattenyta",
category: "Stad och arkitektur",
},
"immich-e3a7d5bc-9ee1-4d29-8673-65922736295f-20260920-DSC08813": {
alt: "En svartgul humla i flykt mot en lila blomma omgiven av mörkgröna blad",
category: "Makro och detaljer",
},
"immich-e7048f3d-da0d-432e-b833-8e766df5708b-20240914-20240914-WJ9E0285": {
alt: "En person med långt hår sedd bakifrån i ett solrosfält i varmt kvällsljus",
category: "Porträtt och människor",
},
"immich-f1f02aac-7ef7-4cc0-818a-e06e0ecdf7f7-20241018-20241018-IMG_9028": {
alt: "En stenbro med valv som reflekteras i mörkt vatten under ett varmt gyllene ljus",
category: "Stad och arkitektur",
},
"immich-f2d03140-50d5-4e94-94e8-425fb605a110-20260905-DSC07904": {
alt: "Ett barn i grön tröja pumpar vatten ur en grön handpump på en gräsmatta",
category: "Porträtt och människor",
},
"immich-f836d606-e331-4277-9500-b9906305544e-20260621-Kopia_av_IMG_8222": {
alt: "En rödbrun ekorre sitter på marken mot en grov grå trädstam i dagsljus",
category: "Natur och djur",
},
"immich-f8943f55-4cba-4521-b9a5-4e735f31a336-20241018-20241018-IMG_9057": {
alt: "Stadsmiljö i skymning med upplysta hus och färgglada ljusreflektioner i ett mörkt vatten",
category: "Stad och arkitektur",
},
"immich-f9dd008c-3445-4394-b4f4-329b0034fd2c-20241018-20241018-IMG_8996": {
alt: "En mörk stenbro över vatten med röda höstlöv och en byggnad i bakgrunden",
category: "Stad och arkitektur",
},
"immich-faee21c0-1609-4a35-aab5-0b5d68c4cab4-20260905-DSC07618": {
alt: "Ett leende barn i blå tröja håller en vuxens hand mot en grön bakgrund",
category: "Porträtt och människor",
},
"immich-fbb8346a-5992-4136-a099-596c06bbde30-20260905-DSC07723": {
alt: "En man i ljus jacka och två barn springer över en grön gräsmatta under träd",
category: "Porträtt och människor",
},
"immich-fed1e776-2242-468a-9800-8a8901df2be0-20250413-WJ9E0284": {
alt: "En kvinna i blå jeansjacka med en kamera i en park med gula tulpaner",
category: "Porträtt och människor",
},
};
+95 -24
View File
@@ -1,56 +1,127 @@
--- ---
import "../styles/global.css"; import "../styles/global.css";
const { title } = Astro.props; import type { ImageMetadata } from 'astro';
import { getImage } from 'astro:assets';
import SEO from '../components/SEO.astro';
import SchemaOrg from '../components/SchemaOrg.astro';
import type { FaqItem } from '../data/faq';
import { business } from '../data/business';
import defaultOgImage from '../assets/images/3d-scroll/img5.jpg';
export interface Props {
/** Unik svensk titel för sidan. */
title: string;
/** Unik svensk meta description, ca 120–160 tecken. */
description: string;
/** Kanonisk sökväg, t.ex. "/tjanster". Utelämnas = härleds från URL:en. */
path?: string;
image?: ImageMetadata;
imageAlt?: string;
pageType?: 'WebPage' | 'AboutPage' | 'ContactPage' | 'CollectionPage';
/** Namn i brödsmulan (BreadcrumbList) för undersidor. */
breadcrumb?: string;
faq?: FaqItem[];
noindex?: boolean;
}
const {
title,
description,
path,
image = defaultOgImage,
imageAlt = 'Svartvit bild av en man som bär ett litet barn bland såpbubblor i motljus',
pageType,
breadcrumb,
faq,
noindex = false,
} = Astro.props;
// og:image: ny JPG via Astros bildtjänst (EXIF/GPS rensas bort), absolut URL.
const site = Astro.site ?? new URL(business.url);
const og = await getImage({ src: image, width: 1200, format: 'jpg', quality: 82 });
const ogImageUrl = new URL(og.src, site).toString();
const ogWidth = Number(og.attributes.width) || undefined;
const ogHeight = Number(og.attributes.height) || undefined;
const nav = [
{ href: '/portfolio', label: 'Portfolio' },
{ href: '/tjanster', label: 'Tjänster' },
{ href: '/about', label: 'Om mig' },
{ href: '/contact', label: 'Kontakt' },
];
--- ---
<html lang="sv" class="scroll-smooth"> <head> <!doctype html>
<html lang="sv" class="scroll-smooth">
<head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#171717" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/favicon-512.png" /> <link rel="icon" type="image/png" sizes="512x512" href="/favicon-512.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon-180.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/favicon-180.png" />
<title>{title}</title> <link rel="sitemap" type="application/xml" href="/sitemap-index.xml" />
<SEO
title={title}
description={description}
path={path}
imageUrl={ogImageUrl}
imageWidth={ogWidth}
imageHeight={ogHeight}
imageAlt={imageAlt}
noindex={noindex}
/>
{!noindex && (
<SchemaOrg
title={title}
description={description}
path={path}
pageType={pageType}
breadcrumb={breadcrumb}
faq={faq}
imageUrl={ogImageUrl}
/>
)}
</head> </head>
<body class="bg-neutral-900 text-white font-sans antialiased min-h-screen flex flex-col"> <body class="bg-neutral-900 text-white font-sans antialiased min-h-screen flex flex-col">
<nav class="fixed top-0 w-full z-50 border-b border-white/10 py-6 bg-neutral-900/80 backdrop-blur-md transition-all duration-300"> <nav class="fixed top-0 w-full z-50 border-b border-white/10 py-4 md:py-6 bg-neutral-900/80 backdrop-blur-md transition-all duration-300">
<div class="container mx-auto px-4 flex justify-between items-center"> <div class="container mx-auto px-4 flex flex-wrap gap-y-2 justify-between items-center">
<a href="/" class="text-2xl font-bold tracking-wider uppercase hover:text-gray-300 transition relative z-50"> <a href="/" class="text-lg md:text-2xl font-bold tracking-wider uppercase hover:text-gray-300 transition relative z-50">
Images by Olofsson Images by Olofsson
</a> </a>
<ul class="flex space-x-6 text-sm font-medium text-gray-400"> <ul class="flex space-x-4 md:space-x-6 text-sm font-medium text-gray-300">
<li><a href="/portfolio" class="hover:text-white transition">Portfolio</a></li> {nav.map((item) => (
<li><a href="/about" class="hover:text-white transition">About</a></li> <li><a href={item.href} class="hover:text-white transition">{item.label}</a></li>
<li><a href="/contact" class="hover:text-white transition">Contact</a></li> ))}
</ul> </ul>
</div> </div>
</nav> </nav>
<main class="flex-grow pt-24"> <main class="flex-grow pt-28 md:pt-24">
<slot /> <slot />
</main> </main>
<footer class="border-t border-neutral-800 py-8 text-center text-gray-500 text-sm bg-neutral-900 relative z-40"> <footer class="border-t border-neutral-800 py-8 text-center text-gray-400 text-sm bg-neutral-900 relative z-40">
<p class="mb-2"> <p class="mb-2">
Crafting digital experiences for {business.brand} – {business.person.jobTitle.toLowerCase()} {business.person.name}, {business.country.name}
<a href="https://lyxlimo.se/" target="_blank" rel="noopener noreferrer" data-glow-link="green" class="text-gray-400 hover:text-white transition-colors">lyxlimo.se</a> &middot; <a href={`mailto:${business.email}`} class="text-gray-300 hover:text-white transition-colors">{business.email}</a>
&amp; &middot; <a href={business.social.instagram.url} target="_blank" rel="noopener noreferrer" class="text-gray-300 hover:text-white transition-colors">Instagram</a>
<a href="https://karleksguiden.se/" target="_blank" rel="noopener noreferrer" data-glow-link="magenta" class="text-gray-400 hover:text-white transition-colors">karleksguiden.se</a>
</p> </p>
<p>&copy; {new Date().getFullYear()} Images by Olofsson. All rights reserved. &middot; <a href="/privacy" class="text-gray-400 hover:text-white transition-colors">Privacy</a> &middot; <button type="button" data-consent-open class="text-gray-400 hover:text-white transition-colors">Cookie settings</button></p> <p>&copy; {new Date().getFullYear()} {business.brand}. 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>
</footer> </footer>
<!-- Cookie consent: Meta Pixel laddas forst efter "Accept" --> <!-- Cookie consent: Meta Pixel laddas först efter "Godkänn" -->
<div data-consent-banner hidden class="fixed bottom-4 inset-x-4 md:left-auto md:right-6 md:max-w-md z-[60] bg-neutral-900/95 backdrop-blur-md border border-white/10 rounded-2xl p-5 shadow-2xl text-sm text-gray-300"> <div data-consent-banner hidden class="fixed bottom-4 inset-x-4 md:left-auto md:right-6 md:max-w-md z-[60] bg-neutral-900/95 backdrop-blur-md border border-white/10 rounded-2xl p-5 shadow-2xl text-sm text-gray-300">
<p class="mb-4"> <p class="mb-4">
I use the Meta Pixel to see how visitors from Instagram and Facebook use this site. It sets cookies and sends data to Meta. Jag använder Meta Pixel för att se hur besökare från Instagram och Facebook använder sajten. Den sätter cookies och skickar data till Meta.
<a href="/privacy" class="text-white underline hover:text-gray-300">Read more</a> <a href="/privacy" class="text-white underline hover:text-gray-300">Läs mer</a>
</p> </p>
<div class="flex gap-3 justify-end"> <div class="flex gap-3 justify-end">
<button type="button" data-consent="denied" class="px-4 py-2 rounded-full border border-white/20 text-gray-300 hover:text-white hover:border-white/40 transition-colors">Decline</button> <button type="button" data-consent="denied" class="px-4 py-2 rounded-full border border-white/20 text-gray-300 hover:text-white hover:border-white/40 transition-colors">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">Accept</button> <button type="button" data-consent="granted" class="px-4 py-2 rounded-full bg-white text-neutral-900 font-medium hover:bg-gray-200 transition-colors">Godkänn</button>
</div> </div>
</div> </div>
@@ -78,7 +149,7 @@ const { title } = Astro.props;
/* End Meta Pixel Code */ /* End Meta Pixel Code */
} }
// Aterkallat samtycke: ta bort Metas forsta-parts-cookies. // Återkallat samtycke: ta bort Metas förstapartscookies.
function clearMetaCookies() { function clearMetaCookies() {
['_fbp', '_fbc'].forEach(function (name) { ['_fbp', '_fbc'].forEach(function (name) {
document.cookie = name + '=; Max-Age=0; path=/'; document.cookie = name + '=; Max-Age=0; path=/';
+12
View File
@@ -0,0 +1,12 @@
// src/lib/pages.ts
// Sajtens riktiga sidor, härledda från src/pages/*.astro (404 och _-filer exkluderas).
// Nya sidor hamnar automatiskt i sitemap.
const pageFiles = import.meta.glob('../pages/*.astro');
export function sitePaths(): string[] {
return Object.keys(pageFiles)
.map((file) => file.replace(/^\.\.\/pages\//, '').replace(/\.astro$/, ''))
.filter((name) => name !== '404' && !name.startsWith('_') && !name.startsWith('['))
.map((name) => (name === 'index' ? '/' : `/${name}`))
.sort((a, b) => (a === '/' ? -1 : b === '/' ? 1 : a.localeCompare(b)));
}
+8
View File
@@ -0,0 +1,8 @@
// src/lib/url.ts
// Gör om en byggsökväg till kanonisk form: "/", "/about", "/tjanster".
// Klarar både build.format "file" (/about.html) och "directory" (/about/).
export function normalizePath(p: string): string {
let out = p.replace(/\/index\.html$/, '/').replace(/\.html$/, '');
if (out.length > 1) out = out.replace(/\/+$/, '');
return out || '/';
}
+35
View File
@@ -0,0 +1,35 @@
---
// src/pages/404.astro -> dist/404.html (servern svarar med status 404)
import MainLayout from '../layouts/MainLayout.astro';
import WaveBackground from '../components/WaveBackground.astro';
const links = [
{ href: '/', label: 'Startsidan' },
{ href: '/portfolio', label: 'Portfolio' },
{ href: '/tjanster', label: 'Tjänster och bokning' },
{ 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."
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-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:
</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>
</li>
))}
</ul>
</div>
</MainLayout>
+34 -24
View File
@@ -4,9 +4,18 @@ import MainLayout from '../layouts/MainLayout.astro';
import { Image } from 'astro:assets'; import { Image } from 'astro:assets';
import profileImg from '../assets/images/about/profile.jpg'; import profileImg from '../assets/images/about/profile.jpg';
import WaveBackground from '../components/WaveBackground.astro'; import WaveBackground from '../components/WaveBackground.astro';
import { business, basedIn } from '../data/business';
--- ---
<MainLayout title="About | Images by Olofsson"> <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" /> <WaveBackground opacity="opacity-80" />
@@ -21,7 +30,7 @@ import WaveBackground from '../components/WaveBackground.astro';
<div class="relative rounded-2xl overflow-hidden shadow-2xl border border-white/10 transform transition duration-500 group-hover:scale-[1.01]"> <div class="relative rounded-2xl overflow-hidden shadow-2xl border border-white/10 transform transition duration-500 group-hover:scale-[1.01]">
<Image <Image
src={profileImg} src={profileImg}
alt="Linus Olofsson Photographer" alt="Porträtt av fotografen Linus Olofsson"
width={800} width={800}
height={1000} height={1000}
class="w-full h-auto object-cover grayscale hover:grayscale-0 transition-all duration-700 ease-in-out" class="w-full h-auto object-cover grayscale hover:grayscale-0 transition-all duration-700 ease-in-out"
@@ -31,25 +40,24 @@ import WaveBackground from '../components/WaveBackground.astro';
<div class="w-full lg:w-1/2 space-y-6"> <div class="w-full lg:w-1/2 space-y-6">
<h1 class="text-4xl md:text-5xl font-bold leading-tight"> <h1 class="text-4xl md:text-5xl font-bold leading-tight">
Dynamic moments, <br/> Levande ögonblick, <br/>
<span class="text-transparent bg-clip-text bg-gradient-to-r from-white to-gray-500">Frozen in high resolution.</span> <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> </h1>
<div class="space-y-4 text-gray-300 text-lg leading-relaxed"> <div class="space-y-4 text-gray-300 text-lg leading-relaxed">
<p> <p>
Hi — I’m Linus Olofsson. Hej, jag heter {business.person.name} och är fotografen bakom {business.brand}.
Cloud specialist by day, passionate photographer by night.
</p> </p>
<p> <p>
Based in Sweden, my journey began with a simple curiosity for how light shapes the way we perceive the world. Over time, that curiosity evolved into an obsession with capturing the subtle details most people overlook — textures, structures, contrasts and moments that flicker by unnoticed. 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>
<p> <p>
I find beauty in the small things: a stone, a tree, an empty street, a quiet forest path, or the expression of a stranger passing by. 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>
<p> <p>
Whether it’s the intricate patterns of a macro shot, the vast silence of nature, or the energy of a city, my goal is 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.
always the same — to create images that convey emotion and tell a story without words.
</p> </p>
<p>{business.recognition}</p>
</div> </div>
<div class="pt-4"> <div class="pt-4">
@@ -58,10 +66,13 @@ import WaveBackground from '../components/WaveBackground.astro';
<div class="flex flex-wrap gap-4 pt-6"> <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"> <a href="/contact" class="inline-block bg-white text-black px-8 py-3 rounded-full font-bold hover:bg-gray-200 transition-colors shadow-lg">
Get in touch Kontakta mig
</a> </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"> <a href="/portfolio" class="inline-block border border-white text-white px-8 py-3 rounded-full font-bold hover:bg-white/10 transition-colors">
View the world through my eyes 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> </a>
</div> </div>
</div> </div>
@@ -69,7 +80,7 @@ import WaveBackground from '../components/WaveBackground.astro';
</div> </div>
</div> </div>
<div class="border-t border-white/10 pt-16"> <div class="border-t border-white/10 pt-16">
<h2 class="text-3xl font-bold mb-10 text-center text-white">What's in my bag?</h2> <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 class="grid grid-cols-1 md:grid-cols-3 gap-8">
@@ -77,20 +88,19 @@ import WaveBackground from '../components/WaveBackground.astro';
<div class="icon-box"> <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> <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> </div>
<h3 class="text-xl font-bold mb-2 text-white">Camera Body</h3> <h3 class="text-xl font-bold mb-2 text-white">Kamerahus</h3>
<p class="text-gray-400">Sony A7RV</p> <p class="text-gray-300">{business.camera}</p>
<p class="text-gray-500 text-sm mt-2">A trusty companion that has been with me on countless adventures.</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>
<div data-trail-box class="gear-card"> <div data-trail-box class="gear-card">
<div class="icon-box"> <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> <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> </div>
<h3 class="text-xl font-bold mb-2 text-white">Lenses</h3> <h3 class="text-xl font-bold mb-2 text-white">Objektiv</h3>
<ul class="text-gray-400 text-sm space-y-1"> <ul class="text-gray-300 text-sm space-y-1">
<li>Sony FE 70-200mm f/4 G OSS</li> {business.lenses.map((lens) => <li>{lens}</li>)}
<li>Sony FE 20mm f/1.8 G</li> <li>och ett par extra batterier</li>
<li>and a couple of extra batteries</li>
</ul> </ul>
</div> </div>
@@ -98,9 +108,9 @@ import WaveBackground from '../components/WaveBackground.astro';
<div class="icon-box"> <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> <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> </div>
<h3 class="text-xl font-bold mb-2 text-white">Editing</h3> <h3 class="text-xl font-bold mb-2 text-white">Redigering</h3>
<p class="text-gray-400">Adobe Lightroom Classic</p> <p class="text-gray-300">{business.editing}</p>
<p class="text-gray-500 text-sm mt-2">Where the raw captures come to life. I focus on natural tones and contrast.</p> <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>
+53 -40
View File
@@ -2,55 +2,66 @@
// src/pages/contact.astro // src/pages/contact.astro
import MainLayout from '../layouts/MainLayout.astro'; import MainLayout from '../layouts/MainLayout.astro';
import WaveBackground from '../components/WaveBackground.astro'; import WaveBackground from '../components/WaveBackground.astro';
import { business } from '../data/business';
--- ---
<MainLayout title="Contact | Images by Olofsson"> <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> <div>
<WaveBackground opacity="opacity-80" theme="purple" /> <WaveBackground opacity="opacity-80" theme="purple" />
</div> </div>
<div class="container mx-auto px-4 py-12 md:py-20 relative z-10"> <div class="container mx-auto px-4 py-12 md:py-20 relative z-10">
<div class="container mx-auto px-4 py-12 md:py-20">
<div class="grid grid-cols-1 md:grid-cols-2 gap-12 lg:gap-20 max-w-6xl mx-auto items-start"> <div class="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 class="space-y-8">
<div> <div>
<h1 class="text-4xl md:text-5xl font-bold mb-6">Get in touch</h1> <h1 class="text-4xl md:text-5xl font-bold mb-6">Kontakta mig</h1>
<p class="text-gray-400 text-lg leading-relaxed"> <p class="text-gray-300 text-lg leading-relaxed">
Do you have questions about any of my photos, collaborations, or just want to say hi? Har du frågor om någon av mina bilder, vill du samarbeta eller bara säga hej?
Connect with me on social media or send me a direct email. Hör av dig via sociala medier eller mejla mig direkt.
</p> </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>
<div> <div>
<h2 class="text-white font-semibold text-xl mb-4">You can also find me on...</h2> <h2 class="text-white font-semibold text-xl mb-4">Du hittar mig också på</h2>
<div class="grid grid-cols-3 gap-4"> <div class="grid grid-cols-3 gap-4">
<a href="https://www.instagram.com/imagesbyolofsson/" target="_blank" rel="noopener noreferrer" class="social-card group" aria-label="Instagram"> <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> <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>
<a href="https://www.flickr.com/photos/imagesbyolofsson/" target="_blank" rel="noopener noreferrer" class="social-card group" aria-label="Flickr"> <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> <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>
<a href="https://lightroom.adobe.com/u/linusolofsson1" target="_blank" rel="noopener noreferrer" class="social-card group" aria-label="Adobe Lightroom"> <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"> <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"/> <path d="M4 4v16h11v-4H8V4H4zm13 4c-2.21 0-4 1.79-4 4v8h4v-7c0-1.1.9-2 2-2v-3h-2z"/>
</svg> </svg>
</a> </a>
<a href="https://github.com/LinusOlofsson-maker" target="_blank" rel="noopener noreferrer" class="social-card group" aria-label="Github"> <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> <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>
<a href="https://www.linkedin.com/in/linus-olofsson-/" target="_blank" rel="noopener noreferrer" class="social-card group" aria-label="LinkedIn"> <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> <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>
<a href="mailto:Admin@imagesbyolofsson.se" class="social-card group" aria-label="Email"> <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> <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> </a>
@@ -63,8 +74,8 @@ import WaveBackground from '../components/WaveBackground.astro';
<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 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> <div>
<h3 class="text-2xl font-bold text-white mb-2">Waiting to hear from you</h3> <h2 class="text-2xl font-bold text-white mb-2">Jag ser fram emot att höra av dig</h2>
<p class="text-gray-400">Feel free to reach out for inquiries or just a chat.</p> <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>
<div class="space-y-4"> <div class="space-y-4">
@@ -74,18 +85,21 @@ import WaveBackground from '../components/WaveBackground.astro';
</svg> </svg>
</div> </div>
<div> <div>
<p class="text-sm text-gray-500 uppercase tracking-widest mb-2">Email me at</p> <p class="text-sm text-gray-400 uppercase tracking-widest mb-2">Mejla mig på</p>
<button id="email-btn" class="text-xl md:text-3xl font-bold text-white hover:text-purple-400 transition-colors break-all" data-email="Admin@imagesbyolofsson.se"> <a href={`mailto:${business.email}`} class="text-xl md:text-3xl font-bold text-white hover:text-purple-400 transition-colors break-all">
Admin@imagesbyolofsson.se {business.email}
</button> </a>
<p id="copy-feedback" class="h-4 text-xs text-green-400 mt-2 opacity-0 transition-opacity">Copied to clipboard!</p> <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> </div>
<div class="pt-2"> <div class="pt-2">
<p class="text-sm text-gray-500 uppercase tracking-widest mb-4">Or DM me on Instagram</p> <p class="text-sm text-gray-400 uppercase tracking-widest mb-4">Eller skriv till mig på Instagram</p>
<a href="https://www.instagram.com/imagesbyolofsson/" target="_blank" class="inline-flex items-center gap-2 text-xl font-bold text-white hover:text-pink-500 transition-colors"> <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">
@imagesbyolofsson {business.social.instagram.handle}
</a> </a>
</div> </div>
</div> </div>
@@ -93,22 +107,7 @@ import WaveBackground from '../components/WaveBackground.astro';
</div> </div>
</div> </div>
</MainLayout> <script>
<style>
@reference "../styles/global.css";
.social-card {
@apply flex items-center justify-center p-4 rounded-xl bg-neutral-800 border border-white/5 text-gray-400 transition-all duration-300;
min-height: 80px;
}
.social-card:hover {
@apply bg-neutral-700 border-white/20 transform -translate-y-1 shadow-lg shadow-white/5;
}
</style>
<script>
const emailBtn = document.getElementById('email-btn'); const emailBtn = document.getElementById('email-btn');
const feedback = document.getElementById('copy-feedback'); const feedback = document.getElementById('copy-feedback');
@@ -125,4 +124,18 @@ import WaveBackground from '../components/WaveBackground.astro';
} }
}); });
} }
</script> </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>
+135 -105
View File
@@ -3,9 +3,14 @@
import MainLayout from '../layouts/MainLayout.astro'; import MainLayout from '../layouts/MainLayout.astro';
import ThreeDScroll from '../components/ThreeDScroll.astro'; import ThreeDScroll from '../components/ThreeDScroll.astro';
import WaveBackground from '../components/WaveBackground.astro'; import WaveBackground from '../components/WaveBackground.astro';
import { business, basedIn, genreList } from '../data/business';
--- ---
<MainLayout title="Home | Images by Olofsson"> <MainLayout
title="Images by Olofsson – fotograf Linus Olofsson i Sverige"
description={`Fotograf Linus Olofsson (Images by Olofsson) i ${basedIn()}: porträtt, natur och djur, stad, makro, norrsken och familjefester. Boka via kundportalen.`}
path="/"
>
<div id="wave-wrapper" class="fixed inset-0 z-0 pointer-events-none transition-opacity duration-1000 ease-in-out" style="opacity: 0.4;"> <div id="wave-wrapper" class="fixed inset-0 z-0 pointer-events-none transition-opacity duration-1000 ease-in-out" style="opacity: 0.4;">
<WaveBackground opacity="opacity-100" theme="cyan" /> <WaveBackground opacity="opacity-100" theme="cyan" />
@@ -38,7 +43,8 @@ import WaveBackground from '../components/WaveBackground.astro';
</div> </div>
<p id="scroll-arrow" class="text-white/70 mt-6 text-lg font-light tracking-widest uppercase transition-opacity duration-300">↓</p> <p class="text-white/80 mt-4 text-base md:text-lg tracking-widest uppercase">Fotograf {business.person.name} &middot; {basedIn()}</p>
<p id="scroll-arrow" aria-hidden="true" class="text-white/70 mt-6 text-lg font-light tracking-widest uppercase transition-opacity duration-300">↓</p>
</div> </div>
</div> </div>
@@ -52,14 +58,14 @@ import WaveBackground from '../components/WaveBackground.astro';
<div class="flex flex-col md:flex-row gap-12 md:gap-24 items-center text-center"> <div class="flex flex-col md:flex-row gap-12 md:gap-24 items-center text-center">
<div data-trail-box class="flex flex-col items-center px-6 py-4 rounded-2xl"> <div data-trail-box class="flex flex-col items-center px-6 py-4 rounded-2xl">
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Looking for inspiration?</h2> <h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Letar du inspiration?</h2>
<a href="/portfolio" class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105"> <a href="/portfolio" class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105">
My Portfolio Se min portfolio
</a> </a>
</div> </div>
<div class="flex flex-row md:flex-col gap-6 items-center justify-center"> <div class="flex flex-row md:flex-col gap-6 items-center justify-center">
<a href="https://www.instagram.com/imagesbyolofsson/" target="_blank" rel="noopener noreferrer" class="circle-btn group" aria-label="Instagram"> <a href={business.social.instagram.url} target="_blank" rel="noopener noreferrer" class="circle-btn group" aria-label="Instagram">
<svg class="outline-ring" viewBox="0 0 100 100"> <svg class="outline-ring" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="48" /> <circle cx="50" cy="50" r="48" />
</svg> </svg>
@@ -69,7 +75,7 @@ import WaveBackground from '../components/WaveBackground.astro';
</svg> </svg>
</div> </div>
</a> </a>
<a href="https://lightroom.adobe.com/u/linusolofsson1" target="_blank" rel="noopener noreferrer" class="circle-btn group" aria-label="Lightroom"> <a href={business.social.lightroom} target="_blank" rel="noopener noreferrer" class="circle-btn group" aria-label="Adobe Lightroom">
<svg class="outline-ring" viewBox="0 0 100 100"> <svg class="outline-ring" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="48" /> <circle cx="50" cy="50" r="48" />
</svg> </svg>
@@ -80,15 +86,135 @@ import WaveBackground from '../components/WaveBackground.astro';
</div> </div>
<div data-trail-box class="flex flex-col items-center px-6 py-4 rounded-2xl"> <div data-trail-box class="flex flex-col items-center px-6 py-4 rounded-2xl">
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Ready to collaborate?</h2> <h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Vill du boka en fotografering?</h2>
<a href="/contact" class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105"> <a href="/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">
Contact me Tjänster och bokning
</a> </a>
</div> </div>
</div> </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>
<script>
// SVG Längd
const path = document.getElementById('outline-path') as unknown as SVGPathElement;
if (path) {
const length = path.getTotalLength();
path.closest('svg')?.style.setProperty('--length', length.toString());
}
const heroText = document.getElementById('hero-text');
const textWrapper = document.getElementById('text-wrapper');
const arrow = document.getElementById('scroll-arrow');
const smokyPath = document.querySelector('.smoky-path') as SVGElement;
const waveWrapper = document.getElementById('wave-wrapper'); // Hämta bakgrunden
// LOGIK FÖR GRADVIS FADE BASERAT PÅ SCROLL
window.addEventListener('scroll', () => {
const scrollY = window.scrollY;
const windowHeight = window.innerHeight;
if (arrow) {
arrow.style.opacity = scrollY > 50 ? '0' : '1';
}
if (heroText && !footerIsVisible) {
const fadeProgress = Math.min(scrollY / (windowHeight * 3), 1);
heroText.style.opacity = fadeProgress.toString();
}
if (scrollY > 2500) {
if (smokyPath) smokyPath.style.animationDuration = '4s';
} else {
if (smokyPath) smokyPath.style.animationDuration = '6s';
}
});
// FOOTER INTERAKTION
const footer = document.getElementById('footer-section');
const imagesContainer = document.getElementById('images-container');
let footerIsVisible = false;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
footerIsVisible = entry.isIntersecting;
if (entry.isIntersecting) {
// -- FOOTER SYNS --
if (imagesContainer) imagesContainer.style.opacity = '0';
if (heroText) {
heroText.style.opacity = '1';
heroText.style.transform = 'translateY(-25vh)';
}
// Tänd Brackets
if (textWrapper) textWrapper.classList.add('show-brackets');
// HÖJ OPACITY TILL 90% (Hårdkodat via style)
if (waveWrapper) {
waveWrapper.style.opacity = '0.9';
}
} else {
// -- FOOTER SYNS INTE --
if (imagesContainer) imagesContainer.style.opacity = '1';
if (heroText) {
heroText.style.transform = 'translateY(0)';
}
// Släck Brackets
if (textWrapper) textWrapper.classList.remove('show-brackets');
// ÅTERSTÄLL OPACITY TILL 40% (Hårdkodat via style)
if (waveWrapper) {
waveWrapper.style.opacity = '0.4';
}
}
});
}, { threshold: 0.1 });
if (footer) observer.observe(footer);
</script>
</MainLayout> </MainLayout>
<style> <style>
@@ -213,99 +339,3 @@ import WaveBackground from '../components/WaveBackground.astro';
.outline-ring circle { fill: none; stroke: white; stroke-width: 2; stroke-dasharray: 302; stroke-dashoffset: 302; transition: stroke-dashoffset 0.6s cubic-bezier(0.25, 1, 0.5, 1); opacity: 0.8; } .outline-ring circle { fill: none; stroke: white; stroke-width: 2; stroke-dasharray: 302; stroke-dashoffset: 302; transition: stroke-dashoffset 0.6s cubic-bezier(0.25, 1, 0.5, 1); opacity: 0.8; }
.circle-btn:hover .outline-ring circle { stroke-dashoffset: 0; } .circle-btn:hover .outline-ring circle { stroke-dashoffset: 0; }
</style> </style>
<script>
// SVG Längd
const path = document.getElementById('outline-path') as unknown as SVGPathElement;
if (path) {
const length = path.getTotalLength();
path.closest('svg')?.style.setProperty('--length', length.toString());
}
const heroText = document.getElementById('hero-text');
const textWrapper = document.getElementById('text-wrapper');
const arrow = document.getElementById('scroll-arrow');
const smokyPath = document.querySelector('.smoky-path') as SVGElement;
const waveWrapper = document.getElementById('wave-wrapper'); // Hämta bakgrunden
// LOGIK FÖR GRADVIS FADE BASERAT PÅ SCROLL
window.addEventListener('scroll', () => {
const scrollY = window.scrollY;
const windowHeight = window.innerHeight;
if (arrow) {
arrow.style.opacity = scrollY > 50 ? '0' : '1';
}
if (heroText && !footerIsVisible) {
const fadeProgress = Math.min(scrollY / (windowHeight * 3), 1);
heroText.style.opacity = fadeProgress.toString();
}
if (scrollY > 2500) {
if (smokyPath) smokyPath.style.animationDuration = '4s';
} else {
if (smokyPath) smokyPath.style.animationDuration = '6s';
}
});
// FOOTER INTERAKTION
const footer = document.getElementById('footer-section');
const imagesContainer = document.getElementById('images-container');
let footerIsVisible = false;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
footerIsVisible = entry.isIntersecting;
if (entry.isIntersecting) {
// -- FOOTER SYNS --
if (imagesContainer) imagesContainer.style.opacity = '0';
if (heroText) {
heroText.style.opacity = '1';
heroText.style.transform = 'translateY(-25vh)';
}
// Tänd Brackets
if (textWrapper) textWrapper.classList.add('show-brackets');
// HÖJ OPACITY TILL 90% (Hårdkodat via style)
if (waveWrapper) {
waveWrapper.style.opacity = '0.9';
}
} else {
// -- FOOTER SYNS INTE --
if (imagesContainer) imagesContainer.style.opacity = '1';
if (heroText) {
heroText.style.transform = 'translateY(0)';
}
// Släck Brackets
if (textWrapper) textWrapper.classList.remove('show-brackets');
// ÅTERSTÄLL OPACITY TILL 40% (Hårdkodat via style)
if (waveWrapper) {
waveWrapper.style.opacity = '0.4';
}
}
});
}, { threshold: 0.1 });
if (footer) observer.observe(footer);
// FOOTER-LÄNKAR HOVER: ändra logo-glow när användare hovrar lyxlimo/karleksguiden-länkar
const glowLinks = document.querySelectorAll<HTMLElement>('[data-glow-link]');
glowLinks.forEach(link => {
link.addEventListener('mouseenter', () => {
if (!textWrapper) return;
const tint = link.getAttribute('data-glow-link');
if (tint) textWrapper.setAttribute('data-logo-tint', tint);
});
link.addEventListener('mouseleave', () => {
if (!textWrapper) return;
textWrapper.removeAttribute('data-logo-tint');
});
});
</script>
+64
View File
@@ -0,0 +1,64 @@
// src/pages/llms.txt.ts -> /llms.txt (https://llmstxt.org/)
// Byggs från src/data/business.ts så att fakta bara finns på ett ställe.
// Okända fakta (null) skrivs som "lämnas på förfrågan", aldrig som gissningar.
import type { APIRoute } from 'astro';
import { business, basedIn, formatSek, genreList } from '../data/business';
import { faq } from '../data/faq';
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}`,
'',
`> ${business.brand} är namnet som ${business.person.jobTitle.toLowerCase()}en ${business.person.name} fotograferar under. Han är baserad i ${basedIn()} och fotograferar ${genreList()}. ${business.recognitionThirdPerson}`,
'',
'## Fakta',
'',
`- Fotograf: ${business.person.name}`,
`- Namn/varumärke: ${business.brand}`,
`- Baserad i: ${basedIn()}`,
business.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}`),
'',
'## Profiler',
'',
`- [Instagram](${business.social.instagram.url})`,
`- [Flickr](${business.social.flickr})`,
`- [Adobe Lightroom](${business.social.lightroom})`,
`- [LinkedIn](${business.social.linkedin})`,
'',
'## Användning av innehållet',
'',
`AI-assistenter och sökmotorer får läsa och använda innehållet på den här sajten, inklusive för träning, med hänvisning till ${business.brand} (${url('/')}). Fotografierna är upphovsrättsskyddade av ${business.person.name}.`,
'',
];
const body = lines.filter((l): l is string => l !== null).join('\n');
return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
};
+191 -111
View File
@@ -1,31 +1,32 @@
--- ---
// src/pages/portfolio.astro // src/pages/portfolio.astro
import MainLayout from '../layouts/MainLayout.astro'; import MainLayout from '../layouts/MainLayout.astro';
import { Image } from 'astro:assets'; import { Image, getImage } from 'astro:assets';
import exifr from 'exifr'; import exifr from 'exifr';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import WaveBackground from '../components/WaveBackground.astro'; import WaveBackground from '../components/WaveBackground.astro';
import { business, genreList } from '../data/business';
import { portfolioMeta } from '../data/portfolio-meta';
// --- SERVER-SIDE LOGIC --- // SERVER-SIDE LOGIC (byggtid)
// 1. Hämta alla bilder // 1. Hämta alla galleribilder.
const allImageFiles = import.meta.glob('../assets/images/portfolio/*.{jpeg,jpg,png,webp,JPG,JPEG,PNG}', { eager: true }); // -s (800px) och -m (1600px) i mappen är gamla nedladdningsvarianter med EXIF kvar.
// -s (800px) och -m (1600px) ar NEDLADDNINGSVARIANTER, inte egna galleribilder. // De importeras INTE längre (då hamnar de inte i dist/); nedladdningsstorlekarna
// Utan filtret visas varje bild tre ganger. // tas i stället fram av Astros bildtjänst nedan, vilket också rensar bort EXIF
const imageFiles = Object.fromEntries( // (GPS, kamerans serienummer, Artist m.m.).
Object.entries(allImageFiles).filter(([pathKey]) => !/-[sm]\.(jpe?g|png|webp)$/i.test(pathKey)) const imageFiles = import.meta.glob(
[
'../assets/images/portfolio/*.{jpeg,jpg,png,webp,JPG,JPEG,PNG}',
'!../assets/images/portfolio/*-{s,m}.{jpeg,jpg,png,webp,JPG,JPEG,PNG}',
],
{ eager: true }
); );
// Karta assetId -> { s, m } med Astros hashade URL:er, sa klienten kan lanka // OBS: läs aldrig .src/.width/.height direkt på de importerade bilderna här.
// till ratt nedladdningsvariant. Large ar galleribilden sjalv, original gar via proxy. // Då räknar Astro originalet som "använt" och kopierar originalfilen (med EXIF)
const downloadVariants: Record<string, Record<string, string>> = {}; // till dist/. Gå alltid via <Image>/getImage.
for (const [pathKey, file] of Object.entries(allImageFiles) as [string, any][]) {
const m = pathKey.match(/immich-([0-9a-f-]{36})-.*-([sm])\.(?:jpe?g|png|webp)$/i);
if (!m) continue;
const id = m[1].toLowerCase();
(downloadVariants[id] ||= {})[m[2].toLowerCase()] = file.default.src;
}
function formatShutterSpeed(exposureTime: number) { function formatShutterSpeed(exposureTime: number) {
if (!exposureTime) return null; if (!exposureTime) return null;
@@ -34,40 +35,78 @@ function formatShutterSpeed(exposureTime: number) {
return `1/${fraction}`; return `1/${fraction}`;
} }
// Datum ur filnamnet: "immich-<id>-20260905-..." eller "20250510-...".
function dateFromFilename(name: string): Date | null {
const m = name.match(/(?:^|-)((?:19|20)\d{2})(\d{2})(\d{2})-/);
if (!m) return null;
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
return isNaN(d.getTime()) ? null : d;
}
const widthOf = (descriptor: string) => parseInt(descriptor, 10) || 0;
const galleryItems = await Promise.all(Object.entries(imageFiles).map(async ([relativePath, file]: [string, any]) => { const galleryItems = await Promise.all(Object.entries(imageFiles).map(async ([relativePath, file]: [string, any]) => {
let exifData: any = {}; let exifData: any = {};
let dateTaken = null; let dateTaken: Date | null = null;
const absolutePath = path.resolve('./src/pages', relativePath); const absolutePath = path.resolve('./src/pages', relativePath);
const fileName = path.basename(relativePath);
// Stabil nyckel = filnamn utan ändelse. Används för alt-texter (portfolio-meta.ts) och statistik.
const key = fileName.replace(/\.[^.]+$/, '');
try { try {
const fileBuffer = fs.readFileSync(absolutePath); const fileBuffer = fs.readFileSync(absolutePath);
exifData = await exifr.parse(fileBuffer, [ exifData = await exifr.parse(fileBuffer, [
'ISO', 'Model', 'FNumber', 'ExposureTime', 'FocalLength', 'DateTimeOriginal', 'CreateDate' 'ISO', 'Model', 'FNumber', 'ExposureTime', 'FocalLength', 'DateTimeOriginal', 'CreateDate'
]); ]);
dateTaken = exifData?.DateTimeOriginal || exifData?.CreateDate; const exifDate = exifData?.DateTimeOriginal || exifData?.CreateDate;
if (!dateTaken) dateTaken = new Date(); if (exifDate) dateTaken = new Date(exifDate);
} catch (e) { } catch (e) {
console.error(`Kunde inte läsa EXIF för ${relativePath}`); console.error(`Kunde inte läsa EXIF för ${relativePath}`);
dateTaken = new Date();
} }
if (!dateTaken || isNaN(dateTaken.getTime())) dateTaken = dateFromFilename(fileName);
const hasDate = !!dateTaken;
const dateObj = dateTaken ?? new Date();
const dateObj = new Date(dateTaken);
const year = dateObj.getFullYear().toString(); const year = dateObj.getFullYear().toString();
const month = dateObj.toLocaleString('sv-SE', { month: 'long' }).toLowerCase(); const month = dateObj.toLocaleString('sv-SE', { month: 'long' }).toLowerCase();
const monthIndex = dateObj.getMonth(); const monthIndex = dateObj.getMonth();
const fullDate = dateObj.toLocaleDateString('sv-SE', { const fullDate = hasDate
day: 'numeric', month: 'long', year: 'numeric' ? dateObj.toLocaleDateString('sv-SE', { day: 'numeric', month: 'long', year: 'numeric' })
}); : '';
const shutterSpeed = formatShutterSpeed(exifData?.ExposureTime); const shutterSpeed = formatShutterSpeed(exifData?.ExposureTime);
const focalLength = exifData?.FocalLength ? `${Math.round(exifData.FocalLength)}mm` : null; const focalLength = exifData?.FocalLength ? `${Math.round(exifData.FocalLength)}mm` : null;
const fStop = exifData?.FNumber ? `f/${exifData.FNumber}` : null; const fStop = exifData?.FNumber ? `f/${exifData.FNumber}` : null;
const iso = exifData?.ISO ? `ISO ${exifData.ISO}` : null; const iso = exifData?.ISO ? `ISO ${exifData.ISO}` : null;
// Alt-text: riktig text från portfolio-meta.ts om den finns, annars en neutral
// svensk reservtext. Aldrig kameramodell, aldrig "undefined".
const meta = portfolioMeta[key] ?? {};
const fallbackAlt = [
meta.category ? `${meta.category}:` : null,
`fotografi av ${business.person.name}`,
fullDate ? `taget ${fullDate}` : null,
].filter(Boolean).join(' ');
const alt = meta.alt?.trim() || fallbackAlt.charAt(0).toUpperCase() + fallbackAlt.slice(1);
// Nedladdning/lightbox: nya, EXIF-fria JPG:er i 800/1600/3000 px
// (Astro hoppar över bredder större än originalet).
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 { return {
...file, year, month, monthIndex, fullDate, model: exifData?.Model, img: file.default, key, alt, title: meta.title ?? null,
fStop, shutterSpeed, iso, focalLength year, month, monthIndex, fullDate, model: exifData?.Model,
fStop, shutterSpeed, iso, focalLength,
dlS: small, dlM: medium, dlL: large, srcset: jpg.srcSet.attribute,
assetId, statKey,
}; };
})); }));
@@ -78,7 +117,13 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
.map((item: any) => item.name); .map((item: any) => item.name);
--- ---
<MainLayout title="Portfolio | Images by Olofsson"> <MainLayout
title="Portfolio – fotografier av Linus Olofsson | Images by Olofsson"
description={`Portfolio med fotografier av Linus Olofsson: ${genreList()}. Filtrera på år och månad och öppna bilderna i större format.`}
path="/portfolio"
pageType="CollectionPage"
breadcrumb="Portfolio"
>
<div> <div>
<WaveBackground opacity="opacity-55" /> <WaveBackground opacity="opacity-55" />
@@ -88,32 +133,44 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
<div class="text-center mb-12"> <div class="text-center mb-12">
<h1 class="text-4xl md:text-6xl font-bold mb-4">Portfolio</h1> <h1 class="text-4xl md:text-6xl font-bold mb-4">Portfolio</h1>
<p class="text-gray-400">A curated selection of my work across different styles.</p> <p class="text-gray-300 max-w-2xl mx-auto">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>
</div> </div>
<div class="flex flex-col items-center gap-6 mb-12"> <div class="flex flex-col items-center gap-6 mb-12">
<div class="flex flex-wrap justify-center gap-2"> <div class="flex flex-wrap justify-center gap-2">
<button class="filter-btn active" data-filter="all" data-type="year">Alla år</button> <button type="button" class="filter-btn active" data-filter="all" data-type="year">Alla år</button>
{uniqueYears.map(year => ( {uniqueYears.map(year => (
<button class="filter-btn" data-filter={year} data-type="year">{year}</button> <button type="button" class="filter-btn" data-filter={year} data-type="year">{year}</button>
))} ))}
</div> </div>
<div class="flex flex-wrap justify-center gap-2"> <div class="flex flex-wrap justify-center gap-2">
<button class="filter-btn active" data-filter="all" data-type="month">Alla månader</button> <button type="button" class="filter-btn active" data-filter="all" data-type="month">Alla månader</button>
{uniqueMonths.map(month => ( {uniqueMonths.map(month => (
<button class="filter-btn capitalize" data-filter={month} data-type="month">{month}</button> <button type="button" class="filter-btn capitalize" data-filter={month} data-type="month">{month}</button>
))} ))}
</div> </div>
</div> </div>
<h2 class="sr-only">Bilder</h2>
<div class="columns-1 md:columns-2 lg:columns-3 gap-6 space-y-6" id="gallery-grid"> <div class="columns-1 md:columns-2 lg:columns-3 gap-6 space-y-6" id="gallery-grid">
{galleryItems.map((item: any) => ( {galleryItems.map((item: any, index: number) => (
<div <a
class="gallery-item break-inside-avoid relative group overflow-hidden rounded-lg cursor-pointer bg-neutral-800 shadow-md" href={item.dlL}
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-year={item.year}
data-month={item.month} data-month={item.month}
data-src={item.default.src} data-src={item.dlL}
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}
data-model={item.model} data-model={item.model}
data-iso={item.iso} data-iso={item.iso}
data-fstop={item.fStop} data-fstop={item.fStop}
@@ -122,23 +179,27 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
data-date={item.fullDate} data-date={item.fullDate}
> >
<Image <Image
src={item.default} src={item.img}
alt={`Foto taget med ${item.model}`} alt={item.alt}
width={800} width={800}
widths={[480, 800]}
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" class="w-full h-auto object-cover transform transition duration-700 group-hover:scale-105"
/> />
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent opacity-0 group-hover:opacity-100 transition duration-300 flex flex-col justify-end p-6 pointer-events-none"> <div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition duration-300 flex flex-col justify-end p-6 pointer-events-none">
<p class="text-white font-bold text-lg">{item.fullDate}</p> {(item.title || item.fullDate) && <p class="text-white font-bold text-lg">{item.title ?? item.fullDate}</p>}
<p class="text-xs text-gray-400 uppercase tracking-widest mt-1">Visa bild</p> <p class="text-xs text-gray-300 uppercase tracking-widest mt-1">Visa bild</p>
</div>
</div> </div>
</a>
))} ))}
</div> </div>
<div id="no-results" class="hidden text-center text-gray-500 mt-20"> <div id="no-results" class="hidden text-center text-gray-400 mt-20">
<p class="text-xl">Inga bilder hittades för denna tidpunkt.</p> <p class="text-xl">Inga bilder hittades för denna tidpunkt.</p>
<button id="reset-btn" class="mt-4 text-white underline hover:text-gray-300">Visa alla bilder</button> <button type="button" id="reset-btn" class="mt-4 text-white underline hover:text-gray-300">Visa alla bilder</button>
</div> </div>
</div> </div>
@@ -146,84 +207,85 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
<div id="lightbox" class="fixed inset-0 z-50 hidden bg-black/95 backdrop-blur-md flex items-center justify-center opacity-0 transition-opacity duration-300 p-2 md:p-8"> <div id="lightbox" class="fixed inset-0 z-50 hidden bg-black/95 backdrop-blur-md flex items-center justify-center opacity-0 transition-opacity duration-300 p-2 md:p-8">
<div class="bg-neutral-900 w-full max-w-6xl h-[85vh] md:h-[90vh] flex flex-col rounded-xl overflow-hidden shadow-2xl relative border border-white/10"> <div class="bg-neutral-900 w-full max-w-6xl h-[85vh] md:h-[90vh] flex flex-col rounded-xl overflow-hidden shadow-2xl relative border border-white/10">
<button id="lb-close" class="absolute top-4 right-4 z-50 bg-black/50 hover:bg-black/80 text-white p-2 rounded-full transition-colors"> <button type="button" id="lb-close" aria-label="Stäng" class="absolute top-4 right-4 z-50 bg-black/50 hover:bg-black/80 text-white p-2 rounded-full transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button> </button>
<div class="flex-1 relative bg-black flex items-center justify-center overflow-hidden group"> <div class="flex-1 relative bg-black flex items-center justify-center overflow-hidden group">
<img id="lb-img" src="" alt="Full size" class="max-h-full max-w-full w-full h-full object-contain p-2 md:p-4" /> <img id="lb-img" src="" alt="" sizes="100vw" class="max-h-full max-w-full w-full h-full object-contain p-2 md:p-4" />
<button id="lb-prev" class="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 bg-black/40 hover:bg-black/70 text-white p-3 rounded-full transition-all"> <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">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button> </button>
<button id="lb-next" class="absolute right-2 md:right-4 top-1/2 -translate-y-1/2 bg-black/40 hover:bg-black/70 text-white p-3 rounded-full transition-all"> <button type="button" id="lb-next" aria-label="Nästa bild" class="absolute right-2 md:right-4 top-1/2 -translate-y-1/2 bg-black/40 hover:bg-black/70 text-white p-3 rounded-full transition-all">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>
</button> </button>
</div> </div>
<div class="bg-neutral-800 p-4 md:p-6 shrink-0 border-t border-white/5 flex flex-col md:flex-row justify-between items-start md:items-center gap-4"> <div class="bg-neutral-800 p-4 md:p-6 shrink-0 border-t border-white/5 flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="text-left"> <div class="text-left">
<h3 id="lb-date" class="text-white font-bold text-lg md:text-xl capitalize"></h3> <p id="lb-date" class="text-white font-bold text-lg md:text-xl"></p>
<p id="lb-model" class="text-gray-400 text-sm font-medium tracking-wide mt-1"></p> <p id="lb-model" class="text-gray-400 text-sm font-medium tracking-wide mt-1"></p>
</div> </div>
<div class="flex flex-wrap items-center gap-4 md:gap-6"> <div class="flex flex-wrap items-center gap-4 md:gap-6">
<div class="relative"> <div class="relative">
<button id="lb-download" class="group inline-flex items-center gap-2 px-6 py-2.5 bg-neutral-700/80 hover:bg-neutral-600/80 text-gray-200 hover:text-white font-semibold text-xs uppercase tracking-widest rounded-full border border-black/60 shadow-lg shadow-black/30 ring-1 ring-white/5 transition-all duration-200 transform hover:scale-105 active:scale-95 active:duration-75"> <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> <svg id="lb-dl-arrow" xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 transition-colors duration-200" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 19h16"/></svg>
Download Ladda ner
</button> </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"> <div id="lb-sizes" class="hidden absolute bottom-full mb-3 left-0 md:left-auto md:right-0 min-w-[260px] bg-neutral-900/95 backdrop-blur-md border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden origin-bottom opacity-0 translate-y-2 transition-all duration-200 ease-out">
<a data-size="s" class="dl-opt group flex items-center justify-between px-5 py-3.5 text-sm text-gray-200 hover:bg-black/40 hover:text-white cursor-pointer transition-colors border-b border-white/5"><span class="font-medium uppercase tracking-widest text-xs">Small</span><span class="text-gray-500 text-xs font-mono group-hover:text-gray-300 transition-colors">800 px</span></a> <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>
<a data-size="m" class="dl-opt group flex items-center justify-between px-5 py-3.5 text-sm text-gray-200 hover:bg-black/40 hover:text-white cursor-pointer transition-colors border-b border-white/5"><span class="font-medium uppercase tracking-widest text-xs">Medium</span><span class="text-gray-500 text-xs font-mono group-hover:text-gray-300 transition-colors">1600 px</span></a> <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>
<a data-size="l" class="dl-opt group flex items-center justify-between px-5 py-3.5 text-sm text-gray-200 hover:bg-black/40 hover:text-white cursor-pointer transition-colors border-b border-white/5"><span class="font-medium uppercase tracking-widest text-xs">Large</span><span class="text-gray-500 text-xs font-mono group-hover:text-gray-300 transition-colors">3000 px</span></a> <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>
<a data-size="orig" class="dl-opt group flex items-center justify-between px-5 py-3.5 text-sm text-gray-200 hover:bg-black/40 hover:text-white cursor-pointer transition-colors "><span class="font-medium uppercase tracking-widest text-xs">Original<span class="block text-[10px] text-gray-500 normal-case tracking-normal mt-0.5">Kräver lösenord</span></span><span class="text-gray-500 text-xs font-mono group-hover:text-gray-300 transition-colors">full storlek</span></a> <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> </div>
<div class="flex flex-wrap gap-3 md:gap-6 text-xs md:text-sm font-mono text-gray-300"> <div class="flex flex-wrap gap-3 md:gap-6 text-xs md:text-sm font-mono text-gray-300">
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-500 uppercase text-[10px] font-bold">Focal</span><span id="lb-focal">--</span></div> <div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold">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-500 uppercase text-[10px] font-bold">Aperture</span><span id="lb-fstop">--</span></div> <div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold">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-500 uppercase text-[10px] font-bold">Shutter</span><span id="lb-shutter">--</span></div> <div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold">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-500 uppercase text-[10px] font-bold">ISO</span><span id="lb-iso">--</span></div> <div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold">ISO</span><span id="lb-iso">--</span></div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<script type="application/json" id="dl-variants" set:html={JSON.stringify(downloadVariants)}></script> <!-- 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. -->
<!-- Losenordsruta for ORIGINAL. Originalen ligger inte i repot (27-70 MB/st) <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">
utan hamtas fran Immich via /download/original, som kraver losenord. -->
<div id="pw-modal" class="hidden fixed inset-0 z-[60] bg-black/85 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-neutral-900 border border-white/10 rounded-xl shadow-2xl p-7 w-full max-w-sm"> <div class="bg-neutral-900 border border-white/10 rounded-xl shadow-2xl p-7 w-full max-w-sm">
<h4 class="text-white font-bold text-lg tracking-tight mb-1">Ladda ner original</h4> <p 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> <p class="text-gray-400 text-sm mb-4">Originalfilen är i full upplösning och kräver lösenord.</p>
<input id="pw-input" type="password" placeholder="Lösenord" autocomplete="current-password" <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" /> 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> <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"> <div class="flex gap-2 justify-end mt-2">
<button id="pw-cancel" class="px-5 py-2.5 text-xs uppercase tracking-widest text-gray-400 hover:text-white rounded-full transition-colors">Avbryt</button> <button 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 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> <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>
</div> </div>
</div> </div>
</MainLayout>
<style> <script>
@reference "../styles/global.css"; // --- ANONYM STATISTIK ---
// Räknare per dag och bild i kundportalen: ingen cookie, ingen IP, inget id.
.filter-btn { const STATS_URL = 'https://customer.imagesbyolofsson.se/api/stats/hit';
@apply px-4 py-1.5 rounded-full border border-white/10 bg-neutral-800/50 text-sm text-gray-400 transition hover:bg-white hover:text-black hover:border-white; // Bildens statistiknyckel räknas ut vid bygget (data-stat) och är densamma som
// tidigare ("portfolio/<filnamn utan ändelse>"), så historiken fortsätter.
function statsItem(item?: HTMLElement): string {
return item?.dataset.stat || 'portfolio/okand';
} }
function stat(kind: 'view' | 'open' | 'download', item: string) {
.filter-btn.active { try {
@apply bg-white text-black border-white font-medium shadow-[0_0_15px_rgba(255,255,255,0.3)]; const body = new Blob([JSON.stringify({ kind, item })], { type: 'text/plain' });
navigator.sendBeacon?.(STATS_URL, body);
} catch { /* statistik får aldrig störa sidan */ }
} }
</style> stat('view', 'page/portfolio');
<script>
// --- FILTER LOGIK --- // --- FILTER LOGIK ---
// MULTI-select: flera ar + manader samtidigt. 'all' = samtligt. // MULTI-select: flera år + månader samtidigt. 'all' = samtligt.
let selectedYears = new Set(['all']); let selectedYears = new Set(['all']);
let selectedMonths = new Set(['all']); let selectedMonths = new Set(['all']);
@@ -293,28 +355,22 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
const pwInput = document.getElementById('pw-input') as HTMLInputElement; const pwInput = document.getElementById('pw-input') as HTMLInputElement;
const pwError = document.getElementById('pw-error'); const pwError = document.getElementById('pw-error');
// Astro hashar filnamnen, men assetId:t finns kvar i dem: // Small/Medium/Large är EXIF-fria JPG:er som Astro tar fram vid bygget
// /_astro/immich-<assetId>-<datum>-<namn>.<hash>.jpg // (data-dl-s/m/l). Original hämtas från Immich via proxyn och kräver lösenord.
function assetIdFor(src: string): string | null {
const m = src.match(/immich-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-/i);
return m ? m[1].toLowerCase() : null;
}
// Large ar filen galleriet redan visar. -s/-m ligger bredvid i samma katalog,
// men med egen Astro-hash - darfor laser vi dem ur en genererad karta.
const VARIANTS: Record<string, Record<string, string>> = JSON.parse(
document.getElementById('dl-variants')?.textContent || '{}'
);
function urlForSize(size: string): string | null { function urlForSize(size: string): string | null {
const item = visibleItems[currentImageIndex]; const item = visibleItems[currentImageIndex];
if (!item) return null; if (!item) return null;
const large = item.dataset.src || ''; if (size === 's') return item.dataset.dlS || null;
if (size === 'l') return large; if (size === 'm') return item.dataset.dlM || null;
const id = assetIdFor(large); if (size === 'l') return item.dataset.dlL || null;
if (!id) return null; if (size === 'orig') return item.dataset.asset ? `/download/original?asset=${item.dataset.asset}` : null;
if (size === 'orig') return `/download/original?asset=${id}`; return null;
return VARIANTS[id]?.[size] || 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) { function startDownload(url: string, filnamn?: string) {
@@ -330,9 +386,10 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
function visaMeny(visa: boolean) { function visaMeny(visa: boolean) {
if (!dlMenu) return; if (!dlMenu) return;
// Pilen gar gra medan menyn ar oppen och tillbaka till vanligt nar den stangs // Pilen går grå medan menyn är öppen och tillbaka till vanligt när den stängs
// (bade vid val av storlek och nar man klickar bort menyn). // (både vid val av storlek och när man klickar bort menyn).
dlArrow?.classList.toggle('text-gray-500', visa); dlArrow?.classList.toggle('text-gray-500', visa);
dlBtn?.setAttribute('aria-expanded', visa ? 'true' : 'false');
if (visa) { if (visa) {
dlMenu.classList.remove('hidden'); dlMenu.classList.remove('hidden');
requestAnimationFrame(() => dlMenu.classList.remove('opacity-0', 'translate-y-2')); requestAnimationFrame(() => dlMenu.classList.remove('opacity-0', 'translate-y-2'));
@@ -357,9 +414,10 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
const size = (opt as HTMLElement).dataset.size || 'l'; const size = (opt as HTMLElement).dataset.size || 'l';
visaMeny(false); visaMeny(false);
const url = urlForSize(size); const url = urlForSize(size);
if (!url) { alert('Den storleken saknas for den har bilden.'); return; } if (!url) { alert('Den storleken saknas för den här bilden.'); return; }
stat('download', statsItem(visibleItems[currentImageIndex]));
if (size === 'orig') { if (size === 'orig') {
// Losenordet sparas bara for fliken, aldrig i localStorage. // Lösenordet sparas bara för fliken, aldrig i localStorage.
const sparat = sessionStorage.getItem('dlpw'); const sparat = sessionStorage.getItem('dlpw');
if (sparat) { startDownload(`${url}&pw=${encodeURIComponent(sparat)}`); return; } if (sparat) { startDownload(`${url}&pw=${encodeURIComponent(sparat)}`); return; }
pwError?.classList.add('hidden'); pwError?.classList.add('hidden');
@@ -367,7 +425,7 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
pwModal?.classList.remove('hidden'); pwModal?.classList.remove('hidden');
setTimeout(() => pwInput?.focus(), 50); setTimeout(() => pwInput?.focus(), 50);
} else { } else {
startDownload(url); startDownload(url, fileNameFor(size));
} }
}); });
}); });
@@ -377,12 +435,12 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
if (!pw) return; if (!pw) return;
const url = urlForSize('orig'); const url = urlForSize('orig');
if (!url) return; if (!url) return;
// HEAD forst: da far vi 401 utan att dra igang en 70 MB-nedladdning. // HEAD först: då får vi 401 utan att dra igång en 70 MB-nedladdning.
try { try {
const r = await fetch(`${url}&pw=${encodeURIComponent(pw)}`, { method: 'HEAD' }); const r = await fetch(`${url}&pw=${encodeURIComponent(pw)}`, { method: 'HEAD' });
if (r.status === 401) { pwError?.classList.remove('hidden'); return; } if (r.status === 401) { pwError?.classList.remove('hidden'); return; }
if (!r.ok) { alert('Kunde inte hamta originalet just nu.'); return; } if (!r.ok) { alert('Kunde inte hämta originalet just nu.'); return; }
} catch { /* natverksfel -> forsok anda nedan */ } } catch { /* nätverksfel -> försök ändå nedan */ }
sessionStorage.setItem('dlpw', pw); sessionStorage.setItem('dlpw', pw);
pwModal?.classList.add('hidden'); pwModal?.classList.add('hidden');
startDownload(`${url}&pw=${encodeURIComponent(pw)}`); startDownload(`${url}&pw=${encodeURIComponent(pw)}`);
@@ -412,9 +470,13 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
if (!item) return; if (!item) return;
visaMeny(false); visaMeny(false);
// srcset först så att webbläsaren väljer 800/1600/3000 px efter skärmen.
lbImg.srcset = item.dataset.srcset || '';
lbImg.src = item.dataset.src || ''; lbImg.src = item.dataset.src || '';
lbImg.alt = item.dataset.alt || '';
stat('open', statsItem(item));
if(lbDate) lbDate.innerText = item.dataset.date || ''; if(lbDate) lbDate.innerText = [item.dataset.title, item.dataset.date].filter(Boolean).join(' – ');
if(lbModel) lbModel.innerText = item.dataset.model || ''; if(lbModel) lbModel.innerText = item.dataset.model || '';
if(lbFocal) lbFocal.innerText = item.dataset.focal || '--'; if(lbFocal) lbFocal.innerText = item.dataset.focal || '--';
if(lbFstop) lbFstop.innerText = item.dataset.fstop || '--'; if(lbFstop) lbFstop.innerText = item.dataset.fstop || '--';
@@ -435,7 +497,12 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
// Event Listeners // Event Listeners
items.forEach((item) => { items.forEach((item) => {
item.classList.add('visible-item'); item.classList.add('visible-item');
item.addEventListener('click', () => { item.addEventListener('click', (e) => {
// Länken pekar på den stora bilden (crawlbar/öppningsbar i ny flik),
// men ett vanligt klick öppnar lightboxen.
const me = e as MouseEvent;
if (me.metaKey || me.ctrlKey || me.shiftKey || me.button === 1) return;
e.preventDefault();
updateVisibleItems(); updateVisibleItems();
const index = visibleItems.indexOf(item); const index = visibleItems.indexOf(item);
if (index !== -1) openLightbox(index); if (index !== -1) openLightbox(index);
@@ -487,4 +554,17 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
document.querySelector('.filter-btn[data-filter="all"][data-type="month"]')?.classList.add('active'); document.querySelector('.filter-btn[data-filter="all"][data-type="month"]')?.classList.add('active');
filterImages(); filterImages();
}); });
</script> </script>
</MainLayout>
<style>
@reference "../styles/global.css";
.filter-btn {
@apply px-4 py-1.5 rounded-full border border-white/10 bg-neutral-800/50 text-sm text-gray-400 transition hover:bg-white hover:text-black hover:border-white;
}
.filter-btn.active {
@apply bg-white text-black border-white font-medium shadow-[0_0_15px_rgba(255,255,255,0.3)];
}
</style>
+56 -34
View File
@@ -2,80 +2,102 @@
// src/pages/privacy.astro // src/pages/privacy.astro
import MainLayout from '../layouts/MainLayout.astro'; import MainLayout from '../layouts/MainLayout.astro';
import WaveBackground from '../components/WaveBackground.astro'; import WaveBackground from '../components/WaveBackground.astro';
import { business } from '../data/business';
const link = 'text-white underline hover:text-gray-300';
--- ---
<MainLayout title="Privacy Policy | Images by Olofsson"> <MainLayout
title="Integritetspolicy | Images by Olofsson"
description="Hur imagesbyolofsson.se hanterar personuppgifter: Meta Pixel endast med samtycke, anonym bildstatistik utan cookies och dina rättigheter enligt GDPR."
path="/privacy"
breadcrumb="Integritetspolicy"
>
<WaveBackground opacity="opacity-80" /> <WaveBackground opacity="opacity-80" />
<div class="container mx-auto px-4 py-12 md:py-20 relative z-10"> <div class="container mx-auto px-4 py-12 md:py-20 relative z-10">
<div data-trail-box class="bg-neutral-900/60 backdrop-blur-md border border-white/10 rounded-3xl p-8 md:p-12 shadow-2xl mb-20 max-w-3xl mx-auto"> <div data-trail-box class="bg-neutral-900/60 backdrop-blur-md border border-white/10 rounded-3xl p-8 md:p-12 shadow-2xl mb-20 max-w-3xl mx-auto">
<h1 class="text-4xl md:text-5xl font-bold leading-tight mb-2">Privacy Policy</h1> <h1 class="text-4xl md:text-5xl font-bold leading-tight mb-2">Integritetspolicy</h1>
<p class="text-gray-500 mb-10">Last updated: 25 October 2026</p> <p class="text-gray-400 mb-10">Senast uppdaterad: 27 oktober 2026</p>
<div class="space-y-8 text-gray-300 text-lg leading-relaxed"> <div class="space-y-8 text-gray-300 text-lg leading-relaxed">
<section class="space-y-3"> <section class="space-y-3">
<h2 class="text-2xl font-bold text-white">Who is responsible</h2> <h2 class="text-2xl font-bold text-white">Vem som är ansvarig</h2>
<p> <p>
Images by Olofsson is run by Linus Olofsson, Sweden. Questions about privacy: {business.brand} drivs av {business.person.name}, {business.country.name}. Frågor om integritet:
<a href="mailto:Admin@imagesbyolofsson.se" class="text-white underline hover:text-gray-300">Admin@imagesbyolofsson.se</a>. <a href={`mailto:${business.email}`} class={link}>{business.email}</a>.
</p> </p>
</section> </section>
<section class="space-y-3"> <section class="space-y-3">
<h2 class="text-2xl font-bold text-white">This website</h2> <h2 class="text-2xl font-bold text-white">Den här webbplatsen</h2>
<ul class="list-disc pl-6 space-y-2"> <ul class="list-disc pl-6 space-y-2">
<li>There are no user accounts and no contact forms.</li> <li>Det finns inga användarkonton och inga kontaktformulär på den här webbplatsen. Bokningar görs i den separata kundportalen.</li>
<li>If you email me, your address and message are used only to answer you and are not shared with anyone.</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>Password-protected galleries keep the password only in your open browser tab; it is not stored after you close it.</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> <li>
The site is delivered through Cloudflare, which processes technical data such as your IP address to deliver Webbplatsen levereras via Cloudflare, som behandlar tekniska uppgifter som din IP-adress för att leverera
and protect the site. See <a href="https://www.cloudflare.com/privacypolicy/" target="_blank" rel="noopener noreferrer" class="text-white underline hover:text-gray-300">Cloudflare's privacy policy</a>. och skydda webbplatsen. Se <a href="https://www.cloudflare.com/privacypolicy/" target="_blank" rel="noopener noreferrer" class={link}>Cloudflares integritetspolicy</a>.
</li> </li>
<li>If you appear in one of my photos and want it removed, email me and I will take it down.</li> <li>Om du syns på någon av mina bilder och vill att den tas bort, mejla mig så tar jag bort den.</li>
</ul> </ul>
</section> </section>
<section class="space-y-3"> <section class="space-y-3">
<h2 class="text-2xl font-bold text-white">Meta Pixel (only with your consent)</h2> <h2 class="text-2xl font-bold text-white">Meta Pixel (bara med ditt samtycke)</h2>
<p> <p>
If you click "Accept" in the cookie box, the site loads the Meta Pixel from Meta Platforms Ireland Ltd. Om du klickar på ”Godkänn” i cookierutan laddar webbplatsen Meta Pixel från Meta Platforms Ireland Ltd.
I use it to see how visitors coming from my Instagram and Facebook use the site. Jag använder den för att se hur besökare som kommer från mitt Instagram och Facebook använder webbplatsen.
</p> </p>
<ul class="list-disc pl-6 space-y-2"> <ul class="list-disc pl-6 space-y-2">
<li>It sets cookies (such as <code>_fbp</code>) and sends Meta information like the pages you visit, your IP address and browser details.</li> <li>Den sätter cookies (till exempel <code>_fbp</code>) och skickar uppgifter till Meta, som vilka sidor du besöker, din IP-adress och information om din webbläsare.</li>
<li>Meta may link this to your Facebook or Instagram account and may transfer data outside the EU. See <li>Meta kan koppla detta till ditt Facebook- eller Instagramkonto och kan föra över uppgifter utanför EU. Se
<a href="https://www.facebook.com/privacy/policy/" target="_blank" rel="noopener noreferrer" class="text-white underline hover:text-gray-300">Meta's privacy policy</a>.</li> <a href="https://www.facebook.com/privacy/policy/" target="_blank" rel="noopener noreferrer" class={link}>Metas integritetspolicy</a>.</li>
<li>If you click "Decline" or do nothing, the pixel is never loaded.</li> <li>Om du klickar på ”Avböj” eller inte gör något laddas pixeln aldrig.</li>
<li>You can change your choice at any time via "Cookie settings" at the bottom of every page. Your choice is stored in your own browser.</li> <li>Du kan ändra ditt val när som helst via ”Cookie-inställningar” längst ner på varje sida. Ditt val sparas i din egen webbläsare.</li>
</ul> </ul>
</section> </section>
<section class="space-y-3"> <section class="space-y-3">
<h2 class="text-2xl font-bold text-white">Photo backup app (Google)</h2> <h2 class="text-2xl font-bold text-white">Anonym statistik för portfolion</h2>
<p> <p>
"townhouse-backup" is a private tool used only by me to back up my own photos to my own Google account. För att se vilka bilder som uppskattas räknar portfolion hur många gånger varje bild öppnas eller laddas ner, per dag.
Bara antalet sparas, på min egen server i Sverige. Inga cookies, ingen IP-adress, ingen webbläsarinformation och inget
som kan identifiera dig. Det sker utan samtycke eftersom inga uppgifter om dig samlas in.
</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-bold text-white">App för säkerhetskopiering av bilder (Google)</h2>
<p>
”townhouse-backup” är ett privat verktyg som bara jag använder för att säkerhetskopiera mina egna bilder till mitt eget Google-konto.
</p> </p>
<ul class="list-disc pl-6 space-y-2"> <ul class="list-disc pl-6 space-y-2">
<li>Google Drive: it only accesses files it has created itself (a backup copy of my photo archive).</li> <li>Google Drive: verktyget kommer bara åt filer som det själv har skapat (en säkerhetskopia av mitt bildarkiv).</li>
<li>Google Photos: it only uploads my photos and adds them to albums it created; it cannot see anything else in the library.</li> <li>Google Foto: verktyget laddar bara upp mina bilder och lägger dem i album som det själv har skapat; det kan inte se något annat i biblioteket.</li>
<li>No data from anyone else is collected. Nothing is sold, shared with third parties, used for advertising or used to train AI models.</li> <li>Inga uppgifter om någon annan samlas in. Ingenting säljs, delas med tredje part, används för reklam eller används för att träna AI-modeller.</li>
<li> <li>
Its use of information received from Google APIs adheres to the Verktygets användning av information från Googles API:er följer
<a href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank" rel="noopener noreferrer" class="text-white underline hover:text-gray-300">Google API Services User Data Policy</a>, <a href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank" rel="noopener noreferrer" class={link}>Google API Services User Data Policy</a>,
inklusive kraven på begränsad användning (Limited Use).
</li>
</ul>
<p lang="en" class="text-base text-gray-400">
In English: townhouse-backup's use of information received from Google APIs adheres to the
<a href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank" rel="noopener noreferrer" class={link}>Google API Services User Data Policy</a>,
including the Limited Use requirements. including the Limited Use requirements.
</li> </p>
</ul>
</section> </section>
<section class="space-y-3"> <section class="space-y-3">
<h2 class="text-2xl font-bold text-white">Your rights</h2> <h2 class="text-2xl font-bold text-white">Dina rättigheter</h2>
<p> <p>
Under the GDPR you can ask what personal data I hold about you, and have it corrected or deleted — just email me. Enligt GDPR kan du fråga vilka personuppgifter jag har om dig och få dem rättade eller raderade – mejla mig bara.
You can also complain to the Swedish Authority for Privacy Protection Du kan också klaga hos Integritetsskyddsmyndigheten
(<a href="https://www.imy.se/" target="_blank" rel="noopener noreferrer" class="text-white underline hover:text-gray-300">IMY</a>). (<a href="https://www.imy.se/" target="_blank" rel="noopener noreferrer" class={link}>IMY</a>).
</p> </p>
</section> </section>
+23
View File
@@ -0,0 +1,23 @@
// src/pages/sitemap-0.xml.ts -> /sitemap-0.xml
// Listar sajtens riktiga sidor (inte 404). Ingen <lastmod>: vi har inget
// pålitligt ändringsdatum per sida, och ett påhittat datum gör mer skada än nytta.
import type { APIRoute } from 'astro';
import { sitePaths } from '../lib/pages';
export const prerender = true;
const escapeXml = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
export const GET: APIRoute = ({ site }) => {
const base = site ?? new URL('https://imagesbyolofsson.se');
const urls = sitePaths()
.map((p) => ` <url><loc>${escapeXml(new URL(p, base).toString())}</loc></url>`)
.join('\n');
const body = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>
`;
return new Response(body, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } });
};
+15
View File
@@ -0,0 +1,15 @@
// src/pages/sitemap-index.xml.ts -> /sitemap-index.xml
// Egen sitemap utan @astrojs/sitemap (inga nya beroenden).
import type { APIRoute } from 'astro';
export const prerender = true;
export const GET: APIRoute = ({ site }) => {
const base = site ?? new URL('https://imagesbyolofsson.se');
const body = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>${new URL('/sitemap-0.xml', base).toString()}</loc></sitemap>
</sitemapindex>
`;
return new Response(body, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } });
};
+127
View File
@@ -0,0 +1,127 @@
---
// 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>