create-foliokit 0.0.0-stage → 1.0.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 +377 -2
- package/bin/index.js +6 -0
- package/package.json +50 -4
- package/src/cli.js +111 -0
- package/src/generator.js +133 -0
- package/src/prompts.js +44 -0
- package/src/utils/errors.js +7 -0
- package/src/utils/paths.js +12 -0
- package/src/utils/progress.js +5 -0
- package/src/utils/project-name.js +30 -0
- package/templates/default/.gitignore +20 -0
- package/templates/default/data.ts +227 -0
- package/templates/default/index.html +23 -0
- package/templates/default/package-lock.json +1280 -0
- package/templates/default/package.json +28 -0
- package/templates/default/public/avatar-placeholder.svg +10 -0
- package/templates/default/public/footer-heart.png +0 -0
- package/templates/default/public/love-always-wins.png +0 -0
- package/templates/default/public/project-placeholder.svg +10 -0
- package/templates/default/public/robots.txt +2 -0
- package/templates/default/public/stars-foreground-landscape.svg +202 -0
- package/templates/default/public/stars-foreground-portrait.svg +202 -0
- package/templates/default/public/stars-landscape.svg +322 -0
- package/templates/default/public/stars-portrait.svg +322 -0
- package/templates/default/scripts/generate-seo.mjs +34 -0
- package/templates/default/src/App.tsx +552 -0
- package/templates/default/src/BrandIcon.tsx +36 -0
- package/templates/default/src/ProjectMagazineGallery.css +90 -0
- package/templates/default/src/ProjectMagazineGallery.tsx +210 -0
- package/templates/default/src/main.tsx +10 -0
- package/templates/default/src/styles.css +536 -0
- package/templates/default/src/utils.ts +2 -0
- package/templates/default/src/vite-env.d.ts +1 -0
- package/templates/default/tests/portfolio.test.js +96 -0
- package/templates/default/tsconfig.app.json +21 -0
- package/templates/default/tsconfig.json +7 -0
- package/templates/default/tsconfig.node.json +16 -0
- package/templates/default/vite.config.ts +38 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
.project-magazine-gallery {
|
|
2
|
+
--magazine-gold: #D6B77A;
|
|
3
|
+
padding: 2px 0 14px;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.magazine-viewport {
|
|
7
|
+
position: relative;
|
|
8
|
+
width: 100%;
|
|
9
|
+
height: 320px;
|
|
10
|
+
overflow: hidden;
|
|
11
|
+
perspective: 1100px;
|
|
12
|
+
perspective-origin: 50% 43%;
|
|
13
|
+
touch-action: pan-y;
|
|
14
|
+
overscroll-behavior-x: contain;
|
|
15
|
+
outline: none;
|
|
16
|
+
user-select: none;
|
|
17
|
+
-webkit-tap-highlight-color: transparent;
|
|
18
|
+
}
|
|
19
|
+
.magazine-viewport:focus-visible { outline: 1px solid color-mix(in srgb, var(--magazine-gold) 55%, transparent); outline-offset: -2px; }
|
|
20
|
+
.magazine-viewport.is-dragging { cursor: grabbing; }
|
|
21
|
+
.magazine-viewport.is-dragging .magazine-cover { cursor: grabbing; transition: none; }
|
|
22
|
+
|
|
23
|
+
.magazine-cover {
|
|
24
|
+
--tilt-x: 0deg;
|
|
25
|
+
--tilt-y: 0deg;
|
|
26
|
+
position: absolute;
|
|
27
|
+
top: 15px;
|
|
28
|
+
left: 50%;
|
|
29
|
+
display: flex;
|
|
30
|
+
width: min(250px, 40%);
|
|
31
|
+
height: 270px;
|
|
32
|
+
flex-direction: column;
|
|
33
|
+
padding: 0;
|
|
34
|
+
overflow: hidden;
|
|
35
|
+
border: 1px solid rgba(214, 183, 122, .25);
|
|
36
|
+
border-radius: 5px;
|
|
37
|
+
color: var(--warm-white);
|
|
38
|
+
background: #070707;
|
|
39
|
+
box-shadow: 0 15px 34px rgba(0, 0, 0, .52), 0 0 0 1px rgba(255, 255, 255, .025) inset, 1px 0 rgba(214, 183, 122, .13) inset;
|
|
40
|
+
cursor: grab;
|
|
41
|
+
opacity: .68;
|
|
42
|
+
transform: translate3d(calc(-50% + var(--translate-x) + var(--drag-x, 0px) + var(--hover-shift)), var(--hover-lift), var(--depth-z)) rotateY(calc(var(--rotate-y) + var(--tilt-y))) rotateX(var(--tilt-x)) scale(var(--card-scale));
|
|
43
|
+
transform-style: preserve-3d;
|
|
44
|
+
transition: transform 460ms cubic-bezier(.22, .8, .22, 1), opacity 360ms ease, box-shadow 400ms ease, border-color 400ms ease;
|
|
45
|
+
animation: magazine-cover-appear 750ms cubic-bezier(.2, .72, .18, 1) var(--card-delay) both;
|
|
46
|
+
-webkit-user-select: none;
|
|
47
|
+
}
|
|
48
|
+
.magazine-cover.is-active { z-index: 4 !important; opacity: 1; cursor: pointer; border-color: rgba(214, 183, 122, .54); box-shadow: 0 20px 44px rgba(0, 0, 0, .62), 0 0 22px rgba(214, 183, 122, .1), 0 0 0 1px rgba(214, 183, 122, .06) inset; }
|
|
49
|
+
.magazine-cover.is-hovered { opacity: 1; border-color: rgba(214, 183, 122, .68); box-shadow: 0 24px 50px rgba(0, 0, 0, .66), 0 0 28px rgba(214, 183, 122, .16), 0 0 0 1px rgba(214, 183, 122, .1) inset; }
|
|
50
|
+
.magazine-cover.is-hidden { pointer-events: none; opacity: 0; }
|
|
51
|
+
.magazine-cover:focus-visible { outline: 1px solid var(--magazine-gold); outline-offset: 3px; }
|
|
52
|
+
.magazine-cover-image-wrap { position: relative; display: block; height: 56%; flex: 0 0 56%; overflow: hidden; background: #101010; transform: translateZ(8px); }
|
|
53
|
+
.magazine-cover-image-wrap::after { position: absolute; inset: 0; border-bottom: 1px solid rgba(214, 183, 122, .22); background: linear-gradient(180deg, rgba(0, 0, 0, .24), transparent 35%, rgba(0, 0, 0, .16)); content: ''; pointer-events: none; }
|
|
54
|
+
.magazine-cover-image { display: block; width: 100%; height: 100%; object-fit: cover; filter: grayscale(1) brightness(.82); transition: filter 480ms ease, transform 700ms cubic-bezier(.2, .72, .18, 1); pointer-events: none; }
|
|
55
|
+
.magazine-cover-image-placeholder { display: grid; width: 100%; height: 100%; place-items: center; background: radial-gradient(circle at 35% 25%, #242424, #050505 72%); color: #e5cf9c; font: 600 46px/1 var(--heading-font); }
|
|
56
|
+
.magazine-cover.is-active .magazine-cover-image, .magazine-cover.is-hovered .magazine-cover-image { filter: grayscale(0) brightness(.98); transform: scale(1.035); }
|
|
57
|
+
.magazine-cover-info { display: flex; min-height: 0; flex: 1; flex-direction: column; align-items: flex-start; padding: 12px 14px 10px; text-align: left; transform: translateZ(14px); }
|
|
58
|
+
.magazine-cover-meta { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 6px; color: var(--muted); font: 8px/1.2 var(--body-font); letter-spacing: .035em; text-transform: uppercase; }
|
|
59
|
+
.magazine-cover-meta span { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
|
|
60
|
+
.magazine-cover-title { margin-top: 7px; color: var(--warm-white); font: 600 17px/1.1 var(--heading-font); letter-spacing: -.035em; }
|
|
61
|
+
.magazine-cover-description { display: -webkit-box; max-height: 0; overflow: hidden; color: var(--muted); font: 10px/1.45 var(--body-font); opacity: 0; -webkit-box-orient: vertical; -webkit-line-clamp: 3; transition: max-height 360ms ease, opacity 280ms ease, margin 280ms ease; }
|
|
62
|
+
.magazine-cover.is-active .magazine-cover-description, .magazine-cover.is-hovered .magazine-cover-description, .magazine-cover:focus-visible .magazine-cover-description { max-height: 48px; margin-top: 6px; opacity: 1; }
|
|
63
|
+
.magazine-cover-hint { display: flex; width: 100%; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 5px; color: var(--magazine-gold); font: 600 8px/1 var(--heading-font); letter-spacing: .1em; opacity: .78; }
|
|
64
|
+
.magazine-cover-hint span { font-size: 13px; line-height: .8; }
|
|
65
|
+
|
|
66
|
+
@keyframes magazine-cover-appear {
|
|
67
|
+
from { opacity: 0; filter: blur(3px); }
|
|
68
|
+
to { filter: blur(0); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@media (min-width: 1024px) {
|
|
72
|
+
.magazine-viewport { height: 345px; perspective: 1250px; }
|
|
73
|
+
.magazine-cover { top: 18px; width: min(275px, 36%); height: 292px; }
|
|
74
|
+
}
|
|
75
|
+
@media (min-width: 768px) and (max-width: 1023px) {
|
|
76
|
+
.magazine-viewport { height: 315px; perspective: 1000px; }
|
|
77
|
+
.magazine-cover { top: 16px; width: min(238px, 42%); height: 267px; }
|
|
78
|
+
}
|
|
79
|
+
@media (max-width: 767px) {
|
|
80
|
+
.project-magazine-gallery { padding: 0 0 12px; }
|
|
81
|
+
.magazine-viewport { height: 300px; perspective: 850px; perspective-origin: 50% 42%; }
|
|
82
|
+
.magazine-cover { top: 14px; width: min(238px, 69%); height: 260px; border-radius: 4px; }
|
|
83
|
+
.magazine-cover-image-wrap { height: 53%; flex-basis: 53%; }
|
|
84
|
+
.magazine-cover-info { padding: 10px 12px 9px; }
|
|
85
|
+
.magazine-cover-description { font-size: 9px; }
|
|
86
|
+
.magazine-cover.is-active .magazine-cover-description { max-height: 52px; }
|
|
87
|
+
}
|
|
88
|
+
@media (prefers-reduced-motion: reduce) {
|
|
89
|
+
.magazine-cover, .magazine-cover-image, .magazine-cover-description { animation: none !important; transition-duration: .01ms !important; }
|
|
90
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent, type KeyboardEvent, type WheelEvent } from 'react'
|
|
2
|
+
import { projectCardId } from './utils'
|
|
3
|
+
import type { Project } from '../data'
|
|
4
|
+
import './ProjectMagazineGallery.css'
|
|
5
|
+
|
|
6
|
+
type Props = { projects: Project[] }
|
|
7
|
+
|
|
8
|
+
function getOffset(index: number, activeIndex: number, count: number) {
|
|
9
|
+
let offset = index - activeIndex
|
|
10
|
+
if (offset > count / 2) offset -= count
|
|
11
|
+
if (offset < -count / 2) offset += count
|
|
12
|
+
return offset
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function ProjectMagazineGallery({ projects }: Props) {
|
|
16
|
+
const [activeIndex, setActiveIndex] = useState(0)
|
|
17
|
+
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
|
|
18
|
+
const [dragX, setDragX] = useState(0)
|
|
19
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
20
|
+
const [reducedMotion, setReducedMotion] = useState(false)
|
|
21
|
+
const dragStart = useRef<number | null>(null)
|
|
22
|
+
const wasDragged = useRef(false)
|
|
23
|
+
const clickResetTimer = useRef<number | undefined>(undefined)
|
|
24
|
+
const wheelDelta = useRef(0)
|
|
25
|
+
const wheelGestureHandled = useRef(false)
|
|
26
|
+
const wheelGestureTimer = useRef<number | undefined>(undefined)
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const query = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
30
|
+
const update = () => setReducedMotion(query.matches)
|
|
31
|
+
update()
|
|
32
|
+
query.addEventListener('change', update)
|
|
33
|
+
return () => {
|
|
34
|
+
query.removeEventListener('change', update)
|
|
35
|
+
window.clearTimeout(clickResetTimer.current)
|
|
36
|
+
window.clearTimeout(wheelGestureTimer.current)
|
|
37
|
+
}
|
|
38
|
+
}, [])
|
|
39
|
+
|
|
40
|
+
if (projects.length === 0) return null
|
|
41
|
+
|
|
42
|
+
const goTo = (nextIndex: number) => {
|
|
43
|
+
setActiveIndex((nextIndex + projects.length) % projects.length)
|
|
44
|
+
setHoverIndex(null)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const openProject = (index: number) => {
|
|
48
|
+
setActiveIndex(index)
|
|
49
|
+
document.getElementById(projectCardId(projects[index].name))?.scrollIntoView({
|
|
50
|
+
behavior: reducedMotion ? 'auto' : 'smooth',
|
|
51
|
+
block: 'center',
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
|
56
|
+
if (event.pointerType === 'mouse' && event.button !== 0) return
|
|
57
|
+
dragStart.current = event.clientX
|
|
58
|
+
setDragX(0)
|
|
59
|
+
const cover = (event.target as Element | null)?.closest<HTMLButtonElement>('.magazine-cover')
|
|
60
|
+
try {
|
|
61
|
+
(cover ?? event.currentTarget).setPointerCapture(event.pointerId)
|
|
62
|
+
} catch {
|
|
63
|
+
// Pointer capture may be unavailable on synthetic or cancelled pointer events.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
|
68
|
+
if (dragStart.current === null) return
|
|
69
|
+
const delta = event.clientX - dragStart.current
|
|
70
|
+
setDragX(delta)
|
|
71
|
+
if (Math.abs(delta) > 8) setIsDragging(true)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const finishDrag = (event: PointerEvent<HTMLDivElement>) => {
|
|
75
|
+
if (dragStart.current === null) return
|
|
76
|
+
if (event.type === 'pointercancel') {
|
|
77
|
+
dragStart.current = null
|
|
78
|
+
setIsDragging(false)
|
|
79
|
+
setDragX(0)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
const delta = event.clientX - dragStart.current
|
|
83
|
+
if (Math.abs(delta) > 48) {
|
|
84
|
+
wasDragged.current = true
|
|
85
|
+
goTo(activeIndex + (delta < 0 ? 1 : -1))
|
|
86
|
+
window.clearTimeout(clickResetTimer.current)
|
|
87
|
+
clickResetTimer.current = window.setTimeout(() => { wasDragged.current = false }, 140)
|
|
88
|
+
}
|
|
89
|
+
dragStart.current = null
|
|
90
|
+
setIsDragging(false)
|
|
91
|
+
setDragX(0)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const handleWheel = (event: WheelEvent<HTMLDivElement>) => {
|
|
95
|
+
if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return
|
|
96
|
+
event.preventDefault()
|
|
97
|
+
window.clearTimeout(wheelGestureTimer.current)
|
|
98
|
+
wheelGestureTimer.current = window.setTimeout(() => {
|
|
99
|
+
wheelDelta.current = 0
|
|
100
|
+
wheelGestureHandled.current = false
|
|
101
|
+
}, 280)
|
|
102
|
+
if (wheelGestureHandled.current) return
|
|
103
|
+
wheelDelta.current += event.deltaX
|
|
104
|
+
if (Math.abs(wheelDelta.current) < 36) return
|
|
105
|
+
goTo(activeIndex + (wheelDelta.current > 0 ? 1 : -1))
|
|
106
|
+
wheelDelta.current = 0
|
|
107
|
+
wheelGestureHandled.current = true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
111
|
+
if (event.key === 'ArrowRight') { event.preventDefault(); goTo(activeIndex + 1) }
|
|
112
|
+
if (event.key === 'ArrowLeft') { event.preventDefault(); goTo(activeIndex - 1) }
|
|
113
|
+
if (event.key === 'Home') { event.preventDefault(); goTo(0) }
|
|
114
|
+
if (event.key === 'End') { event.preventDefault(); goTo(projects.length - 1) }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const viewportStyle = { '--drag-x': `${dragX}px` } as CSSProperties
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<section className="project-magazine-gallery" aria-label="Featured project carousel">
|
|
121
|
+
<div
|
|
122
|
+
className={`magazine-viewport${isDragging ? ' is-dragging' : ''}`}
|
|
123
|
+
style={viewportStyle}
|
|
124
|
+
role="region"
|
|
125
|
+
aria-roledescription="project carousel"
|
|
126
|
+
aria-label="Project magazine gallery. Swipe, drag, or use a horizontal trackpad gesture to browse. Use the left and right arrow keys while focused."
|
|
127
|
+
tabIndex={0}
|
|
128
|
+
onKeyDown={handleKeyDown}
|
|
129
|
+
onWheel={handleWheel}
|
|
130
|
+
onPointerDown={handlePointerDown}
|
|
131
|
+
onPointerMove={handlePointerMove}
|
|
132
|
+
onPointerUp={finishDrag}
|
|
133
|
+
onPointerCancel={finishDrag}
|
|
134
|
+
>
|
|
135
|
+
{projects.map((project, index) => {
|
|
136
|
+
const offset = getOffset(index, activeIndex, projects.length)
|
|
137
|
+
const distance = Math.abs(offset)
|
|
138
|
+
const isActive = index === activeIndex
|
|
139
|
+
const isHovered = index === hoverIndex
|
|
140
|
+
const hoverShift = hoverIndex !== null && index !== hoverIndex ? (index < hoverIndex ? '-9px' : '9px') : '0px'
|
|
141
|
+
const cardStyle = {
|
|
142
|
+
'--translate-x': offset === 0 ? '0%' : offset < 0 ? `${Math.max(-96, offset * 76)}%` : `${Math.min(96, offset * 76)}%`,
|
|
143
|
+
'--depth-z': `${-distance * 74}px`,
|
|
144
|
+
'--rotate-y': `${offset * -11}deg`,
|
|
145
|
+
'--card-scale': `${Math.max(.76, 1 - distance * .1)}`,
|
|
146
|
+
'--hover-lift': isHovered && !reducedMotion ? '24px' : '0px',
|
|
147
|
+
'--hover-shift': hoverShift,
|
|
148
|
+
'--card-delay': `${index * 110}ms`,
|
|
149
|
+
zIndex: projects.length - distance,
|
|
150
|
+
} as CSSProperties
|
|
151
|
+
|
|
152
|
+
const pointerTilt = (event: PointerEvent<HTMLButtonElement>) => {
|
|
153
|
+
if (event.pointerType !== 'mouse' || reducedMotion || isDragging) return
|
|
154
|
+
const bounds = event.currentTarget.getBoundingClientRect()
|
|
155
|
+
const relX = (event.clientX - bounds.left) / bounds.width - .5
|
|
156
|
+
const relY = (event.clientY - bounds.top) / bounds.height - .5
|
|
157
|
+
event.currentTarget.style.setProperty('--tilt-y', `${relX * 5}deg`)
|
|
158
|
+
event.currentTarget.style.setProperty('--tilt-x', `${-relY * 4}deg`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const clearTilt = (event: PointerEvent<HTMLButtonElement>) => {
|
|
162
|
+
event.currentTarget.style.removeProperty('--tilt-y')
|
|
163
|
+
event.currentTarget.style.removeProperty('--tilt-x')
|
|
164
|
+
setHoverIndex(null)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<button
|
|
169
|
+
className={`magazine-cover${isActive ? ' is-active' : ''}${isHovered ? ' is-hovered' : ''}${distance > 1 ? ' is-hidden' : ''}`}
|
|
170
|
+
key={project.name}
|
|
171
|
+
data-featured={project.featured || undefined}
|
|
172
|
+
type="button"
|
|
173
|
+
style={cardStyle}
|
|
174
|
+
aria-label={`Open ${project.name} project details`}
|
|
175
|
+
aria-hidden={distance > 1}
|
|
176
|
+
tabIndex={distance > 1 ? -1 : 0}
|
|
177
|
+
onPointerEnter={(event) => { if (event.pointerType === 'mouse') setHoverIndex(index) }}
|
|
178
|
+
onPointerMove={pointerTilt}
|
|
179
|
+
onPointerLeave={clearTilt}
|
|
180
|
+
onFocus={() => { setActiveIndex(index); setHoverIndex(index) }}
|
|
181
|
+
onBlur={() => setHoverIndex(null)}
|
|
182
|
+
onClick={() => {
|
|
183
|
+
if (wasDragged.current) { wasDragged.current = false; return }
|
|
184
|
+
openProject(index)
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
<span className="magazine-cover-image-wrap">
|
|
188
|
+
{project.image ? <>
|
|
189
|
+
<img src={project.image} alt={project.imageAlt ?? `${project.name} project cover`} className="magazine-cover-image" loading="lazy" draggable={false} onError={(event) => { event.currentTarget.hidden = true; const fallback = event.currentTarget.nextElementSibling; if (fallback instanceof HTMLElement) fallback.hidden = false }} />
|
|
190
|
+
<span className="magazine-cover-image-placeholder" aria-hidden="true" hidden>{project.name.slice(0, 2).toUpperCase()}</span>
|
|
191
|
+
</> : <span className="magazine-cover-image-placeholder" aria-hidden="true">{project.name.slice(0, 2).toUpperCase()}</span>}
|
|
192
|
+
</span>
|
|
193
|
+
<span className="magazine-cover-info">
|
|
194
|
+
<span className="magazine-cover-meta">
|
|
195
|
+
<span>{project.category}</span>
|
|
196
|
+
</span>
|
|
197
|
+
<span className="magazine-cover-title">{project.name}</span>
|
|
198
|
+
<span className="magazine-cover-description">{project.description}</span>
|
|
199
|
+
<span className="magazine-cover-hint">VIEW PROJECT <span aria-hidden="true">↗</span></span>
|
|
200
|
+
</span>
|
|
201
|
+
</button>
|
|
202
|
+
)
|
|
203
|
+
})}
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
</section>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export default ProjectMagazineGallery
|