diff --git a/deploy/caddy-site.snippet b/deploy/caddy-site.snippet index 550c8c4..a4752df 100644 --- a/deploy/caddy-site.snippet +++ b/deploy/caddy-site.snippet @@ -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 # } # diff --git a/deploy/cleura/install-portfolio-sync.sh b/deploy/cleura/install-portfolio-sync.sh new file mode 100644 index 0000000..b83344f --- /dev/null +++ b/deploy/cleura/install-portfolio-sync.sh @@ -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 diff --git a/deploy/cleura/portfolio-sync.service b/deploy/cleura/portfolio-sync.service new file mode 100644 index 0000000..1580b20 --- /dev/null +++ b/deploy/cleura/portfolio-sync.service @@ -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 diff --git a/deploy/cleura/portfolio-sync.sh b/deploy/cleura/portfolio-sync.sh new file mode 100644 index 0000000..8c68b5c --- /dev/null +++ b/deploy/cleura/portfolio-sync.sh @@ -0,0 +1,71 @@ +#!/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" +docker run --rm --memory 2g --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' + +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 diff --git a/deploy/cleura/portfolio-sync.timer b/deploy/cleura/portfolio-sync.timer new file mode 100644 index 0000000..dadf8f4 --- /dev/null +++ b/deploy/cleura/portfolio-sync.timer @@ -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 diff --git a/public/robots.txt b/public/robots.txt index 20c0216..81b2f88 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -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 diff --git a/src/layouts/MainLayout.astro b/src/layouts/MainLayout.astro index 0132447..82329ec 100644 --- a/src/layouts/MainLayout.astro +++ b/src/layouts/MainLayout.astro @@ -45,6 +45,7 @@ const ogHeight = Number(og.attributes.height) || undefined; const nav = [ { href: '/portfolio', label: 'Portfolio' }, + { href: '/posters', label: 'Posters' }, { href: '/tjanster', label: 'Tjänster' }, { href: '/about', label: 'Om mig' }, { href: '/contact', label: 'Kontakt' }, diff --git a/src/lib/pages.ts b/src/lib/pages.ts index 77379c8..18c264f 100644 --- a/src/lib/pages.ts +++ b/src/lib/pages.ts @@ -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))); } diff --git a/src/pages/404.astro b/src/pages/404.astro index 484f2fc..ee65fc2 100644 --- a/src/pages/404.astro +++ b/src/pages/404.astro @@ -6,6 +6,7 @@ import WaveBackground from '../components/WaveBackground.astro'; const links = [ { href: '/', label: 'Startsidan' }, { href: '/portfolio', label: 'Portfolio' }, + { href: '/posters', label: 'Posters' }, { href: '/tjanster', label: 'Tjänster och bokning' }, { href: '/contact', label: 'Kontakt' }, ]; diff --git a/src/pages/about.astro b/src/pages/about.astro deleted file mode 100644 index ee17719..0000000 --- a/src/pages/about.astro +++ /dev/null @@ -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'; ---- - - - - - -
- -
- -
- -
-
-
- Porträtt av fotografen Linus Olofsson -
-
- -
-

- Levande ögonblick,
- fångade i hög upplösning. -

- -
-

- Hej, jag heter {business.person.name} och är fotografen bakom {business.brand}. -

-

- 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. -

-

- 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. -

-

- 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. -

-

{business.recognition}

-
- -
-

Linus Olofsson

-
- - -
- -
-
-
-

Vad har jag i kameraväskan?

- -
- -
-
- -
-

Kamerahus

-

{business.camera}

-

En pålitlig följeslagare som har varit med mig på otaliga äventyr.

-
- -
-
- -
-

Objektiv

-
    - {business.lenses.map((lens) =>
  • {lens}
  • )} -
  • och ett par extra batterier
  • -
-
- -
-
- -
-

Redigering

-

{business.editing}

-

Här får råfilerna liv. Jag fokuserar på naturliga toner och kontrast.

-
- -
-
- -
-
- - \ No newline at end of file diff --git a/src/pages/contact.astro b/src/pages/contact.astro deleted file mode 100644 index dc7dc39..0000000 --- a/src/pages/contact.astro +++ /dev/null @@ -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'; ---- - - -
- -
- -
- -
- -
-
-

Kontakta mig

-

- 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. -

-

- 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 Tjänster. -

- Skicka en bokningsförfrågan -
- -
-

Du hittar mig också på

- -
- - - - - - - - - - - - - -
-
-
- -
-
- -
-
-

Jag ser fram emot att höra av dig

-

Hör gärna av dig med frågor, förfrågningar eller bara för att prata.

-
- -
-
- - - -
-
-

Mejla mig på

- - {business.email} - -
- -
-

Kopierad!

-
-
- -
-

Eller skriv till mig på Instagram

- - {business.social.instagram.handle} - -
-
-
- -
-
- -
- - diff --git a/src/pages/llms.txt.ts b/src/pages/llms.txt.ts index 05fdb88..45a9c62 100644 --- a/src/pages/llms.txt.ts +++ b/src/pages/llms.txt.ts @@ -40,7 +40,8 @@ export const GET: APIRoute = ({ site }) => { `- [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`, + `- [Posters](${url('/posters')}): fotografier som posters, välj storlek och material`, + `- [Kontakt och samarbeten](${url('/contact')}): samarbeten, reklam och sponsring`, '', '## Vanliga frågor', '', diff --git a/src/pages/portfolio.astro b/src/pages/portfolio.astro index 15e5df6..1527bdb 100644 --- a/src/pages/portfolio.astro +++ b/src/pages/portfolio.astro @@ -90,23 +90,20 @@ const galleryItems = await Promise.all(Object.entries(imageFiles).map(async ([re ].filter(Boolean).join(' '); const alt = meta.alt?.trim() || fallbackAlt.charAt(0).toUpperCase() + fallbackAlt.slice(1); - // Nedladdning/lightbox: nya, EXIF-fria JPG:er i 800/1600/3000 px - // (Astro hoppar över bredder större än originalet). + // Lightbox: EXIF-fria JPG:er i 800/1600/3000 px (Astro hoppar över bredder större än originalet). + // Ingen nedladdning längre (Linus 2026-11-06): bilderna säljs som posters i stället. const jpg = await getImage({ src: file.default, format: 'jpg', quality: 88, width: 800, widths: [800, 1600, 3000] }); const variants = [...jpg.srcSet.values].sort((a, b) => widthOf(a.descriptor ?? '') - widthOf(b.descriptor ?? '')); - const small = variants[0]?.url ?? jpg.src; const large = variants[variants.length - 1]?.url ?? jpg.src; - const medium = variants.find((v) => widthOf(v.descriptor ?? '') === 1600)?.url ?? large; - const assetId = key.match(/^immich-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-/i)?.[1]?.toLowerCase() ?? null; const statKey = ('portfolio/' + key.replace(/[^\w\-.]/g, '_')).slice(0, 150); return { img: file.default, key, alt, title: meta.title ?? null, year, month, monthIndex, fullDate, model: exifData?.Model, fStop, shutterSpeed, iso, focalLength, - dlS: small, dlM: medium, dlL: large, srcset: jpg.srcSet.attribute, - assetId, statKey, + large, srcset: jpg.srcSet.attribute, + statKey, }; })); @@ -157,17 +154,13 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
-
- - -
+
+ Köp som poster +
Brännvidd--
Bländare--
@@ -249,21 +233,6 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
- - @@ -276,7 +245,7 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value function statsItem(item?: HTMLElement): string { return item?.dataset.stat || 'portfolio/okand'; } - function stat(kind: 'view' | 'open' | 'download', item: string) { + function stat(kind: 'view' | 'open', item: string) { try { const body = new Blob([JSON.stringify({ kind, item })], { type: 'text/plain' }); navigator.sendBeacon?.(STATS_URL, body); @@ -348,115 +317,6 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value document.body.style.overflow = 'hidden'; } - // --- NEDLADDNING (Small/Medium/Large ur repot, Original via proxy) --- - const dlBtn = document.getElementById('lb-download'); - const dlMenu = document.getElementById('lb-sizes'); - const pwModal = document.getElementById('pw-modal'); - const pwInput = document.getElementById('pw-input') as HTMLInputElement; - const pwError = document.getElementById('pw-error'); - - // Small/Medium/Large är EXIF-fria JPG:er som Astro tar fram vid bygget - // (data-dl-s/m/l). Original hämtas från Immich via proxyn och kräver lösenord. - function urlForSize(size: string): string | null { - const item = visibleItems[currentImageIndex]; - if (!item) return null; - if (size === 's') return item.dataset.dlS || null; - if (size === 'm') return item.dataset.dlM || null; - if (size === 'l') return item.dataset.dlL || null; - if (size === 'orig') return item.dataset.asset ? `/download/original?asset=${item.dataset.asset}` : null; - return null; - } - - function fileNameFor(size: string): string { - const item = visibleItems[currentImageIndex]; - const key = (item?.dataset.key || 'bild').replace(/[^\w\-]/g, '_'); - return `imagesbyolofsson-${key}-${size}.jpg`; - } - - function startDownload(url: string, filnamn?: string) { - const a = document.createElement('a'); - a.href = url; - a.download = filnamn || ''; - document.body.appendChild(a); - a.click(); - a.remove(); - } - - const dlArrow = document.getElementById('lb-dl-arrow'); - - function visaMeny(visa: boolean) { - if (!dlMenu) return; - // Pilen går grå medan menyn är öppen och tillbaka till vanligt när den stängs - // (både vid val av storlek och när man klickar bort menyn). - dlArrow?.classList.toggle('text-gray-500', visa); - dlBtn?.setAttribute('aria-expanded', visa ? 'true' : 'false'); - if (visa) { - dlMenu.classList.remove('hidden'); - requestAnimationFrame(() => dlMenu.classList.remove('opacity-0', 'translate-y-2')); - } else { - dlMenu.classList.add('opacity-0', 'translate-y-2'); - setTimeout(() => dlMenu.classList.add('hidden'), 180); - } - } - - dlBtn?.addEventListener('click', (e) => { - e.stopPropagation(); - visaMeny(dlMenu?.classList.contains('hidden') ?? true); - }); - - document.addEventListener('click', (e) => { - if (!dlMenu || dlMenu.classList.contains('hidden')) return; - if (!dlMenu.contains(e.target as Node) && !dlBtn?.contains(e.target as Node)) visaMeny(false); - }); - - document.querySelectorAll('.dl-opt').forEach((opt) => { - opt.addEventListener('click', () => { - const size = (opt as HTMLElement).dataset.size || 'l'; - visaMeny(false); - const url = urlForSize(size); - if (!url) { alert('Den storleken saknas för den här bilden.'); return; } - stat('download', statsItem(visibleItems[currentImageIndex])); - if (size === 'orig') { - // Lösenordet sparas bara för fliken, aldrig i localStorage. - const sparat = sessionStorage.getItem('dlpw'); - if (sparat) { startDownload(`${url}&pw=${encodeURIComponent(sparat)}`); return; } - pwError?.classList.add('hidden'); - if (pwInput) pwInput.value = ''; - pwModal?.classList.remove('hidden'); - setTimeout(() => pwInput?.focus(), 50); - } else { - startDownload(url, fileNameFor(size)); - } - }); - }); - - async function forsokOriginal() { - const pw = pwInput?.value || ''; - if (!pw) return; - const url = urlForSize('orig'); - if (!url) return; - // HEAD först: då får vi 401 utan att dra igång en 70 MB-nedladdning. - try { - const r = await fetch(`${url}&pw=${encodeURIComponent(pw)}`, { method: 'HEAD' }); - if (r.status === 401) { pwError?.classList.remove('hidden'); return; } - if (!r.ok) { alert('Kunde inte hämta originalet just nu.'); return; } - } catch { /* nätverksfel -> försök ändå nedan */ } - sessionStorage.setItem('dlpw', pw); - pwModal?.classList.add('hidden'); - startDownload(`${url}&pw=${encodeURIComponent(pw)}`); - } - - document.getElementById('pw-ok')?.addEventListener('click', forsokOriginal); - pwInput?.addEventListener('keydown', (e) => { if ((e as KeyboardEvent).key === 'Enter') forsokOriginal(); }); - document.getElementById('pw-cancel')?.addEventListener('click', () => pwModal?.classList.add('hidden')); - - document.addEventListener('keydown', (e) => { - if ((e as KeyboardEvent).key === 'Escape' && !dlMenu?.classList.contains('hidden')) { - e.stopImmediatePropagation(); - visaMeny(false); - } - }, true); - function closeLightbox() { lightbox?.classList.add('opacity-0'); setTimeout(() => { @@ -468,8 +328,6 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value function updateLightboxContent() { const item = visibleItems[currentImageIndex]; if (!item) return; - visaMeny(false); - // srcset först så att webbläsaren väljer 800/1600/3000 px efter skärmen. lbImg.srcset = item.dataset.srcset || ''; lbImg.src = item.dataset.src || ''; diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro index 916f5e5..af72057 100644 --- a/src/pages/privacy.astro +++ b/src/pages/privacy.astro @@ -37,7 +37,6 @@ const link = 'text-white underline hover:text-gray-300';