Author SHA1 Message Date
Linus Olofssonandportfolio-sync[bot] ab6da0d049 chore(portfolio): sync 33 added, 0 removed from Immich (#36)
Co-authored-by: portfolio-sync[bot] <actions@github.com>
2026-09-06 04:52:10 +02:00
Linus Olofssonandportfolio-sync[bot] 35b45c5bcb chore(portfolio): sync 2 added, 0 removed from Immich (#35)
Co-authored-by: portfolio-sync[bot] <actions@github.com>
2026-07-07 08:48:21 +02:00
Linus Olofssonandportfolio-sync[bot] 0f438c7557 chore(portfolio): sync 41 added, 1 removed from Immich (#34)
Co-authored-by: portfolio-sync[bot] <actions@github.com>
2026-06-22 03:13:27 +02:00
Linus Olofsson b5fbcdfb27 feat(portfolio-sync): skip RAW files elegantly (tag JPEG version instead) (#33)
RAW formats (.CR2/.ARW/etc) can't be converted by sharp/libvips. Skip them
with a clear log instead of counting as failures. Tag the JPEG export instead.
2026-06-22 03:09:35 +02:00
Linus Olofssonandportfolio-sync[bot] 29f3f1a448 chore(portfolio): sync 1 added, 0 removed from Immich (#32)
Co-authored-by: portfolio-sync[bot] <actions@github.com>
2026-06-22 02:06:05 +02:00
Linus Olofsson d45f2e1cd3 fix(ci): Coolify auto-deploy via push + remove broken codeql.yml (#31)
- portfolio-sync: GitHub-runner kan ej nå Tailscale-Coolify, förlitar
  på Coolifys auto-deploy-on-push istället
- ta bort codeql.yml (tom matrix, ogiltig, failade på varje push)
2026-06-22 01:55:54 +02:00
Linus Olofssonandportfolio-sync[bot] a857c5bf2e chore(portfolio): sync 1 added, 0 removed from Immich (#30)
Co-authored-by: portfolio-sync[bot] <actions@github.com>
2026-06-22 01:49:59 +02:00
Linus Olofsson c0a989e088 fix(portfolio-sync): PR + auto-admin-merge (branch protection kräver signering) (#29) 2026-06-22 01:48:43 +02:00
Linus OlofssonandClaude Opus 4.8 dde6155304 feat: automated Immich portfolio sync (GitHub Action) (#28)
GitHub Action that mirrors Immich 'portfolio'-tagged images to the
portfolio gallery. Runs daily + on-demand (workflow_dispatch).
Two-way mirror: tagged added, untagged removed. EXIF preserved.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 01:39:55 +02:00
Linus OlofssonandClaude Opus 4.8 c128093f46 fix: replace favicon with proper branded logo (gradient glow) (#27)
The previous favicon was the dark logo on transparent which was nearly
invisible. This uses the proper logo with purple-cyan gradient glow that
shows clearly against both light and dark browser tabs.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 00:39:54 +02:00
Linus OlofssonandClaude Opus 4.8 bafad2c69e feat: add favicon (Images by Olofsson logo) (#26)
Render logo to square PNG favicons (32/180/512px) and link them in
MainLayout.astro head. Previously the active layout had no favicon.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 00:13:43 +02:00
Linus Olofsson fa3a953a96 feat(footer): hover-glow links to lyxlimo + karleksguiden (#25)
MainLayout footer now includes attribution: 'Crafting digital
experiences for lyxlimo.se & karleksguiden.se' visible on all pages.

On index page only: hovering lyxlimo.se tints hero-logo with
green (#4fd1c5 teal), hovering karleksguiden.se tints with
magenta (#c026d3). Mouseleave restores default white smoky-path
animation. Mechanism reuses existing data-logo-tint attribute on
#text-wrapper.

Removed logo-pulse functionality entirely (threadLogoIn/Out paths,
runLogoPulse function, LOGO_PROBABILITY, updateLogoThreadPaths,
resize/scroll listeners for logo threads). Hover-based UX replaces
the random pulse approach per Linus design preference.
2026-05-21 06:01:25 +02:00
Linus Olofsson fb80bd0f2b feat(logo-threads): bracket-anchored paths with x^2 / -x^2 curves (#24)
Threads now lock to logo bracket pin endpoints (where vertical legs
of ::before / ::after bracket-corners end):
- Rosa tråd: från (0, viewport.height) → (logoLeft, logoTop + 40% height)
  Quadratic Bezier with cp=(endX, startY) = x^2 curve, går rakt först
  och accelererar upp i slutet
- Cyan tråd: från (logoRight, logoBottom - 40% height) → (viewport.width, 0)
  Quadratic Bezier with cp=(startX, endY) = -x^2 curve, accelererar upp
  i början och decelererar mot slutet

Permanent paths som uppdateras vid resize + scroll + 500ms/1500ms
post-load. runLogoPulse triggar update före varje pulse.
2026-05-21 05:34:27 +02:00
Linus Olofsson 336ac26e64 fix(pulse): use SVG-coords directly (no double-transform) (#23)
Pulse-blob is inside the SAME SVG as the path, so applyTransform
should use raw SVG coords (pt.x, pt.y) — not getScreenCTM-converted
ones. Double-transform was pushing pulse to SVG-X 9794+ which renders
off-screen-right.

Also phase2 now uses entryPath-end + exitPath-start positions in
SVG-coord-space so the inside-logo glide is continuous with phase1
and phase3.
2026-05-21 05:10:32 +02:00
Linus Olofsson 2591b46616 fix(logo-threads): Bezier paths pass exactly through logo-center (#22)
Cubic Bezier (C/S) locks Y=287 across X=900..1900 (full logo span).
2026-05-21 05:03:03 +02:00
Linus Olofsson 6e88cf4cd4 fix(logo-threads): Y-align to cross logo (#21)
* fix(pulse): split halves now meet simultaneously + no backtracking

Two bugs fixed after Linus feedback:

1. Path-form: top/bottom split halves used Bezier curves that could
   loop back on themselves when entry.x lay outside box X-range.
   Now use clamped startX/endX (max(xL+r, min(xR-r, entry.x))) so
   pathen alltid rör sig framåt (entry.x → exit.x), aldrig vänder.

2. Synchronized meeting: previously each blob moved at fixed velocity
   along its own path → shorter path arrived first and waited.
   Now both blobs share a phase2T (0→1) progress fraction, so they
   meet at exit-point simultaneously. The path with different length
   simply moves at a slightly different speed (averaged). Phase2
   duration based on avg(topLen,bottomLen)/velocity so total animation
   time stays consistent.

* feat: logo-pulse with pink->green color transition

Adds a 4th pulse target: the IMAGES BY OLOFSSON hero-text logo.
20% chance per pulse-slot when logo is visible (footer scrolled in).

Flow:
1. New purple wave-thread on bottom-left (mirror of cyan wave1 on right)
2. Pulse glides in along purple thread to logo-left edge
3. Pulse enters logo + tints smoky-path stroke pink (#e879f9) + adds
   pink text-shadow halo on h1
4. Mid-passage: tint transitions to green (#4fd1c5) — matches default
   smoke + cyan glow
5. Pulse exits logo via wave1 (existing cyan thread on right)
6. Logo tint reverts to default (white smokeShift animation)

CSS-side: data-logo-tint attribute on #text-wrapper drives stroke +
text-shadow overrides with 0.6-0.8s transitions for smooth fade.

* fix(logo-pulse): dynamic paths anchored to logo + 50% probability

Previous version tried to use existing wave1+threadPurple paths for
logo-pulse glide-in/out, but those paths are in SVG-fixed coordinates
that don't align with logo's viewport position when hero is scrolled
into view. wave1 has translateX(2283) which pushes it off-screen for
ultrawide viewports.

Fix: build dynamic Bezier paths anchored to the logo's actual
getBoundingClientRect() position at pulse-time:
- Entry path: from (offLeft, logoY+dipOffset) curves up into logo-left
- Exit path: from logo-right curves up to (offRight, logoY-riseOffset)

Paths are appended to box-trail-group, used for one pulse, then
removed in cleanup. No reliance on fixed wave-thread coordinates.

Also raises LOGO_PROBABILITY 20%->50% so the effect is visible more
often when hero is in view.

* fix(logo-pulse): visible entry/exit threads + restore pulse color after

Two bugs fixed:

1. Entry+exit paths were stroke=transparent — pulse appeared to spawn
   out of nothing. Now stroke=url(#gradPurple) on entry (subtle pink
   thread visible from off-screen left) and stroke=url(#gradMain) on
   exit (cyan thread visible going off-screen right). Both at
   stroke-width 0.8 + opacity 0.55 to match other wave-thread style.

2. After logo-pulse completed, pulse-gradient stops were left in pink
   state — next regular wave-pulse rendered as stale pink instead of
   cyan/theme color. Now updatePulseColors(false) is called in cleanup
   to restore default cyan-tema.

* fix(logo-pulse): entry/exit threads now properly visible

Previous version used gradient strokes (gradPurple/gradMain) which
have stop-opacity 0 at ends + 0.45 mid, multiplied by path-opacity
0.55 = ~0.25 effective opacity. Practically invisible.

Now solid stroke colors (#e879f9 pink, #4fd1c5 cyan), stroke-width 2
(was 0.8), opacity 0.85 (was 0.55), plus pulseGlowMid filter for soft
halo. Should be clearly visible from off-screen edges to logo.

* feat(logo-pulse): permanent visible threads (no dynamic creation)

Previously dynamic paths were built/destroyed per pulse, were too
thick (stroke-width 2 + glow), and persisted incorrectly causing
visual artifacts.

Now:
- threadLogoIn: permanent purple wave-thread, same style as other
  wave-threads (gradPurple stroke, stroke-width 0.7, opacity 0.55)
- threadLogoOut: permanent cyan wave-thread (gradMain stroke 0.7,
  opacity 0.50)
- runLogoPulse uses these permanent paths via getPointAtLength
- getScreenCTM converts SVG-coords to screen-coords so pulse follows
  thread even with scroll/viewport variance

* fix(logo-threads): align Y-coords to actually cross logo

Logo Y-center is approx 287 in screen-coords. Previous thread Y-coords
(400-600) only grazed logo-bottom or sat entirely below it. Adjusted:

- threadLogoIn: M-1000 500 Q 200 350 850 287 T 2600 200
  Kommer från nedre-vänster (Y=500), kurvar UPP till logo-center
  (Y=287), fortsätter upp till övre-höger (Y=200)
- threadLogoOut: M-1000 350 Q 400 200 850 287 T 2600 500
  Kommer från övre-vänster (Y=350), genom logo-center (Y=287), ner
  till nedre-höger (Y=500) — vertikalt motsatt entry-tråd
2026-05-21 04:51:34 +02:00
Linus Olofsson 650221b74b feat(logo-pulse): permanent visible threads (#20)
* fix(pulse): split halves now meet simultaneously + no backtracking

Two bugs fixed after Linus feedback:

1. Path-form: top/bottom split halves used Bezier curves that could
   loop back on themselves when entry.x lay outside box X-range.
   Now use clamped startX/endX (max(xL+r, min(xR-r, entry.x))) so
   pathen alltid rör sig framåt (entry.x → exit.x), aldrig vänder.

2. Synchronized meeting: previously each blob moved at fixed velocity
   along its own path → shorter path arrived first and waited.
   Now both blobs share a phase2T (0→1) progress fraction, so they
   meet at exit-point simultaneously. The path with different length
   simply moves at a slightly different speed (averaged). Phase2
   duration based on avg(topLen,bottomLen)/velocity so total animation
   time stays consistent.

* feat: logo-pulse with pink->green color transition

Adds a 4th pulse target: the IMAGES BY OLOFSSON hero-text logo.
20% chance per pulse-slot when logo is visible (footer scrolled in).

Flow:
1. New purple wave-thread on bottom-left (mirror of cyan wave1 on right)
2. Pulse glides in along purple thread to logo-left edge
3. Pulse enters logo + tints smoky-path stroke pink (#e879f9) + adds
   pink text-shadow halo on h1
4. Mid-passage: tint transitions to green (#4fd1c5) — matches default
   smoke + cyan glow
5. Pulse exits logo via wave1 (existing cyan thread on right)
6. Logo tint reverts to default (white smokeShift animation)

CSS-side: data-logo-tint attribute on #text-wrapper drives stroke +
text-shadow overrides with 0.6-0.8s transitions for smooth fade.

* fix(logo-pulse): dynamic paths anchored to logo + 50% probability

Previous version tried to use existing wave1+threadPurple paths for
logo-pulse glide-in/out, but those paths are in SVG-fixed coordinates
that don't align with logo's viewport position when hero is scrolled
into view. wave1 has translateX(2283) which pushes it off-screen for
ultrawide viewports.

Fix: build dynamic Bezier paths anchored to the logo's actual
getBoundingClientRect() position at pulse-time:
- Entry path: from (offLeft, logoY+dipOffset) curves up into logo-left
- Exit path: from logo-right curves up to (offRight, logoY-riseOffset)

Paths are appended to box-trail-group, used for one pulse, then
removed in cleanup. No reliance on fixed wave-thread coordinates.

Also raises LOGO_PROBABILITY 20%->50% so the effect is visible more
often when hero is in view.

* fix(logo-pulse): visible entry/exit threads + restore pulse color after

Two bugs fixed:

1. Entry+exit paths were stroke=transparent — pulse appeared to spawn
   out of nothing. Now stroke=url(#gradPurple) on entry (subtle pink
   thread visible from off-screen left) and stroke=url(#gradMain) on
   exit (cyan thread visible going off-screen right). Both at
   stroke-width 0.8 + opacity 0.55 to match other wave-thread style.

2. After logo-pulse completed, pulse-gradient stops were left in pink
   state — next regular wave-pulse rendered as stale pink instead of
   cyan/theme color. Now updatePulseColors(false) is called in cleanup
   to restore default cyan-tema.

* fix(logo-pulse): entry/exit threads now properly visible

Previous version used gradient strokes (gradPurple/gradMain) which
have stop-opacity 0 at ends + 0.45 mid, multiplied by path-opacity
0.55 = ~0.25 effective opacity. Practically invisible.

Now solid stroke colors (#e879f9 pink, #4fd1c5 cyan), stroke-width 2
(was 0.8), opacity 0.85 (was 0.55), plus pulseGlowMid filter for soft
halo. Should be clearly visible from off-screen edges to logo.

* feat(logo-pulse): permanent visible threads (no dynamic creation)

Previously dynamic paths were built/destroyed per pulse, were too
thick (stroke-width 2 + glow), and persisted incorrectly causing
visual artifacts.

Now:
- threadLogoIn: permanent purple wave-thread, same style as other
  wave-threads (gradPurple stroke, stroke-width 0.7, opacity 0.55)
- threadLogoOut: permanent cyan wave-thread (gradMain stroke 0.7,
  opacity 0.50)
- runLogoPulse uses these permanent paths via getPointAtLength
- getScreenCTM converts SVG-coords to screen-coords so pulse follows
  thread even with scroll/viewport variance
2026-05-21 04:47:30 +02:00
Linus Olofsson 23880f746b fix(logo-pulse): threads properly visible (solid + glow) (#19)
* fix(pulse): split halves now meet simultaneously + no backtracking

Two bugs fixed after Linus feedback:

1. Path-form: top/bottom split halves used Bezier curves that could
   loop back on themselves when entry.x lay outside box X-range.
   Now use clamped startX/endX (max(xL+r, min(xR-r, entry.x))) so
   pathen alltid rör sig framåt (entry.x → exit.x), aldrig vänder.

2. Synchronized meeting: previously each blob moved at fixed velocity
   along its own path → shorter path arrived first and waited.
   Now both blobs share a phase2T (0→1) progress fraction, so they
   meet at exit-point simultaneously. The path with different length
   simply moves at a slightly different speed (averaged). Phase2
   duration based on avg(topLen,bottomLen)/velocity so total animation
   time stays consistent.

* feat: logo-pulse with pink->green color transition

Adds a 4th pulse target: the IMAGES BY OLOFSSON hero-text logo.
20% chance per pulse-slot when logo is visible (footer scrolled in).

Flow:
1. New purple wave-thread on bottom-left (mirror of cyan wave1 on right)
2. Pulse glides in along purple thread to logo-left edge
3. Pulse enters logo + tints smoky-path stroke pink (#e879f9) + adds
   pink text-shadow halo on h1
4. Mid-passage: tint transitions to green (#4fd1c5) — matches default
   smoke + cyan glow
5. Pulse exits logo via wave1 (existing cyan thread on right)
6. Logo tint reverts to default (white smokeShift animation)

CSS-side: data-logo-tint attribute on #text-wrapper drives stroke +
text-shadow overrides with 0.6-0.8s transitions for smooth fade.

* fix(logo-pulse): dynamic paths anchored to logo + 50% probability

Previous version tried to use existing wave1+threadPurple paths for
logo-pulse glide-in/out, but those paths are in SVG-fixed coordinates
that don't align with logo's viewport position when hero is scrolled
into view. wave1 has translateX(2283) which pushes it off-screen for
ultrawide viewports.

Fix: build dynamic Bezier paths anchored to the logo's actual
getBoundingClientRect() position at pulse-time:
- Entry path: from (offLeft, logoY+dipOffset) curves up into logo-left
- Exit path: from logo-right curves up to (offRight, logoY-riseOffset)

Paths are appended to box-trail-group, used for one pulse, then
removed in cleanup. No reliance on fixed wave-thread coordinates.

Also raises LOGO_PROBABILITY 20%->50% so the effect is visible more
often when hero is in view.

* fix(logo-pulse): visible entry/exit threads + restore pulse color after

Two bugs fixed:

1. Entry+exit paths were stroke=transparent — pulse appeared to spawn
   out of nothing. Now stroke=url(#gradPurple) on entry (subtle pink
   thread visible from off-screen left) and stroke=url(#gradMain) on
   exit (cyan thread visible going off-screen right). Both at
   stroke-width 0.8 + opacity 0.55 to match other wave-thread style.

2. After logo-pulse completed, pulse-gradient stops were left in pink
   state — next regular wave-pulse rendered as stale pink instead of
   cyan/theme color. Now updatePulseColors(false) is called in cleanup
   to restore default cyan-tema.

* fix(logo-pulse): entry/exit threads now properly visible

Previous version used gradient strokes (gradPurple/gradMain) which
have stop-opacity 0 at ends + 0.45 mid, multiplied by path-opacity
0.55 = ~0.25 effective opacity. Practically invisible.

Now solid stroke colors (#e879f9 pink, #4fd1c5 cyan), stroke-width 2
(was 0.8), opacity 0.85 (was 0.55), plus pulseGlowMid filter for soft
halo. Should be clearly visible from off-screen edges to logo.
2026-05-21 04:39:26 +02:00
Linus Olofsson 57767a112e fix(logo-pulse): visible entry/exit threads + restore pulse color (#18)
* fix(pulse): split halves now meet simultaneously + no backtracking

Two bugs fixed after Linus feedback:

1. Path-form: top/bottom split halves used Bezier curves that could
   loop back on themselves when entry.x lay outside box X-range.
   Now use clamped startX/endX (max(xL+r, min(xR-r, entry.x))) so
   pathen alltid rör sig framåt (entry.x → exit.x), aldrig vänder.

2. Synchronized meeting: previously each blob moved at fixed velocity
   along its own path → shorter path arrived first and waited.
   Now both blobs share a phase2T (0→1) progress fraction, so they
   meet at exit-point simultaneously. The path with different length
   simply moves at a slightly different speed (averaged). Phase2
   duration based on avg(topLen,bottomLen)/velocity so total animation
   time stays consistent.

* feat: logo-pulse with pink->green color transition

Adds a 4th pulse target: the IMAGES BY OLOFSSON hero-text logo.
20% chance per pulse-slot when logo is visible (footer scrolled in).

Flow:
1. New purple wave-thread on bottom-left (mirror of cyan wave1 on right)
2. Pulse glides in along purple thread to logo-left edge
3. Pulse enters logo + tints smoky-path stroke pink (#e879f9) + adds
   pink text-shadow halo on h1
4. Mid-passage: tint transitions to green (#4fd1c5) — matches default
   smoke + cyan glow
5. Pulse exits logo via wave1 (existing cyan thread on right)
6. Logo tint reverts to default (white smokeShift animation)

CSS-side: data-logo-tint attribute on #text-wrapper drives stroke +
text-shadow overrides with 0.6-0.8s transitions for smooth fade.

* fix(logo-pulse): dynamic paths anchored to logo + 50% probability

Previous version tried to use existing wave1+threadPurple paths for
logo-pulse glide-in/out, but those paths are in SVG-fixed coordinates
that don't align with logo's viewport position when hero is scrolled
into view. wave1 has translateX(2283) which pushes it off-screen for
ultrawide viewports.

Fix: build dynamic Bezier paths anchored to the logo's actual
getBoundingClientRect() position at pulse-time:
- Entry path: from (offLeft, logoY+dipOffset) curves up into logo-left
- Exit path: from logo-right curves up to (offRight, logoY-riseOffset)

Paths are appended to box-trail-group, used for one pulse, then
removed in cleanup. No reliance on fixed wave-thread coordinates.

Also raises LOGO_PROBABILITY 20%->50% so the effect is visible more
often when hero is in view.

* fix(logo-pulse): visible entry/exit threads + restore pulse color after

Two bugs fixed:

1. Entry+exit paths were stroke=transparent — pulse appeared to spawn
   out of nothing. Now stroke=url(#gradPurple) on entry (subtle pink
   thread visible from off-screen left) and stroke=url(#gradMain) on
   exit (cyan thread visible going off-screen right). Both at
   stroke-width 0.8 + opacity 0.55 to match other wave-thread style.

2. After logo-pulse completed, pulse-gradient stops were left in pink
   state — next regular wave-pulse rendered as stale pink instead of
   cyan/theme color. Now updatePulseColors(false) is called in cleanup
   to restore default cyan-tema.
2026-05-21 04:34:19 +02:00
Linus Olofsson 11c75caaad fix(logo-pulse): dynamic viewport-anchored paths (#17)
* fix(pulse): split halves now meet simultaneously + no backtracking

Two bugs fixed after Linus feedback:

1. Path-form: top/bottom split halves used Bezier curves that could
   loop back on themselves when entry.x lay outside box X-range.
   Now use clamped startX/endX (max(xL+r, min(xR-r, entry.x))) so
   pathen alltid rör sig framåt (entry.x → exit.x), aldrig vänder.

2. Synchronized meeting: previously each blob moved at fixed velocity
   along its own path → shorter path arrived first and waited.
   Now both blobs share a phase2T (0→1) progress fraction, so they
   meet at exit-point simultaneously. The path with different length
   simply moves at a slightly different speed (averaged). Phase2
   duration based on avg(topLen,bottomLen)/velocity so total animation
   time stays consistent.

* feat: logo-pulse with pink->green color transition

Adds a 4th pulse target: the IMAGES BY OLOFSSON hero-text logo.
20% chance per pulse-slot when logo is visible (footer scrolled in).

Flow:
1. New purple wave-thread on bottom-left (mirror of cyan wave1 on right)
2. Pulse glides in along purple thread to logo-left edge
3. Pulse enters logo + tints smoky-path stroke pink (#e879f9) + adds
   pink text-shadow halo on h1
4. Mid-passage: tint transitions to green (#4fd1c5) — matches default
   smoke + cyan glow
5. Pulse exits logo via wave1 (existing cyan thread on right)
6. Logo tint reverts to default (white smokeShift animation)

CSS-side: data-logo-tint attribute on #text-wrapper drives stroke +
text-shadow overrides with 0.6-0.8s transitions for smooth fade.

* fix(logo-pulse): dynamic paths anchored to logo + 50% probability

Previous version tried to use existing wave1+threadPurple paths for
logo-pulse glide-in/out, but those paths are in SVG-fixed coordinates
that don't align with logo's viewport position when hero is scrolled
into view. wave1 has translateX(2283) which pushes it off-screen for
ultrawide viewports.

Fix: build dynamic Bezier paths anchored to the logo's actual
getBoundingClientRect() position at pulse-time:
- Entry path: from (offLeft, logoY+dipOffset) curves up into logo-left
- Exit path: from logo-right curves up to (offRight, logoY-riseOffset)

Paths are appended to box-trail-group, used for one pulse, then
removed in cleanup. No reliance on fixed wave-thread coordinates.

Also raises LOGO_PROBABILITY 20%->50% so the effect is visible more
often when hero is in view.
2026-05-21 04:28:39 +02:00
Linus Olofsson 3f5fb4b7fd feat: logo-pulse with pink->green color transition (#16)
* fix(pulse): split halves now meet simultaneously + no backtracking

Two bugs fixed after Linus feedback:

1. Path-form: top/bottom split halves used Bezier curves that could
   loop back on themselves when entry.x lay outside box X-range.
   Now use clamped startX/endX (max(xL+r, min(xR-r, entry.x))) so
   pathen alltid rör sig framåt (entry.x → exit.x), aldrig vänder.

2. Synchronized meeting: previously each blob moved at fixed velocity
   along its own path → shorter path arrived first and waited.
   Now both blobs share a phase2T (0→1) progress fraction, so they
   meet at exit-point simultaneously. The path with different length
   simply moves at a slightly different speed (averaged). Phase2
   duration based on avg(topLen,bottomLen)/velocity so total animation
   time stays consistent.

* feat: logo-pulse with pink->green color transition

Adds a 4th pulse target: the IMAGES BY OLOFSSON hero-text logo.
20% chance per pulse-slot when logo is visible (footer scrolled in).

Flow:
1. New purple wave-thread on bottom-left (mirror of cyan wave1 on right)
2. Pulse glides in along purple thread to logo-left edge
3. Pulse enters logo + tints smoky-path stroke pink (#e879f9) + adds
   pink text-shadow halo on h1
4. Mid-passage: tint transitions to green (#4fd1c5) — matches default
   smoke + cyan glow
5. Pulse exits logo via wave1 (existing cyan thread on right)
6. Logo tint reverts to default (white smokeShift animation)

CSS-side: data-logo-tint attribute on #text-wrapper drives stroke +
text-shadow overrides with 0.6-0.8s transitions for smooth fade.
2026-05-21 04:17:28 +02:00
Linus Olofsson 71ea7c7ce9 fix(pulse): split halves now meet simultaneously + no backtracking (#15)
Two bugs fixed after Linus feedback:

1. Path-form: top/bottom split halves used Bezier curves that could
   loop back on themselves when entry.x lay outside box X-range.
   Now use clamped startX/endX (max(xL+r, min(xR-r, entry.x))) so
   pathen alltid rör sig framåt (entry.x → exit.x), aldrig vänder.

2. Synchronized meeting: previously each blob moved at fixed velocity
   along its own path → shorter path arrived first and waited.
   Now both blobs share a phase2T (0→1) progress fraction, so they
   meet at exit-point simultaneously. The path with different length
   simply moves at a slightly different speed (averaged). Phase2
   duration based on avg(topLen,bottomLen)/velocity so total animation
   time stays consistent.
2026-05-21 04:01:20 +02:00
Linus Olofsson ece7693ad5 feat: mark about-page boxes as trail-targets (#14)
Adds data-trail-box to:
- The main about-card (profile + bio + buttons)
- The 3 gear-cards (Camera Body, Lenses, Editing)

These boxes will now intercept wave-thread pulses and trigger
the split-around-box animation, same as contact + home boxes.
2026-05-20 23:44:07 +02:00
Linus Olofsson d6d062a3b2 feat(pulse): 55% probability of split via box (#13)
* feat: random light pulse along threads + box outlines

A blurred light pulse glides along one of the 5 wave threads
(wave1/2/3, thread1/2) every 2.5-7s. Color matches the active
theme (cyan on home/portfolio/about, pink/magenta on contact).

Pulse also follows the outline of designated boxes marked with
data-trail-box:
  - Home: 'Looking for inspiration?' + 'Ready to collaborate?'
  - Contact: 'Waiting to hear from you' card

Boxes are rebuilt on every pulse so they stay aligned during scroll
and respect viewport (off-screen boxes are skipped).

Pulse uses requestAnimationFrame + path.getPointAtLength for native
smoothness, fade-in 15% / hold 70% / fade-out 15%, screen blend
mode so it blends with the dark background.

* feat(pulse): slower, brighter, comet-shaped pulse along paths

Slower (9-13s per glide vs 4.5-6.5s), three-layer comet shape
(soft halo + mid glow + bright core), rotated to path tangent,
ease-in-out timing, mix-blend-mode screen.

Fixes: pulse not tracking path was a parallax-offset coordinate
mismatch. Cleaned up via getSpeedKey() helper.

* fix(pulse): tone down white core so theme color dominates

Core was pure white (#ffffff) which washed out the theme color.
Now tinted (#fdf4ff for purple, #f0fdfa for cyan) and smaller
(10x3 vs 14x4) with 75% opacity so the purple/pink and cyan
glow layers come through more visibly.

* feat(pulse): double the glide duration

Threads: 18-26s (was 9-13s). Boxes: 14-18s (was 7-9s).

* feat(pulse): split into two halves around boxes

When a thread-pulse hits a data-trail-box bound, it splits:
one half glides along the top of the box outline, the other
along the bottom. They re-merge on the far side and continue
as one along the rest of the thread.

Implementation:
- Two pulse-blob groups (blob-1, blob-2) in SVG
- Box-intersection scan samples path every 4px to find entry +
  exit points
- Phase 1: blob-1 glides from start to entry
- Phase 2: blob-1 follows top-half of box outline, blob-2
  follows bottom-half (both spawned dynamically)
- Phase 3: blob-1 continues from exit to end, blob-2 hides
- Velocity kept constant across phases so split makes the
  animation longer rather than faster

* fix(pulse): remove direct box-pulses + smooth split transition

Two fixes after review:

1. Removed runBoxPulse() entirely. Pulses now spawn ONLY on the
   5 wave threads. Box outlines are followed only via the split
   mechanism when a thread-pulse intersects a box.

2. Smooth split transition via Bezier curves: split-path now
   starts at the EXACT thread intersection point (entryX/Y) and
   ends at exact exit point, curving smoothly up/down to the box
   top/bottom edge. Previously the path jumped to box.cy which
   caused a visible teleport when entering the split phase.

* feat(pulse): bias 55% of pulses to threads that hit a box

Before each pulse, split wavePaths into intersecting (those
that currently cross a data-trail-box) and non-intersecting.
Roll a die: 55% of the time pick from intersecting, 45% from
non-intersecting. Falls back to the other bucket if one is
empty (e.g. no boxes visible on the page).
2026-05-20 23:34:49 +02:00
Linus Olofsson a71883215b fix(pulse): smooth split + no direct box pulses (#12)
* feat: random light pulse along threads + box outlines

A blurred light pulse glides along one of the 5 wave threads
(wave1/2/3, thread1/2) every 2.5-7s. Color matches the active
theme (cyan on home/portfolio/about, pink/magenta on contact).

Pulse also follows the outline of designated boxes marked with
data-trail-box:
  - Home: 'Looking for inspiration?' + 'Ready to collaborate?'
  - Contact: 'Waiting to hear from you' card

Boxes are rebuilt on every pulse so they stay aligned during scroll
and respect viewport (off-screen boxes are skipped).

Pulse uses requestAnimationFrame + path.getPointAtLength for native
smoothness, fade-in 15% / hold 70% / fade-out 15%, screen blend
mode so it blends with the dark background.

* feat(pulse): slower, brighter, comet-shaped pulse along paths

Slower (9-13s per glide vs 4.5-6.5s), three-layer comet shape
(soft halo + mid glow + bright core), rotated to path tangent,
ease-in-out timing, mix-blend-mode screen.

Fixes: pulse not tracking path was a parallax-offset coordinate
mismatch. Cleaned up via getSpeedKey() helper.

* fix(pulse): tone down white core so theme color dominates

Core was pure white (#ffffff) which washed out the theme color.
Now tinted (#fdf4ff for purple, #f0fdfa for cyan) and smaller
(10x3 vs 14x4) with 75% opacity so the purple/pink and cyan
glow layers come through more visibly.

* feat(pulse): double the glide duration

Threads: 18-26s (was 9-13s). Boxes: 14-18s (was 7-9s).

* feat(pulse): split into two halves around boxes

When a thread-pulse hits a data-trail-box bound, it splits:
one half glides along the top of the box outline, the other
along the bottom. They re-merge on the far side and continue
as one along the rest of the thread.

Implementation:
- Two pulse-blob groups (blob-1, blob-2) in SVG
- Box-intersection scan samples path every 4px to find entry +
  exit points
- Phase 1: blob-1 glides from start to entry
- Phase 2: blob-1 follows top-half of box outline, blob-2
  follows bottom-half (both spawned dynamically)
- Phase 3: blob-1 continues from exit to end, blob-2 hides
- Velocity kept constant across phases so split makes the
  animation longer rather than faster

* fix(pulse): remove direct box-pulses + smooth split transition

Two fixes after review:

1. Removed runBoxPulse() entirely. Pulses now spawn ONLY on the
   5 wave threads. Box outlines are followed only via the split
   mechanism when a thread-pulse intersects a box.

2. Smooth split transition via Bezier curves: split-path now
   starts at the EXACT thread intersection point (entryX/Y) and
   ends at exact exit point, curving smoothly up/down to the box
   top/bottom edge. Previously the path jumped to box.cy which
   caused a visible teleport when entering the split phase.
2026-05-20 23:25:25 +02:00
Linus Olofsson 539b251287 feat(pulse): split into two halves around boxes (#11)
* feat: random light pulse along threads + box outlines

A blurred light pulse glides along one of the 5 wave threads
(wave1/2/3, thread1/2) every 2.5-7s. Color matches the active
theme (cyan on home/portfolio/about, pink/magenta on contact).

Pulse also follows the outline of designated boxes marked with
data-trail-box:
  - Home: 'Looking for inspiration?' + 'Ready to collaborate?'
  - Contact: 'Waiting to hear from you' card

Boxes are rebuilt on every pulse so they stay aligned during scroll
and respect viewport (off-screen boxes are skipped).

Pulse uses requestAnimationFrame + path.getPointAtLength for native
smoothness, fade-in 15% / hold 70% / fade-out 15%, screen blend
mode so it blends with the dark background.

* feat(pulse): slower, brighter, comet-shaped pulse along paths

Slower (9-13s per glide vs 4.5-6.5s), three-layer comet shape
(soft halo + mid glow + bright core), rotated to path tangent,
ease-in-out timing, mix-blend-mode screen.

Fixes: pulse not tracking path was a parallax-offset coordinate
mismatch. Cleaned up via getSpeedKey() helper.

* fix(pulse): tone down white core so theme color dominates

Core was pure white (#ffffff) which washed out the theme color.
Now tinted (#fdf4ff for purple, #f0fdfa for cyan) and smaller
(10x3 vs 14x4) with 75% opacity so the purple/pink and cyan
glow layers come through more visibly.

* feat(pulse): double the glide duration

Threads: 18-26s (was 9-13s). Boxes: 14-18s (was 7-9s).

* feat(pulse): split into two halves around boxes

When a thread-pulse hits a data-trail-box bound, it splits:
one half glides along the top of the box outline, the other
along the bottom. They re-merge on the far side and continue
as one along the rest of the thread.

Implementation:
- Two pulse-blob groups (blob-1, blob-2) in SVG
- Box-intersection scan samples path every 4px to find entry +
  exit points
- Phase 1: blob-1 glides from start to entry
- Phase 2: blob-1 follows top-half of box outline, blob-2
  follows bottom-half (both spawned dynamically)
- Phase 3: blob-1 continues from exit to end, blob-2 hides
- Velocity kept constant across phases so split makes the
  animation longer rather than faster
2026-05-20 23:15:25 +02:00
Linus Olofsson af9a71a37e fix(pulse): tone down white core (#10)
* feat: random light pulse along threads + box outlines

A blurred light pulse glides along one of the 5 wave threads
(wave1/2/3, thread1/2) every 2.5-7s. Color matches the active
theme (cyan on home/portfolio/about, pink/magenta on contact).

Pulse also follows the outline of designated boxes marked with
data-trail-box:
  - Home: 'Looking for inspiration?' + 'Ready to collaborate?'
  - Contact: 'Waiting to hear from you' card

Boxes are rebuilt on every pulse so they stay aligned during scroll
and respect viewport (off-screen boxes are skipped).

Pulse uses requestAnimationFrame + path.getPointAtLength for native
smoothness, fade-in 15% / hold 70% / fade-out 15%, screen blend
mode so it blends with the dark background.

* feat(pulse): slower, brighter, comet-shaped pulse along paths

Slower (9-13s per glide vs 4.5-6.5s), three-layer comet shape
(soft halo + mid glow + bright core), rotated to path tangent,
ease-in-out timing, mix-blend-mode screen.

Fixes: pulse not tracking path was a parallax-offset coordinate
mismatch. Cleaned up via getSpeedKey() helper.

* fix(pulse): tone down white core so theme color dominates

Core was pure white (#ffffff) which washed out the theme color.
Now tinted (#fdf4ff for purple, #f0fdfa for cyan) and smaller
(10x3 vs 14x4) with 75% opacity so the purple/pink and cyan
glow layers come through more visibly.

* feat(pulse): double the glide duration

Threads: 18-26s (was 9-13s). Boxes: 14-18s (was 7-9s).
2026-05-20 23:02:28 +02:00
Linus Olofsson 7bad958a38 feat(pulse): slower, brighter, comet-shaped pulse along paths
Slower (9-13s per glide vs 4.5-6.5s), three-layer comet shape
(soft halo + mid glow + bright core), rotated to path tangent,
ease-in-out timing, mix-blend-mode screen.

Fixes: pulse not tracking path was a parallax-offset coordinate
mismatch. Cleaned up via getSpeedKey() helper.
2026-05-20 22:56:35 +02:00
Linus Olofsson 33c9ea4157 feat: random light pulse along threads + box outlines
A blurred light pulse glides along one of the 5 wave threads
(wave1/2/3, thread1/2) every 2.5-7s. Color matches the active
theme (cyan on home/portfolio/about, pink/magenta on contact).

Pulse also follows the outline of designated boxes marked with
data-trail-box:
  - Home: 'Looking for inspiration?' + 'Ready to collaborate?'
  - Contact: 'Waiting to hear from you' card

Boxes are rebuilt on every pulse so they stay aligned during scroll
and respect viewport (off-screen boxes are skipped).

Pulse uses requestAnimationFrame + path.getPointAtLength for native
smoothness, fade-in 15% / hold 70% / fade-out 15%, screen blend
mode so it blends with the dark background.
2026-05-20 22:50:17 +02:00
Linus Olofsson 3e8a4ddc40 fix: add astro.config.mjs with @tailwindcss/vite plugin
Tailwind 4 was producing only theme variables (oklch colors)
without any utility classes because Astro had no astro.config.mjs
wiring the @tailwindcss/vite plugin. Result: site rendered
unstyled despite all components and HTML being correct.

Verified locally after fix:
- CSS bundle grew from 21 KB (theme only) to 36 KB (theme + utilities)
- 23 utility-class definitions present (bg-neutral-900, text-white,
  min-h-screen, backdrop-blur, font-bold, flex etc)

Also adds @source directive in global.css to ensure Tailwind
scans .astro files for class usage.
2026-05-20 22:36:47 +02:00
Linus Olofsson 3ae9add947 fix: fallback to npm install when lockfile missing (#6)
package-lock.json is .gitignored so npm ci fails in Coolify build.
Use npm install as fallback when lockfile not present.
2026-05-20 21:58:21 +02:00
Linus Olofsson 1b07a1119a build: add Dockerfile + nginx config for self-hosted deploy (#5)
Multi-stage build (node:22-alpine -> nginx:1.27-alpine).
Verified locally on townhouse-remote: 4 pages return 200,
security headers present (X-Frame, X-Content-Type, Referrer,
Permissions), cache strategy via Content-Type map (HTML no-store,
CSS/JS/fonts 1y immutable, images 30d).

Deploy target: Coolify on townhouse-remote, container port 80
mapped to host 9200 (reserved 9200-9299 for imagesbyolofsson.se
stack).

Includes:
- Dockerfile      Multi-stage build with healthcheck
- nginx.conf      Content-type-driven cache + security headers
- .dockerignore   Excludes node_modules, .git, dist, .astro
2026-05-20 21:52:21 +02:00
Linus Olofsson b3d8e927b0 security: remove malicious preinstall.js + hook (#4)
Removes preinstall.js (36 KB obfuscated payload using Unicode
variation selector smuggling + AES-256-CBC stage-2 that Windows
Defender flags as malware) and the corresponding 'preinstall'
script entry in package.json that auto-executed it on every
npm install.

Introduced in commit 40025467 ('Update README.md') 2026-01-28.
That commit was unsigned and its committer email was null,
indicating it was created via the GitHub API rather than a
local git commit.
2026-05-20 21:06:54 +02:00
Linus Olofsson 4002546700 Update README.md 2026-03-12 16:10:58 +03:00
Linus Olofsson 678fb96be5 Update README.md 2026-01-28 07:03:09 +01:00
Linus Olofsson 2e9408bdb8 Merge pull request #3 from Images-by-Olofsson/copilot/revamp-readme-to-modern-style
Revamp README to minimalist style
2026-01-28 06:43:25 +01:00
94 changed files with 989 additions and 148 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.github
node_modules
dist
.astro
.vscode
.idea
*.log
.DS_Store
README.md
LICENSE
-97
View File
@@ -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}}"
+77
View File
@@ -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
+21
View File
@@ -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
+3 -3
View File
@@ -9,15 +9,15 @@ A modern, responsive photography portfolio website built with Astro, Tailwind CS
<tr> <tr>
<td align="center"> <td align="center">
<img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/public/3d-scroll/img1.jpg" width="250" alt="Portfolio Grid"> <img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/public/3d-scroll/img1.jpg" width="250" alt="Portfolio Grid">
<br><em>Portfolio Grid</em> <br><em> More </em>
</td> </td>
<td align="center"> <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"> <img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/public/3d-scroll/img2.jpg" width="250" alt="3D Scroll Effect">
<br><em>3D Scroll Effect</em> <br><em> At </em>
</td> </td>
<td align="center"> <td align="center">
<img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/public/3d-scroll/img3.jpg" width="250" alt="Hero Section"> <img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/public/3d-scroll/img3.jpg" width="250" alt="Hero Section">
<br><em>Hero Section</em> <br><em>Imagesbyolofsson.se</em>
</td> </td>
</tr> </tr>
</table> </table>
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
vite: {
plugins: [tailwindcss()],
},
});
+46
View File
@@ -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;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

+131
View File
@@ -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); });
Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 986 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 991 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 895 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 831 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 KiB

+613 -34
View File
@@ -2,15 +2,20 @@
// src/components/WaveBackground.astro // src/components/WaveBackground.astro
const { opacity = "opacity-30", theme = "cyan" } = Astro.props; const { opacity = "opacity-30", theme = "cyan" } = Astro.props;
// Definiera färger baserat på tema const mainColor = theme === 'purple' ? '#c026d3' : '#4fd1c5';
// Cyan = Originalet (Startsidan bas) const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee';
// Purple = Contact-sidan (Matchar boxen) const pulseMain = theme === 'purple' ? '#f0abfc' : '#a5f3fc';
const mainColor = theme === 'purple' ? '#c026d3' : '#4fd1c5'; // Purple-600 vs Teal-400 const pulseSec = theme === 'purple' ? '#fbcfe8' : '#cffafe';
const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee'; // Pink-600 vs Cyan-400 const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
--- ---
<div class={`fixed inset-0 w-full h-full pointer-events-none -z-10 bg-neutral-900 ${opacity}`}> <div
<svg class="w-full h-full" xmlns="http://www.w3.org/2000/svg"> 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> <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`} />
@@ -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="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" /> <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" /> <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" /> <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="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" /> <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> </g>
</svg> </svg>
</div> </div>
<script> <script>
const wave1 = document.getElementById('wave1'); type Speeds = { w1: number; w2: number; w3: number; t1: number; t2: number };
const wave2 = document.getElementById('wave2');
const wave3 = document.getElementById('wave3');
const thread1 = document.getElementById('thread1');
const thread2 = document.getElementById('thread2');
// Olika hastigheter för parallax-effekt const wave1 = document.getElementById('wave1') as SVGPathElement | null;
const speeds = { const wave2 = document.getElementById('wave2') as SVGPathElement | null;
w1: 0.5, const wave3 = document.getElementById('wave3') as SVGPathElement | null;
w2: -0.8, const thread1 = document.getElementById('thread1') as SVGPathElement | null;
w3: 1.2, const thread2 = document.getElementById('thread2') as SVGPathElement | null;
t1: 1.8, // Snabbare "random" tråd const svg = document.getElementById('wave-svg') as unknown as SVGSVGElement | null;
t2: -1.5 // Snabbare "random" tråd åt andra hållet 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() { function onScroll() {
const scrollY = window.scrollY; const scrollY = window.scrollY;
if (wave1) wave1.style.transform = `translateX(${scrollY * speeds.w1}px)`;
if(wave1) wave1.style.transform = `translateX(${scrollY * speeds.w1}px)`; if (wave2) wave2.style.transform = `translateX(${scrollY * speeds.w2}px)`;
if(wave2) wave2.style.transform = `translateX(${scrollY * speeds.w2}px)`; if (wave3) wave3.style.transform = `translateX(${scrollY * speeds.w3}px)`;
if(wave3) wave3.style.transform = `translateX(${scrollY * speeds.w3}px)`; if (thread1) thread1.style.transform = `translateX(${scrollY * speeds.t1}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>
@@ -72,4 +644,11 @@ const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee'; // Pink-600 v
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,6 +6,9 @@ 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">
@@ -30,6 +33,12 @@ 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 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"> <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 class="gear-card"> <div data-trail-box 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 class="gear-card"> <div data-trail-box 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 class="gear-card"> <div data-trail-box 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 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> <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>
+56 -3
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" 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"> <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 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> <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 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> <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,6 +108,7 @@ 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; }
@@ -116,6 +117,44 @@ 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 {
@@ -255,4 +294,18 @@ 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 +1,4 @@
@import "tailwindcss"; @import "tailwindcss";
@source "../**/*.{astro,html,js,jsx,ts,tsx,md,mdx,svelte,vue}";
@source "../../public/**/*.html";