Skip to content

Commit 92b1156

Browse files
committed
feat: add Vinyl Record theme to github thumbnail generator
1 parent 5d0ffc1 commit 92b1156

File tree

3 files changed

+257
-0
lines changed

3 files changed

+257
-0
lines changed

src/pages/apps/github-thumbnail/GithubThumbnailGeneratorPage.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ const THEME_OPTIONS = [
6969
{ value: 'modernNature', label: 'MODERN_NATURE' },
7070
{ value: 'splitFlap', label: 'SPLIT_FLAP_BOARD' },
7171
{ value: 'passportStamp', label: 'PASSPORT_STAMP' },
72+
{ value: 'vinylRecord', label: 'VINYL_RECORD' },
7273
];
7374

7475
const GithubThumbnailGeneratorPage = () => {

src/pages/apps/github-thumbnail/themes.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { cassetteJCard } from './themes/cassetteJCard';
4949
import { modernNature } from './themes/modernNature';
5050
import { splitFlap } from './themes/splitFlap';
5151
import { passportStamp } from './themes/passportStamp';
52+
import { vinylRecord } from './themes/vinylRecord';
5253

5354
export const themeRenderers = {
5455
modern,
@@ -102,4 +103,5 @@ export const themeRenderers = {
102103
modernNature,
103104
splitFlap,
104105
passportStamp,
106+
vinylRecord,
105107
};
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
export const vinylRecord = (ctx, width, height, scale, data) => {
2+
const {
3+
primaryColor,
4+
secondaryColor,
5+
repoOwner,
6+
repoName,
7+
description,
8+
language,
9+
stars,
10+
forks,
11+
supportUrl,
12+
bgColor,
13+
showPattern,
14+
} = data;
15+
16+
// --- Album sleeve background ---
17+
ctx.fillStyle = bgColor;
18+
ctx.fillRect(0, 0, width, height);
19+
20+
// Subtle cardboard texture (if showPattern)
21+
if (showPattern) {
22+
ctx.save();
23+
ctx.globalAlpha = 0.03;
24+
for (let i = 0; i < 800; i++) {
25+
const x = ((i * 131 + 7) % 1280) * (width / 1280);
26+
const y = ((i * 79 + 23) % 640) * (height / 640);
27+
ctx.fillStyle = i % 2 === 0 ? '#fff' : '#000';
28+
ctx.fillRect(x, y, (1 + (i % 2)) * scale, scale);
29+
}
30+
ctx.restore();
31+
}
32+
33+
// --- The vinyl record ---
34+
const recordCX = width * 0.38;
35+
const recordCY = height * 0.5;
36+
const recordR = height * 0.44;
37+
38+
// Record shadow
39+
ctx.save();
40+
ctx.globalAlpha = 0.25;
41+
ctx.fillStyle = '#000';
42+
ctx.beginPath();
43+
ctx.arc(recordCX + 8 * scale, recordCY + 8 * scale, recordR, 0, Math.PI * 2);
44+
ctx.fill();
45+
ctx.restore();
46+
47+
// Record body (black vinyl)
48+
ctx.fillStyle = '#111';
49+
ctx.beginPath();
50+
ctx.arc(recordCX, recordCY, recordR, 0, Math.PI * 2);
51+
ctx.fill();
52+
53+
// Grooves — concentric rings with subtle sheen
54+
if (showPattern) {
55+
ctx.save();
56+
ctx.strokeStyle = '#222';
57+
ctx.lineWidth = 0.5 * scale;
58+
for (let r = recordR * 0.35; r < recordR * 0.95; r += 3 * scale) {
59+
ctx.globalAlpha = 0.3 + Math.sin(r * 0.1) * 0.15;
60+
ctx.beginPath();
61+
ctx.arc(recordCX, recordCY, r, 0, Math.PI * 2);
62+
ctx.stroke();
63+
}
64+
ctx.restore();
65+
}
66+
67+
// Light reflection arc across record
68+
ctx.save();
69+
ctx.globalAlpha = 0.06;
70+
ctx.strokeStyle = '#fff';
71+
ctx.lineWidth = 40 * scale;
72+
ctx.beginPath();
73+
ctx.arc(recordCX - 30 * scale, recordCY - 30 * scale, recordR * 0.7, -0.8, 0.4);
74+
ctx.stroke();
75+
ctx.restore();
76+
77+
// --- Center label ---
78+
const labelR = recordR * 0.3;
79+
80+
// Label background with gradient
81+
const labelGrad = ctx.createRadialGradient(
82+
recordCX, recordCY, 0,
83+
recordCX, recordCY, labelR,
84+
);
85+
labelGrad.addColorStop(0, primaryColor);
86+
labelGrad.addColorStop(1, secondaryColor);
87+
ctx.fillStyle = labelGrad;
88+
ctx.beginPath();
89+
ctx.arc(recordCX, recordCY, labelR, 0, Math.PI * 2);
90+
ctx.fill();
91+
92+
// Label rim
93+
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
94+
ctx.lineWidth = 2 * scale;
95+
ctx.beginPath();
96+
ctx.arc(recordCX, recordCY, labelR, 0, Math.PI * 2);
97+
ctx.stroke();
98+
99+
// Inner ring on label
100+
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
101+
ctx.lineWidth = 1 * scale;
102+
ctx.beginPath();
103+
ctx.arc(recordCX, recordCY, labelR * 0.85, 0, Math.PI * 2);
104+
ctx.stroke();
105+
106+
// Spindle hole
107+
ctx.fillStyle = '#111';
108+
ctx.beginPath();
109+
ctx.arc(recordCX, recordCY, 6 * scale, 0, Math.PI * 2);
110+
ctx.fill();
111+
112+
// --- Label text ---
113+
ctx.textAlign = 'center';
114+
ctx.textBaseline = 'middle';
115+
116+
// Record label name (owner as label)
117+
ctx.fillStyle = 'rgba(255,255,255,0.9)';
118+
ctx.font = `bold ${11 * scale}px "JetBrains Mono"`;
119+
ctx.fillText(repoOwner.toUpperCase() + ' RECORDS', recordCX, recordCY - labelR * 0.55);
120+
121+
// Repo name as track title
122+
ctx.fillStyle = '#fff';
123+
ctx.font = `bold ${20 * scale}px "Inter", sans-serif`;
124+
const nameDisplay = repoName.length > 14 ? repoName.substring(0, 13) + '…' : repoName;
125+
ctx.fillText(nameDisplay, recordCX, recordCY - 4 * scale);
126+
127+
// Language as genre
128+
ctx.fillStyle = 'rgba(255,255,255,0.7)';
129+
ctx.font = `italic ${12 * scale}px "Inter", sans-serif`;
130+
ctx.fillText(language, recordCX, recordCY + 18 * scale);
131+
132+
// RPM / format text
133+
ctx.fillStyle = 'rgba(255,255,255,0.4)';
134+
ctx.font = `${9 * scale}px "JetBrains Mono"`;
135+
ctx.fillText('33⅓ RPM • STEREO', recordCX, recordCY + labelR * 0.55);
136+
137+
// --- Right side: Album sleeve info ---
138+
const infoX = width * 0.64;
139+
const infoY = height * 0.14;
140+
const infoW = width * 0.32;
141+
142+
// Album title (repo name, large)
143+
ctx.textAlign = 'left';
144+
ctx.textBaseline = 'top';
145+
ctx.fillStyle = primaryColor;
146+
ctx.font = `bold ${52 * scale}px "Inter", sans-serif`;
147+
148+
// Word wrap for long names
149+
const nameWords = repoName.split('');
150+
let fittedName = repoName;
151+
ctx.font = `bold ${52 * scale}px "Inter", sans-serif`;
152+
if (ctx.measureText(repoName).width > infoW) {
153+
ctx.font = `bold ${38 * scale}px "Inter", sans-serif`;
154+
fittedName = repoName;
155+
}
156+
ctx.fillText(fittedName, infoX, infoY);
157+
158+
// Artist line (owner)
159+
const titleMetrics = ctx.measureText(fittedName);
160+
const artistY = infoY + (ctx.measureText('M').actualBoundingBoxAscent + ctx.measureText('M').actualBoundingBoxDescent) + 16 * scale;
161+
ctx.fillStyle = 'rgba(255,255,255,0.5)';
162+
ctx.font = `${20 * scale}px "JetBrains Mono"`;
163+
ctx.fillText(`by ${repoOwner}`, infoX, artistY);
164+
165+
// Divider line
166+
const divY = artistY + 36 * scale;
167+
const divGrad = ctx.createLinearGradient(infoX, 0, infoX + infoW, 0);
168+
divGrad.addColorStop(0, primaryColor);
169+
divGrad.addColorStop(1, 'transparent');
170+
ctx.fillStyle = divGrad;
171+
ctx.fillRect(infoX, divY, infoW, 2 * scale);
172+
173+
// Description as liner notes
174+
const notesY = divY + 20 * scale;
175+
ctx.fillStyle = 'rgba(255,255,255,0.55)';
176+
ctx.font = `italic ${18 * scale}px "Georgia", serif`;
177+
// Manual wrap
178+
const words = description.split(' ');
179+
let line = '';
180+
let lineY = notesY;
181+
for (let i = 0; i < words.length; i++) {
182+
const test = line + words[i] + ' ';
183+
if (ctx.measureText(test).width > infoW && i > 0) {
184+
ctx.fillText(line.trim(), infoX, lineY);
185+
line = words[i] + ' ';
186+
lineY += 26 * scale;
187+
} else {
188+
line = test;
189+
}
190+
}
191+
ctx.fillText(line.trim(), infoX, lineY);
192+
193+
// --- Track listing style stats ---
194+
const statsY = lineY + 50 * scale;
195+
ctx.font = `${14 * scale}px "JetBrains Mono"`;
196+
ctx.fillStyle = 'rgba(255,255,255,0.35)';
197+
198+
let statLine = statsY;
199+
if (stars) {
200+
ctx.fillStyle = 'rgba(255,255,255,0.3)';
201+
ctx.fillText('A1', infoX, statLine);
202+
ctx.fillStyle = 'rgba(255,255,255,0.6)';
203+
ctx.fillText(`${stars} Stars`, infoX + 30 * scale, statLine);
204+
// Dotted leader
205+
ctx.fillStyle = 'rgba(255,255,255,0.15)';
206+
const dotsStart = infoX + 30 * scale + ctx.measureText(`${stars} Stars`).width + 8 * scale;
207+
for (let dx = dotsStart; dx < infoX + infoW - 40 * scale; dx += 6 * scale) {
208+
ctx.fillRect(dx, statLine + 5 * scale, 2 * scale, 1 * scale);
209+
}
210+
ctx.fillStyle = 'rgba(255,255,255,0.3)';
211+
ctx.textAlign = 'right';
212+
ctx.fillText('3:45', infoX + infoW, statLine);
213+
ctx.textAlign = 'left';
214+
statLine += 22 * scale;
215+
}
216+
217+
if (forks) {
218+
ctx.fillStyle = 'rgba(255,255,255,0.3)';
219+
ctx.fillText('A2', infoX, statLine);
220+
ctx.fillStyle = 'rgba(255,255,255,0.6)';
221+
ctx.fillText(`${forks} Forks`, infoX + 30 * scale, statLine);
222+
ctx.fillStyle = 'rgba(255,255,255,0.15)';
223+
const dotsStart = infoX + 30 * scale + ctx.measureText(`${forks} Forks`).width + 8 * scale;
224+
for (let dx = dotsStart; dx < infoX + infoW - 40 * scale; dx += 6 * scale) {
225+
ctx.fillRect(dx, statLine + 5 * scale, 2 * scale, 1 * scale);
226+
}
227+
ctx.fillStyle = 'rgba(255,255,255,0.3)';
228+
ctx.textAlign = 'right';
229+
ctx.fillText('4:12', infoX + infoW, statLine);
230+
ctx.textAlign = 'left';
231+
statLine += 22 * scale;
232+
}
233+
234+
ctx.fillStyle = 'rgba(255,255,255,0.3)';
235+
ctx.fillText('B1', infoX, statLine);
236+
ctx.fillStyle = 'rgba(255,255,255,0.6)';
237+
ctx.fillText(language, infoX + 30 * scale, statLine);
238+
239+
// --- Support URL as catalog number ---
240+
if (supportUrl) {
241+
ctx.fillStyle = 'rgba(255,255,255,0.2)';
242+
ctx.font = `${12 * scale}px "JetBrains Mono"`;
243+
ctx.textAlign = 'right';
244+
ctx.textBaseline = 'bottom';
245+
ctx.fillText(`CAT# ${supportUrl}`, width - 40 * scale, height - 24 * scale);
246+
}
247+
248+
// --- Copyright line ---
249+
ctx.fillStyle = 'rgba(255,255,255,0.15)';
250+
ctx.font = `${10 * scale}px "JetBrains Mono"`;
251+
ctx.textAlign = 'left';
252+
ctx.textBaseline = 'bottom';
253+
ctx.fillText(${repoOwner.toUpperCase()} RECORDS. ALL RIGHTS RESERVED.`, 40 * scale, height - 24 * scale);
254+
};

0 commit comments

Comments
 (0)