Skip to content

Commit 7c7bfdc

Browse files
committed
add api/carousel
1 parent 8d27de3 commit 7c7bfdc

2 files changed

Lines changed: 320 additions & 0 deletions

File tree

api/carousel.js

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
// @ts-check
2+
3+
const DEFAULT_WIDTH = 800;
4+
const DEFAULT_HEIGHT = 200;
5+
const DEFAULT_HOLD = 3;
6+
const DEFAULT_TRANSITION = 0.8;
7+
8+
/**
9+
* @typedef {"linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out"} Easing
10+
*/
11+
12+
/**
13+
* @param {Easing} easing Easing type
14+
* @returns {{ calcMode: "linear" | "spline"; keySplines: string | null }} Easing parameters
15+
*/
16+
function getEasingParams(easing) {
17+
const map = {
18+
linear: { calcMode: "linear", keySplines: null },
19+
ease: { calcMode: "spline", keySplines: "0.25 0.1 0.25 1" },
20+
"ease-in": { calcMode: "spline", keySplines: "0.42 0 1 1" },
21+
"ease-out": { calcMode: "spline", keySplines: "0 0 0.58 1" },
22+
"ease-in-out": { calcMode: "spline", keySplines: "0.42 0 0.58 1" },
23+
};
24+
return map[easing] || map["ease-in-out"];
25+
}
26+
27+
/**
28+
* @param {string} svg SVG string
29+
* @returns {{ viewBox: string; content: string }} Extracted viewBox and inner content
30+
*/
31+
function stripXmlWrapper(svg) {
32+
// Drop XML declaration / DOCTYPE
33+
let s = svg.replace(/<\?xml[^>]*?>/g, "").replace(/<!DOCTYPE[^>]*?>/gi, "");
34+
// Extract viewBox (or fallback)
35+
const viewBoxMatch = s.match(/viewBox=["']([^"']+)["']/i);
36+
const widthMatch = s.match(/width=["'](\d+(\.\d+)*)["']/i);
37+
const heightMatch = s.match(/height=["'](\d+(\.\d+)*)["']/i);
38+
const viewBox = viewBoxMatch
39+
? viewBoxMatch[1]
40+
: widthMatch && heightMatch
41+
? `0 0 ${widthMatch[1]} ${heightMatch[1]}`
42+
: `0 0 ${DEFAULT_WIDTH} ${DEFAULT_HEIGHT}`;
43+
44+
// Extract inner content between outer <svg> tags
45+
const innerMatch = s.match(/<svg[^>]*>([\s\S]*?)<\/svg>/i);
46+
const inner = innerMatch ? innerMatch[1] : s;
47+
48+
return { viewBox, content: inner };
49+
}
50+
51+
/**
52+
* @param {string} content SVG content
53+
* @param {string} suffix Suffix to append to IDs
54+
* @returns {string} Modified SVG content with unique IDs
55+
*/
56+
function uniquifyIds(content, suffix) {
57+
let modified = content.replace(/id=["']([^"']+)["']/g, `id="$1_${suffix}"`);
58+
modified = modified.replace(/url\(#([^)]+)\)/g, `url(#$1_${suffix})`);
59+
modified = modified.replace(
60+
/(xlink:)?href=["']#([^"']+)["']/g,
61+
`$1href="#$2_${suffix}"`,
62+
);
63+
return modified;
64+
}
65+
66+
/**
67+
* @param {number} numSegments Number of segments
68+
* @param {string | null} spline Spline parameters
69+
* @returns {string} Generated keySplines string
70+
*/
71+
function generateKeySplines(numSegments, spline) {
72+
if (!spline || numSegments <= 0) {
73+
return "";
74+
}
75+
return Array.from({ length: numSegments }, () => spline).join(";");
76+
}
77+
78+
/**
79+
* @typedef {{ content: string; viewBox: string; duration: number }} SlideConfig
80+
*/
81+
82+
/**
83+
* Generate timing for one slide.
84+
* @param {number} slideIndex - Index of the slide to animate
85+
* @param {SlideConfig[]} slides - Array of all slide configurations
86+
* @param {number} width - Width of the carousel container
87+
* @param {number} hold - Duration to hold each slide in seconds
88+
* @param {number} transition - Duration of slide transition in seconds
89+
* @param {boolean} loop - Whether the carousel should loop indefinitely
90+
* @returns {{ values: string; keyTimes: string; dur: string; repeatCount: string }} Animation parameters for the slide
91+
*/
92+
function generateAnimationForSlide(
93+
slideIndex,
94+
slides,
95+
width,
96+
hold,
97+
transition,
98+
loop,
99+
) {
100+
const numSlides = slides.length;
101+
/**
102+
* Calculate total duration for a single slide (transition in + hold + transition out)
103+
* @param {SlideConfig} s - Slide configuration
104+
* @returns {number} Total duration in seconds
105+
*/
106+
const perSlideDuration = (s) => s.duration + 2 * transition; // in + hold + out
107+
const cycleDuration = slides.reduce((sum, s) => sum + perSlideDuration(s), 0);
108+
109+
const positions = [];
110+
const keyTimes = [];
111+
112+
const offRight = `${width} 0`;
113+
const offLeft = `${-width} 0`;
114+
115+
let t = 0;
116+
117+
// Initial keyframe
118+
positions.push(offRight);
119+
keyTimes.push(0);
120+
121+
for (let i = 0; i < numSlides; i++) {
122+
const slide = slides[i];
123+
if (i === slideIndex) {
124+
// slide in
125+
t += transition;
126+
positions.push("0 0");
127+
keyTimes.push(t / cycleDuration);
128+
129+
// hold
130+
t += slide.duration;
131+
positions.push("0 0");
132+
keyTimes.push(t / cycleDuration);
133+
134+
// slide out
135+
t += transition;
136+
positions.push(offLeft);
137+
keyTimes.push(t / cycleDuration);
138+
} else {
139+
t += perSlideDuration(slide);
140+
}
141+
}
142+
143+
if (loop) {
144+
const lastKT = keyTimes[keyTimes.length - 1];
145+
if (lastKT < 0.9999) {
146+
positions.push(offRight);
147+
keyTimes.push(1);
148+
} else {
149+
keyTimes[keyTimes.length - 1] = 1;
150+
}
151+
} else {
152+
const lastKT = keyTimes[keyTimes.length - 1];
153+
if (Math.abs(lastKT - 1) > 1e-6) {
154+
const lastPos = positions[positions.length - 1];
155+
positions.push(lastPos);
156+
keyTimes.push(1);
157+
}
158+
}
159+
160+
return {
161+
values: positions.join(";"),
162+
keyTimes: keyTimes.join(";"),
163+
dur: `${cycleDuration}s`,
164+
repeatCount: loop ? "indefinite" : "1",
165+
};
166+
}
167+
168+
/**
169+
* @param req request
170+
* @param res response
171+
*/
172+
export default async function handler(req, res) {
173+
try {
174+
const {
175+
username,
176+
repos,
177+
width,
178+
height,
179+
hold,
180+
transition,
181+
easing,
182+
loop,
183+
...passThrough // theme, hide, etc – forwarded to /api/pin
184+
} = /** @type {Record<string, string>} */ (req.query);
185+
186+
if (!username || !repos) {
187+
res.status(400).send("Missing username or repos");
188+
return;
189+
}
190+
191+
const repoList = repos
192+
.split(",")
193+
.map((r) => r.trim())
194+
.filter(Boolean);
195+
if (repoList.length === 0) {
196+
res.status(400).send("No valid repos specified");
197+
return;
198+
}
199+
if (repoList.length > 8) {
200+
// safety cap
201+
res.status(400).send("Too many repos (max 8)");
202+
return;
203+
}
204+
205+
const W = Number(width) || DEFAULT_WIDTH;
206+
const H = Number(height) || DEFAULT_HEIGHT;
207+
const holdDuration = hold ? Number(hold) : DEFAULT_HOLD;
208+
const transitionDuration = transition
209+
? Number(transition)
210+
: DEFAULT_TRANSITION;
211+
const easingParams = getEasingParams(easing || "ease-in-out");
212+
const loopFlag = loop !== "false";
213+
214+
const host = req.headers.host;
215+
const baseUrl = `https://${host}`;
216+
217+
// Fetch all pin cards from your existing /api/pin
218+
const slideSvgs = [];
219+
for (let i = 0; i < repoList.length; i++) {
220+
const repo = repoList[i];
221+
222+
const qs = new URLSearchParams({
223+
username,
224+
repo,
225+
...passThrough,
226+
}).toString();
227+
228+
const url = `${baseUrl}/api/pin?${qs}`;
229+
const resp = await fetch(url);
230+
if (!resp.ok) {
231+
throw new Error(`Failed to fetch card for repo ${repo}`);
232+
}
233+
const svgText = await resp.text();
234+
const { viewBox, content } = stripXmlWrapper(svgText);
235+
const uniq = uniquifyIds(content, `slide${i}`);
236+
slideSvgs.push({
237+
content: uniq,
238+
viewBox,
239+
duration: holdDuration,
240+
});
241+
}
242+
243+
const padding = 0; // could be made configurable later
244+
const innerW = W - 2 * padding;
245+
const innerH = H - 2 * padding;
246+
247+
// Build final SVG
248+
let svg = `<svg width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
249+
<defs>
250+
<clipPath id="carouselClip">
251+
<rect x="0" y="0" width="${W}" height="${H}"/>
252+
</clipPath>
253+
</defs>
254+
<rect width="${W}" height="${H}" fill="transparent"/>
255+
<g clip-path="url(#carouselClip)">
256+
`;
257+
258+
slideSvgs.forEach((slide, idx) => {
259+
const anim = generateAnimationForSlide(
260+
idx,
261+
slideSvgs,
262+
W,
263+
holdDuration,
264+
transitionDuration,
265+
loopFlag,
266+
);
267+
const numSegments = anim.keyTimes.split(";").length - 1;
268+
const keySplines =
269+
easingParams.calcMode === "spline"
270+
? generateKeySplines(numSegments, easingParams.keySplines)
271+
: "";
272+
273+
const easingAttrs =
274+
easingParams.calcMode === "spline"
275+
? `calcMode="spline" keySplines="${keySplines}"`
276+
: 'calcMode="linear"';
277+
278+
svg += `
279+
<!-- Slide ${idx + 1}: ${repoList[idx]} -->
280+
<g id="carouselSlide${idx}">
281+
<g>
282+
<svg
283+
x="${padding}"
284+
y="${padding}"
285+
width="${innerW}"
286+
height="${innerH}"
287+
viewBox="${slide.viewBox}"
288+
preserveAspectRatio="xMidYMid meet"
289+
>
290+
${slide.content}
291+
</svg>
292+
<animateTransform
293+
attributeName="transform"
294+
type="translate"
295+
values="${anim.values}"
296+
keyTimes="${anim.keyTimes}"
297+
dur="${anim.dur}"
298+
${easingAttrs}
299+
repeatCount="${anim.repeatCount}"
300+
fill="freeze"
301+
/>
302+
</g>
303+
</g>
304+
`;
305+
});
306+
307+
svg += ` </g>\n</svg>`;
308+
309+
res.setHeader("Content-Type", "image/svg+xml; charset=utf-8");
310+
res.setHeader(
311+
"Cache-Control",
312+
"public, max-age=0, s-maxage=600, stale-while-revalidate=86400",
313+
);
314+
res.status(200).send(svg);
315+
} catch (err) {
316+
res.status(500).send(`Error generating carousel: ${err.message || err}`);
317+
}
318+
}

express.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import repoCard from "./api/pin.js";
44
import langCard from "./api/top-langs.js";
55
import wakatimeCard from "./api/wakatime.js";
66
import gistCard from "./api/gist.js";
7+
import carouselCard from "./api/carousel.js";
78
import express from "express";
89

910
const app = express();
@@ -14,6 +15,7 @@ router.get("/pin", repoCard);
1415
router.get("/top-langs", langCard);
1516
router.get("/wakatime", wakatimeCard);
1617
router.get("/gist", gistCard);
18+
router.get("/carousel", carouselCard);
1719

1820
app.use("/api", router);
1921

0 commit comments

Comments
 (0)