@fayz-ai/plugin-linkinbio 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/index.cjs +33 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/public/BioPage.d.ts +8 -0
- package/dist/public/BioPage.d.ts.map +1 -0
- package/dist/public/BioPageRenderer.d.ts +11 -0
- package/dist/public/BioPageRenderer.d.ts.map +1 -0
- package/dist/public/__tests__/manifest.test.d.ts +2 -0
- package/dist/public/__tests__/manifest.test.d.ts.map +1 -0
- package/dist/public/blocks/BioSection.d.ts +7 -0
- package/dist/public/blocks/BioSection.d.ts.map +1 -0
- package/dist/public/blocks/CTASection.d.ts +7 -0
- package/dist/public/blocks/CTASection.d.ts.map +1 -0
- package/dist/public/blocks/EmbedSection.d.ts +7 -0
- package/dist/public/blocks/EmbedSection.d.ts.map +1 -0
- package/dist/public/blocks/FeaturedBlock.d.ts +7 -0
- package/dist/public/blocks/FeaturedBlock.d.ts.map +1 -0
- package/dist/public/blocks/GallerySection.d.ts +7 -0
- package/dist/public/blocks/GallerySection.d.ts.map +1 -0
- package/dist/public/blocks/HeroSection.d.ts +9 -0
- package/dist/public/blocks/HeroSection.d.ts.map +1 -0
- package/dist/public/blocks/LinksBlock.d.ts +7 -0
- package/dist/public/blocks/LinksBlock.d.ts.map +1 -0
- package/dist/public/blocks/MusicReleasesBlock.d.ts +7 -0
- package/dist/public/blocks/MusicReleasesBlock.d.ts.map +1 -0
- package/dist/public/blocks/ProfileHeader.d.ts +8 -0
- package/dist/public/blocks/ProfileHeader.d.ts.map +1 -0
- package/dist/public/blocks/ProjectSection.d.ts +7 -0
- package/dist/public/blocks/ProjectSection.d.ts.map +1 -0
- package/dist/public/blocks/SocialLinksSection.d.ts +7 -0
- package/dist/public/blocks/SocialLinksSection.d.ts.map +1 -0
- package/dist/public/blocks/StatsSection.d.ts +7 -0
- package/dist/public/blocks/StatsSection.d.ts.map +1 -0
- package/dist/public/blocks/TextSection.d.ts +7 -0
- package/dist/public/blocks/TextSection.d.ts.map +1 -0
- package/dist/public/blocks/TourDatesBlock.d.ts +7 -0
- package/dist/public/blocks/TourDatesBlock.d.ts.map +1 -0
- package/dist/public/blocks/VenueListSection.d.ts +7 -0
- package/dist/public/blocks/VenueListSection.d.ts.map +1 -0
- package/dist/public/blocks/VideoGridSection.d.ts +7 -0
- package/dist/public/blocks/VideoGridSection.d.ts.map +1 -0
- package/dist/public/blocks/index.d.ts +17 -0
- package/dist/public/blocks/index.d.ts.map +1 -0
- package/dist/public/components/Carousel.d.ts +11 -0
- package/dist/public/components/Carousel.d.ts.map +1 -0
- package/dist/public/components/PlatformIcon.d.ts +9 -0
- package/dist/public/components/PlatformIcon.d.ts.map +1 -0
- package/dist/public/components/Reveal.d.ts +13 -0
- package/dist/public/components/Reveal.d.ts.map +1 -0
- package/dist/public/context.d.ts +18 -0
- package/dist/public/context.d.ts.map +1 -0
- package/dist/public/createPublicLinkInBioPlugin.d.ts +38 -0
- package/dist/public/createPublicLinkInBioPlugin.d.ts.map +1 -0
- package/dist/public/data.d.ts +11 -0
- package/dist/public/data.d.ts.map +1 -0
- package/dist/public/data.supabase.d.ts +11 -0
- package/dist/public/data.supabase.d.ts.map +1 -0
- package/dist/public/index.cjs +1750 -0
- package/dist/public/index.cjs.map +1 -0
- package/dist/public/index.d.ts +18 -0
- package/dist/public/index.d.ts.map +1 -0
- package/dist/public/index.js +1731 -0
- package/dist/public/index.js.map +1 -0
- package/dist/public/registry.d.ts +17 -0
- package/dist/public/registry.d.ts.map +1 -0
- package/dist/public/utils.d.ts +9 -0
- package/dist/public/utils.d.ts.map +1 -0
- package/dist/types.d.ts +251 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +68 -0
- package/src/index.ts +56 -0
- package/src/public/BioPage.tsx +123 -0
- package/src/public/BioPageRenderer.tsx +249 -0
- package/src/public/__tests__/manifest.test.ts +39 -0
- package/src/public/blocks/BioSection.tsx +66 -0
- package/src/public/blocks/CTASection.tsx +56 -0
- package/src/public/blocks/EmbedSection.tsx +53 -0
- package/src/public/blocks/FeaturedBlock.tsx +70 -0
- package/src/public/blocks/GallerySection.tsx +148 -0
- package/src/public/blocks/HeroSection.tsx +107 -0
- package/src/public/blocks/LinksBlock.tsx +80 -0
- package/src/public/blocks/MusicReleasesBlock.tsx +83 -0
- package/src/public/blocks/ProfileHeader.tsx +176 -0
- package/src/public/blocks/ProjectSection.tsx +158 -0
- package/src/public/blocks/SocialLinksSection.tsx +125 -0
- package/src/public/blocks/StatsSection.tsx +46 -0
- package/src/public/blocks/TextSection.tsx +29 -0
- package/src/public/blocks/TourDatesBlock.tsx +83 -0
- package/src/public/blocks/VenueListSection.tsx +91 -0
- package/src/public/blocks/VideoGridSection.tsx +105 -0
- package/src/public/blocks/index.ts +16 -0
- package/src/public/components/Carousel.tsx +90 -0
- package/src/public/components/PlatformIcon.tsx +56 -0
- package/src/public/components/Reveal.tsx +58 -0
- package/src/public/context.tsx +30 -0
- package/src/public/createPublicLinkInBioPlugin.ts +81 -0
- package/src/public/data.supabase.ts +31 -0
- package/src/public/data.ts +29 -0
- package/src/public/index.ts +75 -0
- package/src/public/registry.ts +40 -0
- package/src/public/utils.ts +102 -0
- package/src/types.ts +328 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { HeroSection as HeroSectionType, BioIdentity, BioBranding } from '../../types';
|
|
2
|
+
|
|
3
|
+
interface Props {
|
|
4
|
+
section: HeroSectionType;
|
|
5
|
+
identity: BioIdentity;
|
|
6
|
+
branding: BioBranding;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default function HeroSection({ section, identity, branding }: Props) {
|
|
10
|
+
const hasLogo = section.showLogo && branding.logoUrl;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<section
|
|
14
|
+
className="relative flex flex-col items-center justify-center min-h-screen overflow-hidden"
|
|
15
|
+
id={section.id}
|
|
16
|
+
>
|
|
17
|
+
{/* Background image (skipped when no image — overlay carries the visual) */}
|
|
18
|
+
<div className="absolute inset-0">
|
|
19
|
+
{section.backgroundImageUrl && (
|
|
20
|
+
<img
|
|
21
|
+
src={section.backgroundImageUrl}
|
|
22
|
+
alt={identity.name}
|
|
23
|
+
className="h-full w-full object-cover"
|
|
24
|
+
/>
|
|
25
|
+
)}
|
|
26
|
+
{section.overlayGradient && (
|
|
27
|
+
<div className="absolute inset-0" style={{ background: section.overlayGradient }} />
|
|
28
|
+
)}
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
{/* Content — always centered */}
|
|
32
|
+
<div className="relative z-10 flex flex-col items-center text-center px-6 pt-20 pb-24">
|
|
33
|
+
{hasLogo ? (
|
|
34
|
+
<img
|
|
35
|
+
src={branding.logoUrl}
|
|
36
|
+
alt={`${identity.name} logo`}
|
|
37
|
+
className="mb-6 h-48 w-auto md:h-64 lg:h-80 object-contain drop-shadow-2xl"
|
|
38
|
+
/>
|
|
39
|
+
) : (
|
|
40
|
+
<h1
|
|
41
|
+
className="text-5xl md:text-7xl lg:text-8xl font-bold tracking-tight mb-4 drop-shadow-lg"
|
|
42
|
+
style={{ fontFamily: 'var(--pk-font-heading)', color: 'var(--pk-text)' }}
|
|
43
|
+
>
|
|
44
|
+
{identity.name}
|
|
45
|
+
</h1>
|
|
46
|
+
)}
|
|
47
|
+
|
|
48
|
+
{identity.tagline && (
|
|
49
|
+
<p
|
|
50
|
+
className="text-lg md:text-xl mb-6 max-w-lg opacity-70"
|
|
51
|
+
style={{ color: 'var(--pk-text)' }}
|
|
52
|
+
>
|
|
53
|
+
{identity.tagline}
|
|
54
|
+
</p>
|
|
55
|
+
)}
|
|
56
|
+
|
|
57
|
+
{section.showGenres && identity.genres.length > 0 && (
|
|
58
|
+
<div className="flex flex-wrap justify-center gap-2 mb-4">
|
|
59
|
+
{identity.genres.map((genre) => (
|
|
60
|
+
<span
|
|
61
|
+
key={genre}
|
|
62
|
+
className="px-4 py-1.5 text-sm font-semibold backdrop-blur-sm"
|
|
63
|
+
style={{
|
|
64
|
+
backgroundColor: 'rgba(255,255,255,0.08)',
|
|
65
|
+
color: 'var(--pk-primary)',
|
|
66
|
+
border: '1px solid rgba(255,255,255,0.12)',
|
|
67
|
+
borderRadius: '9999px',
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
{genre}
|
|
71
|
+
</span>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
{section.showLocation && identity.location && (
|
|
77
|
+
<p className="text-sm mt-2 opacity-50" style={{ color: 'var(--pk-text)' }}>
|
|
78
|
+
{identity.location}
|
|
79
|
+
</p>
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
{section.ctaText && section.ctaUrl && (
|
|
83
|
+
<a
|
|
84
|
+
href={section.ctaUrl}
|
|
85
|
+
target="_blank"
|
|
86
|
+
rel="noopener noreferrer"
|
|
87
|
+
className="mt-8 inline-block px-10 py-4 font-bold text-lg tracking-wider transition-transform hover:scale-105"
|
|
88
|
+
style={{
|
|
89
|
+
backgroundColor: 'var(--pk-primary)',
|
|
90
|
+
color: 'var(--pk-bg)',
|
|
91
|
+
borderRadius: '9999px',
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
{section.ctaText}
|
|
95
|
+
</a>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{/* Scroll hint */}
|
|
100
|
+
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-10 animate-bounce opacity-40">
|
|
101
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--pk-text)' }}>
|
|
102
|
+
<path d="M12 5v14M5 12l7 7 7-7" />
|
|
103
|
+
</svg>
|
|
104
|
+
</div>
|
|
105
|
+
</section>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { ChevronRight } from 'lucide-react';
|
|
2
|
+
import type { LinksSection, LinkCardItem } from '../../types';
|
|
3
|
+
import { PlatformIcon } from '../components/PlatformIcon';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
section: LinksSection;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function LinkCard({ item }: { item: LinkCardItem }) {
|
|
10
|
+
return (
|
|
11
|
+
<a
|
|
12
|
+
href={item.url}
|
|
13
|
+
target="_blank"
|
|
14
|
+
rel="noopener noreferrer"
|
|
15
|
+
className="group flex items-center gap-4 p-3 transition-all duration-200 hover:scale-[1.015] active:scale-[0.99]"
|
|
16
|
+
style={{
|
|
17
|
+
backgroundColor: 'rgba(255,255,255,0.05)',
|
|
18
|
+
border: '1px solid rgba(255,255,255,0.07)',
|
|
19
|
+
borderRadius: 'var(--pk-radius)',
|
|
20
|
+
}}
|
|
21
|
+
>
|
|
22
|
+
<div
|
|
23
|
+
className="shrink-0 flex items-center justify-center w-12 h-12 overflow-hidden"
|
|
24
|
+
style={{ borderRadius: 'calc(var(--pk-radius) - 4px)', backgroundColor: 'rgba(255,255,255,0.06)' }}
|
|
25
|
+
>
|
|
26
|
+
{item.thumbnailUrl ? (
|
|
27
|
+
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
|
|
28
|
+
) : (
|
|
29
|
+
<PlatformIcon platform={item.icon ?? 'custom'} size={20} style={{ color: 'var(--pk-primary)' }} />
|
|
30
|
+
)}
|
|
31
|
+
</div>
|
|
32
|
+
<div className="min-w-0 flex-1">
|
|
33
|
+
<div className="flex items-center gap-2">
|
|
34
|
+
<span className="text-sm font-semibold truncate" style={{ color: 'var(--pk-text)' }}>
|
|
35
|
+
{item.title}
|
|
36
|
+
</span>
|
|
37
|
+
{item.badge && (
|
|
38
|
+
<span
|
|
39
|
+
className="text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-full shrink-0"
|
|
40
|
+
style={{ backgroundColor: 'var(--pk-primary)', color: 'var(--pk-bg)' }}
|
|
41
|
+
>
|
|
42
|
+
{item.badge}
|
|
43
|
+
</span>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
{item.subtitle && (
|
|
47
|
+
<p className="text-xs truncate mt-0.5" style={{ color: 'var(--pk-muted)' }}>
|
|
48
|
+
{item.subtitle}
|
|
49
|
+
</p>
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
<ChevronRight
|
|
53
|
+
size={18}
|
|
54
|
+
className="shrink-0 opacity-40 group-hover:opacity-80 group-hover:translate-x-0.5 transition-all"
|
|
55
|
+
style={{ color: 'var(--pk-text)' }}
|
|
56
|
+
/>
|
|
57
|
+
</a>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default function LinksBlock({ section }: Props) {
|
|
62
|
+
const isGrid = section.layout === 'grid';
|
|
63
|
+
return (
|
|
64
|
+
<section className="py-6" id={section.id}>
|
|
65
|
+
{section.title && (
|
|
66
|
+
<h2
|
|
67
|
+
className="text-xl md:text-2xl font-bold mb-4 px-1"
|
|
68
|
+
style={{ fontFamily: 'var(--pk-font-heading)', color: 'var(--pk-text)' }}
|
|
69
|
+
>
|
|
70
|
+
{section.title}
|
|
71
|
+
</h2>
|
|
72
|
+
)}
|
|
73
|
+
<div className={isGrid ? 'grid grid-cols-2 gap-3' : 'flex flex-col gap-3'}>
|
|
74
|
+
{section.links.map((item, i) => (
|
|
75
|
+
<LinkCard key={item.url + i} item={item} />
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
</section>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Play } from 'lucide-react';
|
|
2
|
+
import type { MusicReleasesSection, MusicReleaseItem } from '../../types';
|
|
3
|
+
import { Carousel } from '../components/Carousel';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
section: MusicReleasesSection;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function ReleaseCard({ item, cta, wide }: { item: MusicReleaseItem; cta: string; wide?: boolean }) {
|
|
10
|
+
return (
|
|
11
|
+
<a
|
|
12
|
+
href={item.url}
|
|
13
|
+
target="_blank"
|
|
14
|
+
rel="noopener noreferrer"
|
|
15
|
+
className={`group ${wide ? 'flex-none w-40 snap-start' : ''} flex flex-col transition-transform hover:scale-[1.02]`}
|
|
16
|
+
>
|
|
17
|
+
<div
|
|
18
|
+
className="relative w-full aspect-square overflow-hidden"
|
|
19
|
+
style={{ borderRadius: 'var(--pk-radius)', backgroundColor: 'rgba(255,255,255,0.05)' }}
|
|
20
|
+
>
|
|
21
|
+
<img src={item.artworkUrl} alt={item.title} className="w-full h-full object-cover" loading="lazy" />
|
|
22
|
+
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
23
|
+
style={{ background: 'rgba(0,0,0,0.35)' }}
|
|
24
|
+
>
|
|
25
|
+
<span
|
|
26
|
+
className="flex items-center justify-center w-11 h-11 rounded-full"
|
|
27
|
+
style={{ backgroundColor: 'var(--pk-primary)', color: 'var(--pk-bg)' }}
|
|
28
|
+
>
|
|
29
|
+
<Play size={20} fill="currentColor" />
|
|
30
|
+
</span>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
<div className="mt-2 px-0.5">
|
|
34
|
+
<p className="text-sm font-semibold truncate" style={{ color: 'var(--pk-text)' }}>
|
|
35
|
+
{item.title}
|
|
36
|
+
</p>
|
|
37
|
+
{item.artist && (
|
|
38
|
+
<p className="text-xs truncate" style={{ color: 'var(--pk-muted)' }}>
|
|
39
|
+
{item.artist}
|
|
40
|
+
</p>
|
|
41
|
+
)}
|
|
42
|
+
<span
|
|
43
|
+
className="inline-flex items-center gap-1 mt-1.5 px-3 py-1 text-xs font-semibold rounded-full"
|
|
44
|
+
style={{ backgroundColor: 'rgba(255,255,255,0.08)', color: 'var(--pk-text)' }}
|
|
45
|
+
>
|
|
46
|
+
<Play size={11} fill="currentColor" /> {cta}
|
|
47
|
+
</span>
|
|
48
|
+
</div>
|
|
49
|
+
</a>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default function MusicReleasesBlock({ section }: Props) {
|
|
54
|
+
const cta = section.ctaLabel ?? 'Ouvir';
|
|
55
|
+
if (section.layout === 'grid') {
|
|
56
|
+
return (
|
|
57
|
+
<section className="py-6" id={section.id}>
|
|
58
|
+
{section.title && (
|
|
59
|
+
<h2
|
|
60
|
+
className="text-xl md:text-2xl font-bold mb-4 px-1"
|
|
61
|
+
style={{ fontFamily: 'var(--pk-font-heading)', color: 'var(--pk-text)' }}
|
|
62
|
+
>
|
|
63
|
+
{section.title}
|
|
64
|
+
</h2>
|
|
65
|
+
)}
|
|
66
|
+
<div className="grid grid-cols-2 gap-4">
|
|
67
|
+
{section.releases.map((item, i) => (
|
|
68
|
+
<ReleaseCard key={item.url + i} item={item} cta={cta} />
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return (
|
|
75
|
+
<section className="py-6" id={section.id}>
|
|
76
|
+
<Carousel title={section.title}>
|
|
77
|
+
{section.releases.map((item, i) => (
|
|
78
|
+
<ReleaseCard key={item.url + i} item={item} cta={cta} wide />
|
|
79
|
+
))}
|
|
80
|
+
</Carousel>
|
|
81
|
+
</section>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { ChevronDown } from 'lucide-react';
|
|
3
|
+
import type { ProfileHeaderSection, BioPage } from '../../types';
|
|
4
|
+
import { platformLabels } from '../utils';
|
|
5
|
+
import { PlatformIcon } from '../components/PlatformIcon';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
section: ProfileHeaderSection;
|
|
9
|
+
page: BioPage;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function ProfileHeader({ section, page }: Props) {
|
|
13
|
+
const { identity, branding } = page;
|
|
14
|
+
const isHub = page.layout === 'hub';
|
|
15
|
+
const socials = section.socials ?? page.socialLinks ?? [];
|
|
16
|
+
const hasLogo = section.showLogo && branding.logoUrl;
|
|
17
|
+
|
|
18
|
+
// Scroll-driven parallax: the image lags while the logo lifts + fades on its
|
|
19
|
+
// own curve, so the hero opens clean (just the logo) and dissolves on scroll.
|
|
20
|
+
const heroRef = useRef<HTMLDivElement>(null);
|
|
21
|
+
const [sy, setSy] = useState(0);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const container = document.querySelector('.fixed.inset-0') as HTMLElement | null;
|
|
24
|
+
if (!container) return;
|
|
25
|
+
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
26
|
+
if (reduce) return;
|
|
27
|
+
let raf = 0;
|
|
28
|
+
const onScroll = () => {
|
|
29
|
+
if (raf) return;
|
|
30
|
+
raf = requestAnimationFrame(() => {
|
|
31
|
+
setSy(container.scrollTop);
|
|
32
|
+
raf = 0;
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
container.addEventListener('scroll', onScroll, { passive: true });
|
|
36
|
+
return () => {
|
|
37
|
+
container.removeEventListener('scroll', onScroll);
|
|
38
|
+
if (raf) cancelAnimationFrame(raf);
|
|
39
|
+
};
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
const heroH = heroRef.current?.offsetHeight ?? 700;
|
|
43
|
+
const p = Math.min(1, sy / heroH); // 0 → 1 over the hero's height
|
|
44
|
+
// imageOffsetTop nudges the image content DOWN inside the frame (not a margin);
|
|
45
|
+
// the baseline scale (>1) leaves overflow room so no gap opens at the top.
|
|
46
|
+
const imageOffsetTop = section.imageOffsetTop ?? '0px';
|
|
47
|
+
const imageTransform = `translateY(calc(${imageOffsetTop} + ${sy * 0.22}px)) scale(1.18)`;
|
|
48
|
+
const logoTransform = `translateY(${-sy * 0.5}px) scale(${1 - p * 0.12})`;
|
|
49
|
+
const logoOpacity = Math.max(0, 1 - p * 1.25);
|
|
50
|
+
const hintOpacity = Math.max(0, 1 - p * 4);
|
|
51
|
+
|
|
52
|
+
const scrollTo = (id: string) => {
|
|
53
|
+
const el = typeof document !== 'undefined' ? document.getElementById(id) : null;
|
|
54
|
+
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<header id={section.id} className="relative">
|
|
59
|
+
{/* Hero — fills the viewport so the page opens clean on just the logo. */}
|
|
60
|
+
<div
|
|
61
|
+
ref={heroRef}
|
|
62
|
+
className={`relative w-full h-[86vh] max-h-[880px] overflow-hidden ${
|
|
63
|
+
isHub ? 'sm:rounded-[26px]' : ''
|
|
64
|
+
}`}
|
|
65
|
+
>
|
|
66
|
+
{section.imageUrl && (
|
|
67
|
+
<img
|
|
68
|
+
src={section.imageUrl}
|
|
69
|
+
alt={identity.name}
|
|
70
|
+
className="absolute inset-0 h-full w-full object-cover will-change-transform"
|
|
71
|
+
style={{ transform: imageTransform, objectPosition: section.imageObjectPosition }}
|
|
72
|
+
/>
|
|
73
|
+
)}
|
|
74
|
+
<div
|
|
75
|
+
className="absolute inset-0"
|
|
76
|
+
style={{
|
|
77
|
+
background:
|
|
78
|
+
section.overlayGradient ??
|
|
79
|
+
'linear-gradient(to bottom, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.35) 60%, var(--pk-bg) 100%)',
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
|
|
83
|
+
{/* Logo (or name) overlaid — its own motion curve */}
|
|
84
|
+
<div className="absolute inset-x-0 bottom-0 flex flex-col items-center text-center px-6 pb-14">
|
|
85
|
+
{hasLogo ? (
|
|
86
|
+
<img
|
|
87
|
+
src={branding.logoUrl}
|
|
88
|
+
alt={`${identity.name} logo`}
|
|
89
|
+
className="w-[82%] max-w-[380px] h-auto object-contain drop-shadow-[0_4px_24px_rgba(0,0,0,0.55)] will-change-transform"
|
|
90
|
+
style={{ transform: logoTransform, opacity: logoOpacity }}
|
|
91
|
+
/>
|
|
92
|
+
) : (
|
|
93
|
+
<h1
|
|
94
|
+
className="text-5xl sm:text-6xl font-bold tracking-tight drop-shadow-lg will-change-transform"
|
|
95
|
+
style={{
|
|
96
|
+
fontFamily: 'var(--pk-font-heading)',
|
|
97
|
+
color: 'var(--pk-text)',
|
|
98
|
+
transform: logoTransform,
|
|
99
|
+
opacity: logoOpacity,
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
{identity.name}
|
|
103
|
+
</h1>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{/* Scroll hint */}
|
|
108
|
+
<div
|
|
109
|
+
className="absolute bottom-4 left-1/2 -translate-x-1/2 animate-bounce"
|
|
110
|
+
style={{ opacity: hintOpacity, color: 'var(--pk-text)' }}
|
|
111
|
+
>
|
|
112
|
+
<ChevronDown size={22} />
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{/* Below the hero (revealed on scroll): tagline, socials, nav */}
|
|
117
|
+
{(identity.tagline || (section.showLocation && identity.location)) && (
|
|
118
|
+
<div className="text-center mt-5 px-4">
|
|
119
|
+
{identity.tagline && (
|
|
120
|
+
<p className="text-base font-medium" style={{ color: 'var(--pk-text)' }}>
|
|
121
|
+
{identity.tagline}
|
|
122
|
+
</p>
|
|
123
|
+
)}
|
|
124
|
+
{section.showLocation && identity.location && (
|
|
125
|
+
<p className="text-xs mt-1 opacity-60" style={{ color: 'var(--pk-muted)' }}>
|
|
126
|
+
{identity.location}
|
|
127
|
+
</p>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
{/* Social icon row */}
|
|
133
|
+
{socials.length > 0 && (
|
|
134
|
+
<div className="flex flex-wrap justify-center gap-3 mt-4 px-4">
|
|
135
|
+
{socials.map((link) => (
|
|
136
|
+
<a
|
|
137
|
+
key={link.platform + link.url}
|
|
138
|
+
href={link.url}
|
|
139
|
+
target="_blank"
|
|
140
|
+
rel="noopener noreferrer"
|
|
141
|
+
aria-label={link.label ?? platformLabels[link.platform] ?? 'Link'}
|
|
142
|
+
className="flex items-center justify-center w-11 h-11 rounded-full transition-transform hover:scale-110"
|
|
143
|
+
style={{ backgroundColor: 'rgba(255,255,255,0.07)', color: 'var(--pk-text)' }}
|
|
144
|
+
>
|
|
145
|
+
<PlatformIcon platform={link.platform} size={19} />
|
|
146
|
+
</a>
|
|
147
|
+
))}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{/* Quick-nav pills */}
|
|
152
|
+
{section.navPills && section.navPills.length > 0 && (
|
|
153
|
+
<nav
|
|
154
|
+
className="flex gap-2 overflow-x-auto scrollbar-hide mt-4 px-4 pb-1 justify-start sm:justify-center"
|
|
155
|
+
aria-label="Seções"
|
|
156
|
+
>
|
|
157
|
+
{section.navPills.map((pill) => (
|
|
158
|
+
<button
|
|
159
|
+
key={pill.targetId}
|
|
160
|
+
type="button"
|
|
161
|
+
onClick={() => scrollTo(pill.targetId)}
|
|
162
|
+
className="whitespace-nowrap px-4 py-2 text-sm font-medium rounded-full transition-colors shrink-0"
|
|
163
|
+
style={{
|
|
164
|
+
backgroundColor: 'rgba(255,255,255,0.06)',
|
|
165
|
+
color: 'var(--pk-text)',
|
|
166
|
+
border: '1px solid rgba(255,255,255,0.08)',
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
{pill.label}
|
|
170
|
+
</button>
|
|
171
|
+
))}
|
|
172
|
+
</nav>
|
|
173
|
+
)}
|
|
174
|
+
</header>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { ProjectSection as ProjectSectionType } from '../../types';
|
|
2
|
+
import { renderMarkdown } from '../utils';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
section: ProjectSectionType;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default function ProjectSection({ section }: Props) {
|
|
9
|
+
const isSideBySide = section.layout === 'side-by-side';
|
|
10
|
+
const hasMedia = section.media && section.media.length > 0;
|
|
11
|
+
const firstMedia = hasMedia ? section.media![0] : null;
|
|
12
|
+
const isVideo = firstMedia?.type === 'video';
|
|
13
|
+
|
|
14
|
+
const MediaBlock = () => {
|
|
15
|
+
if (!firstMedia) return null;
|
|
16
|
+
if (isVideo) {
|
|
17
|
+
return (
|
|
18
|
+
<div className="relative w-full" style={{ paddingBottom: '56.25%', borderRadius: 'var(--pk-radius)', overflow: 'hidden' }}>
|
|
19
|
+
<iframe
|
|
20
|
+
src={firstMedia.url}
|
|
21
|
+
className="absolute inset-0 w-full h-full"
|
|
22
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
23
|
+
allowFullScreen
|
|
24
|
+
loading="lazy"
|
|
25
|
+
title={firstMedia.alt ?? section.title ?? 'Video'}
|
|
26
|
+
/>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return (
|
|
31
|
+
<img
|
|
32
|
+
src={firstMedia.url}
|
|
33
|
+
alt={firstMedia.alt ?? ''}
|
|
34
|
+
className="w-full h-full object-cover"
|
|
35
|
+
style={{ borderRadius: 'var(--pk-radius)' }}
|
|
36
|
+
loading="lazy"
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const ContentBlock = () => (
|
|
42
|
+
<div className="flex flex-col justify-center">
|
|
43
|
+
{section.label && (
|
|
44
|
+
<span
|
|
45
|
+
className="inline-block self-start px-4 py-1.5 text-xs font-bold uppercase tracking-[0.15em] mb-5"
|
|
46
|
+
style={{
|
|
47
|
+
backgroundColor: 'transparent',
|
|
48
|
+
color: 'var(--pk-accent)',
|
|
49
|
+
border: '1px solid var(--pk-accent)',
|
|
50
|
+
borderRadius: '4px',
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
{section.label}
|
|
54
|
+
</span>
|
|
55
|
+
)}
|
|
56
|
+
|
|
57
|
+
{section.title && (
|
|
58
|
+
<h2
|
|
59
|
+
className="text-3xl md:text-4xl lg:text-5xl font-bold mb-5"
|
|
60
|
+
style={{ fontFamily: 'var(--pk-font-heading)', color: 'var(--pk-text)' }}
|
|
61
|
+
>
|
|
62
|
+
{section.title}
|
|
63
|
+
</h2>
|
|
64
|
+
)}
|
|
65
|
+
|
|
66
|
+
{section.content && (
|
|
67
|
+
<div
|
|
68
|
+
className="text-base md:text-lg leading-relaxed [&_p]:mb-4 [&_strong]:font-semibold [&_strong]:text-white [&_em]:italic"
|
|
69
|
+
style={{ color: 'var(--pk-muted)' }}
|
|
70
|
+
dangerouslySetInnerHTML={{ __html: renderMarkdown(section.content) }}
|
|
71
|
+
/>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
{section.tags && section.tags.length > 0 && (
|
|
75
|
+
<div className="flex flex-wrap gap-2 mt-5">
|
|
76
|
+
{section.tags.map((tag) => (
|
|
77
|
+
<span
|
|
78
|
+
key={tag}
|
|
79
|
+
className="px-3 py-1 text-xs font-medium"
|
|
80
|
+
style={{
|
|
81
|
+
color: 'var(--pk-muted)',
|
|
82
|
+
border: '1px solid rgba(255,255,255,0.1)',
|
|
83
|
+
borderRadius: 'var(--pk-radius)',
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{tag}
|
|
87
|
+
</span>
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{section.links && section.links.length > 0 && (
|
|
93
|
+
<div className="flex flex-wrap gap-4 mt-6">
|
|
94
|
+
{section.links.map((link) => (
|
|
95
|
+
<a
|
|
96
|
+
key={link.url}
|
|
97
|
+
href={link.url}
|
|
98
|
+
target="_blank"
|
|
99
|
+
rel="noopener noreferrer"
|
|
100
|
+
className="text-sm font-medium underline underline-offset-4 hover:opacity-80 transition-opacity"
|
|
101
|
+
style={{ color: 'var(--pk-primary)' }}
|
|
102
|
+
>
|
|
103
|
+
{link.label}
|
|
104
|
+
</a>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<section className="py-20 md:py-28 px-6" id={section.id}>
|
|
113
|
+
<div className="max-w-5xl mx-auto">
|
|
114
|
+
{isSideBySide && hasMedia ? (
|
|
115
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-16 items-center">
|
|
116
|
+
<div className="order-1">
|
|
117
|
+
<ContentBlock />
|
|
118
|
+
</div>
|
|
119
|
+
<div className="order-2">
|
|
120
|
+
<MediaBlock />
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
) : (
|
|
124
|
+
<div>
|
|
125
|
+
<ContentBlock />
|
|
126
|
+
{hasMedia && (
|
|
127
|
+
<div className={`grid grid-cols-1 gap-4 mt-8 ${section.media!.length > 1 ? 'md:grid-cols-2' : ''}`}>
|
|
128
|
+
{section.media!.map((item, i) =>
|
|
129
|
+
item.type === 'video' ? (
|
|
130
|
+
<div key={i} className="aspect-video overflow-hidden" style={{ borderRadius: 'var(--pk-radius)' }}>
|
|
131
|
+
<iframe
|
|
132
|
+
src={item.url}
|
|
133
|
+
className="h-full w-full"
|
|
134
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
135
|
+
allowFullScreen
|
|
136
|
+
loading="lazy"
|
|
137
|
+
title={item.alt ?? 'Video'}
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
) : (
|
|
141
|
+
<img
|
|
142
|
+
key={i}
|
|
143
|
+
src={item.url}
|
|
144
|
+
alt={item.alt ?? ''}
|
|
145
|
+
className="w-full aspect-[4/3] object-cover"
|
|
146
|
+
style={{ borderRadius: 'var(--pk-radius)' }}
|
|
147
|
+
loading="lazy"
|
|
148
|
+
/>
|
|
149
|
+
)
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
</section>
|
|
157
|
+
);
|
|
158
|
+
}
|