Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2aab0ef8b | ||
|
|
1d4a010aae | ||
|
|
e499ae533e | ||
|
|
69cb075618 | ||
|
|
63baa35b92 | ||
|
|
63a4b3a848 | ||
|
|
c760e11c4c | ||
|
|
d841cf98b5 | ||
|
|
5a5b2b42d2 | ||
|
|
24a59c6e93 | ||
|
|
8c9afb65c3 | ||
|
|
dc2bda8c07 | ||
|
|
6bb56e6035 | ||
|
|
1b24607e02 | ||
|
|
85bd49b0c3 |
@@ -10,8 +10,8 @@
|
||||
# import ibo_www_redirect
|
||||
# }
|
||||
# imagesbyolofsson.se {
|
||||
# # Befintliga specialregler (t.ex. handle /download/original* -> Immich-proxyn)
|
||||
# # ska ligga FÖRE importen, så att de vinner över try_files/file_server.
|
||||
# # Specialregler (på Cleura: handle för /about, /contact, /posters -> kundportalen,
|
||||
# # se kundportalens deploy/cleura/caddy/Caddyfile) ska ligga FÖRE importen.
|
||||
# import ibo_site /srv/imagesbyolofsson/dist
|
||||
# }
|
||||
#
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy/cleura/install-portfolio-sync.sh – installs the portfolio sync on the Cleura server (run as root from a
|
||||
# checkout of this repo). Needs /etc/portal/portfolio-sync.env (root 600) first; see portfolio-sync.sh for the keys.
|
||||
# Undo: systemctl disable --now portfolio-sync.timer && rm /etc/systemd/system/portfolio-sync.{service,timer}
|
||||
set -euo pipefail
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
ENVF=/etc/portal/portfolio-sync.env
|
||||
[ -f "$ENVF" ] || { echo "$ENVF is missing - copy IMMICH_URL, PORTFOLIO_TAG_ID, IMMICH_API_KEY and GIT_URL there first"; exit 2; }
|
||||
[ "$(stat -c %a "$ENVF")" = "600" ] || chmod 600 "$ENVF"
|
||||
install -m 755 -o root -g root "$HERE/portfolio-sync.sh" /opt/portal/site/portfolio-sync.sh
|
||||
install -m 644 -o root -g root "$HERE/portfolio-sync.service" /etc/systemd/system/portfolio-sync.service
|
||||
install -m 644 -o root -g root "$HERE/portfolio-sync.timer" /etc/systemd/system/portfolio-sync.timer
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now portfolio-sync.timer
|
||||
systemctl list-timers portfolio-sync.timer --no-pager
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Immich portfolio tag -> imagesbyolofsson-se repo -> live site (deploy/cleura/portfolio-sync.sh)
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/portal/site/portfolio-sync.sh
|
||||
TimeoutStartSec=45min
|
||||
Nice=10
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy/cleura/portfolio-sync.sh – Immich "portfolio" tag -> this repo -> imagesbyolofsson.se, on the Cleura server.
|
||||
#
|
||||
# Replaces /opt/immich-sync/run-sync.sh on Nurem (started by n8n over SSH) when imagesbyolofsson.se moved to Cleura
|
||||
# (Linus 2026-11-06: everything on the Cleura server). Same steps: fetch main, run scripts/portfolio-sync.mjs,
|
||||
# commit + push to Gitea main when photos were added or removed, and then rebuild the live site with
|
||||
# /opt/portal/site/build-live.sh (EXIF check included). Nothing is built when nothing changed.
|
||||
#
|
||||
# Installed by deploy/cleura/install-portfolio-sync.sh as /opt/portal/site/portfolio-sync.sh, run by
|
||||
# portfolio-sync.timer every 20 minutes. Secrets: /etc/portal/portfolio-sync.env (root 600) with
|
||||
# IMMICH_URL, PORTFOLIO_TAG_ID, IMMICH_API_KEY (read-only Immich key) and GIT_URL (Gitea URL that may push main,
|
||||
# with user:token@ in it, as on Nurem).
|
||||
#
|
||||
# The Gitea token never lands on disk outside that env file: the clone's remote URL is stored without it, git gets
|
||||
# it from a credential helper at fetch/push time, and the Node container (npm install runs third-party code) gets
|
||||
# an empty tmpfs over .git and only the Immich variables.
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
ENVF=${PORTFOLIO_SYNC_ENV:-/etc/portal/portfolio-sync.env}
|
||||
DIR=${PORTFOLIO_SYNC_DIR:-/opt/portal/site/sync/repo}
|
||||
NODE=node:22.22.0-alpine@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
|
||||
|
||||
exec 9>/run/lock/portfolio-sync.lock
|
||||
flock -n 9 || { echo "[sync] already running"; exit 0; }
|
||||
|
||||
set -a; . "$ENVF"; set +a
|
||||
export IMMICH_URL="${IMMICH_URL:-https://foto.imagesbyolofsson.se/api}"
|
||||
export PORTFOLIO_TAG_ID="${PORTFOLIO_TAG_ID:-67d16b6c-0dea-42ef-a289-2c9e57fa5c5a}"
|
||||
[ -n "${IMMICH_API_KEY:-}" ] || { echo "FATAL: IMMICH_API_KEY missing in $ENVF"; exit 2; }
|
||||
[ -n "${GIT_URL:-}" ] || { echo "FATAL: GIT_URL missing in $ENVF"; exit 2; }
|
||||
|
||||
# Split GIT_URL into the clean URL (stored in .git/config) and user/token (kept in this process only).
|
||||
read -r CLEAN_URL GIT_USER GIT_TOKEN < <(python3 - "$GIT_URL" <<'PY'
|
||||
import sys, urllib.parse as u
|
||||
p = u.urlsplit(sys.argv[1])
|
||||
host = p.hostname + (":%d" % p.port if p.port else "")
|
||||
print(u.urlunsplit((p.scheme, host, p.path, "", "")), u.unquote(p.username or "-"), u.unquote(p.password or "-"))
|
||||
PY
|
||||
)
|
||||
unset GIT_URL
|
||||
export GIT_USER GIT_TOKEN
|
||||
# shellcheck disable=SC2016 # $GIT_USER/$GIT_TOKEN are expanded by the helper's shell, not here.
|
||||
G=(git -c credential.helper= -c 'credential.helper=!f() { echo "username=$GIT_USER"; echo "password=$GIT_TOKEN"; }; f')
|
||||
|
||||
if [ ! -d "$DIR/.git" ]; then
|
||||
mkdir -p "$(dirname "$DIR")"
|
||||
"${G[@]}" clone -q "$CLEAN_URL" "$DIR"
|
||||
fi
|
||||
chmod 700 "$(dirname "$DIR")" "$DIR"
|
||||
git -C "$DIR" remote set-url origin "$CLEAN_URL"
|
||||
echo "[sync] fetch + reset main"
|
||||
"${G[@]}" -C "$DIR" fetch -q origin main
|
||||
git -C "$DIR" checkout -q -B main origin/main
|
||||
git -C "$DIR" reset -q --hard origin/main
|
||||
|
||||
echo "[sync] scripts/portfolio-sync.mjs"
|
||||
# portfolio-sync.mjs exits 1 when a single photo fails but still writes the others; those are committed below, and the
|
||||
# run ends with exit 1 so systemd shows that something needs a look (journalctl -u portfolio-sync).
|
||||
SYNC_RC=0
|
||||
docker run --rm --memory 3g --cpus 2 --network bridge \
|
||||
-e IMMICH_URL -e IMMICH_API_KEY -e PORTFOLIO_TAG_ID \
|
||||
-v "$DIR":/app --mount type=tmpfs,destination=/app/.git -w /app "$NODE" \
|
||||
sh -c 'npm install --no-audit --no-fund --loglevel=error >/dev/null && node scripts/portfolio-sync.mjs' || SYNC_RC=$?
|
||||
|
||||
if git -C "$DIR" status --porcelain -- src/assets/images/portfolio | grep -q .; then
|
||||
git -C "$DIR" add -A src/assets/images/portfolio
|
||||
git -C "$DIR" -c user.name="portfolio-sync (Cleura)" -c user.email="Admin@imagesbyolofsson.se" \
|
||||
commit -q -m "chore(portfolio): sync $(date -u +%Y-%m-%d)"
|
||||
"${G[@]}" -C "$DIR" push -q origin main
|
||||
echo "[sync] pushed $(git -C "$DIR" rev-parse --short HEAD), rebuilding the live site"
|
||||
/opt/portal/site/build-live.sh main
|
||||
else
|
||||
echo "[sync] no changes"
|
||||
fi
|
||||
if [ "$SYNC_RC" != 0 ]; then
|
||||
echo "[sync] WARNING: portfolio-sync.mjs exited $SYNC_RC - some photos failed (see the lines with '!' above)"
|
||||
exit "$SYNC_RC"
|
||||
fi
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Portfolio sync every 20 minutes (Immich has no event when a photo is tagged, so it polls)
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*:0/20
|
||||
RandomizedDelaySec=60
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -8,8 +8,6 @@
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
# Lösenordsskyddad nedladdning av originalfiler – inget att indexera.
|
||||
Disallow: /download/
|
||||
|
||||
# AI-crawlers och träningsbotar: uttryckligen tillåtna.
|
||||
User-agent: GPTBot
|
||||
@@ -36,6 +34,5 @@ User-agent: YouBot
|
||||
User-agent: DuckAssistBot
|
||||
User-agent: MistralAI-User
|
||||
Allow: /
|
||||
Disallow: /download/
|
||||
|
||||
Sitemap: https://imagesbyolofsson.se/sitemap-index.xml
|
||||
|
||||
@@ -77,7 +77,9 @@ function destName(asset) {
|
||||
async function downloadAndResize(asset, destPath) {
|
||||
const res = await api("GET", `/assets/${asset.id}/original`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
await sharp(buf)
|
||||
// Pixel shift and the 15000 px Instagram squares can exceed sharp's default limit (268 MP). JPEGs are shrunk while
|
||||
// they are decoded, so memory stays low; the sync container still has a memory cap.
|
||||
await sharp(buf, { limitInputPixels: 1_000_000_000 })
|
||||
.rotate() // respektera EXIF-orientering
|
||||
.resize(MAX_DIM, MAX_DIM, { fit: "inside", withoutEnlargement: true })
|
||||
.withMetadata() // BEVARA EXIF (kritiskt för portfolions datum/kamera)
|
||||
|
||||
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 497 KiB |
|
After Width: | Height: | Size: 949 KiB |
|
After Width: | Height: | Size: 910 KiB |
|
After Width: | Height: | Size: 365 KiB |
|
After Width: | Height: | Size: 829 KiB |
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 383 KiB |
|
After Width: | Height: | Size: 451 KiB |
|
After Width: | Height: | Size: 938 KiB |
|
After Width: | Height: | Size: 714 KiB |
|
After Width: | Height: | Size: 667 KiB |
@@ -12,7 +12,7 @@ export interface Props {
|
||||
description: string;
|
||||
path?: string;
|
||||
pageType?: 'WebPage' | 'AboutPage' | 'ContactPage' | 'CollectionPage';
|
||||
/** Namn i brödsmulan för undersidor, t.ex. "Tjänster". */
|
||||
/** Namn i brödsmulan för undersidor, t.ex. "Portfolio". */
|
||||
breadcrumb?: string;
|
||||
faq?: FaqItem[];
|
||||
/** Absolut URL till sidans huvudbild (og:image). */
|
||||
@@ -80,7 +80,7 @@ const graph: Record<string, unknown>[] = [
|
||||
sameAs: [business.social.linkedin, business.social.lightroom],
|
||||
},
|
||||
{
|
||||
'@type': 'ProfessionalService',
|
||||
'@type': 'Organization',
|
||||
'@id': ids.business,
|
||||
name: business.brand,
|
||||
url: `${site}/`,
|
||||
@@ -89,31 +89,9 @@ const graph: Record<string, unknown>[] = [
|
||||
telephone: business.phone,
|
||||
image: imageUrl,
|
||||
address,
|
||||
areaServed: business.serviceArea,
|
||||
priceRange: business.priceRange,
|
||||
founder: { '@id': ids.person },
|
||||
employee: { '@id': ids.person },
|
||||
sameAs: [business.social.instagram.url, business.social.facebook, business.social.flickr, business.social.lightroom],
|
||||
potentialAction: {
|
||||
'@type': 'ReserveAction',
|
||||
name: 'Skicka en bokningsförfrågan',
|
||||
target: business.portal.bookingUrl,
|
||||
},
|
||||
hasOfferCatalog: {
|
||||
'@type': 'OfferCatalog',
|
||||
name: 'Fotografering',
|
||||
itemListElement: business.genres.map((g) => ({
|
||||
'@type': 'Offer',
|
||||
priceCurrency: business.priceFromSek != null ? 'SEK' : null,
|
||||
price: business.priceFromSek,
|
||||
itemOffered: {
|
||||
'@type': 'Service',
|
||||
name: g.name,
|
||||
description: g.description,
|
||||
provider: { '@id': ids.business },
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
// src/components/T.astro – en text på svenska och engelska (Linus 2026-11-06: en svensk och en engelsk version,
|
||||
// SV/EN-knappen i menyn växlar). Båda finns i sidan; CSS i MainLayout visar den som hör till <html lang>.
|
||||
// <T sv="Om mig" en="About me" /> enkel text
|
||||
// <T><Fragment slot="sv">…</Fragment><Fragment slot="en">…</Fragment></T> text med länkar eller taggar
|
||||
export interface Props {
|
||||
sv?: string;
|
||||
en?: string;
|
||||
}
|
||||
const { sv, en } = Astro.props;
|
||||
---
|
||||
<span class="i18n-sv"><slot name="sv">{sv}</slot></span><span class="i18n-en" lang="en"><slot name="en">{en}</slot></span>
|
||||
@@ -1,63 +1,5 @@
|
||||
// src/data/faq.ts
|
||||
// Vanliga frågor för /tjanster. Samma text används på sidan och i FAQPage-JSON-LD.
|
||||
// Svaren byggs ENBART av fakta i business.ts. Okända fakta (null) ger ett
|
||||
// "kontakta mig"-svar i stället för en gissning.
|
||||
import { business, basedIn, formatSek, genreList } from './business';
|
||||
// Typ för vanliga frågor (FAQPage-JSON-LD). Frågorna om bokning, offert och gallerier togs bort 2026-11-06:
|
||||
// Linus erbjuder inga tjänster, bara posters och samarbeten.
|
||||
|
||||
export type FaqItem = { question: string; answer: string };
|
||||
|
||||
function areaAnswer(): string {
|
||||
if (business.serviceArea) {
|
||||
return `Jag utgår från ${basedIn()} och tar uppdrag i ${business.serviceArea}. Hör av dig om du är osäker på om jag kan komma till dig.`;
|
||||
}
|
||||
return `Jag är baserad i ${basedIn()}. Kontakta mig så stämmer vi av om jag kan fotografera där du befinner dig.`;
|
||||
}
|
||||
|
||||
function priceAnswer(): string {
|
||||
const from = formatSek(business.priceFromSek);
|
||||
if (from) {
|
||||
return `En fotografering kostar från ${from}. Det slutliga priset beror på uppdraget och står i offerten du får efter din förfrågan.`;
|
||||
}
|
||||
return 'Priset beror på uppdraget. Kontakta mig eller skicka en förfrågan i kundportalen, så får du en offert för just det du vill ha fotograferat.';
|
||||
}
|
||||
|
||||
function deliveryAnswer(): string {
|
||||
const base = 'Du får bilderna i ett privat galleri i kundportalen. När betalningen är klar kan du ladda ner originalfilerna i full upplösning.';
|
||||
if (business.deliveryTime) return `${base} Normal leveranstid är ${business.deliveryTime}.`;
|
||||
return base;
|
||||
}
|
||||
|
||||
export const faq: FaqItem[] = [
|
||||
{
|
||||
question: 'Vad fotograferar du?',
|
||||
answer: `Jag fotograferar ${genreList()}.`,
|
||||
},
|
||||
{
|
||||
question: 'Var finns du och var kan du fotografera?',
|
||||
answer: areaAnswer(),
|
||||
},
|
||||
{
|
||||
question: 'Vad kostar en fotografering?',
|
||||
answer: priceAnswer(),
|
||||
},
|
||||
{
|
||||
question: 'Hur bokar jag en fotografering?',
|
||||
answer: `Skicka en förfrågan i kundportalen (${business.portal.bookingUrl}). Jag återkommer med en offert, och när du har godkänt den bokar vi in fotograferingen.`,
|
||||
},
|
||||
{
|
||||
question: 'Hur får jag mina bilder?',
|
||||
answer: deliveryAnswer(),
|
||||
},
|
||||
{
|
||||
question: 'Vilken utrustning använder du?',
|
||||
answer: `Jag fotograferar med en ${business.camera} och redigerar i ${business.editing}.`,
|
||||
},
|
||||
{
|
||||
question: 'Har dina bilder uppmärksammats?',
|
||||
answer: business.recognition,
|
||||
},
|
||||
{
|
||||
question: 'Hur kontaktar jag dig?',
|
||||
answer: `Mejla ${business.email} eller skicka ett meddelande på Instagram (${business.social.instagram.handle}).`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ImageMetadata } from 'astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import SEO from '../components/SEO.astro';
|
||||
import SchemaOrg from '../components/SchemaOrg.astro';
|
||||
import T from '../components/T.astro';
|
||||
import type { FaqItem } from '../data/faq';
|
||||
import { business } from '../data/business';
|
||||
import defaultOgImage from '../assets/images/3d-scroll/img5.jpg';
|
||||
@@ -13,7 +14,7 @@ export interface Props {
|
||||
title: string;
|
||||
/** Unik svensk meta description, ca 120–160 tecken. */
|
||||
description: string;
|
||||
/** Kanonisk sökväg, t.ex. "/tjanster". Utelämnas = härleds från URL:en. */
|
||||
/** Kanonisk sökväg, t.ex. "/portfolio". Utelämnas = härleds från URL:en. */
|
||||
path?: string;
|
||||
image?: ImageMetadata;
|
||||
imageAlt?: string;
|
||||
@@ -43,12 +44,14 @@ const ogImageUrl = new URL(og.src, site).toString();
|
||||
const ogWidth = Number(og.attributes.width) || undefined;
|
||||
const ogHeight = Number(og.attributes.height) || undefined;
|
||||
|
||||
// Samma meny på alla sidor (Linus 2026-11-06): Portfolio, Posters, Om mig, Kontakt.
|
||||
const nav = [
|
||||
{ href: '/portfolio', label: 'Portfolio' },
|
||||
{ href: '/tjanster', label: 'Tjänster' },
|
||||
{ href: '/about', label: 'Om mig' },
|
||||
{ href: '/contact', label: 'Kontakt' },
|
||||
{ href: '/portfolio', sv: 'Portfolio', en: 'Portfolio' },
|
||||
{ href: '/posters', sv: 'Posters', en: 'Posters' },
|
||||
{ href: '/about', sv: 'Om mig', en: 'About me' },
|
||||
{ href: '/contact', sv: 'Kontakt', en: 'Contact' },
|
||||
];
|
||||
const langBtn = 'rounded-full px-2.5 py-1 text-xs font-semibold transition-colors text-gray-400 hover:text-white aria-pressed:bg-white aria-pressed:text-neutral-900';
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -61,6 +64,24 @@ const nav = [
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/favicon-512.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon-180.png" />
|
||||
<link rel="sitemap" type="application/xml" href="/sitemap-index.xml" />
|
||||
<!-- Språket väljs innan sidan ritas (ingen blinkning): ?lang=, sedan besökarens val (samma nyckel som kundportalen),
|
||||
sedan webbläsaren. Svenska/nordiska webbläsare får svenska. -->
|
||||
<script is:inline>
|
||||
(function () {
|
||||
var lang = null;
|
||||
try { var q = new URLSearchParams(location.search).get('lang'); if (q === 'sv' || q === 'en') lang = q; } catch (e) {}
|
||||
if (!lang) { try { var s = localStorage.getItem('ibo_lang'); if (s === 'sv' || s === 'en') lang = s; } catch (e) {} }
|
||||
if (!lang) {
|
||||
var b = ((navigator.languages && navigator.languages[0]) || navigator.language || 'sv').toLowerCase();
|
||||
lang = /^(sv|nb|no|da|nn)/.test(b) ? 'sv' : 'en';
|
||||
}
|
||||
document.documentElement.lang = lang;
|
||||
})();
|
||||
</script>
|
||||
<style is:global>
|
||||
html[lang="en"] .i18n-sv { display: none !important; }
|
||||
html:not([lang="en"]) .i18n-en { display: none !important; }
|
||||
</style>
|
||||
<SEO
|
||||
title={title}
|
||||
description={description}
|
||||
@@ -82,6 +103,7 @@ const nav = [
|
||||
imageUrl={ogImageUrl}
|
||||
/>
|
||||
)}
|
||||
<script is:inline defer src="/ibo-stats.js"></script>
|
||||
</head>
|
||||
<body class="bg-neutral-900 text-white font-sans antialiased min-h-screen flex flex-col">
|
||||
|
||||
@@ -92,11 +114,17 @@ const nav = [
|
||||
Images by Olofsson
|
||||
</a>
|
||||
|
||||
<ul class="flex space-x-4 md:space-x-6 text-sm font-medium text-gray-300">
|
||||
{nav.map((item) => (
|
||||
<li><a href={item.href} class="hover:text-white transition">{item.label}</a></li>
|
||||
))}
|
||||
</ul>
|
||||
<div class="flex items-center gap-4 md:gap-6">
|
||||
<ul class="flex space-x-4 md:space-x-6 text-sm font-medium text-gray-300">
|
||||
{nav.map((item) => (
|
||||
<li><a href={item.href} class="hover:text-white transition"><T sv={item.sv} en={item.en} /></a></li>
|
||||
))}
|
||||
</ul>
|
||||
<div class="inline-flex items-center rounded-full border border-neutral-700 p-0.5" role="group" aria-label="Språk / Language">
|
||||
<button type="button" data-lang-set="sv" class={langBtn} aria-label="Svenska">SV</button>
|
||||
<button type="button" data-lang-set="en" class={langBtn} aria-label="English">EN</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -106,26 +134,49 @@ const nav = [
|
||||
|
||||
<footer class="border-t border-neutral-800 py-8 text-center text-gray-400 text-sm bg-neutral-900 relative z-40">
|
||||
<p class="mb-2">
|
||||
{business.brand} – {business.person.jobTitle.toLowerCase()} {business.person.name}, {business.country.name}
|
||||
{business.brand} – <T sv={`fotograf ${business.person.name}, Sverige`} en={`photographer ${business.person.name}, Sweden`} />
|
||||
· <a href={`mailto:${business.email}`} class="text-gray-300 hover:text-white transition-colors">{business.email}</a>
|
||||
· <a href={business.social.instagram.url} target="_blank" rel="noopener noreferrer" class="text-gray-300 hover:text-white transition-colors">Instagram</a>
|
||||
· <a href={business.social.instagram.url} data-umami-event="instagram-click" target="_blank" rel="noopener noreferrer" class="text-gray-300 hover:text-white transition-colors">Instagram</a>
|
||||
</p>
|
||||
<p>© {new Date().getFullYear()} {business.brand}. Alla rättigheter förbehållna. · <a href="/privacy" class="text-gray-300 hover:text-white transition-colors">Integritetspolicy</a> · <button type="button" data-consent-open class="text-gray-300 hover:text-white transition-colors">Cookie-inställningar</button></p>
|
||||
<p>© {new Date().getFullYear()} {business.brand}. <T sv="Alla rättigheter förbehållna." en="All rights reserved." /> · <a href="/privacy" class="text-gray-300 hover:text-white transition-colors"><T sv="Integritetspolicy" en="Privacy policy" /></a> · <button type="button" data-consent-open class="text-gray-300 hover:text-white transition-colors"><T sv="Cookie-inställningar" en="Cookie settings" /></button></p>
|
||||
</footer>
|
||||
|
||||
<!-- Cookie consent: Meta Pixel laddas först efter "Godkänn" -->
|
||||
<div data-consent-banner hidden class="fixed bottom-4 inset-x-4 md:left-auto md:right-6 md:max-w-md z-[60] bg-neutral-900/95 backdrop-blur-md border border-white/10 rounded-2xl p-5 shadow-2xl text-sm text-gray-300">
|
||||
<p class="mb-4">
|
||||
Jag använder Meta Pixel för att se hur besökare från Instagram och Facebook använder sajten. Den sätter cookies och skickar data till Meta.
|
||||
<a href="/privacy" class="text-white underline hover:text-gray-300">Läs mer</a>
|
||||
<T sv="Jag använder Meta Pixel för att se hur besökare från Instagram och Facebook använder sajten. Den sätter cookies och skickar data till Meta."
|
||||
en="I use the Meta Pixel to see how visitors from Instagram and Facebook use the site. It sets cookies and sends data to Meta." />
|
||||
<a href="/privacy" class="text-white underline hover:text-gray-300"><T sv="Läs mer" en="Read more" /></a>
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button type="button" data-consent="denied" class="px-4 py-2 rounded-full border border-white/20 text-gray-300 hover:text-white hover:border-white/40 transition-colors">Avböj</button>
|
||||
<button type="button" data-consent="granted" class="px-4 py-2 rounded-full bg-white text-neutral-900 font-medium hover:bg-gray-200 transition-colors">Godkänn</button>
|
||||
<button type="button" data-consent="denied" class="px-4 py-2 rounded-full border border-white/20 text-gray-300 hover:text-white hover:border-white/40 transition-colors"><T sv="Avböj" en="Decline" /></button>
|
||||
<button type="button" data-consent="granted" class="px-4 py-2 rounded-full bg-white text-neutral-900 font-medium hover:bg-gray-200 transition-colors"><T sv="Godkänn" en="Accept" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
// SV/EN-knappen: sparar valet (samma nyckel som kundportalen), byter <html lang> och meddelar sidans skript.
|
||||
(function () {
|
||||
function show(lang) {
|
||||
document.documentElement.lang = lang;
|
||||
document.querySelectorAll('[data-lang-set]').forEach(function (b) {
|
||||
b.setAttribute('aria-pressed', b.getAttribute('data-lang-set') === lang ? 'true' : 'false');
|
||||
});
|
||||
document.querySelectorAll('[data-aria-sv]').forEach(function (el) {
|
||||
el.setAttribute('aria-label', el.getAttribute(lang === 'en' ? 'data-aria-en' : 'data-aria-sv'));
|
||||
});
|
||||
document.dispatchEvent(new CustomEvent('ibo-lang', { detail: lang }));
|
||||
}
|
||||
show(document.documentElement.lang === 'en' ? 'en' : 'sv');
|
||||
document.querySelectorAll('[data-lang-set]').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
var lang = b.getAttribute('data-lang-set');
|
||||
try { localStorage.setItem('ibo_lang', lang); } catch (e) {}
|
||||
show(lang);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
(function () {
|
||||
var KEY = 'ibo-consent';
|
||||
var banner = document.querySelector('[data-consent-banner]');
|
||||
|
||||
@@ -3,10 +3,16 @@
|
||||
// Nya sidor hamnar automatiskt i sitemap.
|
||||
const pageFiles = import.meta.glob('../pages/*.astro');
|
||||
|
||||
// Sidor på samma domän som kundportalen (samma Cleura-server) visar, så att Om mig-texten,
|
||||
// samarbeten och posters redigeras på ett ställe i admin (Linus 2026-11-06). Caddy skickar dem
|
||||
// till portalen; enskilda posters listas i portalens egen /posters/sitemap.xml.
|
||||
export const PORTAL_PAGES = ['/about', '/contact', '/posters'] as const;
|
||||
|
||||
export function sitePaths(): string[] {
|
||||
return Object.keys(pageFiles)
|
||||
const own = Object.keys(pageFiles)
|
||||
.map((file) => file.replace(/^\.\.\/pages\//, '').replace(/\.astro$/, ''))
|
||||
.filter((name) => name !== '404' && !name.startsWith('_') && !name.startsWith('['))
|
||||
.map((name) => (name === 'index' ? '/' : `/${name}`))
|
||||
.map((name) => (name === 'index' ? '/' : `/${name}`));
|
||||
return [...new Set([...own, ...PORTAL_PAGES])]
|
||||
.sort((a, b) => (a === '/' ? -1 : b === '/' ? 1 : a.localeCompare(b)));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/lib/url.ts
|
||||
// Gör om en byggsökväg till kanonisk form: "/", "/about", "/tjanster".
|
||||
// Gör om en byggsökväg till kanonisk form: "/", "/about", "/portfolio".
|
||||
// Klarar både build.format "file" (/about.html) och "directory" (/about/).
|
||||
export function normalizePath(p: string): string {
|
||||
let out = p.replace(/\/index\.html$/, '/').replace(/\.html$/, '');
|
||||
|
||||
@@ -2,32 +2,33 @@
|
||||
// src/pages/404.astro -> dist/404.html (servern svarar med status 404)
|
||||
import MainLayout from '../layouts/MainLayout.astro';
|
||||
import WaveBackground from '../components/WaveBackground.astro';
|
||||
import T from '../components/T.astro';
|
||||
|
||||
const links = [
|
||||
{ href: '/', label: 'Startsidan' },
|
||||
{ href: '/portfolio', label: 'Portfolio' },
|
||||
{ href: '/tjanster', label: 'Tjänster och bokning' },
|
||||
{ href: '/contact', label: 'Kontakt' },
|
||||
{ href: '/', sv: 'Startsidan', en: 'Home' },
|
||||
{ href: '/portfolio', sv: 'Portfolio', en: 'Portfolio' },
|
||||
{ href: '/posters', sv: 'Posters', en: 'Posters' },
|
||||
{ href: '/contact', sv: 'Kontakt', en: 'Contact' },
|
||||
];
|
||||
---
|
||||
|
||||
<MainLayout
|
||||
title="Sidan hittades inte | Images by Olofsson"
|
||||
description="Sidan du letade efter finns inte. Gå till startsidan, portfolion eller tjänsterna hos fotograf Linus Olofsson."
|
||||
description="Sidan du letade efter finns inte. Gå till startsidan, portfolion eller posters av fotograf Linus Olofsson."
|
||||
noindex
|
||||
>
|
||||
<WaveBackground opacity="opacity-60" />
|
||||
|
||||
<div class="container mx-auto px-4 py-20 md:py-32 relative z-10 text-center">
|
||||
<p class="text-sm text-gray-400 uppercase tracking-widest mb-4">Fel 404</p>
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-6">Sidan hittades inte</h1>
|
||||
<p class="text-sm text-gray-400 uppercase tracking-widest mb-4"><T sv="Fel 404" en="Error 404" /></p>
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-6"><T sv="Sidan hittades inte" en="Page not found" /></h1>
|
||||
<p class="text-gray-300 text-lg max-w-xl mx-auto mb-10">
|
||||
Sidan du letade efter finns inte längre, eller så har adressen ändrats. Här kan du hitta tillbaka:
|
||||
<T sv="Sidan du letade efter finns inte längre, eller så har adressen ändrats. Här kan du hitta tillbaka:" en="The page you were looking for no longer exists, or its address has changed. Find your way back here:" />
|
||||
</p>
|
||||
<ul class="flex flex-wrap justify-center gap-4">
|
||||
{links.map((l) => (
|
||||
<li>
|
||||
<a href={l.href} class="inline-block px-6 py-3 rounded-full border border-white/20 text-white hover:bg-white hover:text-black transition-colors">{l.label}</a>
|
||||
<a href={l.href} class="inline-block px-6 py-3 rounded-full border border-white/20 text-white hover:bg-white hover:text-black transition-colors"><T sv={l.sv} en={l.en} /></a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
---
|
||||
// src/pages/about.astro
|
||||
import MainLayout from '../layouts/MainLayout.astro';
|
||||
import { Image } from 'astro:assets';
|
||||
import profileImg from '../assets/images/about/profile.jpg';
|
||||
import WaveBackground from '../components/WaveBackground.astro';
|
||||
import { business, basedIn } from '../data/business';
|
||||
---
|
||||
|
||||
<MainLayout
|
||||
title="Om Linus Olofsson – fotograf | Images by Olofsson"
|
||||
description={`Linus Olofsson är fotografen bakom Images by Olofsson, baserad i ${basedIn()}. ${business.recognitionThirdPerson} Läs om hans bildspråk och utrustning.`}
|
||||
path="/about"
|
||||
image={profileImg}
|
||||
imageAlt="Porträtt av fotografen Linus Olofsson"
|
||||
pageType="AboutPage"
|
||||
breadcrumb="Om mig"
|
||||
>
|
||||
|
||||
<WaveBackground opacity="opacity-80" />
|
||||
|
||||
<div class="container mx-auto px-4 py-12 md:py-20 relative z-10">
|
||||
|
||||
<div data-trail-box class="bg-neutral-900/60 backdrop-blur-md border border-white/10 rounded-3xl p-8 md:p-12 shadow-2xl mb-20">
|
||||
|
||||
<div class="flex flex-col lg:flex-row items-center gap-12 lg:gap-20">
|
||||
|
||||
<div class="w-full lg:w-1/2 relative group">
|
||||
<div class="absolute inset-0 bg-gradient-to-tr from-neutral-900/50 to-transparent rounded-2xl z-10"></div>
|
||||
<div class="relative rounded-2xl overflow-hidden shadow-2xl border border-white/10 transform transition duration-500 group-hover:scale-[1.01]">
|
||||
<Image
|
||||
src={profileImg}
|
||||
alt="Porträtt av fotografen Linus Olofsson"
|
||||
width={800}
|
||||
height={1000}
|
||||
class="w-full h-auto object-cover grayscale hover:grayscale-0 transition-all duration-700 ease-in-out"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full lg:w-1/2 space-y-6">
|
||||
<h1 class="text-4xl md:text-5xl font-bold leading-tight">
|
||||
Levande ögonblick, <br/>
|
||||
<span class="text-transparent bg-clip-text bg-gradient-to-r from-white to-gray-400">fångade i hög upplösning.</span>
|
||||
</h1>
|
||||
|
||||
<div class="space-y-4 text-gray-300 text-lg leading-relaxed">
|
||||
<p>
|
||||
Hej, jag heter {business.person.name} och är fotografen bakom {business.brand}.
|
||||
</p>
|
||||
<p>
|
||||
Jag är baserad i {basedIn()}. Allt började med en enkel nyfikenhet på hur ljuset formar sättet vi ser världen på. Med tiden blev nyfikenheten en besatthet av att fånga detaljerna som de flesta missar – texturer, strukturer, kontraster och ögonblick som passerar obemärkt förbi.
|
||||
</p>
|
||||
<p>
|
||||
Jag hittar skönheten i det lilla: en sten, ett träd, en tom gata, en stilla skogsstig eller uttrycket hos en främling som går förbi.
|
||||
</p>
|
||||
<p>
|
||||
Oavsett om det är de invecklade mönstren i en makrobild, naturens stora tystnad eller stadens energi är målet alltid detsamma – att skapa bilder som förmedlar känslor och berättar en historia utan ord.
|
||||
</p>
|
||||
<p>{business.recognition}</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<p class="font-handwriting text-2xl text-white">Linus Olofsson</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-4 pt-6">
|
||||
<a href="/contact" class="inline-block bg-white text-black px-8 py-3 rounded-full font-bold hover:bg-gray-200 transition-colors shadow-lg">
|
||||
Kontakta mig
|
||||
</a>
|
||||
<a href="/portfolio" class="inline-block border border-white text-white px-8 py-3 rounded-full font-bold hover:bg-white/10 transition-colors">
|
||||
Se världen genom mina ögon
|
||||
</a>
|
||||
<a href="/tjanster" class="inline-block border border-white/40 text-white px-8 py-3 rounded-full font-bold hover:bg-white/10 transition-colors">
|
||||
Tjänster och bokning
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-white/10 pt-16">
|
||||
<h2 class="text-3xl font-bold mb-10 text-center text-white">Vad har jag i kameraväskan?</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
|
||||
<div data-trail-box class="gear-card">
|
||||
<div class="icon-box">
|
||||
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2 text-white">Kamerahus</h3>
|
||||
<p class="text-gray-300">{business.camera}</p>
|
||||
<p class="text-gray-400 text-sm mt-2">En pålitlig följeslagare som har varit med mig på otaliga äventyr.</p>
|
||||
</div>
|
||||
|
||||
<div data-trail-box class="gear-card">
|
||||
<div class="icon-box">
|
||||
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2 text-white">Objektiv</h3>
|
||||
<ul class="text-gray-300 text-sm space-y-1">
|
||||
{business.lenses.map((lens) => <li>{lens}</li>)}
|
||||
<li>och ett par extra batterier</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div data-trail-box class="gear-card">
|
||||
<div class="icon-box">
|
||||
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2 text-white">Redigering</h3>
|
||||
<p class="text-gray-300">{business.editing}</p>
|
||||
<p class="text-gray-400 text-sm mt-2">Här får råfilerna liv. Jag fokuserar på naturliga toner och kontrast.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</MainLayout>
|
||||
|
||||
<style>
|
||||
@reference "../styles/global.css";
|
||||
|
||||
.font-handwriting {
|
||||
font-family: 'Brush Script MT', cursive;
|
||||
}
|
||||
|
||||
.gear-card {
|
||||
/* Jag la även till lite backdrop-blur på korten här nere för enhetlighet */
|
||||
@apply bg-neutral-900/80 backdrop-blur-sm p-8 rounded-xl border border-white/5 hover:border-white/20 transition-all duration-300 flex flex-col items-center text-center;
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
@apply w-16 h-16 bg-neutral-800 rounded-full flex items-center justify-center mb-6 text-white shadow-lg border border-white/5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,141 +0,0 @@
|
||||
---
|
||||
// src/pages/contact.astro
|
||||
import MainLayout from '../layouts/MainLayout.astro';
|
||||
import WaveBackground from '../components/WaveBackground.astro';
|
||||
import { business } from '../data/business';
|
||||
---
|
||||
|
||||
<MainLayout
|
||||
title="Kontakt och bokning | Images by Olofsson"
|
||||
description={`Kontakta fotograf Linus Olofsson på ${business.email} eller Instagram ${business.social.instagram.handle}, eller skicka en bokningsförfrågan via kundportalen.`}
|
||||
path="/contact"
|
||||
pageType="ContactPage"
|
||||
breadcrumb="Kontakt"
|
||||
>
|
||||
<div>
|
||||
<WaveBackground opacity="opacity-80" theme="purple" />
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 py-12 md:py-20 relative z-10">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-12 lg:gap-20 max-w-6xl mx-auto items-start">
|
||||
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h1 class="text-4xl md:text-5xl font-bold mb-6">Kontakta mig</h1>
|
||||
<p class="text-gray-300 text-lg leading-relaxed">
|
||||
Har du frågor om någon av mina bilder, vill du samarbeta eller bara säga hej?
|
||||
Hör av dig via sociala medier eller mejla mig direkt.
|
||||
</p>
|
||||
<p class="text-gray-300 text-lg leading-relaxed mt-4">
|
||||
Vill du boka en fotografering? Skicka en förfrågan i kundportalen så återkommer jag med en offert.
|
||||
Läs mer om hur det går till under <a href="/tjanster" class="text-white underline underline-offset-4 hover:text-gray-300">Tjänster</a>.
|
||||
</p>
|
||||
<a href={business.portal.bookingUrl} class="inline-block mt-6 px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition">Skicka en bokningsförfrågan</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-white font-semibold text-xl mb-4">Du hittar mig också på</h2>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
|
||||
<a href={business.social.instagram.url} target="_blank" rel="noopener noreferrer" class="social-card group" aria-label="Instagram">
|
||||
<svg class="w-8 h-8 group-hover:text-pink-500 transition-colors" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/></svg>
|
||||
</a>
|
||||
|
||||
<a href={business.social.flickr} target="_blank" rel="noopener noreferrer" class="social-card group" aria-label="Flickr">
|
||||
<svg class="w-8 h-8 group-hover:text-blue-500 transition-colors" viewBox="0 0 24 24" fill="currentColor"><path d="M24 4v16h-24v-16h24zm-16.146 11.908c1.652 0 2.992-1.339 2.992-2.991 0-1.652-1.34-2.991-2.992-2.991-1.651 0-2.99 1.339-2.99 2.991 0 1.652 1.339 2.991 2.99 2.991zm8.008 0c1.652 0 2.992-1.339 2.992-2.991 0-1.652-1.34-2.991-2.992-2.991-1.652 0-2.992 1.339-2.992 2.991 0 1.652 1.34 2.991 2.992 2.991z"/></svg>
|
||||
</a>
|
||||
|
||||
<a href={business.social.lightroom} target="_blank" rel="noopener noreferrer" class="social-card group" aria-label="Adobe Lightroom">
|
||||
<svg class="w-8 h-8 group-hover:text-cyan-400 transition-colors" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M4 4v16h11v-4H8V4H4zm13 4c-2.21 0-4 1.79-4 4v8h4v-7c0-1.1.9-2 2-2v-3h-2z"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a href={business.social.github} target="_blank" rel="noopener noreferrer" class="social-card group" aria-label="GitHub">
|
||||
<svg class="w-8 h-8 group-hover:text-purple-500 transition-colors" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
</a>
|
||||
|
||||
<a href={business.social.linkedin} target="_blank" rel="noopener noreferrer" class="social-card group" aria-label="LinkedIn">
|
||||
<svg class="w-8 h-8 group-hover:text-blue-400 transition-colors" viewBox="0 0 24 24" fill="currentColor"><path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/></svg>
|
||||
</a>
|
||||
|
||||
<a href={`mailto:${business.email}`} class="social-card group" aria-label="E-post">
|
||||
<svg class="w-8 h-8 group-hover:text-green-500 transition-colors" viewBox="0 0 24 24" fill="currentColor"><path d="M0 3v18h24v-18h-24zm6.623 7.929l-4.623 5.712v-11.174l4.623 5.462zm5.377 2.738l-5.354-6.324-3.791 3.214 9.145 10.829 9.145-10.829-3.791-3.214-5.354 6.324zm5.377-2.738l4.623-5.462v11.174l-4.623-5.712z"/></svg>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative group">
|
||||
<div class="absolute -inset-1 bg-gradient-to-r from-purple-600 to-pink-600 rounded-2xl blur opacity-25 group-hover:opacity-50 transition duration-1000 group-hover:duration-200"></div>
|
||||
|
||||
<div data-trail-box class="relative bg-neutral-900 ring-1 ring-white/10 rounded-xl p-8 md:p-12 text-center space-y-10">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white mb-2">Jag ser fram emot att höra av dig</h2>
|
||||
<p class="text-gray-300">Hör gärna av dig med frågor, förfrågningar eller bara för att prata.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="inline-flex items-center justify-center p-4 bg-white/5 rounded-full mb-4 group-hover:scale-110 transition-transform duration-300">
|
||||
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400 uppercase tracking-widest mb-2">Mejla mig på</p>
|
||||
<a href={`mailto:${business.email}`} class="text-xl md:text-3xl font-bold text-white hover:text-purple-400 transition-colors break-all">
|
||||
{business.email}
|
||||
</a>
|
||||
<div>
|
||||
<button type="button" id="email-btn" class="mt-3 text-xs uppercase tracking-widest text-gray-400 hover:text-white transition-colors" data-email={business.email}>Kopiera adressen</button>
|
||||
</div>
|
||||
<p id="copy-feedback" class="h-4 text-xs text-green-400 mt-2 opacity-0 transition-opacity" aria-live="polite">Kopierad!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<p class="text-sm text-gray-400 uppercase tracking-widest mb-4">Eller skriv till mig på Instagram</p>
|
||||
<a href={business.social.instagram.url} target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 text-xl font-bold text-white hover:text-pink-500 transition-colors">
|
||||
{business.social.instagram.handle}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const emailBtn = document.getElementById('email-btn');
|
||||
const feedback = document.getElementById('copy-feedback');
|
||||
|
||||
if (emailBtn && feedback) {
|
||||
emailBtn.addEventListener('click', () => {
|
||||
const email = emailBtn.getAttribute('data-email');
|
||||
if (email) {
|
||||
navigator.clipboard.writeText(email).then(() => {
|
||||
feedback.style.opacity = '1';
|
||||
setTimeout(() => {
|
||||
feedback.style.opacity = '0';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</MainLayout>
|
||||
|
||||
<style>
|
||||
@reference "../styles/global.css";
|
||||
|
||||
.social-card {
|
||||
@apply flex items-center justify-center p-4 rounded-xl bg-neutral-800 border border-white/5 text-gray-400 transition-all duration-300;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.social-card:hover {
|
||||
@apply bg-neutral-700 border-white/20 transform -translate-y-1 shadow-lg shadow-white/5;
|
||||
}
|
||||
</style>
|
||||
@@ -3,12 +3,21 @@
|
||||
import MainLayout from '../layouts/MainLayout.astro';
|
||||
import ThreeDScroll from '../components/ThreeDScroll.astro';
|
||||
import WaveBackground from '../components/WaveBackground.astro';
|
||||
import T from '../components/T.astro';
|
||||
import { business, basedIn, genreList } from '../data/business';
|
||||
|
||||
const footerButtons = [
|
||||
{ href: '/posters', sv: 'Posters', en: 'Posters' },
|
||||
{ href: '/contact', sv: 'Samarbeten', en: 'Collaborations' },
|
||||
{ href: '/about', sv: 'Mer om mig', en: 'More about me' },
|
||||
{ href: '/portfolio', sv: 'Portfolio', en: 'Portfolio' },
|
||||
{ href: '/contact', sv: 'Kontakt', en: 'Contact' },
|
||||
];
|
||||
---
|
||||
|
||||
<MainLayout
|
||||
title="Images by Olofsson – fotograf Linus Olofsson i Sverige"
|
||||
description={`Fotograf Linus Olofsson (Images by Olofsson) i ${basedIn()}: porträtt, natur och djur, stad, makro, norrsken och familjefester. Boka via kundportalen.`}
|
||||
description={`Fotograf Linus Olofsson (Images by Olofsson) i ${basedIn()}: porträtt, natur och djur, stad, makro, norrsken och familjefester. Fotografierna finns som posters.`}
|
||||
path="/"
|
||||
>
|
||||
|
||||
@@ -43,7 +52,7 @@ import { business, basedIn, genreList } from '../data/business';
|
||||
|
||||
</div>
|
||||
|
||||
<p class="text-white/80 mt-4 text-base md:text-lg tracking-widest uppercase">Fotograf {business.person.name} · {basedIn()}</p>
|
||||
<p class="text-white/80 mt-4 text-base md:text-lg tracking-widest uppercase"><T sv={`Fotograf ${business.person.name} · ${basedIn()}`} en={`Photographer ${business.person.name} · Örebro, Sweden`} /></p>
|
||||
<p id="scroll-arrow" aria-hidden="true" class="text-white/70 mt-6 text-lg font-light tracking-widest uppercase transition-opacity duration-300">↓</p>
|
||||
</div>
|
||||
|
||||
@@ -58,14 +67,14 @@ import { business, basedIn, genreList } from '../data/business';
|
||||
<div class="flex flex-col md:flex-row gap-12 md:gap-24 items-center text-center">
|
||||
|
||||
<div data-trail-box class="flex flex-col items-center px-6 py-4 rounded-2xl">
|
||||
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Letar du inspiration?</h2>
|
||||
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6"><T sv="Letar du inspiration?" en="Looking for inspiration?" /></h2>
|
||||
<a href="/portfolio" class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105">
|
||||
Se min portfolio
|
||||
<T sv="Se min portfolio" en="See my portfolio" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row md:flex-col gap-6 items-center justify-center">
|
||||
<a href={business.social.instagram.url} target="_blank" rel="noopener noreferrer" class="circle-btn group" aria-label="Instagram">
|
||||
<a href={business.social.instagram.url} data-umami-event="instagram-click" target="_blank" rel="noopener noreferrer" class="circle-btn group" aria-label="Instagram">
|
||||
<svg class="outline-ring" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="48" />
|
||||
</svg>
|
||||
@@ -86,52 +95,23 @@ import { business, basedIn, genreList } from '../data/business';
|
||||
</div>
|
||||
|
||||
<div data-trail-box class="flex flex-col items-center px-6 py-4 rounded-2xl">
|
||||
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Vill du boka en fotografering?</h2>
|
||||
<a href="/tjanster" class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105">
|
||||
Tjänster och bokning
|
||||
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6"><T sv="Redo att samarbeta?" en="Ready to collaborate?" /></h2>
|
||||
<a href="/contact" class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105">
|
||||
<T sv="Kontakta mig" en="Contact me" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fakta om vem, vad, var och bokning: läsbar text för besökare, sökmotorer och AI-assistenter. -->
|
||||
<section id="om-images-by-olofsson" class="relative z-30 bg-neutral-900 border-t border-white/10 py-16 md:py-24">
|
||||
<div class="container mx-auto px-4 max-w-5xl grid gap-12 md:grid-cols-2">
|
||||
<div class="space-y-5 text-gray-300 text-lg leading-relaxed">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white">Fotograf {business.person.name}</h2>
|
||||
<p>
|
||||
Jag heter {business.person.name} och fotograferar under namnet {business.brand}.
|
||||
Jag är baserad i {basedIn()} och fotograferar {genreList()}.
|
||||
</p>
|
||||
<p>{business.recognition}</p>
|
||||
<p>
|
||||
Jag fotograferar med en {business.camera} och redigerar i {business.editing}, med fokus på naturliga toner och kontrast.
|
||||
</p>
|
||||
<p class="flex flex-wrap gap-x-6 gap-y-2 text-base">
|
||||
<a href="/about" class="text-white underline underline-offset-4 hover:text-gray-300">Mer om mig</a>
|
||||
<a href="/portfolio" class="text-white underline underline-offset-4 hover:text-gray-300">Portfolio</a>
|
||||
<a href={`mailto:${business.email}`} class="text-white underline underline-offset-4 hover:text-gray-300">{business.email}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white">Så bokar du</h2>
|
||||
<ol class="space-y-4 text-gray-300">
|
||||
{business.bookingSteps.map((step, i) => (
|
||||
<li class="flex gap-4">
|
||||
<span class="shrink-0 w-8 h-8 rounded-full border border-white/30 flex items-center justify-center text-sm font-bold text-white">{i + 1}</span>
|
||||
<span><strong class="text-white">{step.title}.</strong> {step.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div class="flex flex-wrap gap-4 pt-2">
|
||||
<a href={business.portal.bookingUrl} class="inline-block px-6 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition">Skicka en förfrågan</a>
|
||||
<a href="/tjanster" class="inline-block px-6 py-3 border border-white text-white font-bold rounded-full hover:bg-white/10 transition">Vanliga frågor</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Länkar längst ner (Linus 2026-11-06): bara knappar, avlånga och raka, ingen text och inga textlänkar. -->
|
||||
<nav id="lankar" aria-label="Mer på sajten" data-aria-sv="Mer på sajten" data-aria-en="More on the site" class="relative z-30 bg-neutral-900 border-t border-white/10 py-12 md:py-16">
|
||||
<ul class="container mx-auto px-4 flex flex-wrap justify-center gap-3 md:gap-4">
|
||||
{footerButtons.map((b) => (
|
||||
<li><a href={b.href} class="inline-flex min-w-[11rem] items-center justify-center rounded-md border border-white/20 bg-white/[0.03] px-8 py-3 text-xs md:text-sm font-semibold uppercase tracking-[0.2em] text-white transition-colors hover:border-white hover:bg-white hover:text-neutral-900"><T sv={b.sv} en={b.en} /></a></li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
// SVG Längd
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
// Byggs från src/data/business.ts så att fakta bara finns på ett ställe.
|
||||
// Okända fakta (null) skrivs som "lämnas på förfrågan", aldrig som gissningar.
|
||||
import type { APIRoute } from 'astro';
|
||||
import { business, basedIn, formatSek, genreList } from '../data/business';
|
||||
import { faq } from '../data/faq';
|
||||
import { business, basedIn, genreList } from '../data/business';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
const base = site ?? new URL(business.url);
|
||||
const url = (p: string) => new URL(p, base).toString();
|
||||
const price = formatSek(business.priceFromSek);
|
||||
|
||||
const lines: (string | null)[] = [
|
||||
`# ${business.brand}`,
|
||||
@@ -22,29 +20,20 @@ export const GET: APIRoute = ({ site }) => {
|
||||
`- Fotograf: ${business.person.name}`,
|
||||
`- Namn/varumärke: ${business.brand}`,
|
||||
`- Baserad i: ${basedIn()}`,
|
||||
business.serviceArea ? `- Tar uppdrag i: ${business.serviceArea}` : '- Upptagningsområde: stäms av vid förfrågan',
|
||||
price ? `- Pris från: ${price}` : '- Priser: lämnas som offert efter förfrågan',
|
||||
business.deliveryTime ? `- Leveranstid: ${business.deliveryTime}` : null,
|
||||
business.phone ? `- Telefon: ${business.phone}` : null,
|
||||
`- E-post: ${business.email}`,
|
||||
`- Instagram: ${business.social.instagram.handle} (${business.social.instagram.url})`,
|
||||
`- Facebook: ${business.social.facebook}`,
|
||||
`- Kamera: ${business.camera}`,
|
||||
`- Redigering: ${business.editing}`,
|
||||
`- Bokning: ${business.portal.bookingUrl}`,
|
||||
`- Bokningsgång: ${business.bookingSteps.map((s) => s.title.toLowerCase()).join(' → ')}`,
|
||||
'',
|
||||
'## Sidor',
|
||||
'',
|
||||
`- [Startsida](${url('/')}): vem ${business.person.name} är och vad han fotograferar`,
|
||||
`- [Tjänster och vanliga frågor](${url('/tjanster')}): vad du kan boka, hur bokningen går till och svar på vanliga frågor`,
|
||||
`- [Portfolio](${url('/portfolio')}): urval av fotografier`,
|
||||
`- [Om mig](${url('/about')}): ${business.person.name}, bakgrund och utrustning`,
|
||||
`- [Kontakt](${url('/contact')}): e-post, Instagram och bokning`,
|
||||
'',
|
||||
'## Vanliga frågor',
|
||||
'',
|
||||
...faq.map((f) => `- ${f.question} ${f.answer}`),
|
||||
`- [Posters](${url('/posters')}): fotografier som posters, välj storlek och material`,
|
||||
`- [Kontakt och samarbeten](${url('/contact')}): samarbeten, reklam och sponsring`,
|
||||
'',
|
||||
'## Profiler',
|
||||
'',
|
||||
|
||||
@@ -6,6 +6,7 @@ import exifr from 'exifr';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import WaveBackground from '../components/WaveBackground.astro';
|
||||
import T from '../components/T.astro';
|
||||
import { business, genreList } from '../data/business';
|
||||
import { portfolioMeta } from '../data/portfolio-meta';
|
||||
|
||||
@@ -74,6 +75,9 @@ const galleryItems = await Promise.all(Object.entries(imageFiles).map(async ([re
|
||||
const fullDate = hasDate
|
||||
? dateObj.toLocaleDateString('sv-SE', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: '';
|
||||
const fullDateEn = hasDate
|
||||
? dateObj.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: '';
|
||||
|
||||
const shutterSpeed = formatShutterSpeed(exifData?.ExposureTime);
|
||||
const focalLength = exifData?.FocalLength ? `${Math.round(exifData.FocalLength)}mm` : null;
|
||||
@@ -90,23 +94,20 @@ const galleryItems = await Promise.all(Object.entries(imageFiles).map(async ([re
|
||||
].filter(Boolean).join(' ');
|
||||
const alt = meta.alt?.trim() || fallbackAlt.charAt(0).toUpperCase() + fallbackAlt.slice(1);
|
||||
|
||||
// Nedladdning/lightbox: nya, EXIF-fria JPG:er i 800/1600/3000 px
|
||||
// (Astro hoppar över bredder större än originalet).
|
||||
// Lightbox: EXIF-fria JPG:er i 800/1600/3000 px (Astro hoppar över bredder större än originalet).
|
||||
// Ingen nedladdning längre (Linus 2026-11-06): bilderna säljs som posters i stället.
|
||||
const jpg = await getImage({ src: file.default, format: 'jpg', quality: 88, width: 800, widths: [800, 1600, 3000] });
|
||||
const variants = [...jpg.srcSet.values].sort((a, b) => widthOf(a.descriptor ?? '') - widthOf(b.descriptor ?? ''));
|
||||
const small = variants[0]?.url ?? jpg.src;
|
||||
const large = variants[variants.length - 1]?.url ?? jpg.src;
|
||||
const medium = variants.find((v) => widthOf(v.descriptor ?? '') === 1600)?.url ?? large;
|
||||
|
||||
const assetId = key.match(/^immich-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-/i)?.[1]?.toLowerCase() ?? null;
|
||||
const statKey = ('portfolio/' + key.replace(/[^\w\-.]/g, '_')).slice(0, 150);
|
||||
|
||||
return {
|
||||
img: file.default, key, alt, title: meta.title ?? null,
|
||||
year, month, monthIndex, fullDate, model: exifData?.Model,
|
||||
year, month, monthIndex, fullDate, fullDateEn, model: exifData?.Model,
|
||||
fStop, shutterSpeed, iso, focalLength,
|
||||
dlS: small, dlM: medium, dlL: large, srcset: jpg.srcSet.attribute,
|
||||
assetId, statKey,
|
||||
large, srcset: jpg.srcSet.attribute,
|
||||
statKey,
|
||||
};
|
||||
}));
|
||||
|
||||
@@ -114,7 +115,8 @@ const uniqueYears = [...new Set(galleryItems.map((item: any) => item.year))].sor
|
||||
const allMonths = galleryItems.map((item: any) => ({ name: item.month, index: item.monthIndex }));
|
||||
const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).values()]
|
||||
.sort((a: any, b: any) => a.index - b.index)
|
||||
.map((item: any) => item.name);
|
||||
.map((item: any) => ({ sv: item.name, en: new Date(2000, item.index, 1).toLocaleString('en-GB', { month: 'long' }) }));
|
||||
const genresEn = 'portraits and people, nature and animals, city and architecture, macro and details, northern lights, and celebrations and family occasions';
|
||||
---
|
||||
|
||||
<MainLayout
|
||||
@@ -133,41 +135,40 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
|
||||
|
||||
<div class="text-center mb-12">
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-4">Portfolio</h1>
|
||||
<p class="text-gray-300 max-w-2xl mx-auto">Ett urval av mina bilder i olika stilar – {genreList()}.
|
||||
Vill du boka en fotografering? Läs om <a href="/tjanster" class="text-white underline underline-offset-4 hover:text-gray-300">tjänster och bokning</a>.</p>
|
||||
<p class="text-gray-300 max-w-2xl mx-auto"><T sv={`Ett urval av mina bilder i olika stilar – ${genreList()}.`} en={`A selection of my photos in different styles – ${genresEn}.`} /></p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-6 mb-12">
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<button type="button" class="filter-btn active" data-filter="all" data-type="year">Alla år</button>
|
||||
<button type="button" class="filter-btn active" data-filter="all" data-type="year"><T sv="Alla år" en="All years" /></button>
|
||||
{uniqueYears.map(year => (
|
||||
<button type="button" class="filter-btn" data-filter={year} data-type="year">{year}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<button type="button" class="filter-btn active" data-filter="all" data-type="month">Alla månader</button>
|
||||
<button type="button" class="filter-btn active" data-filter="all" data-type="month"><T sv="Alla månader" en="All months" /></button>
|
||||
{uniqueMonths.map(month => (
|
||||
<button type="button" class="filter-btn capitalize" data-filter={month} data-type="month">{month}</button>
|
||||
<button type="button" class="filter-btn capitalize" data-filter={month.sv} data-type="month"><T sv={month.sv} en={month.en} /></button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="sr-only">Bilder</h2>
|
||||
<h2 class="sr-only"><T sv="Bilder" en="Photos" /></h2>
|
||||
<div class="columns-1 md:columns-2 lg:columns-3 gap-6 space-y-6" id="gallery-grid">
|
||||
{galleryItems.map((item: any, index: number) => (
|
||||
<a
|
||||
href={item.dlL}
|
||||
class="gallery-item block break-inside-avoid relative group overflow-hidden rounded-lg cursor-pointer bg-neutral-800 shadow-md"
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={item.fullDate ? `Visa bilden från ${item.fullDate}` : 'Visa bilden'}
|
||||
data-aria-sv={item.fullDate ? `Visa bilden från ${item.fullDate}` : 'Visa bilden'}
|
||||
data-aria-en={item.fullDateEn ? `View the photo from ${item.fullDateEn}` : 'View the photo'}
|
||||
class="gallery-item protected block break-inside-avoid relative group overflow-hidden rounded-lg cursor-pointer bg-neutral-800 shadow-md"
|
||||
data-year={item.year}
|
||||
data-month={item.month}
|
||||
data-src={item.dlL}
|
||||
data-src={item.large}
|
||||
data-srcset={item.srcset}
|
||||
data-dl-s={item.dlS}
|
||||
data-dl-m={item.dlM}
|
||||
data-dl-l={item.dlL}
|
||||
data-key={item.key}
|
||||
data-asset={item.assetId}
|
||||
data-stat={item.statKey}
|
||||
data-alt={item.alt}
|
||||
data-title={item.title}
|
||||
@@ -177,6 +178,7 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
|
||||
data-shutter={item.shutterSpeed}
|
||||
data-focal={item.focalLength}
|
||||
data-date={item.fullDate}
|
||||
data-date-en={item.fullDateEn}
|
||||
>
|
||||
<Image
|
||||
src={item.img}
|
||||
@@ -186,20 +188,23 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
|
||||
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
|
||||
loading={index < 3 ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
class="w-full h-auto object-cover transform transition duration-700 group-hover:scale-105"
|
||||
draggable="false"
|
||||
class="pointer-events-none select-none w-full h-auto object-cover transform transition duration-700 group-hover:scale-105"
|
||||
/>
|
||||
<!-- Genomskinligt lager över bilden (som på Posters): högerklick och långt tryck träffar inte bilden. -->
|
||||
<div class="absolute inset-0" aria-hidden="true"></div>
|
||||
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition duration-300 flex flex-col justify-end p-6 pointer-events-none">
|
||||
{(item.title || item.fullDate) && <p class="text-white font-bold text-lg">{item.title ?? item.fullDate}</p>}
|
||||
<p class="text-xs text-gray-300 uppercase tracking-widest mt-1">Visa bild</p>
|
||||
{item.fullDate && <p class="text-white font-bold text-lg"><T sv={item.fullDate} en={item.fullDateEn} /></p>}
|
||||
<p class="text-xs text-gray-300 uppercase tracking-widest mt-1"><T sv="Visa bild" en="View photo" /></p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div id="no-results" class="hidden text-center text-gray-400 mt-20">
|
||||
<p class="text-xl">Inga bilder hittades för denna tidpunkt.</p>
|
||||
<button type="button" id="reset-btn" class="mt-4 text-white underline hover:text-gray-300">Visa alla bilder</button>
|
||||
<p class="text-xl"><T sv="Inga bilder hittades för denna tidpunkt." en="No photos found for this time." /></p>
|
||||
<button type="button" id="reset-btn" class="mt-4 text-white underline hover:text-gray-300"><T sv="Visa alla bilder" en="Show all photos" /></button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -207,16 +212,17 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
|
||||
<div id="lightbox" class="fixed inset-0 z-50 hidden bg-black/95 backdrop-blur-md flex items-center justify-center opacity-0 transition-opacity duration-300 p-2 md:p-8">
|
||||
<div class="bg-neutral-900 w-full max-w-6xl h-[85vh] md:h-[90vh] flex flex-col rounded-xl overflow-hidden shadow-2xl relative border border-white/10">
|
||||
|
||||
<button type="button" id="lb-close" aria-label="Stäng" class="absolute top-4 right-4 z-50 bg-black/50 hover:bg-black/80 text-white p-2 rounded-full transition-colors">
|
||||
<button type="button" id="lb-close" aria-label="Stäng" data-aria-sv="Stäng" data-aria-en="Close" class="absolute top-4 right-4 z-50 bg-black/50 hover:bg-black/80 text-white p-2 rounded-full transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
|
||||
<div class="flex-1 relative bg-black flex items-center justify-center overflow-hidden group">
|
||||
<img id="lb-img" src="" alt="" sizes="100vw" class="max-h-full max-w-full w-full h-full object-contain p-2 md:p-4" />
|
||||
<button type="button" id="lb-prev" aria-label="Föregående bild" class="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 bg-black/40 hover:bg-black/70 text-white p-3 rounded-full transition-all">
|
||||
<div class="protected flex-1 relative bg-black flex items-center justify-center overflow-hidden group">
|
||||
<img id="lb-img" src="" alt="" sizes="100vw" draggable="false" class="pointer-events-none select-none max-h-full max-w-full w-full h-full object-contain p-2 md:p-4" />
|
||||
<div class="absolute inset-0" aria-hidden="true"></div>
|
||||
<button type="button" id="lb-prev" aria-label="Föregående bild" data-aria-sv="Föregående bild" data-aria-en="Previous photo" class="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 bg-black/40 hover:bg-black/70 text-white p-3 rounded-full transition-all">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
|
||||
</button>
|
||||
<button type="button" id="lb-next" aria-label="Nästa bild" class="absolute right-2 md:right-4 top-1/2 -translate-y-1/2 bg-black/40 hover:bg-black/70 text-white p-3 rounded-full transition-all">
|
||||
<button type="button" id="lb-next" aria-label="Nästa bild" data-aria-sv="Nästa bild" data-aria-en="Next photo" class="absolute right-2 md:right-4 top-1/2 -translate-y-1/2 bg-black/40 hover:bg-black/70 text-white p-3 rounded-full transition-all">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -227,43 +233,16 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
|
||||
<p id="lb-model" class="text-gray-400 text-sm font-medium tracking-wide mt-1"></p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-4 md:gap-6">
|
||||
<div class="relative">
|
||||
<button type="button" id="lb-download" aria-haspopup="true" aria-expanded="false" class="group inline-flex items-center gap-2 px-6 py-2.5 bg-neutral-700/80 hover:bg-neutral-600/80 text-gray-200 hover:text-white font-semibold text-xs uppercase tracking-widest rounded-full border border-black/60 shadow-lg shadow-black/30 ring-1 ring-white/5 transition-all duration-200 transform hover:scale-105 active:scale-95 active:duration-75">
|
||||
<svg id="lb-dl-arrow" xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 transition-colors duration-200" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 19h16"/></svg>
|
||||
Ladda ner
|
||||
</button>
|
||||
<div id="lb-sizes" class="hidden absolute bottom-full mb-3 left-0 md:left-auto md:right-0 min-w-[260px] bg-neutral-900/95 backdrop-blur-md border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden origin-bottom opacity-0 translate-y-2 transition-all duration-200 ease-out">
|
||||
<button type="button" data-size="s" class="dl-opt w-full text-left group flex items-center justify-between px-5 py-3.5 text-sm text-gray-200 hover:bg-black/40 hover:text-white cursor-pointer transition-colors border-b border-white/5"><span class="font-medium uppercase tracking-widest text-xs">Liten</span><span class="text-gray-400 text-xs font-mono group-hover:text-gray-300 transition-colors">800 px</span></button>
|
||||
<button type="button" data-size="m" class="dl-opt w-full text-left group flex items-center justify-between px-5 py-3.5 text-sm text-gray-200 hover:bg-black/40 hover:text-white cursor-pointer transition-colors border-b border-white/5"><span class="font-medium uppercase tracking-widest text-xs">Mellan</span><span class="text-gray-400 text-xs font-mono group-hover:text-gray-300 transition-colors">1600 px</span></button>
|
||||
<button type="button" data-size="l" class="dl-opt w-full text-left group flex items-center justify-between px-5 py-3.5 text-sm text-gray-200 hover:bg-black/40 hover:text-white cursor-pointer transition-colors border-b border-white/5"><span class="font-medium uppercase tracking-widest text-xs">Stor</span><span class="text-gray-400 text-xs font-mono group-hover:text-gray-300 transition-colors">3000 px</span></button>
|
||||
<button type="button" data-size="orig" class="dl-opt w-full text-left group flex items-center justify-between px-5 py-3.5 text-sm text-gray-200 hover:bg-black/40 hover:text-white cursor-pointer transition-colors "><span class="font-medium uppercase tracking-widest text-xs">Original<span class="block text-[10px] text-gray-400 normal-case tracking-normal mt-0.5">Kräver lösenord</span></span><span class="text-gray-400 text-xs font-mono group-hover:text-gray-300 transition-colors">full storlek</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3 md:gap-6 text-xs md:text-sm font-mono text-gray-300">
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold">Brännvidd</span><span id="lb-focal">--</span></div>
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold">Bländare</span><span id="lb-fstop">--</span></div>
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold">Slutartid</span><span id="lb-shutter">--</span></div>
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold"><T sv="Brännvidd" en="Focal length" /></span><span id="lb-focal">--</span></div>
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold"><T sv="Bländare" en="Aperture" /></span><span id="lb-fstop">--</span></div>
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold"><T sv="Slutartid" en="Shutter speed" /></span><span id="lb-shutter">--</span></div>
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold">ISO</span><span id="lb-iso">--</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lösenordsruta för ORIGINAL. Originalen ligger inte i repot (27–70 MB/st)
|
||||
utan hämtas från Immich via /download/original, som kräver lösenord. -->
|
||||
<div id="pw-modal" role="dialog" aria-modal="true" aria-labelledby="pw-title" class="hidden fixed inset-0 z-[60] bg-black/85 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="bg-neutral-900 border border-white/10 rounded-xl shadow-2xl p-7 w-full max-w-sm">
|
||||
<p id="pw-title" class="text-white font-bold text-lg tracking-tight mb-1">Ladda ner original</p>
|
||||
<p class="text-gray-400 text-sm mb-4">Originalfilen är i full upplösning och kräver lösenord.</p>
|
||||
<input id="pw-input" type="password" aria-label="Lösenord" placeholder="Lösenord" autocomplete="current-password"
|
||||
class="w-full bg-black/60 border border-white/20 rounded-lg text-white px-4 py-2.5 mb-2 focus:outline-none focus:border-white/60 focus:ring-1 focus:ring-white/20 transition" />
|
||||
<p id="pw-error" class="hidden text-red-400 text-xs tracking-wide mb-2">Fel lösenord — försök igen.</p>
|
||||
<div class="flex gap-2 justify-end mt-2">
|
||||
<button type="button" id="pw-cancel" class="px-5 py-2.5 text-xs uppercase tracking-widest text-gray-400 hover:text-white rounded-full transition-colors">Avbryt</button>
|
||||
<button type="button" id="pw-ok" class="px-5 py-2.5 text-xs uppercase tracking-widest bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105">Ladda ner</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -276,7 +255,7 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
|
||||
function statsItem(item?: HTMLElement): string {
|
||||
return item?.dataset.stat || 'portfolio/okand';
|
||||
}
|
||||
function stat(kind: 'view' | 'open' | 'download', item: string) {
|
||||
function stat(kind: 'view' | 'open', item: string) {
|
||||
try {
|
||||
const body = new Blob([JSON.stringify({ kind, item })], { type: 'text/plain' });
|
||||
navigator.sendBeacon?.(STATS_URL, body);
|
||||
@@ -348,115 +327,6 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
// --- NEDLADDNING (Small/Medium/Large ur repot, Original via proxy) ---
|
||||
const dlBtn = document.getElementById('lb-download');
|
||||
const dlMenu = document.getElementById('lb-sizes');
|
||||
const pwModal = document.getElementById('pw-modal');
|
||||
const pwInput = document.getElementById('pw-input') as HTMLInputElement;
|
||||
const pwError = document.getElementById('pw-error');
|
||||
|
||||
// Small/Medium/Large är EXIF-fria JPG:er som Astro tar fram vid bygget
|
||||
// (data-dl-s/m/l). Original hämtas från Immich via proxyn och kräver lösenord.
|
||||
function urlForSize(size: string): string | null {
|
||||
const item = visibleItems[currentImageIndex];
|
||||
if (!item) return null;
|
||||
if (size === 's') return item.dataset.dlS || null;
|
||||
if (size === 'm') return item.dataset.dlM || null;
|
||||
if (size === 'l') return item.dataset.dlL || null;
|
||||
if (size === 'orig') return item.dataset.asset ? `/download/original?asset=${item.dataset.asset}` : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function fileNameFor(size: string): string {
|
||||
const item = visibleItems[currentImageIndex];
|
||||
const key = (item?.dataset.key || 'bild').replace(/[^\w\-]/g, '_');
|
||||
return `imagesbyolofsson-${key}-${size}.jpg`;
|
||||
}
|
||||
|
||||
function startDownload(url: string, filnamn?: string) {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filnamn || '';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
const dlArrow = document.getElementById('lb-dl-arrow');
|
||||
|
||||
function visaMeny(visa: boolean) {
|
||||
if (!dlMenu) return;
|
||||
// Pilen går grå medan menyn är öppen och tillbaka till vanligt när den stängs
|
||||
// (både vid val av storlek och när man klickar bort menyn).
|
||||
dlArrow?.classList.toggle('text-gray-500', visa);
|
||||
dlBtn?.setAttribute('aria-expanded', visa ? 'true' : 'false');
|
||||
if (visa) {
|
||||
dlMenu.classList.remove('hidden');
|
||||
requestAnimationFrame(() => dlMenu.classList.remove('opacity-0', 'translate-y-2'));
|
||||
} else {
|
||||
dlMenu.classList.add('opacity-0', 'translate-y-2');
|
||||
setTimeout(() => dlMenu.classList.add('hidden'), 180);
|
||||
}
|
||||
}
|
||||
|
||||
dlBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
visaMeny(dlMenu?.classList.contains('hidden') ?? true);
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!dlMenu || dlMenu.classList.contains('hidden')) return;
|
||||
if (!dlMenu.contains(e.target as Node) && !dlBtn?.contains(e.target as Node)) visaMeny(false);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.dl-opt').forEach((opt) => {
|
||||
opt.addEventListener('click', () => {
|
||||
const size = (opt as HTMLElement).dataset.size || 'l';
|
||||
visaMeny(false);
|
||||
const url = urlForSize(size);
|
||||
if (!url) { alert('Den storleken saknas för den här bilden.'); return; }
|
||||
stat('download', statsItem(visibleItems[currentImageIndex]));
|
||||
if (size === 'orig') {
|
||||
// Lösenordet sparas bara för fliken, aldrig i localStorage.
|
||||
const sparat = sessionStorage.getItem('dlpw');
|
||||
if (sparat) { startDownload(`${url}&pw=${encodeURIComponent(sparat)}`); return; }
|
||||
pwError?.classList.add('hidden');
|
||||
if (pwInput) pwInput.value = '';
|
||||
pwModal?.classList.remove('hidden');
|
||||
setTimeout(() => pwInput?.focus(), 50);
|
||||
} else {
|
||||
startDownload(url, fileNameFor(size));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function forsokOriginal() {
|
||||
const pw = pwInput?.value || '';
|
||||
if (!pw) return;
|
||||
const url = urlForSize('orig');
|
||||
if (!url) return;
|
||||
// HEAD först: då får vi 401 utan att dra igång en 70 MB-nedladdning.
|
||||
try {
|
||||
const r = await fetch(`${url}&pw=${encodeURIComponent(pw)}`, { method: 'HEAD' });
|
||||
if (r.status === 401) { pwError?.classList.remove('hidden'); return; }
|
||||
if (!r.ok) { alert('Kunde inte hämta originalet just nu.'); return; }
|
||||
} catch { /* nätverksfel -> försök ändå nedan */ }
|
||||
sessionStorage.setItem('dlpw', pw);
|
||||
pwModal?.classList.add('hidden');
|
||||
startDownload(`${url}&pw=${encodeURIComponent(pw)}`);
|
||||
}
|
||||
|
||||
document.getElementById('pw-ok')?.addEventListener('click', forsokOriginal);
|
||||
pwInput?.addEventListener('keydown', (e) => { if ((e as KeyboardEvent).key === 'Enter') forsokOriginal(); });
|
||||
document.getElementById('pw-cancel')?.addEventListener('click', () => pwModal?.classList.add('hidden'));
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e as KeyboardEvent).key === 'Escape' && !dlMenu?.classList.contains('hidden')) {
|
||||
e.stopImmediatePropagation();
|
||||
visaMeny(false);
|
||||
}
|
||||
}, true);
|
||||
|
||||
function closeLightbox() {
|
||||
lightbox?.classList.add('opacity-0');
|
||||
setTimeout(() => {
|
||||
@@ -468,15 +338,15 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
|
||||
function updateLightboxContent() {
|
||||
const item = visibleItems[currentImageIndex];
|
||||
if (!item) return;
|
||||
visaMeny(false);
|
||||
|
||||
// srcset först så att webbläsaren väljer 800/1600/3000 px efter skärmen.
|
||||
lbImg.srcset = item.dataset.srcset || '';
|
||||
lbImg.src = item.dataset.src || '';
|
||||
lbImg.alt = item.dataset.alt || '';
|
||||
stat('open', statsItem(item));
|
||||
(window as any).iboStats?.track('photo-open', { photo: statsItem(item) });
|
||||
|
||||
if(lbDate) lbDate.innerText = [item.dataset.title, item.dataset.date].filter(Boolean).join(' – ');
|
||||
const en = document.documentElement.lang === 'en';
|
||||
if(lbDate) lbDate.innerText = (en ? item.dataset.dateEn : item.dataset.date) || '';
|
||||
if(lbModel) lbModel.innerText = item.dataset.model || '';
|
||||
if(lbFocal) lbFocal.innerText = item.dataset.focal || '--';
|
||||
if(lbFstop) lbFstop.innerText = item.dataset.fstop || '--';
|
||||
@@ -494,8 +364,28 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
|
||||
updateLightboxContent();
|
||||
}
|
||||
|
||||
// Skydd som på Posters: ingen högerklick-meny, ingen dragning, inget långt tryck på bilderna.
|
||||
document.addEventListener('contextmenu', (e) => {
|
||||
if ((e.target as HTMLElement).closest('.protected')) e.preventDefault();
|
||||
});
|
||||
document.addEventListener('dragstart', (e) => {
|
||||
if ((e.target as HTMLElement).closest('.protected')) e.preventDefault();
|
||||
});
|
||||
// Byter besökaren språk med ljuslådan öppen följer datumet med.
|
||||
document.addEventListener('ibo-lang', () => {
|
||||
if (!lightbox?.classList.contains('hidden')) updateLightboxContent();
|
||||
});
|
||||
|
||||
// Event Listeners
|
||||
items.forEach((item) => {
|
||||
item.addEventListener('keydown', (e) => {
|
||||
const key = (e as KeyboardEvent).key;
|
||||
if (key !== 'Enter' && key !== ' ') return;
|
||||
e.preventDefault();
|
||||
updateVisibleItems();
|
||||
const index = visibleItems.indexOf(item);
|
||||
if (index !== -1) openLightbox(index);
|
||||
});
|
||||
item.classList.add('visible-item');
|
||||
item.addEventListener('click', (e) => {
|
||||
// Länken pekar på den stora bilden (crawlbar/öppningsbar i ny flik),
|
||||
@@ -527,7 +417,7 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
|
||||
// Filter Listeners
|
||||
buttons.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const target = btn as HTMLElement;
|
||||
const type = target.dataset.type;
|
||||
const value = target.dataset.filter;
|
||||
if (!type || !value) return;
|
||||
@@ -560,6 +450,13 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
|
||||
<style>
|
||||
@reference "../styles/global.css";
|
||||
|
||||
.protected, .protected img {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
@apply px-4 py-1.5 rounded-full border border-white/10 bg-neutral-800/50 text-sm text-gray-400 transition hover:bg-white hover:text-black hover:border-white;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// src/pages/privacy.astro
|
||||
import MainLayout from '../layouts/MainLayout.astro';
|
||||
import WaveBackground from '../components/WaveBackground.astro';
|
||||
import T from '../components/T.astro';
|
||||
import { business } from '../data/business';
|
||||
|
||||
const link = 'text-white underline hover:text-gray-300';
|
||||
@@ -19,10 +20,10 @@ const link = 'text-white underline hover:text-gray-300';
|
||||
<div class="container mx-auto px-4 py-12 md:py-20 relative z-10">
|
||||
<div data-trail-box class="bg-neutral-900/60 backdrop-blur-md border border-white/10 rounded-3xl p-8 md:p-12 shadow-2xl mb-20 max-w-3xl mx-auto">
|
||||
|
||||
<h1 class="text-4xl md:text-5xl font-bold leading-tight mb-2">Integritetspolicy</h1>
|
||||
<p class="text-gray-400 mb-10">Senast uppdaterad: 27 oktober 2026</p>
|
||||
<h1 class="text-4xl md:text-5xl font-bold leading-tight mb-2"><T sv="Integritetspolicy" en="Privacy policy" /></h1>
|
||||
<p class="text-gray-400 mb-10"><T sv="Senast uppdaterad: 14 november 2026" en="Last updated: 14 November 2026" /></p>
|
||||
|
||||
<div class="space-y-8 text-gray-300 text-lg leading-relaxed">
|
||||
<div class="i18n-sv space-y-8 text-gray-300 text-lg leading-relaxed">
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">Vem som är ansvarig</h2>
|
||||
@@ -35,9 +36,8 @@ const link = 'text-white underline hover:text-gray-300';
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">Den här webbplatsen</h2>
|
||||
<ul class="list-disc pl-6 space-y-2">
|
||||
<li>Det finns inga användarkonton och inga kontaktformulär på den här webbplatsen. Bokningar görs i den separata kundportalen.</li>
|
||||
<li>Det finns inga användarkonton och inga kontaktformulär på den här webbplatsen. Beställningar av posters och samarbeten sköts i den separata kundportalen.</li>
|
||||
<li>Om du mejlar mig används din adress och ditt meddelande bara för att svara dig och delas inte med någon.</li>
|
||||
<li>Lösenordsskyddade nedladdningar sparar lösenordet bara i din öppna webbläsarflik; det finns inte kvar när du stänger den.</li>
|
||||
<li>
|
||||
Webbplatsen levereras via Cloudflare, som behandlar tekniska uppgifter som din IP-adress för att leverera
|
||||
och skydda webbplatsen. Se <a href="https://www.cloudflare.com/privacypolicy/" target="_blank" rel="noopener noreferrer" class={link}>Cloudflares integritetspolicy</a>.
|
||||
@@ -62,11 +62,19 @@ const link = 'text-white underline hover:text-gray-300';
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">Anonym statistik för portfolion</h2>
|
||||
<h2 class="text-2xl font-bold text-white">Anonym statistik</h2>
|
||||
<p>
|
||||
För att se vilka bilder som uppskattas räknar portfolion hur många gånger varje bild öppnas eller laddas ner, per dag.
|
||||
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.
|
||||
För att se vilka bilder som uppskattas och hur besökare hittar hit räknar jag besök på webbplatsen. Det görs på två sätt, båda på min egen server i Sverige, och ingenting delas med någon annan.
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2">
|
||||
<li>Portfolion räknar hur många gånger varje bild öppnas, per dag. Bara antalet sparas.</li>
|
||||
<li>Med verktyget Umami räknas vilka sidor som besöks, vilka bilder som öppnas, när någon klickar på ”Beställ” eller skickar en beställning och när någon klickar sig vidare till mitt Instagram. För varje besök sparas webbläsartyp, operativsystem, typ av enhet, skärmstorlek, språk, ungefärlig plats (land, region och stad) och vilken webbplats du kom från (bara namnet, till exempel instagram.com).</li>
|
||||
</ul>
|
||||
<p>
|
||||
Inga cookies används och din IP-adress sparas aldrig. Den används bara i stunden för att skapa ett anonymt besöks-id som byts ut varje månad, så jag kan inte se vem du är eller följa dig över tid. Inget du skriver i beställningsformuläret räknas, och ingenting räknas när du är inloggad i kundportalen. Har din webbläsare ”Do Not Track” påslaget räknas du inte alls.
|
||||
</p>
|
||||
<p>
|
||||
Uppgifterna raderas efter 13 månader. Det sker utan samtycke eftersom inga cookies används och du inte kan identifieras.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -102,6 +110,90 @@ const link = 'text-white underline hover:text-gray-300';
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="i18n-en space-y-8 text-gray-300 text-lg leading-relaxed" lang="en">
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">Who is responsible</h2>
|
||||
<p>
|
||||
{business.brand} is run by {business.person.name}, Sweden. Questions about privacy:
|
||||
<a href={`mailto:${business.email}`} class={link}>{business.email}</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">This website</h2>
|
||||
<ul class="list-disc pl-6 space-y-2">
|
||||
<li>There are no user accounts and no contact forms on this website. Poster orders and collaborations are handled in the separate customer portal.</li>
|
||||
<li>If you e-mail me, your address and message are only used to answer you and are not shared with anyone.</li>
|
||||
<li>
|
||||
The website is delivered through Cloudflare, which processes technical data such as your IP address to deliver
|
||||
and protect the website. See <a href="https://www.cloudflare.com/privacypolicy/" target="_blank" rel="noopener noreferrer" class={link}>Cloudflare's privacy policy</a>.
|
||||
</li>
|
||||
<li>If you appear in one of my photos and want it removed, e-mail me and I will remove it.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">Meta Pixel (only with your consent)</h2>
|
||||
<p>
|
||||
If you click "Accept" in the cookie box, the website loads the Meta Pixel from Meta Platforms Ireland Ltd.
|
||||
I use it to see how visitors coming from my Instagram and Facebook use the website.
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2">
|
||||
<li>It sets cookies (for example <code>_fbp</code>) and sends data to Meta, such as the pages you visit, your IP address and information about your browser.</li>
|
||||
<li>Meta may link this to your Facebook or Instagram account and may transfer data outside the EU. See
|
||||
<a href="https://www.facebook.com/privacy/policy/" target="_blank" rel="noopener noreferrer" class={link}>Meta's privacy policy</a>.</li>
|
||||
<li>If you click "Decline" or do nothing, the pixel is never loaded.</li>
|
||||
<li>You can change your choice at any time through "Cookie settings" at the bottom of every page. Your choice is stored in your own browser.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">Anonymous statistics</h2>
|
||||
<p>
|
||||
To see which photos people like and how visitors find the site, I count visits. This is done in two ways, both on my own server in Sweden, and nothing is shared with anyone.
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2">
|
||||
<li>The portfolio counts how many times each photo is opened, per day. Only the number is stored.</li>
|
||||
<li>With the tool Umami I count which pages are visited, which photos are opened, when someone clicks "Order" or sends an order, and when someone clicks through to my Instagram. For each visit, the browser type, operating system, device type, screen size, language, approximate location (country, region and city) and the website you came from (only its name, for example instagram.com) are stored.</li>
|
||||
</ul>
|
||||
<p>
|
||||
No cookies are used and your IP address is never stored. It is only used in the moment to create an anonymous visit id that changes every month, so I cannot see who you are or follow you over time. Nothing you type in the order form is counted, and nothing is counted while you are logged in to the customer portal. If your browser has "Do Not Track" switched on, you are not counted at all.
|
||||
</p>
|
||||
<p>
|
||||
The data is deleted after 13 months. This happens without consent because no cookies are used and you cannot be identified.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">Photo backup app (Google)</h2>
|
||||
<p>
|
||||
"townhouse-backup" is a private tool that only I use to back up my own photos to my own Google account.
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2">
|
||||
<li>Google Drive: the tool only accesses files it has created itself (a backup of my photo archive).</li>
|
||||
<li>Google Photos: the tool only uploads my photos and puts them in albums it has created itself; it cannot see anything else in the library.</li>
|
||||
<li>No data about anyone else is collected. Nothing is sold, shared with third parties, used for advertising or used to train AI models.</li>
|
||||
<li>
|
||||
townhouse-backup's use of information received from Google APIs adheres to the
|
||||
<a href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank" rel="noopener noreferrer" class={link}>Google API Services User Data Policy</a>,
|
||||
including the Limited Use requirements.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-2xl font-bold text-white">Your rights</h2>
|
||||
<p>
|
||||
Under the GDPR you can ask what personal data I hold about you and have it corrected or deleted – just e-mail me.
|
||||
You can also complain to the Swedish Authority for Privacy Protection
|
||||
(<a href="https://www.imy.se/" target="_blank" rel="noopener noreferrer" class={link}>IMY</a>).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export const GET: APIRoute = ({ site }) => {
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<sitemap><loc>${new URL('/sitemap-0.xml', base).toString()}</loc></sitemap>
|
||||
<sitemap><loc>${new URL('/posters/sitemap.xml', base).toString()}</loc></sitemap>
|
||||
</sitemapindex>
|
||||
`;
|
||||
return new Response(body, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } });
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
---
|
||||
// src/pages/tjanster.astro
|
||||
// Vad Linus fotograferar, för vem, var, pris, leverans och hur man bokar + FAQ.
|
||||
// Allt från src/data/business.ts och src/data/faq.ts – okända fakta visas inte.
|
||||
import MainLayout from '../layouts/MainLayout.astro';
|
||||
import WaveBackground from '../components/WaveBackground.astro';
|
||||
import { business, basedIn, formatSek } from '../data/business';
|
||||
import { faq } from '../data/faq';
|
||||
import heroImg from '../assets/images/3d-scroll/img2.jpg';
|
||||
|
||||
const priceFrom = formatSek(business.priceFromSek);
|
||||
const packages = business.packages ?? [];
|
||||
const portalHost = new URL(business.portal.url).host;
|
||||
---
|
||||
|
||||
<MainLayout
|
||||
title="Tjänster och bokning – fotografering | Images by Olofsson"
|
||||
description={`Fotograf Linus Olofsson i ${basedIn()}: porträtt, natur, stad, makro, norrsken och familjefester. Så bokar du via kundportalen, leverans i privat galleri och vanliga frågor.`}
|
||||
path="/tjanster"
|
||||
image={heroImg}
|
||||
imageAlt="Veteranbil med tända strålkastare på en stadsgata i kvällsljus"
|
||||
breadcrumb="Tjänster"
|
||||
faq={faq}
|
||||
>
|
||||
<WaveBackground opacity="opacity-70" />
|
||||
|
||||
<div class="container mx-auto px-4 py-12 md:py-20 relative z-10 max-w-5xl">
|
||||
|
||||
<header class="text-center mb-16">
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-6">Tjänster och bokning</h1>
|
||||
<p class="text-gray-300 text-lg md:text-xl leading-relaxed max-w-3xl mx-auto">
|
||||
Jag heter {business.person.name} och är fotografen bakom {business.brand}, baserad i {basedIn()}.
|
||||
Här ser du vad jag fotograferar, hur en bokning går till och hur du får dina bilder.
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center gap-4 mt-8">
|
||||
<a href={business.portal.bookingUrl} class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition">Skicka en förfrågan</a>
|
||||
<a href="#vanliga-fragor" class="inline-block px-8 py-3 border border-white text-white font-bold rounded-full hover:bg-white/10 transition">Vanliga frågor</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="vad" class="mb-20">
|
||||
<h2 id="vad" class="text-3xl font-bold text-white mb-8">Det här fotograferar jag</h2>
|
||||
<ul class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{business.genres.map((g) => (
|
||||
<li id={g.id} data-trail-box class="bg-neutral-900/80 backdrop-blur-sm p-6 rounded-xl border border-white/10">
|
||||
<h3 class="text-xl font-bold text-white mb-2">{g.name}</h3>
|
||||
<p class="text-gray-300">{g.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p class="text-gray-300 mt-6">
|
||||
Se exempel i min <a href="/portfolio" class="text-white underline underline-offset-4 hover:text-gray-300">portfolio</a>.
|
||||
Har du något annat i tankarna? Beskriv det i din förfrågan, så återkommer jag med ett förslag.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="var-pris" class="mb-20 grid gap-6 md:grid-cols-2">
|
||||
<div data-trail-box class="bg-neutral-900/80 backdrop-blur-sm p-6 md:p-8 rounded-xl border border-white/10">
|
||||
<h2 id="var-pris" class="text-2xl font-bold text-white mb-3">Var</h2>
|
||||
{business.serviceArea ? (
|
||||
<p class="text-gray-300">Jag utgår från {basedIn()} och tar uppdrag i {business.serviceArea}.</p>
|
||||
) : (
|
||||
<p class="text-gray-300">Jag är baserad i {basedIn()}. Kontakta mig så stämmer vi av om jag kan fotografera där du befinner dig.</p>
|
||||
)}
|
||||
</div>
|
||||
<div data-trail-box class="bg-neutral-900/80 backdrop-blur-sm p-6 md:p-8 rounded-xl border border-white/10">
|
||||
<h2 class="text-2xl font-bold text-white mb-3">Pris</h2>
|
||||
{priceFrom ? (
|
||||
<p class="text-gray-300">Fotografering från <strong class="text-white">{priceFrom}</strong>. Det slutliga priset står i offerten.</p>
|
||||
) : (
|
||||
<p class="text-gray-300">Priset beror på uppdraget. Kontakta mig eller skicka en förfrågan, så får du en offert för just det du vill ha fotograferat.</p>
|
||||
)}
|
||||
{packages.length > 0 && (
|
||||
<ul class="mt-4 space-y-3">
|
||||
{packages.map((p) => (
|
||||
<li class="text-gray-300">
|
||||
<strong class="text-white">{p.name}</strong>
|
||||
{formatSek(p.priceSek) && <span> – {formatSek(p.priceSek)}</span>}
|
||||
{p.includes.length > 0 && <span class="block text-sm text-gray-400">{p.includes.join(', ')}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="boka" class="mb-20">
|
||||
<h2 id="boka" class="text-3xl font-bold text-white mb-8">Så går en bokning till</h2>
|
||||
<ol class="space-y-4">
|
||||
{business.bookingSteps.map((step, i) => (
|
||||
<li class="flex gap-4 bg-neutral-900/70 backdrop-blur-sm p-5 rounded-xl border border-white/10">
|
||||
<span class="shrink-0 w-10 h-10 rounded-full border border-white/30 flex items-center justify-center font-bold text-white">{i + 1}</span>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-white">{step.title}</h3>
|
||||
<p class="text-gray-300">{step.text}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p class="text-gray-300 mt-6">
|
||||
Leverans: bilderna levereras i ett privat galleri i kundportalen
|
||||
({portalHost}).{business.deliveryTime && ` Normal leveranstid är ${business.deliveryTime}.`}
|
||||
</p>
|
||||
<a href={business.portal.bookingUrl} class="inline-block mt-6 px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition">Skicka en förfrågan i kundportalen</a>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="vanliga-fragor" class="mb-12">
|
||||
<h2 id="vanliga-fragor" class="text-3xl font-bold text-white mb-8">Vanliga frågor</h2>
|
||||
<div class="space-y-4">
|
||||
{faq.map((item) => (
|
||||
<details class="group bg-neutral-900/80 backdrop-blur-sm rounded-xl border border-white/10 p-5 open:border-white/20">
|
||||
<summary class="cursor-pointer list-none flex justify-between items-center gap-4">
|
||||
<h3 class="text-lg font-bold text-white">{item.question}</h3>
|
||||
<span aria-hidden="true" class="text-gray-400 transition-transform group-open:rotate-45 text-2xl leading-none">+</span>
|
||||
</summary>
|
||||
<p class="text-gray-300 mt-3">{item.answer}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
<p class="text-gray-300 mt-8">
|
||||
Fler frågor? Mejla <a href={`mailto:${business.email}`} class="text-white underline underline-offset-4 hover:text-gray-300">{business.email}</a>
|
||||
eller skriv på Instagram <a href={business.social.instagram.url} target="_blank" rel="noopener noreferrer" class="text-white underline underline-offset-4 hover:text-gray-300">{business.social.instagram.handle}</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</MainLayout>
|
||||