Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab6da0d049 | ||
|
|
35b45c5bcb | ||
|
|
0f438c7557 | ||
|
|
b5fbcdfb27 | ||
|
|
29f3f1a448 | ||
|
|
d45f2e1cd3 | ||
|
|
a857c5bf2e | ||
|
|
c0a989e088 | ||
|
|
dde6155304 | ||
|
|
c128093f46 | ||
|
|
bafad2c69e | ||
|
|
fa3a953a96 | ||
|
|
fb80bd0f2b | ||
|
|
336ac26e64 | ||
|
|
2591b46616 | ||
|
|
6e88cf4cd4 | ||
|
|
650221b74b | ||
|
|
23880f746b | ||
|
|
57767a112e | ||
|
|
11c75caaad | ||
|
|
3f5fb4b7fd | ||
|
|
71ea7c7ce9 | ||
|
|
ece7693ad5 | ||
|
|
d6d062a3b2 | ||
|
|
a71883215b | ||
|
|
539b251287 | ||
|
|
af9a71a37e | ||
|
|
7bad958a38 | ||
|
|
33c9ea4157 | ||
|
|
3e8a4ddc40 | ||
|
|
3ae9add947 | ||
|
|
1b07a1119a | ||
|
|
b3d8e927b0 | ||
|
|
4002546700 | ||
|
|
678fb96be5 | ||
|
|
2e9408bdb8 | ||
|
|
25732fcfc8 | ||
|
|
70faed6c9b |
@@ -0,0 +1,11 @@
|
||||
.git
|
||||
.github
|
||||
node_modules
|
||||
dist
|
||||
.astro
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
.DS_Store
|
||||
README.md
|
||||
LICENSE
|
||||
@@ -1,97 +0,0 @@
|
||||
# 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}}"
|
||||
@@ -0,0 +1,77 @@
|
||||
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
|
||||
@@ -0,0 +1,21 @@
|
||||
# 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
|
||||
@@ -1,22 +1,102 @@
|
||||
# Homepage
|
||||
# Images by Olofsson
|
||||
|
||||
Welcome to the Homepage!\
|
||||
\
|
||||
\
|
||||
A modern, responsive photography portfolio website built with Astro, Tailwind CSS, and advanced animations.
|
||||
|
||||
## Navigation
|
||||
<br>
|
||||
|
||||
- Home\
|
||||
- About\
|
||||
- Contact\
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<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>
|
||||
|
||||
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>
|
||||
<br>
|
||||
|
||||
## Gallery
|
||||
## Features
|
||||
|
||||
Here are some images that represent our work:<br>
|
||||
- <br>
|
||||
- <br>
|
||||
- <br>
|
||||
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.
|
||||
|
||||
Thank you for visiting!
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 119 KiB |
@@ -0,0 +1,131 @@
|
||||
#!/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); });
|
||||
|
After Width: | Height: | Size: 1010 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1000 KiB |
|
After Width: | Height: | Size: 861 KiB |
|
After Width: | Height: | Size: 705 KiB |
|
After Width: | Height: | Size: 802 KiB |
|
After Width: | Height: | Size: 606 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 521 KiB |
|
After Width: | Height: | Size: 728 KiB |
|
After Width: | Height: | Size: 721 KiB |
|
After Width: | Height: | Size: 986 KiB |
|
After Width: | Height: | Size: 742 KiB |
|
After Width: | Height: | Size: 952 KiB |
|
After Width: | Height: | Size: 991 KiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 895 KiB |
|
After Width: | Height: | Size: 836 KiB |
|
After Width: | Height: | Size: 525 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 485 KiB |
|
After Width: | Height: | Size: 505 KiB |
|
After Width: | Height: | Size: 797 KiB |
|
After Width: | Height: | Size: 709 KiB |
|
After Width: | Height: | Size: 732 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 635 KiB |
|
After Width: | Height: | Size: 712 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 784 KiB |
|
After Width: | Height: | Size: 572 KiB |
|
After Width: | Height: | Size: 730 KiB |
|
After Width: | Height: | Size: 832 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 740 KiB |
|
After Width: | Height: | Size: 784 KiB |
|
After Width: | Height: | Size: 497 KiB |
|
After Width: | Height: | Size: 594 KiB |
|
After Width: | Height: | Size: 680 KiB |
|
After Width: | Height: | Size: 831 KiB |
|
After Width: | Height: | Size: 821 KiB |
|
After Width: | Height: | Size: 708 KiB |
|
After Width: | Height: | Size: 591 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 869 KiB |
|
After Width: | Height: | Size: 809 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 529 KiB |
|
After Width: | Height: | Size: 471 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 836 KiB |
|
After Width: | Height: | Size: 701 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 593 KiB |
|
After Width: | Height: | Size: 626 KiB |
|
After Width: | Height: | Size: 732 KiB |
|
After Width: | Height: | Size: 1000 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 861 KiB |
|
After Width: | Height: | Size: 881 KiB |
|
After Width: | Height: | Size: 889 KiB |
|
After Width: | Height: | Size: 963 KiB |
|
After Width: | Height: | Size: 743 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 856 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 756 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 722 KiB |
@@ -2,15 +2,20 @@
|
||||
// src/components/WaveBackground.astro
|
||||
const { opacity = "opacity-30", theme = "cyan" } = Astro.props;
|
||||
|
||||
// Definiera färger baserat på tema
|
||||
// Cyan = Originalet (Startsidan bas)
|
||||
// Purple = Contact-sidan (Matchar boxen)
|
||||
const mainColor = theme === 'purple' ? '#c026d3' : '#4fd1c5'; // Purple-600 vs Teal-400
|
||||
const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee'; // Pink-600 vs Cyan-400
|
||||
const mainColor = theme === 'purple' ? '#c026d3' : '#4fd1c5';
|
||||
const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee';
|
||||
const pulseMain = theme === 'purple' ? '#f0abfc' : '#a5f3fc';
|
||||
const pulseSec = theme === 'purple' ? '#fbcfe8' : '#cffafe';
|
||||
const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
|
||||
---
|
||||
|
||||
<div 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">
|
||||
<div
|
||||
class={`fixed inset-0 w-full h-full pointer-events-none -z-10 bg-neutral-900 ${opacity}`}
|
||||
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>
|
||||
<linearGradient id="gradMain" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style={`stop-color:${mainColor};stop-opacity:0`} />
|
||||
@@ -23,48 +28,615 @@ const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee'; // Pink-600 v
|
||||
<stop offset="30%" style={`stop-color:${secondaryColor};stop-opacity:0.3`} />
|
||||
<stop offset="100%" style={`stop-color:${secondaryColor};stop-opacity:0`} />
|
||||
</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>
|
||||
|
||||
<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" />
|
||||
<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" />
|
||||
<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="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="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="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" />
|
||||
<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="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" />
|
||||
|
||||
</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>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
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');
|
||||
type Speeds = { w1: number; w2: number; w3: number; t1: number; t2: number };
|
||||
|
||||
// Olika hastigheter för parallax-effekt
|
||||
const speeds = {
|
||||
w1: 0.5,
|
||||
w2: -0.8,
|
||||
w3: 1.2,
|
||||
t1: 1.8, // Snabbare "random" tråd
|
||||
t2: -1.5 // Snabbare "random" tråd åt andra hållet
|
||||
};
|
||||
const wave1 = document.getElementById('wave1') as SVGPathElement | null;
|
||||
const wave2 = document.getElementById('wave2') as SVGPathElement | null;
|
||||
const wave3 = document.getElementById('wave3') as SVGPathElement | null;
|
||||
const thread1 = document.getElementById('thread1') as SVGPathElement | null;
|
||||
const thread2 = document.getElementById('thread2') as SVGPathElement | null;
|
||||
const svg = document.getElementById('wave-svg') as unknown as SVGSVGElement | null;
|
||||
const blob1 = document.getElementById('pulse-blob-1') as SVGGElement | null;
|
||||
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() {
|
||||
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 (wave3) wave3.style.transform = `translateX(${scrollY * speeds.w3}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 });
|
||||
|
||||
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>
|
||||
|
||||
<style>
|
||||
@@ -72,4 +644,11 @@ const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee'; // Pink-600 v
|
||||
transition: transform 0.1s linear;
|
||||
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>
|
||||
@@ -6,6 +6,9 @@ const { title } = Astro.props;
|
||||
<html lang="sv" class="scroll-smooth"> <head>
|
||||
<meta charset="utf-8" />
|
||||
<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>
|
||||
</head>
|
||||
<body class="bg-neutral-900 text-white font-sans antialiased min-h-screen flex flex-col">
|
||||
@@ -30,6 +33,12 @@ const { title } = Astro.props;
|
||||
</main>
|
||||
|
||||
<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>
|
||||
&
|
||||
<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>© {new Date().getFullYear()} Images by Olofsson. All rights reserved.</p>
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -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="bg-neutral-900/60 backdrop-blur-md border border-white/10 rounded-3xl p-8 md:p-12 shadow-2xl mb-20">
|
||||
<div data-trail-box class="bg-neutral-900/60 backdrop-blur-md border border-white/10 rounded-3xl p-8 md:p-12 shadow-2xl mb-20">
|
||||
|
||||
<div class="flex flex-col lg:flex-row items-center gap-12 lg:gap-20">
|
||||
|
||||
@@ -73,7 +73,7 @@ import WaveBackground from '../components/WaveBackground.astro';
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
|
||||
<div class="gear-card">
|
||||
<div data-trail-box class="gear-card">
|
||||
<div class="icon-box">
|
||||
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||
</div>
|
||||
@@ -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>
|
||||
</div>
|
||||
|
||||
<div class="gear-card">
|
||||
<div data-trail-box class="gear-card">
|
||||
<div class="icon-box">
|
||||
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
</div>
|
||||
@@ -94,7 +94,7 @@ import WaveBackground from '../components/WaveBackground.astro';
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="gear-card">
|
||||
<div data-trail-box class="gear-card">
|
||||
<div class="icon-box">
|
||||
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
|
||||
</div>
|
||||
|
||||
@@ -61,7 +61,7 @@ import WaveBackground from '../components/WaveBackground.astro';
|
||||
<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="relative bg-neutral-900 ring-1 ring-white/10 rounded-xl p-8 md:p-12 text-center space-y-10">
|
||||
<div data-trail-box class="relative bg-neutral-900 ring-1 ring-white/10 rounded-xl p-8 md:p-12 text-center space-y-10">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
@@ -15,7 +15,7 @@ import WaveBackground from '../components/WaveBackground.astro';
|
||||
|
||||
<div class="text-center animated-text-container relative">
|
||||
|
||||
<div id="text-wrapper" class="inline-block relative p-6 transition-all duration-500">
|
||||
<div id="text-wrapper" data-logo-target 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">
|
||||
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 items-center">
|
||||
<div data-trail-box class="flex flex-col items-center px-6 py-4 rounded-2xl">
|
||||
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6">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">
|
||||
My Portfolio
|
||||
@@ -79,7 +79,7 @@ import WaveBackground from '../components/WaveBackground.astro';
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center">
|
||||
<div data-trail-box class="flex flex-col items-center px-6 py-4 rounded-2xl">
|
||||
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6">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">
|
||||
Contact me
|
||||
@@ -108,6 +108,7 @@ import WaveBackground from '../components/WaveBackground.astro';
|
||||
}
|
||||
.smoky-path {
|
||||
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 {
|
||||
0% { stroke: #374151; }
|
||||
@@ -116,6 +117,44 @@ import WaveBackground from '../components/WaveBackground.astro';
|
||||
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) --- */
|
||||
#text-wrapper::before,
|
||||
#text-wrapper::after {
|
||||
@@ -255,4 +294,18 @@ import WaveBackground from '../components/WaveBackground.astro';
|
||||
}, { threshold: 0.1 });
|
||||
|
||||
if (footer) observer.observe(footer);
|
||||
|
||||
// FOOTER-LÄNKAR HOVER: ändra logo-glow när användare hovrar lyxlimo/karleksguiden-länkar
|
||||
const glowLinks = document.querySelectorAll<HTMLElement>('[data-glow-link]');
|
||||
glowLinks.forEach(link => {
|
||||
link.addEventListener('mouseenter', () => {
|
||||
if (!textWrapper) return;
|
||||
const tint = link.getAttribute('data-glow-link');
|
||||
if (tint) textWrapper.setAttribute('data-logo-tint', tint);
|
||||
});
|
||||
link.addEventListener('mouseleave', () => {
|
||||
if (!textWrapper) return;
|
||||
textWrapper.removeAttribute('data-logo-tint');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -1 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@source "../**/*.{astro,html,js,jsx,ts,tsx,md,mdx,svelte,vue}";
|
||||
@source "../../public/**/*.html";
|
||||
|
||||