94 changed files with 158 additions and 1079 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"> - Home\
<table> - About\
<tr> - Contact\
<td align="center">
<img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/public/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/public/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/public/3d-scroll/img3.jpg" width="250" alt="Hero Section">
<br><em>Imagesbyolofsson.se</em>
</td>
</tr>
</table>
</div>
<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>
<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> Thank you for visiting!
## 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>
-8
View File
@@ -1,8 +0,0 @@
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
vite: {
plugins: [tailwindcss()],
},
});
-46
View File
@@ -1,46 +0,0 @@
map $sent_http_content_type $cache_control_strategy {
default "public, max-age=2592000";
~^text/html "no-store, must-revalidate";
~^text/css "public, max-age=31536000, immutable";
~^application/javascript "public, max-age=31536000, immutable";
~^application/json "no-store, must-revalidate";
~^image/ "public, max-age=2592000";
~^font/ "public, max-age=31536000, immutable";
}
server {
listen 80 default_server;
server_name _;
root /usr/share/nginx/html;
index index.html;
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=()" always;
add_header Cache-Control $cache_control_strategy always;
location / {
try_files $uri $uri/ $uri.html $uri/index.html =404;
}
location /_astro/ {
access_log off;
expires 1y;
}
location ~* \.(webp|avif|jpg|jpeg|png|gif|svg|ico|woff2?|ttf|otf|eot)$ {
access_log off;
expires 30d;
}
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss image/svg+xml;
error_page 404 /404.html;
location = /404.html {
internal;
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

-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: 1010 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1000 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 861 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 802 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 986 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 742 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 991 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 895 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 836 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 797 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 732 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 784 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 730 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 832 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 740 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 784 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 831 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 821 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 869 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 809 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 529 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 836 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 593 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 732 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1000 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 861 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 881 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 963 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 743 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 856 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 722 KiB

+34 -613
View File
@@ -2,20 +2,15 @@
// src/components/WaveBackground.astro // src/components/WaveBackground.astro
const { opacity = "opacity-30", theme = "cyan" } = Astro.props; const { opacity = "opacity-30", theme = "cyan" } = Astro.props;
const mainColor = theme === 'purple' ? '#c026d3' : '#4fd1c5'; // Definiera färger baserat på tema
const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee'; // Cyan = Originalet (Startsidan bas)
const pulseMain = theme === 'purple' ? '#f0abfc' : '#a5f3fc'; // Purple = Contact-sidan (Matchar boxen)
const pulseSec = theme === 'purple' ? '#fbcfe8' : '#cffafe'; const mainColor = theme === 'purple' ? '#c026d3' : '#4fd1c5'; // Purple-600 vs Teal-400
const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa'; const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee'; // Pink-600 vs Cyan-400
--- ---
<div <div class={`fixed inset-0 w-full h-full pointer-events-none -z-10 bg-neutral-900 ${opacity}`}>
class={`fixed inset-0 w-full h-full pointer-events-none -z-10 bg-neutral-900 ${opacity}`} <svg class="w-full h-full" xmlns="http://www.w3.org/2000/svg">
data-wave-theme={theme}
data-pulse-main={pulseMain}
data-pulse-sec={pulseSec}
>
<svg id="wave-svg" class="w-full h-full" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
<defs> <defs>
<linearGradient id="gradMain" x1="0%" y1="0%" x2="100%" y2="0%"> <linearGradient id="gradMain" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style={`stop-color:${mainColor};stop-opacity:0`} /> <stop offset="0%" style={`stop-color:${mainColor};stop-opacity:0`} />
@@ -28,615 +23,48 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
<stop offset="30%" style={`stop-color:${secondaryColor};stop-opacity:0.3`} /> <stop offset="30%" style={`stop-color:${secondaryColor};stop-opacity:0.3`} />
<stop offset="100%" style={`stop-color:${secondaryColor};stop-opacity:0`} /> <stop offset="100%" style={`stop-color:${secondaryColor};stop-opacity:0`} />
</linearGradient> </linearGradient>
<linearGradient id="gradPurple" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#c026d3;stop-opacity:0" />
<stop offset="50%" style="stop-color:#c026d3;stop-opacity:0.45" />
<stop offset="100%" style="stop-color:#c026d3;stop-opacity:0" />
</linearGradient>
<filter id="pulseGlowSoft" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="22" />
</filter>
<filter id="pulseGlowMid" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="9" />
</filter>
<filter id="pulseGlowSharp" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="2.5" />
</filter>
<radialGradient id="pulseHaloGradient">
<stop offset="0%" style={`stop-color:${pulseMain};stop-opacity:0.85`} />
<stop offset="40%" style={`stop-color:${pulseMain};stop-opacity:0.35`} />
<stop offset="100%" style={`stop-color:${pulseMain};stop-opacity:0`} />
</radialGradient>
<radialGradient id="pulseMidGradient">
<stop offset="0%" style={`stop-color:${pulseSec};stop-opacity:1`} />
<stop offset="60%" style={`stop-color:${pulseSec};stop-opacity:0.5`} />
<stop offset="100%" style={`stop-color:${pulseSec};stop-opacity:0`} />
</radialGradient>
<radialGradient id="pulseCoreGradient">
<stop offset="0%" style={`stop-color:${pulseCore};stop-opacity:1`} />
<stop offset="50%" style={`stop-color:${pulseCore};stop-opacity:0.6`} />
<stop offset="100%" style={`stop-color:${pulseCore};stop-opacity:0`} />
</radialGradient>
</defs> </defs>
<g id="wave-group"> <g id="wave-group">
<path id="wave1" d="M-1000 300 Q 250 100 800 300 T 2600 300" fill="none" stroke="url(#gradMain)" stroke-width="1" data-pulse-path="true" /> <path id="wave1" d="M-1000 300 Q 250 100 800 300 T 2600 300" fill="none" stroke="url(#gradMain)" stroke-width="1" />
<path id="wave2" d="M-1000 500 Q 250 700 800 500 T 2600 500" fill="none" stroke="url(#gradMain)" stroke-width="1.5" data-pulse-path="true" /> <path id="wave2" d="M-1000 500 Q 250 700 800 500 T 2600 500" fill="none" stroke="url(#gradMain)" stroke-width="1.5" />
<path id="wave3" d="M-1000 700 Q 250 500 800 700 T 2600 700" fill="none" stroke="url(#gradMain)" stroke-width="0.5" data-pulse-path="true" /> <path id="wave3" d="M-1000 700 Q 250 500 800 700 T 2600 700" fill="none" stroke="url(#gradMain)" stroke-width="0.5" />
<path id="thread1" d="M-1000 200 Q 400 400 900 200 T 2600 200" fill="none" stroke="url(#gradSec)" stroke-width="0.5" class="opacity-50" data-pulse-path="true" /> <path id="thread1" d="M-1000 200 Q 400 400 900 200 T 2600 200" fill="none" stroke="url(#gradSec)" stroke-width="0.5" class="opacity-50" />
<path id="thread2" d="M-1000 800 Q 600 600 1100 800 T 2600 800" fill="none" stroke="url(#gradSec)" stroke-width="0.8" class="opacity-60" data-pulse-path="true" /> <path id="thread2" d="M-1000 800 Q 600 600 1100 800 T 2600 800" fill="none" stroke="url(#gradSec)" stroke-width="0.8" class="opacity-60" />
</g>
<g id="box-trail-group"></g>
<g id="pulse-group">
<g id="pulse-blob-1" opacity="0">
<ellipse rx="55" ry="22" fill="url(#pulseHaloGradient)" filter="url(#pulseGlowSoft)" />
<ellipse rx="32" ry="11" fill="url(#pulseMidGradient)" filter="url(#pulseGlowMid)" />
<ellipse rx="10" ry="3" fill="url(#pulseCoreGradient)" filter="url(#pulseGlowSharp)" opacity="0.75" />
</g>
<g id="pulse-blob-2" opacity="0">
<ellipse rx="55" ry="22" fill="url(#pulseHaloGradient)" filter="url(#pulseGlowSoft)" />
<ellipse rx="32" ry="11" fill="url(#pulseMidGradient)" filter="url(#pulseGlowMid)" />
<ellipse rx="10" ry="3" fill="url(#pulseCoreGradient)" filter="url(#pulseGlowSharp)" opacity="0.75" />
</g>
</g> </g>
</svg> </svg>
</div> </div>
<script> <script>
type Speeds = { w1: number; w2: number; w3: number; t1: number; t2: number }; const wave1 = document.getElementById('wave1');
const wave2 = document.getElementById('wave2');
const wave3 = document.getElementById('wave3');
const thread1 = document.getElementById('thread1');
const thread2 = document.getElementById('thread2');
const wave1 = document.getElementById('wave1') as SVGPathElement | null; // Olika hastigheter för parallax-effekt
const wave2 = document.getElementById('wave2') as SVGPathElement | null; const speeds = {
const wave3 = document.getElementById('wave3') as SVGPathElement | null; w1: 0.5,
const thread1 = document.getElementById('thread1') as SVGPathElement | null; w2: -0.8,
const thread2 = document.getElementById('thread2') as SVGPathElement | null; w3: 1.2,
const svg = document.getElementById('wave-svg') as unknown as SVGSVGElement | null; t1: 1.8, // Snabbare "random" tråd
const blob1 = document.getElementById('pulse-blob-1') as SVGGElement | null; t2: -1.5 // Snabbare "random" tråd åt andra hållet
const blob2 = document.getElementById('pulse-blob-2') as SVGGElement | null; };
const boxGroup = document.getElementById('box-trail-group') as SVGGElement | null;
const wrapper = svg?.parentElement;
const speeds: Speeds = { w1: 0.5, w2: -0.8, w3: 1.2, t1: 1.8, t2: -1.5 };
function getSpeedKey(id: string): keyof Speeds | null {
if (id === 'wave1') return 'w1';
if (id === 'wave2') return 'w2';
if (id === 'wave3') return 'w3';
if (id === 'thread1') return 't1';
if (id === 'thread2') return 't2';
return null;
}
function onScroll() { function onScroll() {
const scrollY = window.scrollY; const scrollY = window.scrollY;
if (wave1) wave1.style.transform = `translateX(${scrollY * speeds.w1}px)`;
if (wave2) wave2.style.transform = `translateX(${scrollY * speeds.w2}px)`; if(wave1) wave1.style.transform = `translateX(${scrollY * speeds.w1}px)`;
if (wave3) wave3.style.transform = `translateX(${scrollY * speeds.w3}px)`; if(wave2) wave2.style.transform = `translateX(${scrollY * speeds.w2}px)`;
if (thread1) thread1.style.transform = `translateX(${scrollY * speeds.t1}px)`; if(wave3) wave3.style.transform = `translateX(${scrollY * speeds.w3}px)`;
if (thread2) thread2.style.transform = `translateX(${scrollY * speeds.t2}px)`;
// Random trådarna rör sig mer
if(thread1) thread1.style.transform = `translateX(${scrollY * speeds.t1}px)`;
if(thread2) thread2.style.transform = `translateX(${scrollY * speeds.t2}px)`;
} }
window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('scroll', onScroll, { passive: true });
type BoxRect = { left: number; right: number; top: number; bottom: number; cx: number; cy: number; r: number };
if (svg && blob1 && blob2 && wrapper) {
const pulseMain = wrapper.getAttribute('data-pulse-main') || '#a5f3fc';
const pulseSec = wrapper.getAttribute('data-pulse-sec') || '#cffafe';
const wavePaths = Array.from(document.querySelectorAll<SVGPathElement>('[data-pulse-path="true"]'));
const NS = 'http://www.w3.org/2000/svg';
function clearBoxGroup() {
if (!boxGroup) return;
while (boxGroup.firstChild) boxGroup.removeChild(boxGroup.firstChild);
}
function refreshBoxes(): BoxRect[] {
const targets = document.querySelectorAll<HTMLElement>('[data-trail-box]');
const out: BoxRect[] = [];
targets.forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.width < 20 || rect.height < 20) return;
if (rect.bottom < 0 || rect.top > window.innerHeight) return;
out.push({
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
cx: rect.left + rect.width / 2,
cy: rect.top + rect.height / 2,
r: 16,
});
});
return out;
}
function updatePulseColors(isSecondary: boolean) {
const haloColor = isSecondary ? pulseSec : pulseMain;
const midColor = isSecondary ? pulseMain : pulseSec;
const haloStops = document.querySelectorAll('#pulseHaloGradient stop');
if (haloStops[0]) haloStops[0].setAttribute('style', `stop-color:${haloColor};stop-opacity:0.85`);
if (haloStops[1]) haloStops[1].setAttribute('style', `stop-color:${haloColor};stop-opacity:0.35`);
if (haloStops[2]) haloStops[2].setAttribute('style', `stop-color:${haloColor};stop-opacity:0`);
const midStops = document.querySelectorAll('#pulseMidGradient stop');
if (midStops[0]) midStops[0].setAttribute('style', `stop-color:${midColor};stop-opacity:1`);
if (midStops[1]) midStops[1].setAttribute('style', `stop-color:${midColor};stop-opacity:0.5`);
if (midStops[2]) midStops[2].setAttribute('style', `stop-color:${midColor};stop-opacity:0`);
}
function pointInBox(x: number, y: number, b: BoxRect): boolean {
return x >= b.left && x <= b.right && y >= b.top && y <= b.bottom;
}
type Intersection = {
boxIdx: number;
entryLen: number;
exitLen: number;
entryX: number;
entryY: number;
exitX: number;
exitY: number;
};
function findBoxIntersection(
path: SVGPathElement,
pathTx: number,
boxes: BoxRect[]
): Intersection | null {
const total = path.getTotalLength();
const step = 3;
let activeBox = -1;
let entryLen = -1;
let entryX = 0, entryY = 0;
for (let len = 0; len <= total; len += step) {
const pt = path.getPointAtLength(len);
const x = pt.x + pathTx;
const y = pt.y;
if (activeBox === -1) {
for (let bi = 0; bi < boxes.length; bi++) {
if (pointInBox(x, y, boxes[bi])) {
activeBox = bi;
entryLen = len;
entryX = x;
entryY = y;
break;
}
}
} else {
if (!pointInBox(x, y, boxes[activeBox])) {
return {
boxIdx: activeBox,
entryLen,
exitLen: len,
entryX,
entryY,
exitX: x,
exitY: y,
};
}
}
}
return null;
}
function buildSplitHalfPath(
box: BoxRect,
entry: { x: number; y: number },
exit: { x: number; y: number },
half: 'top' | 'bottom'
): SVGPathElement {
// Path goes: entry → mjuk kurva mot box-kant (top eller bottom) → traverserar horisontellt över box-kanten → mjuk kurva från box-kant till exit-punkt
// KRITISKT: pathen ska BARA röra sig framåt (entry.x → exit.x), aldrig vända tillbaka
const xL = box.left, xR = box.right, yT = box.top, yB = box.bottom, r = box.r;
// För att undvika "tillbaka-rörelse" när entry/exit ligger UTANFÖR boxens X-range:
// Använd entry.x och exit.x som start/end-punkter på den horisontella delen,
// clampad till box-kanten med hörn-radius
const startX = Math.max(xL + r, Math.min(xR - r, entry.x));
const endX = Math.max(xL + r, Math.min(xR - r, exit.x));
// Top/bottom Y-koordinat för traversal
const traverseY = half === 'top' ? yT : yB;
// Distans från entry/exit till traverseY (för smooth bezier-overgång)
const entryToTraverseY = Math.abs(entry.y - traverseY);
const exitToTraverseY = Math.abs(exit.y - traverseY);
// Kontrollpunkter för Bezier — låt dem peka i rätt riktning (top eller bottom)
// cp1: nära entry, dragen mot traverseY
const cp1x = entry.x;
const cp1y = entry.y + (half === 'top' ? -entryToTraverseY * 0.5 : entryToTraverseY * 0.5);
// cp2: vid startX, redan på traverseY-nivå
const cp2x = startX;
const cp2y = traverseY;
// cp3: vid endX, fortfarande på traverseY
const cp3x = endX;
const cp3y = traverseY;
// cp4: nära exit, dragen från traverseY
const cp4x = exit.x;
const cp4y = exit.y + (half === 'top' ? -exitToTraverseY * 0.5 : exitToTraverseY * 0.5);
const d = [
`M ${entry.x} ${entry.y}`,
`C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${startX} ${traverseY}`,
`L ${endX} ${traverseY}`,
`C ${cp3x} ${cp3y}, ${cp4x} ${cp4y}, ${exit.x} ${exit.y}`,
].join(' ');
const p = document.createElementNS(NS, 'path');
p.setAttribute('d', d);
p.setAttribute('fill', 'none');
p.setAttribute('stroke', 'transparent');
return p;
}
function applyTransform(blob: SVGGElement, x: number, y: number, angle: number) {
blob.setAttribute('transform', `translate(${x} ${y}) rotate(${angle})`);
}
function tangentAngle(path: SVGPathElement, len: number, total: number): number {
const delta = Math.max(1, total * 0.002);
const before = path.getPointAtLength(Math.max(0, len - delta));
const after = path.getPointAtLength(Math.min(total, len + delta));
return Math.atan2(after.y - before.y, after.x - before.x) * (180 / Math.PI);
}
function fadeFactor(t: number): number {
if (t < 0.18) return t / 0.18;
if (t > 0.82) return (1 - t) / 0.18;
return 1;
}
let currentTimeout: number | null = null;
let isPulsing = false;
function runThreadPulse(path: SVGPathElement, durationMs: number) {
if (!blob1 || !blob2 || !boxGroup) return;
isPulsing = true;
clearBoxGroup();
const total = path.getTotalLength();
const isSecondary =
(path.getAttribute('stroke') || '').includes('gradSec') ||
path.id === 'thread1' ||
path.id === 'thread2';
updatePulseColors(isSecondary);
const speedKey = getSpeedKey(path.id);
const boxes = refreshBoxes();
const pathTx0 = speedKey ? window.scrollY * speeds[speedKey] : 0;
const hit = findBoxIntersection(path, pathTx0, boxes);
const velocity = total / durationMs;
const startTime = performance.now();
blob2.setAttribute('opacity', '0');
const entryLen = hit ? hit.entryLen : total;
const exitLen = hit ? hit.exitLen : total;
const phase1Duration = entryLen / velocity;
let topPath: SVGPathElement | null = null;
let bottomPath: SVGPathElement | null = null;
let phase2Duration = 0;
// För TIME-SYNCED meeting: båda blobs animeras med samma t (0→1), olika path-längder = olika faktisk hastighet
// Phase2-duration baseras på genomsnittlig path-längd så hastighets-skillnaden hålls liten
if (hit) {
const entryPt = { x: hit.entryX, y: hit.entryY };
const exitPt = { x: hit.exitX, y: hit.exitY };
topPath = buildSplitHalfPath(boxes[hit.boxIdx], entryPt, exitPt, 'top');
bottomPath = buildSplitHalfPath(boxes[hit.boxIdx], entryPt, exitPt, 'bottom');
boxGroup.appendChild(topPath);
boxGroup.appendChild(bottomPath);
const topLen = topPath.getTotalLength();
const bottomLen = bottomPath.getTotalLength();
// Genomsnittlig längd för fart-beräkning (så snabba/långsamma delas jämnt)
const avgLen = (topLen + bottomLen) / 2;
phase2Duration = avgLen / velocity;
}
const phase3Duration = hit ? (total - exitLen) / velocity : 0;
const totalTime = phase1Duration + phase2Duration + phase3Duration;
function step(now: number) {
if (!blob1 || !blob2) return;
const elapsed = now - startTime;
const globalT = Math.min(elapsed / totalTime, 1);
const fade = fadeFactor(globalT);
const pathTx = speedKey ? window.scrollY * speeds[speedKey] : 0;
if (elapsed <= phase1Duration) {
const len = elapsed * velocity;
const pt = path.getPointAtLength(Math.min(len, total));
const angle = tangentAngle(path, Math.min(len, total), total);
applyTransform(blob1, pt.x + pathTx, pt.y, angle);
blob1.setAttribute('opacity', String(fade));
blob2.setAttribute('opacity', '0');
} else if (hit && elapsed <= phase1Duration + phase2Duration) {
const inPhase2 = elapsed - phase1Duration;
if (topPath && bottomPath) {
const topLen = topPath.getTotalLength();
const bottomLen = bottomPath.getTotalLength();
// TIME-SYNCED: båda blobs har samma t (0→1) inom phase2 → möts garanterat samtidigt
// Hastighets-skillnad uppstår automatiskt om topLen ≠ bottomLen
const phase2T = Math.min(inPhase2 / phase2Duration, 1);
const tDist = phase2T * topLen;
const bDist = phase2T * bottomLen;
const tp = topPath.getPointAtLength(tDist);
const ta = tangentAngle(topPath, tDist, topLen);
applyTransform(blob1, tp.x, tp.y, ta);
blob1.setAttribute('opacity', String(fade));
const bp = bottomPath.getPointAtLength(bDist);
const ba = tangentAngle(bottomPath, bDist, bottomLen);
applyTransform(blob2, bp.x, bp.y, ba);
blob2.setAttribute('opacity', String(fade));
}
} else if (hit) {
const inPhase3 = elapsed - phase1Duration - phase2Duration;
const len = exitLen + inPhase3 * velocity;
const clamped = Math.min(len, total);
const pt = path.getPointAtLength(clamped);
const angle = tangentAngle(path, clamped, total);
applyTransform(blob1, pt.x + pathTx, pt.y, angle);
blob1.setAttribute('opacity', String(fade));
blob2.setAttribute('opacity', '0');
}
if (elapsed < totalTime && globalT < 1) {
requestAnimationFrame(step);
} else {
blob1.setAttribute('opacity', '0');
blob2.setAttribute('opacity', '0');
clearBoxGroup();
isPulsing = false;
}
}
requestAnimationFrame(step);
}
const SPLIT_PROBABILITY = 0.55;
function _unused_isLogoTargetVisible(): HTMLElement | null {
const el = document.querySelector<HTMLElement>('[data-logo-target]');
if (!el) return null;
const rect = el.getBoundingClientRect();
if (rect.bottom < 0 || rect.top > window.innerHeight) return null;
if (rect.width < 50 || rect.height < 50) return null;
return el;
}
function updateLogoThreadPaths() {
const logo = document.querySelector<HTMLElement>('[data-logo-target]');
const threadIn = document.getElementById('threadLogoIn') as SVGPathElement | null;
const threadOut = document.getElementById('threadLogoOut') as SVGPathElement | null;
if (!logo || !threadIn || !threadOut) return;
const rect = logo.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
// Bracket-pinnarnas SLUT (där vertikala benen tar slut)
// ::before: top-left bracket, height=40% av text-wrapper. Vänstra benet går nedåt 40% av höjden
// Lås-punkt: (rect.left, rect.top + 0.40 * rect.height) — pinnens nedre spets
const leftPinX = rect.left;
const leftPinY = rect.top + rect.height * 0.40;
// ::after: bottom-right bracket. Höger ben börjar 40% från botten
// Lås-punkt: (rect.right, rect.bottom - 0.40 * rect.height) — pinnens övre spets
const rightPinX = rect.right;
const rightPinY = rect.bottom - rect.height * 0.40;
// Rosa tråd: från (0, vh) → (leftPinX, leftPinY)
// x² curve: kontrollpunkt (endX, startY) = rakt fram, sedan upp = accelererande lutning
const inStartX = 0, inStartY = vh;
const inEndX = leftPinX, inEndY = leftPinY;
const inCpX = inEndX, inCpY = inStartY;
threadIn.setAttribute('d', `M ${inStartX} ${inStartY} Q ${inCpX} ${inCpY}, ${inEndX} ${inEndY}`);
// Cyan tråd: från (rightPinX, rightPinY) → (vw, 0)
// -x² curve: kontrollpunkt (startX, endY) = upp först, sedan rakt = decelererande lutning
const outStartX = rightPinX, outStartY = rightPinY;
const outEndX = vw, outEndY = 0;
const outCpX = outStartX, outCpY = outEndY;
threadOut.setAttribute('d', `M ${outStartX} ${outStartY} Q ${outCpX} ${outCpY}, ${outEndX} ${outEndY}`);
}
// Uppdatera vid load + resize + scroll (logo-position beror på alla tre)
updateLogoThreadPaths();
window.addEventListener('resize', updateLogoThreadPaths);
window.addEventListener('scroll', updateLogoThreadPaths, { passive: true });
// Re-run efter en kort delay för att Astro layout ska vara klar
setTimeout(updateLogoThreadPaths, 500);
setTimeout(updateLogoThreadPaths, 1500);
function runLogoPulse(durationMs: number) {
if (!blob1) return;
const logo = isLogoTargetVisible();
const entryPath = document.getElementById('threadLogoIn') as SVGPathElement | null;
const exitPath = document.getElementById('threadLogoOut') as SVGPathElement | null;
if (!logo || !entryPath || !exitPath) {
schedule();
return;
}
isPulsing = true;
updatePulseColors(false);
// Uppdatera path-d till EXAKT nuvarande logo-position innan pulse börjar
updateLogoThreadPaths();
const rect = logo.getBoundingClientRect();
const logoLeftX = rect.left + 8;
const logoRightX = rect.right - 8;
const logoY = rect.top + rect.height / 2;
// Använd permanenta wave-trådar (threadLogoIn + threadLogoOut)
// Hitta var de KORSAR logo-x-koordinater i screen-space
const entryTotal = entryPath.getTotalLength();
const exitTotal = exitPath.getTotalLength();
// Sök på entryPath efter den punkt där SVG-pt.x motsvarar logoLeftX
// OBS: pathen är i SVG-koord (-1000..2600), klient-koord är getBoundingClientRect
// Vi mappar via path.getBoundingClientRect() vs path.getBBox()
function findLenForScreenX(p: SVGPathElement, targetScreenX: number, total: number): number {
// SVG till screen: använd ctm
const ctm = p.getScreenCTM();
if (!ctm) return total * 0.5;
let bestLen = total;
for (let l = 0; l <= total; l += 6) {
const pt = p.getPointAtLength(l);
const screenX = ctm.a * pt.x + ctm.c * pt.y + ctm.e;
if (screenX >= targetScreenX) {
bestLen = l;
break;
}
}
return bestLen;
}
const entryLen = findLenForScreenX(entryPath, logoLeftX, entryTotal);
const exitStartLen = findLenForScreenX(exitPath, logoRightX, exitTotal);
const exitLen = Math.min(exitTotal, exitStartLen + (exitTotal - exitStartLen) * 0.9);
const phase1Duration = durationMs * 0.35;
const phase2Duration = durationMs * 0.30;
const phase3Duration = durationMs * 0.35;
const totalTime = phase1Duration + phase2Duration + phase3Duration;
const startTime = performance.now();
const pinkColor = '#e879f9';
const greenColor = '#4fd1c5';
function setPulseTint(color: string) {
const haloStops = document.querySelectorAll('#pulseHaloGradient stop');
if (haloStops[0]) haloStops[0].setAttribute('style', `stop-color:${color};stop-opacity:0.85`);
if (haloStops[1]) haloStops[1].setAttribute('style', `stop-color:${color};stop-opacity:0.35`);
if (haloStops[2]) haloStops[2].setAttribute('style', `stop-color:${color};stop-opacity:0`);
const midStops = document.querySelectorAll('#pulseMidGradient stop');
if (midStops[0]) midStops[0].setAttribute('style', `stop-color:${color};stop-opacity:1`);
if (midStops[1]) midStops[1].setAttribute('style', `stop-color:${color};stop-opacity:0.5`);
if (midStops[2]) midStops[2].setAttribute('style', `stop-color:${color};stop-opacity:0`);
}
setPulseTint(pinkColor);
blob2 && blob2.setAttribute('opacity', '0');
function step(now: number) {
if (!blob1) return;
const elapsed = now - startTime;
const globalT = Math.min(elapsed / totalTime, 1);
const fade = fadeFactor(globalT);
if (elapsed <= phase1Duration) {
// Glide in på entry-path (lila, permanent tråd)
// Pulse är inuti SAMMA SVG som pathen — använd SVG-koord direkt
const t = elapsed / phase1Duration;
const len = t * entryLen;
const pt = entryPath.getPointAtLength(len);
const angle = tangentAngle(entryPath, len, entryTotal);
applyTransform(blob1!, pt.x, pt.y, angle);
blob1!.setAttribute('opacity', String(fade));
} else if (elapsed <= phase1Duration + phase2Duration) {
// INSIDE logo — pulse glider från entry-end-position till exit-start-position
// i SVG-koord-space (samma som phase1 och phase3)
const t = (elapsed - phase1Duration) / phase2Duration;
const entryEnd = entryPath.getPointAtLength(entryLen);
const exitStart = exitPath.getPointAtLength(exitStartLen);
const cx = entryEnd.x + (exitStart.x - entryEnd.x) * t;
const cy = entryEnd.y + (exitStart.y - entryEnd.y) * t;
applyTransform(blob1!, cx, cy, 0);
blob1!.setAttribute('opacity', String(fade * 0.6)); // dimmare i logo (logo-färg tar över visuellt)
// Färg-shift: rosa första halvan, grön andra halvan
if (t < 0.5) {
logo!.setAttribute('data-logo-tint', 'pink');
setPulseTint(pinkColor);
} else {
logo!.setAttribute('data-logo-tint', 'green');
setPulseTint(greenColor);
}
} else {
// Glide ut på exit-path (permanent cyan-tråd) — SVG-koord direkt
const t = Math.min((elapsed - phase1Duration - phase2Duration) / phase3Duration, 1);
const len = exitStartLen + (exitLen - exitStartLen) * t;
const pt = exitPath.getPointAtLength(len);
const angle = tangentAngle(exitPath, len, exitTotal);
applyTransform(blob1!, pt.x, pt.y, angle);
blob1!.setAttribute('opacity', String(fade));
if (logo!.getAttribute('data-logo-tint')) {
logo!.removeAttribute('data-logo-tint');
}
}
if (elapsed < totalTime && globalT < 1) {
requestAnimationFrame(step);
} else {
blob1!.setAttribute('opacity', '0');
logo!.removeAttribute('data-logo-tint');
// Återställ pulse-gradient till default cyan-tema
updatePulseColors(false);
isPulsing = false;
}
}
requestAnimationFrame(step);
}
function pickAndPulse() {
if (isPulsing) {
schedule();
return;
}
if (wavePaths.length === 0) {
schedule();
return;
}
const boxes = refreshBoxes();
const intersecting: SVGPathElement[] = [];
const nonIntersecting: SVGPathElement[] = [];
for (const p of wavePaths) {
const speedKey = getSpeedKey(p.id);
const pathTx = speedKey ? window.scrollY * speeds[speedKey] : 0;
const hit = boxes.length > 0 ? findBoxIntersection(p, pathTx, boxes) : null;
if (hit) intersecting.push(p);
else nonIntersecting.push(p);
}
let choice: SVGPathElement;
const wantSplit = Math.random() < SPLIT_PROBABILITY;
if (wantSplit && intersecting.length > 0) {
choice = intersecting[Math.floor(Math.random() * intersecting.length)];
} else if (!wantSplit && nonIntersecting.length > 0) {
choice = nonIntersecting[Math.floor(Math.random() * nonIntersecting.length)];
} else if (intersecting.length > 0) {
choice = intersecting[Math.floor(Math.random() * intersecting.length)];
} else {
choice = nonIntersecting[Math.floor(Math.random() * nonIntersecting.length)];
}
runThreadPulse(choice, 18000 + Math.random() * 8000);
schedule();
}
function schedule() {
if (currentTimeout) clearTimeout(currentTimeout);
const delay = 3500 + Math.random() * 5500;
currentTimeout = window.setTimeout(pickAndPulse, delay);
}
const startDelay = 1500 + Math.random() * 2000;
currentTimeout = window.setTimeout(pickAndPulse, startDelay);
}
</script> </script>
<style> <style>
@@ -644,11 +72,4 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
transition: transform 0.1s linear; transition: transform 0.1s linear;
will-change: transform; will-change: transform;
} }
#pulse-blob-1, #pulse-blob-2 {
mix-blend-mode: screen;
}
#pulse-blob-1 ellipse, #pulse-blob-2 ellipse {
transform-box: fill-box;
transform-origin: center;
}
</style> </style>
-9
View File
@@ -6,9 +6,6 @@ const { title } = Astro.props;
<html lang="sv" class="scroll-smooth"> <head> <html lang="sv" class="scroll-smooth"> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/favicon-512.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon-180.png" />
<title>{title}</title> <title>{title}</title>
</head> </head>
<body class="bg-neutral-900 text-white font-sans antialiased min-h-screen flex flex-col"> <body class="bg-neutral-900 text-white font-sans antialiased min-h-screen flex flex-col">
@@ -33,12 +30,6 @@ const { title } = Astro.props;
</main> </main>
<footer class="border-t border-neutral-800 py-8 text-center text-gray-500 text-sm bg-neutral-900 relative z-40"> <footer class="border-t border-neutral-800 py-8 text-center text-gray-500 text-sm bg-neutral-900 relative z-40">
<p class="mb-2">
Crafting digital experiences for
<a href="https://lyxlimo.se/" target="_blank" rel="noopener noreferrer" data-glow-link="green" class="text-gray-400 hover:text-white transition-colors">lyxlimo.se</a>
&amp;
<a href="https://karleksguiden.se/" target="_blank" rel="noopener noreferrer" data-glow-link="magenta" class="text-gray-400 hover:text-white transition-colors">karleksguiden.se</a>
</p>
<p>&copy; {new Date().getFullYear()} Images by Olofsson. All rights reserved.</p> <p>&copy; {new Date().getFullYear()} Images by Olofsson. All rights reserved.</p>
</footer> </footer>
+4 -4
View File
@@ -12,7 +12,7 @@ import WaveBackground from '../components/WaveBackground.astro';
<div class="container mx-auto px-4 py-12 md:py-20 relative z-10"> <div class="container mx-auto px-4 py-12 md:py-20 relative z-10">
<div data-trail-box class="bg-neutral-900/60 backdrop-blur-md border border-white/10 rounded-3xl p-8 md:p-12 shadow-2xl mb-20"> <div 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="flex flex-col lg:flex-row items-center gap-12 lg:gap-20">
@@ -73,7 +73,7 @@ import WaveBackground from '../components/WaveBackground.astro';
<div class="grid grid-cols-1 md:grid-cols-3 gap-8"> <div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div data-trail-box class="gear-card"> <div class="gear-card">
<div class="icon-box"> <div class="icon-box">
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" /></svg> <svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
</div> </div>
@@ -82,7 +82,7 @@ import WaveBackground from '../components/WaveBackground.astro';
<p class="text-gray-500 text-sm mt-2">A trusty companion that has been with me on countless adventures.</p> <p class="text-gray-500 text-sm mt-2">A trusty companion that has been with me on countless adventures.</p>
</div> </div>
<div data-trail-box class="gear-card"> <div class="gear-card">
<div class="icon-box"> <div class="icon-box">
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg> <svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
</div> </div>
@@ -94,7 +94,7 @@ import WaveBackground from '../components/WaveBackground.astro';
</ul> </ul>
</div> </div>
<div data-trail-box class="gear-card"> <div class="gear-card">
<div class="icon-box"> <div class="icon-box">
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> <svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
</div> </div>
+1 -1
View File
@@ -61,7 +61,7 @@ import WaveBackground from '../components/WaveBackground.astro';
<div class="relative group"> <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 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 class="relative bg-neutral-900 ring-1 ring-white/10 rounded-xl p-8 md:p-12 text-center space-y-10">
<div> <div>
<h3 class="text-2xl font-bold text-white mb-2">Waiting to hear from you</h3> <h3 class="text-2xl font-bold text-white mb-2">Waiting to hear from you</h3>
<p class="text-gray-400">Feel free to reach out for inquiries or just a chat.</p> <p class="text-gray-400">Feel free to reach out for inquiries or just a chat.</p>
+3 -56
View File
@@ -15,7 +15,7 @@ import WaveBackground from '../components/WaveBackground.astro';
<div class="text-center animated-text-container relative"> <div class="text-center animated-text-container relative">
<div id="text-wrapper" data-logo-target class="inline-block relative p-6 transition-all duration-500"> <div id="text-wrapper" class="inline-block relative p-6 transition-all duration-500">
<h1 class="text-5xl md:text-8xl font-black text-white tracking-tighter leading-none drop-shadow-[0_0_10px_rgba(34,211,238,0.4)] relative z-10"> <h1 class="text-5xl md:text-8xl font-black text-white tracking-tighter leading-none drop-shadow-[0_0_10px_rgba(34,211,238,0.4)] relative z-10">
IMAGES BY <br/> OLOFSSON IMAGES BY <br/> OLOFSSON
@@ -51,7 +51,7 @@ import WaveBackground from '../components/WaveBackground.astro';
<div class="flex flex-col md:flex-row gap-12 md:gap-24 items-center text-center"> <div class="flex flex-col md:flex-row gap-12 md:gap-24 items-center text-center">
<div data-trail-box class="flex flex-col items-center px-6 py-4 rounded-2xl"> <div class="flex flex-col items-center">
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Looking for inspiration?</h2> <h2 class="text-3xl md:text-4xl text-white font-bold mb-6">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"> <a href="/portfolio" class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105">
My Portfolio My Portfolio
@@ -79,7 +79,7 @@ import WaveBackground from '../components/WaveBackground.astro';
</a> </a>
</div> </div>
<div data-trail-box class="flex flex-col items-center px-6 py-4 rounded-2xl"> <div class="flex flex-col items-center">
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Ready to collaborate?</h2> <h2 class="text-3xl md:text-4xl text-white font-bold mb-6">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"> <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">
Contact me Contact me
@@ -108,7 +108,6 @@ import WaveBackground from '../components/WaveBackground.astro';
} }
.smoky-path { .smoky-path {
animation: drawPath 6s cubic-bezier(0.4, 0, 0.2, 1) infinite, smokeShift 10s ease-in-out infinite alternate; animation: drawPath 6s cubic-bezier(0.4, 0, 0.2, 1) infinite, smokeShift 10s ease-in-out infinite alternate;
transition: stroke 0.8s ease, filter 0.8s ease;
} }
@keyframes smokeShift { @keyframes smokeShift {
0% { stroke: #374151; } 0% { stroke: #374151; }
@@ -117,44 +116,6 @@ import WaveBackground from '../components/WaveBackground.astro';
100% { stroke: #1f2937; } 100% { stroke: #1f2937; }
} }
/* Logo-pulse: när data-attribut sätts på text-wrapper, override:a stroke + drop-shadow */
#text-wrapper[data-logo-tint="pink"] ~ * .smoky-path,
#text-wrapper[data-logo-tint="pink"] .smoky-path {
animation: none !important;
stroke: #e879f9 !important;
opacity: 0.95 !important;
filter: url(#fog-filter) drop-shadow(0 0 12px rgba(232,121,249,0.7));
}
#text-wrapper[data-logo-tint="green"] ~ * .smoky-path,
#text-wrapper[data-logo-tint="green"] .smoky-path {
animation: none !important;
stroke: #4fd1c5 !important;
opacity: 0.95 !important;
filter: url(#fog-filter) drop-shadow(0 0 12px rgba(79,209,197,0.7));
}
#text-wrapper[data-logo-tint="pink"] h1 {
text-shadow: 0 0 25px rgba(232,121,249,0.85), 0 0 50px rgba(232,121,249,0.55);
transition: text-shadow 0.6s ease;
}
#text-wrapper[data-logo-tint="green"] h1 {
text-shadow: 0 0 25px rgba(79,209,197,0.85), 0 0 50px rgba(79,209,197,0.55);
transition: text-shadow 0.6s ease;
}
#text-wrapper[data-logo-tint="magenta"] ~ * .smoky-path,
#text-wrapper[data-logo-tint="magenta"] .smoky-path {
animation: none !important;
stroke: #c026d3 !important;
opacity: 0.95 !important;
filter: url(#fog-filter) drop-shadow(0 0 12px rgba(192,38,211,0.7));
}
#text-wrapper[data-logo-tint="magenta"] h1 {
text-shadow: 0 0 25px rgba(192,38,211,0.85), 0 0 50px rgba(192,38,211,0.55);
transition: text-shadow 0.6s ease;
}
#text-wrapper h1 {
transition: text-shadow 0.8s ease;
}
/* --- BRACKETS (Hörnen) --- */ /* --- BRACKETS (Hörnen) --- */
#text-wrapper::before, #text-wrapper::before,
#text-wrapper::after { #text-wrapper::after {
@@ -294,18 +255,4 @@ import WaveBackground from '../components/WaveBackground.astro';
}, { threshold: 0.1 }); }, { threshold: 0.1 });
if (footer) observer.observe(footer); if (footer) observer.observe(footer);
// FOOTER-LÄNKAR HOVER: ändra logo-glow när användare hovrar lyxlimo/karleksguiden-länkar
const glowLinks = document.querySelectorAll<HTMLElement>('[data-glow-link]');
glowLinks.forEach(link => {
link.addEventListener('mouseenter', () => {
if (!textWrapper) return;
const tint = link.getAttribute('data-glow-link');
if (tint) textWrapper.setAttribute('data-logo-tint', tint);
});
link.addEventListener('mouseleave', () => {
if (!textWrapper) return;
textWrapper.removeAttribute('data-logo-tint');
});
});
</script> </script>
-3
View File
@@ -1,4 +1 @@
@import "tailwindcss"; @import "tailwindcss";
@source "../**/*.{astro,html,js,jsx,ts,tsx,md,mdx,svelte,vue}";
@source "../../public/**/*.html";