295 changed files with 533 additions and 3591 deletions
-11
View File
@@ -1,11 +0,0 @@
.git
.github
node_modules
dist
.astro
.vscode
.idea
*.log
.DS_Store
README.md
LICENSE
+97
View File
@@ -0,0 +1,97 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Advanced"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '23 16 * * 1'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- name: Run manual build steps
if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
-77
View File
@@ -1,77 +0,0 @@
name: Portfolio-synk (Immich → portfolio)
on:
schedule:
- cron: '0 3 * * *' # dygnsvis 03:00 UTC
workflow_dispatch: # "på begäran" — manuell knapp / API-trigger
permissions:
contents: write # för att committa nya bilder
concurrency:
group: portfolio-sync # ingen parallell körning (race-skydd)
cancel-in-progress: false
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install sharp
run: npm install sharp@^0.33.5
- name: Run portfolio sync
id: sync
env:
IMMICH_API_KEY: ${{ secrets.IMMICH_API_KEY }}
run: node scripts/portfolio-sync.mjs
- name: Commit, PR & auto-merge if changed
if: steps.sync.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.SYNC_PAT }}
SYNC_PAT: ${{ secrets.SYNC_PAT }}
REPO: ${{ github.repository }}
ADDED: ${{ steps.sync.outputs.added }}
REMOVED: ${{ steps.sync.outputs.removed }}
run: |
set -e
BRANCH="sync/portfolio-$(date +%Y%m%d-%H%M%S)"
git config user.name "portfolio-sync[bot]"
git config user.email "actions@github.com"
git checkout -b "$BRANCH"
git add src/assets/images/portfolio/
git commit -m "chore(portfolio): sync ${ADDED} added, ${REMOVED} removed from Immich"
git push "https://x-access-token:${SYNC_PAT}@github.com/${REPO}.git" "$BRANCH"
gh pr create --head "$BRANCH" --base main \
--title "chore(portfolio): Immich sync (${ADDED} added, ${REMOVED} removed)" \
--body "Automated portfolio sync from Immich portfolio tag."
gh pr merge "$BRANCH" --squash --admin --delete-branch
# Coolify auto-deployar på push till main (webhook) — GitHub-runnern når
# inte Coolify (Tailscale-IP 100.x), så vi triggar inte härifrån.
- name: Wait for Coolify auto-deploy
if: steps.sync.outputs.changed == 'true'
run: sleep 120
- name: Purge Cloudflare cache
if: steps.sync.outputs.changed == 'true'
run: |
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/22c7ed24735e7417d814ef66d90e4397/purge_cache" \
-H "Authorization: Bearer ${{ secrets.CF_PURGE_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"files":["https://imagesbyolofsson.se/portfolio"]}' || echo "CF-purge misslyckades (icke-kritiskt)"
- name: Summary
run: |
echo "### Portfolio-synk klar" >> $GITHUB_STEP_SUMMARY
echo "- Tillagda: ${{ steps.sync.outputs.added }}" >> $GITHUB_STEP_SUMMARY
echo "- Borttagna: ${{ steps.sync.outputs.removed }}" >> $GITHUB_STEP_SUMMARY
echo "- Ändring: ${{ steps.sync.outputs.changed }}" >> $GITHUB_STEP_SUMMARY
-21
View File
@@ -1,21 +0,0 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \
if [ -f package-lock.json ]; then npm ci; else npm install --no-audit --no-fund; fi
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
RUN adduser -D -H -u 101 -s /sbin/nologin nginxuser 2>/dev/null || true
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q --spider http://127.0.0.1/ || exit 1
+15 -95
View File
@@ -1,102 +1,22 @@
# Images by Olofsson
# Homepage
A modern, responsive photography portfolio website built with Astro, Tailwind CSS, and advanced animations.
Welcome to the Homepage!\
![Image 3](img3.jpg)\
![Image 4](img4.jpg)\
<br>
## Navigation
<div align="center">
<table>
<tr>
<td align="center">
<img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/src/assets/images/3d-scroll/img1.jpg" width="250" alt="Portfolio Grid">
<br><em> More </em>
</td>
<td align="center">
<img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/src/assets/images/3d-scroll/img2.jpg" width="250" alt="3D Scroll Effect">
<br><em> At </em>
</td>
<td align="center">
<img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/src/assets/images/3d-scroll/img3.jpg" width="250" alt="Hero Section">
<br><em>Imagesbyolofsson.se</em>
</td>
</tr>
</table>
</div>
- Home\
- About\
- Contact\
<br>
<br>
This is a modern design layout that showcases our projects. The use of images and text is intended to maintain an aesthetic appeal.<br>
## Features
## Gallery
Modern design with minimalist aesthetics and focus on imagery. Advanced animations including wave effects, 3D scroll transforms, and smoke particles. Fully responsive across all devices with optimized performance.
Here are some images that represent our work:<br>
- ![Image 1](img1.jpg)<br>
- ![Image 2](img2.jpg)<br>
- ![Image 3](img3.jpg)<br>
<br>
## Quick Start
**Prerequisites**
Node.js 18.0 or higher
**Installation**
```bash
git clone https://github.com/Images-by-Olofsson/homepage.git
cd homepage
npm install
```
**Development**
```bash
npm run dev
```
**Build**
```bash
npm run build
npm run preview
```
<br>
## Project Structure
```
src/
├── assets/
├── components/
│ ├── WaveBackground.astro
│ ├── ThreeDScroll.astro
│ ├── FloatingOrbs.astro
│ └── Welcome.astro
├── layouts/
│ ├── MainLayout.astro
│ └── Layout.astro
├── pages/
│ ├── index.astro
│ ├── portfolio.astro
│ ├── about.astro
│ └── contact.astro
└── styles/
└── global.css
```
<br>
## Technologies
Astro, Tailwind CSS, TypeScript, Exifr
<br>
## License
MIT License - see LICENSE file for details
<br>
<div align="center">
<p>Built by Linus Olofsson</p>
</div>
Thank you for visiting!
-17
View File
@@ -1,17 +0,0 @@
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
// Kanonisk adress: canonical, og:url, sitemap och JSON-LD byggs från denna.
site: 'https://imagesbyolofsson.se',
// En URL per sida: /about (aldrig /about/). Servern 301:ar /about/ -> /about.
trailingSlash: 'never',
build: {
// about.astro -> dist/about.html (inte dist/about/index.html).
// nginx/Caddy serverar /about via try_files $uri.html.
format: 'file',
},
vite: {
plugins: [tailwindcss()],
},
});
-99
View File
@@ -1,99 +0,0 @@
# deploy/caddy-site.snippet
# Caddy-motsvarigheten till nginx.conf för imagesbyolofsson.se (statisk dist/ från `astro build`,
# build.format "file": /about -> about.html, 404 -> 404.html).
#
# Användning i Caddyfile (Caddy >= 2.7):
#
# import /etc/caddy/snippets/caddy-site.snippet
#
# www.imagesbyolofsson.se {
# import ibo_www_redirect
# }
# imagesbyolofsson.se {
# # Befintliga specialregler (t.ex. handle /download/original* -> Immich-proxyn)
# # ska ligga FÖRE importen, så att de vinner över try_files/file_server.
# import ibo_site /srv/imagesbyolofsson/dist
# }
#
# Argumentet är sökvägen till dist-katalogen på servern (justera till den riktiga).
(ibo_www_redirect) {
redir https://imagesbyolofsson.se{uri} permanent
}
(ibo_site) {
root * {args[0]}
encode zstd gzip
# --- Säkerhetsheaders ---
# CSP: tillåter Meta Pixel (laddas först efter samtycke), anonym statistik via sendBeacon
# till customer.imagesbyolofsson.se och Cloudflares egna skript (/cdn-cgi, ev. Web Analytics).
# 'unsafe-inline' behövs för samtyckesskriptet och Astros inlinade skript/stilar.
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()"
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://connect.facebook.net https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://www.facebook.com https://*.facebook.com; font-src 'self' data:; connect-src 'self' https://customer.imagesbyolofsson.se https://www.facebook.com https://*.facebook.com https://connect.facebook.net https://cloudflareinsights.com; frame-src https://www.facebook.com; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests"
-Server
}
# --- Cache-Control (ömsesidigt uteslutande matchers) ---
@ibo_immutable path /_astro/*
header @ibo_immutable Cache-Control "public, max-age=31536000, immutable"
@ibo_media {
path *.jpg *.jpeg *.png *.webp *.avif *.gif *.svg *.ico *.woff *.woff2 *.ttf *.otf
not path /_astro/*
}
header @ibo_media Cache-Control "public, max-age=2592000"
@ibo_textfiles path *.xml *.txt
header @ibo_textfiles Cache-Control "public, max-age=3600"
@ibo_pages {
not path /_astro/*
not path *.jpg *.jpeg *.png *.webp *.avif *.gif *.svg *.ico *.woff *.woff2 *.ttf *.otf *.xml *.txt
}
header @ibo_pages Cache-Control "public, max-age=0, must-revalidate"
# --- 301:or ---
# Gamla indexerade adresser -> portfolion.
@ibo_old path /my-people /my-people/ /animales-and-nature /animales-and-nature/ /celebrations-with-family /celebrations-with-family/ /northen-lights /northen-lights/
redir @ibo_old /portfolio permanent
# /index.html -> /
redir /index.html / permanent
# /about.html -> /about (404-sidan hanteras separat nedan)
@ibo_htmlext {
path_regexp ibo_htmlext ^(/.+)\.html$
not path /404.html
}
redir @ibo_htmlext {re.ibo_htmlext.1}{?query} permanent
# Avslutande snedstreck: /about/ -> /about
@ibo_trailing path_regexp ibo_trailing ^(/.+)/$
redir @ibo_trailing {re.ibo_trailing.1}{?query} permanent
# 404-sidan ska bara synas som svar på okända adresser, inte som egen URL med 200.
@ibo_404page path /404 /404.html
error @ibo_404page 404
# --- Statiska filer ---
try_files {path} {path}.html
file_server
# Riktig 404: 404.html med status 404 (inte startsidan med 200).
handle_errors {
@ibo_notfound expression {err.status_code} == 404
handle @ibo_notfound {
root * {args[0]}
rewrite * /404.html
file_server {
status 404
}
}
}
}
-90
View File
@@ -1,90 +0,0 @@
# nginx för imagesbyolofsson.se (Docker-image: dist/ i /usr/share/nginx/html).
# Bygget använder build.format "file": /about -> about.html, 404 -> 404.html.
# Samma regler som Caddy-varianten i deploy/caddy-site.snippet.
# Cache-Control per sökväg (inte per Content-Type, så att /_astro/*.jpg också blir immutable).
map $uri $ibo_cache_control {
default "public, max-age=0, must-revalidate";
~^/_astro/ "public, max-age=31536000, immutable";
~*\.(?:jpe?g|png|webp|avif|gif|svg|ico|woff2?|ttf|otf)$ "public, max-age=2592000";
~*\.(?:xml|txt)$ "public, max-age=3600";
}
# www -> apex (om www-trafik når den här containern).
server {
listen 80;
server_name www.imagesbyolofsson.se;
return 301 https://imagesbyolofsson.se$request_uri;
}
server {
listen 80 default_server;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Relativa Location-headers: bakom Cloudflare/proxy blir det aldrig http://host:80/...
absolute_redirect off;
server_tokens off;
# Säkerhetsheaders. OBS: inga add_header i location-blocken nedan, annars ärvs inte dessa.
# CSP: tillåter Meta Pixel (connect.facebook.net/facebook.com, laddas först efter samtycke),
# den anonyma statistiken (sendBeacon till customer.imagesbyolofsson.se) och Cloudflares
# egna skript (/cdn-cgi, ev. Web Analytics). 'unsafe-inline' behövs för samtyckesskriptet
# och Astros inlinade skript/stilar; byt mot hashar (Astro experimental.csp) i ett senare steg.
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://connect.facebook.net https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://www.facebook.com https://*.facebook.com; font-src 'self' data:; connect-src 'self' https://customer.imagesbyolofsson.se https://www.facebook.com https://*.facebook.com https://connect.facebook.net https://cloudflareinsights.com; frame-src https://www.facebook.com; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests" always;
add_header Cache-Control $ibo_cache_control always;
# Gamla indexerade adresser -> närmaste nya sida.
location ~ ^/(?:my-people|animales-and-nature|celebrations-with-family|northen-lights)/?$ {
return 301 /portfolio;
}
# /index.html och /about.html -> rena URL:er (bara externa anrop; try_files nedan
# serverar filerna internt utan att gå hit).
location = /index.html {
return 301 /;
}
location ~ ^(?<ibo_noext>/.+)\.html$ {
return 301 $ibo_noext$is_args$args;
}
# Inga dubbletter med avslutande snedstreck: /about/ -> /about.
location ~ ^(?<ibo_noslash>/.+)/$ {
return 301 $ibo_noslash$is_args$args;
}
# Startsidan serveras direkt (ingen intern omdirigering till /index.html).
location = / {
try_files /index.html =404;
}
# Hashade Astro-filer: cachas ett år (immutable sätts via map ovan).
location ^~ /_astro/ {
access_log off;
try_files $uri =404;
}
# Originalnedladdning (/download/original) proxas till Immich någon annanstans
# (Cloudflare/Caddy). Finns den regeln i den här nginx: lägg den här ovanför.
location / {
try_files $uri $uri.html =404;
}
# Riktig 404: 404.html med status 404 (inte startsidan med 200).
error_page 404 /404.html;
location = /404.html {
internal;
}
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml application/xml application/json application/javascript application/xml+rss image/svg+xml;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

-41
View File
@@ -1,41 +0,0 @@
# robots.txt – https://imagesbyolofsson.se
#
# Policy (beslut av Linus 2026-10-27): ALLA botar är välkomna, även AI-crawlers
# och träningsbotar. Innehållet får användas av AI-assistenter med hänvisning
# till Images by Olofsson (se /llms.txt). Cloudflare-sidan (AI Crawl Control /
# "Block AI bots" / hanterad robots.txt) ställs in separat så att den inte
# blockerar eller skriver över detta.
User-agent: *
Allow: /
# Lösenordsskyddad nedladdning av originalfiler – inget att indexera.
Disallow: /download/
# AI-crawlers och träningsbotar: uttryckligen tillåtna.
User-agent: GPTBot
User-agent: OAI-SearchBot
User-agent: ChatGPT-User
User-agent: ClaudeBot
User-agent: Claude-User
User-agent: Claude-SearchBot
User-agent: anthropic-ai
User-agent: CCBot
User-agent: Google-Extended
User-agent: Applebot
User-agent: Applebot-Extended
User-agent: Bytespider
User-agent: Amazonbot
User-agent: meta-externalagent
User-agent: meta-externalfetcher
User-agent: FacebookBot
User-agent: PerplexityBot
User-agent: Perplexity-User
User-agent: cohere-ai
User-agent: Diffbot
User-agent: YouBot
User-agent: DuckAssistBot
User-agent: MistralAI-User
Allow: /
Disallow: /download/
Sitemap: https://imagesbyolofsson.se/sitemap-index.xml
-32
View File
@@ -1,32 +0,0 @@
// Engangsjobb: skapa -s (800px) och -m (1600px) for redan synkade portfolio-bilder.
// Kallan ar den befintliga 3000px-filen i repot - ingen ny nedladdning fran Immich.
import fs from "node:fs";
import path from "node:path";
import sharp from "sharp";
const DIR = "src/assets/images/portfolio";
const SIZES = [{ suffix: "-s", dim: 800 }, { suffix: "-m", dim: 1600 }];
const QUALITY = 88;
const files = fs.readdirSync(DIR).filter(
(f) => /^immich-[0-9a-f-]{36}-/i.test(f) && /\.jpg$/i.test(f) && !/-[sm]\.jpg$/i.test(f)
);
console.log(`hittade ${files.length} large-bilder`);
let skapade = 0, hoppade = 0;
for (const f of files) {
const src = path.join(DIR, f);
const base = src.replace(/\.jpg$/i, "");
for (const { suffix, dim } of SIZES) {
const dest = `${base}${suffix}.jpg`;
if (fs.existsSync(dest)) { hoppade++; continue; }
await sharp(src)
.rotate()
.resize(dim, dim, { fit: "inside", withoutEnlargement: true })
.withMetadata()
.jpeg({ quality: QUALITY })
.toFile(dest);
skapade++;
}
}
console.log(`klart: ${skapade} skapade, ${hoppade} fanns redan`);
-131
View File
@@ -1,131 +0,0 @@
#!/usr/bin/env node
// Portfolio-synk: speglar Immich "portfolio"-taggade bilder till src/assets/images/portfolio/
// Självläkande: härleder synkat tillstånd från immich-<assetId>-*-filnamn i repot.
// Bevarar EXIF (portfolion läser DateTimeOriginal/Model/ISO för år/månad-filter + lightbox).
import fs from "node:fs";
import path from "node:path";
import sharp from "sharp";
const IMMICH = process.env.IMMICH_URL || "https://foto.imagesbyolofsson.se/api";
const KEY = process.env.IMMICH_API_KEY;
const TAGID = process.env.PORTFOLIO_TAG_ID || "67d16b6c-0dea-42ef-a289-2c9e57fa5c5a";
const PORTFOLIO_DIR = "src/assets/images/portfolio";
const MAX_DIM = 3000;
const JPEG_QUALITY = 88;
// RAW-format som sharp/libvips inte kan konvertera — hoppas över elegant.
// Tagga JPEG-versionen (redigera RAW → exportera JPEG → tagga den) istället.
const RAW_EXTENSIONS = [".cr2", ".cr3", ".arw", ".nef", ".dng", ".raf", ".orf", ".rw2", ".pef", ".srw"];
function isRaw(name) {
const lower = name.toLowerCase();
return RAW_EXTENSIONS.some((ext) => lower.endsWith(ext));
}
if (!KEY) { console.error("IMMICH_API_KEY saknas"); process.exit(1); }
async function api(method, pathname, body) {
const res = await fetch(IMMICH + pathname, {
method,
headers: { "x-api-key": KEY, "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${method} ${pathname} → ${res.status}: ${(await res.text()).slice(0, 200)}`);
return res;
}
// Hämta alla portfolio-taggade assets (paginerat, unikt på id — search/metadata ger dubbletter över sidgräns)
async function fetchTagged() {
const map = new Map();
let page = 1;
while (true) {
const res = await api("POST", "/search/metadata", { tagIds: [TAGID], size: 250, page });
const d = await res.json();
const items = d.assets.items;
if (!items.length) break;
for (const a of items) {
const date = a.exifInfo?.dateTimeOriginal || a.fileCreatedAt;
map.set(a.id, { id: a.id, name: a.originalFileName, date });
}
const next = d.assets.nextPage;
if (!next) break;
page = Number(next);
}
return [...map.values()];
}
// Vilka immich-<assetId>-*.jpg finns redan i repot? → { assetId: filename }
function existingSynced() {
if (!fs.existsSync(PORTFOLIO_DIR)) return {};
const out = {};
for (const f of fs.readdirSync(PORTFOLIO_DIR)) {
const m = f.match(/^immich-([0-9a-f-]{36})-/i);
if (m) out[m[1]] = f;
}
return out;
}
// Bygg destinationsfilnamn: immich-<assetId>-<ÅÅÅÅMMDD>-<safeOriginalName>.jpg
function destName(asset) {
const d = asset.date ? new Date(asset.date) : new Date();
const ymd = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
// strippa ev. "<assetid>__"-prefix från originalnamnet (från migreringen), behåll basnamn utan ändelse
let base = asset.name.replace(/^[0-9a-f-]{36}__/i, "").replace(/\.[^.]+$/, "");
base = base.replace(/[^\w.\-]/g, "_").slice(0, 60);
return `immich-${asset.id}-${ymd}-${base}.jpg`;
}
async function downloadAndResize(asset, destPath) {
const res = await api("GET", `/assets/${asset.id}/original`);
const buf = Buffer.from(await res.arrayBuffer());
await sharp(buf)
.rotate() // respektera EXIF-orientering
.resize(MAX_DIM, MAX_DIM, { fit: "inside", withoutEnlargement: true })
.withMetadata() // BEVARA EXIF (kritiskt för portfolions datum/kamera)
.jpeg({ quality: JPEG_QUALITY })
.toFile(destPath);
}
async function main() {
const taggedAll = await fetchTagged();
// Hoppa över RAW-filer elegant — de räknas inte mot synk (tagga JPEG-versionen istället)
const skippedRaw = taggedAll.filter((a) => isRaw(a.name));
const tagged = taggedAll.filter((a) => !isRaw(a.name));
const synced = existingSynced();
const taggedIds = new Set(tagged.map((a) => a.id));
const toAdd = tagged.filter((a) => !synced[a.id]);
const toRemove = Object.entries(synced).filter(([id]) => !taggedIds.has(id)).map(([id, file]) => ({ id, file }));
console.log(`portfolio-synk: ${taggedAll.length} taggade (${tagged.length} bilder + ${skippedRaw.length} RAW), ${Object.keys(synced).length} redan synkade`);
console.log(`→ lägg till: ${toAdd.length}, ta bort: ${toRemove.length}`);
if (skippedRaw.length) {
console.log(`⏭ Hoppade över ${skippedRaw.length} RAW-fil(er) — tagga JPEG-versionen istället:`);
for (const r of skippedRaw) console.log(` ${r.name}`);
}
fs.mkdirSync(PORTFOLIO_DIR, { recursive: true });
for (const a of toAdd) {
const fname = destName(a);
const dest = path.join(PORTFOLIO_DIR, fname);
try {
await downloadAndResize(a, dest);
console.log(` + ${fname}`);
} catch (e) {
console.error(` ! misslyckades ${a.name}: ${e.message}`);
process.exitCode = 1; // signalera fel men fortsätt med resten
}
}
for (const r of toRemove) {
fs.rmSync(path.join(PORTFOLIO_DIR, r.file), { force: true });
console.log(` - ${r.file}`);
}
// skriv ut om något ändrats (workflow läser detta för att avgöra commit)
const changed = toAdd.length > 0 || toRemove.length > 0;
fs.writeFileSync(process.env.GITHUB_OUTPUT || "/dev/stdout",
`changed=${changed}\nadded=${toAdd.length}\nremoved=${toRemove.length}\n`, { flag: "a" });
}
main().catch((e) => { console.error("FEL:", e.message); process.exit(1); });
Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1010 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1000 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 861 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 937 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 802 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 986 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 742 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 991 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 895 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 836 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Some files were not shown because too many files have changed in this diff Show More