@bytesbrains/weblocks 0.4.0 → 0.6.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/AGENT.md +19 -3
- package/CATALOG.md +69 -4
- package/CHANGELOG.md +62 -0
- package/README.md +30 -4
- package/catalog.json +406 -9
- package/lib/blocks/brandIcons.d.ts +5 -0
- package/lib/blocks/brandIcons.js +18 -0
- package/lib/blocks/copyright.d.ts +2 -0
- package/lib/blocks/copyright.js +48 -0
- package/lib/blocks/experience.d.ts +2 -0
- package/lib/blocks/experience.js +78 -0
- package/lib/blocks/gallery.js +2 -1
- package/lib/blocks/hero.js +37 -4
- package/lib/blocks/profileHeader.d.ts +2 -0
- package/lib/blocks/profileHeader.js +124 -0
- package/lib/blocks/skills.d.ts +2 -0
- package/lib/blocks/skills.js +91 -0
- package/lib/blocks/socialLinks.js +71 -21
- package/lib/blocks/videoGallery.d.ts +2 -0
- package/lib/blocks/videoGallery.js +110 -0
- package/lib/blocks/videoUtil.d.ts +14 -0
- package/lib/blocks/videoUtil.js +30 -0
- package/lib/islands/carousel.d.ts +1 -0
- package/lib/islands/carousel.js +122 -0
- package/lib/islands/lightbox.d.ts +1 -0
- package/lib/islands/lightbox.js +139 -0
- package/lib/islands/resume.d.ts +1 -0
- package/lib/islands/resume.js +42 -0
- package/lib/islands/video.d.ts +1 -0
- package/lib/islands/video.js +48 -0
- package/lib/registry.js +12 -2
- package/lib/render.d.ts +6 -0
- package/lib/render.js +24 -3
- package/lib/types.d.ts +5 -0
- package/package.json +5 -3
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `experience` — a résumé section of dated, structured entries: role, org,
|
|
3
|
+
* period, location, an optional summary, and achievement bullets. One block for
|
|
4
|
+
* the whole family: set its `title` to "Experience", "Education",
|
|
5
|
+
* "Certifications", "Volunteering", etc. Print-friendly (entries avoid page
|
|
6
|
+
* breaks). Static brick.
|
|
7
|
+
*/
|
|
8
|
+
import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
|
|
9
|
+
const schema = {
|
|
10
|
+
title: { kind: 'string', default: 'Experience', max: 120 },
|
|
11
|
+
items: {
|
|
12
|
+
kind: 'array', max: 24,
|
|
13
|
+
of: {
|
|
14
|
+
kind: 'object',
|
|
15
|
+
fields: {
|
|
16
|
+
role: { kind: 'string', required: true, default: '', max: 120 }, // role / degree / title
|
|
17
|
+
org: { kind: 'string', default: '', max: 120 }, // company / school
|
|
18
|
+
period: { kind: 'string', default: '', max: 60 }, // e.g. "2021 – Present"
|
|
19
|
+
location: { kind: 'string', default: '', max: 80 },
|
|
20
|
+
url: { kind: 'string', default: '', max: 500 }, // org link
|
|
21
|
+
summary: { kind: 'string', default: '', max: 300 },
|
|
22
|
+
bullets: { kind: 'array', max: 12, of: { kind: 'string', default: '', max: 300 } },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
const css = `
|
|
28
|
+
.blk-experience{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
|
|
29
|
+
.blk-experience .wrap{max-width:820px;margin:0 auto}
|
|
30
|
+
.blk-experience h2{font-size:var(--fs-xl);margin:0 0 var(--space);font-weight:800}
|
|
31
|
+
.blk-experience .entry{padding:0 0 calc(var(--space)*1.3);margin-bottom:calc(var(--space)*1.3);border-bottom:1px solid color-mix(in srgb,var(--text) 10%,transparent)}
|
|
32
|
+
.blk-experience .entry:last-child{border-bottom:0;margin-bottom:0}
|
|
33
|
+
.blk-experience .top{display:flex;justify-content:space-between;gap:1em;flex-wrap:wrap;align-items:baseline}
|
|
34
|
+
.blk-experience .role{font-weight:700;font-size:var(--fs-lg)}
|
|
35
|
+
.blk-experience .org{color:var(--primary);font-weight:600}
|
|
36
|
+
.blk-experience .org a{color:inherit;text-decoration:none}
|
|
37
|
+
.blk-experience .org a:hover{text-decoration:underline}
|
|
38
|
+
.blk-experience .period{color:var(--muted);font-size:var(--fs-base);font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
39
|
+
.blk-experience .loc{color:var(--muted);font-size:var(--fs-base);margin:.1em 0 0}
|
|
40
|
+
.blk-experience .summary{margin:.5em 0 0;color:var(--muted)}
|
|
41
|
+
.blk-experience ul{margin:.5em 0 0;padding-left:1.3em}
|
|
42
|
+
.blk-experience li{margin:.25em 0;line-height:1.5}
|
|
43
|
+
`.trim();
|
|
44
|
+
function entry(it) {
|
|
45
|
+
if (!it.role)
|
|
46
|
+
return '';
|
|
47
|
+
const url = sanitizeUrl(it.url);
|
|
48
|
+
const org = it.org
|
|
49
|
+
? `<span class="org">${(url !== '#' && it.url) ? `<a href="${escapeAttr(url)}" rel="noopener">${escapeHtml(it.org)}</a>` : escapeHtml(it.org)}</span>`
|
|
50
|
+
: '';
|
|
51
|
+
const bullets = (it.bullets ?? []).filter(Boolean).map((b) => `<li>${escapeHtml(b)}</li>`).join('');
|
|
52
|
+
return `<div class="entry">
|
|
53
|
+
<div class="top">
|
|
54
|
+
<div><span class="role">${escapeHtml(it.role)}</span>${org ? ` · ${org}` : ''}</div>
|
|
55
|
+
${it.period ? `<span class="period">${escapeHtml(it.period)}</span>` : ''}
|
|
56
|
+
</div>
|
|
57
|
+
${it.location ? `<p class="loc">${escapeHtml(it.location)}</p>` : ''}
|
|
58
|
+
${it.summary ? `<p class="summary">${escapeHtml(it.summary)}</p>` : ''}
|
|
59
|
+
${bullets ? `<ul>${bullets}</ul>` : ''}
|
|
60
|
+
</div>`;
|
|
61
|
+
}
|
|
62
|
+
function render(config) {
|
|
63
|
+
const title = config.title;
|
|
64
|
+
const items = (config.items ?? []).map(entry).filter(Boolean);
|
|
65
|
+
return `<section class="blk-experience" aria-label="${escapeAttr(title || 'Experience')}">
|
|
66
|
+
<div class="wrap">
|
|
67
|
+
${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
|
|
68
|
+
${items.join('\n ')}
|
|
69
|
+
</div>
|
|
70
|
+
</section>`;
|
|
71
|
+
}
|
|
72
|
+
export const experience = {
|
|
73
|
+
type: 'experience',
|
|
74
|
+
description: 'A résumé section of dated entries (role, org, period, location, bullets). Reuse for Experience, Education, Certifications by changing the title.',
|
|
75
|
+
schema,
|
|
76
|
+
css,
|
|
77
|
+
render,
|
|
78
|
+
};
|
package/lib/blocks/gallery.js
CHANGED
|
@@ -44,8 +44,9 @@ function figure(it) {
|
|
|
44
44
|
function render(config) {
|
|
45
45
|
const columns = Number.isInteger(config.columns) ? config.columns : 3;
|
|
46
46
|
const gap = GAP_REM[config.gap] ?? GAP_REM.md;
|
|
47
|
+
const lightbox = config.lightbox === true; // flag for the `lightbox` island
|
|
47
48
|
const items = (config.items ?? []).map(figure).filter(Boolean);
|
|
48
|
-
return `<section class="blk-gallery" aria-label="Gallery">
|
|
49
|
+
return `<section class="blk-gallery" aria-label="Gallery"${lightbox ? ' data-wl-lightbox="true"' : ''}>
|
|
49
50
|
<div class="wrap">
|
|
50
51
|
<div class="grid" style="--cols:${columns};--gap:${gap}">
|
|
51
52
|
${items.join('\n ')}
|
package/lib/blocks/hero.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* `hero` — headline + optional eyebrow/subhead/CTA, optionally over a background
|
|
3
|
+
* **image banner**. When `image` is set the photo sits behind the content with a
|
|
4
|
+
* legibility overlay (scrim/dark/light) and the text flips to a readable colour;
|
|
5
|
+
* with no image it renders exactly as a plain text hero. Static brick.
|
|
6
|
+
*/
|
|
2
7
|
import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
|
|
3
8
|
const schema = {
|
|
4
9
|
eyebrow: { kind: 'string', default: '', max: 80 },
|
|
5
10
|
headline: { kind: 'string', required: true, default: 'Your headline here', max: 160 },
|
|
6
11
|
subhead: { kind: 'string', default: '', max: 320 },
|
|
7
12
|
align: { kind: 'enum', values: ['center', 'left'], default: 'center' },
|
|
13
|
+
image: { kind: 'string', default: '', max: 500 }, // background banner image
|
|
14
|
+
overlay: { kind: 'enum', values: ['scrim', 'dark', 'light', 'none'], default: 'scrim' }, // legibility layer (with image)
|
|
15
|
+
minHeight: { kind: 'enum', values: ['auto', 'sm', 'md', 'lg', 'full'], default: 'auto' },
|
|
8
16
|
cta: {
|
|
9
17
|
kind: 'object',
|
|
10
18
|
fields: {
|
|
@@ -22,6 +30,19 @@ const css = `
|
|
|
22
30
|
.blk-hero p{font-size:var(--fs-lg);color:var(--muted);margin:0;max-width:60ch}
|
|
23
31
|
.blk-hero .cta{display:inline-block;background:var(--primary);color:var(--on-primary);padding:.7em 1.4em;border-radius:var(--radius);text-decoration:none;font-weight:600;transition:transform var(--motion)}
|
|
24
32
|
.blk-hero .cta:hover{transform:translateY(-1px)}
|
|
33
|
+
/* Image banner */
|
|
34
|
+
.blk-hero.has-image{position:relative;isolation:isolate;color:#fff}
|
|
35
|
+
.blk-hero.has-image .bg{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:-2}
|
|
36
|
+
.blk-hero.has-image .scrim{position:absolute;inset:0;z-index:-1}
|
|
37
|
+
.blk-hero.overlay-dark .scrim{background:rgba(0,0,0,.55)}
|
|
38
|
+
.blk-hero.overlay-scrim .scrim{background:linear-gradient(180deg,rgba(0,0,0,.2),rgba(0,0,0,.6))}
|
|
39
|
+
.blk-hero.overlay-light{color:var(--text)}
|
|
40
|
+
.blk-hero.overlay-light .scrim{background:rgba(255,255,255,.62)}
|
|
41
|
+
.blk-hero.has-image.overlay-none{color:var(--text)}
|
|
42
|
+
.blk-hero.overlay-none .scrim{display:none}
|
|
43
|
+
.blk-hero.has-image .eyebrow,.blk-hero.has-image p{color:inherit;opacity:.9}
|
|
44
|
+
.blk-hero.mh-sm{min-height:36vh}.blk-hero.mh-md{min-height:52vh}.blk-hero.mh-lg{min-height:72vh}.blk-hero.mh-full{min-height:100vh}
|
|
45
|
+
.blk-hero.mh-sm,.blk-hero.mh-md,.blk-hero.mh-lg,.blk-hero.mh-full{display:flex;flex-direction:column;justify-content:center}
|
|
25
46
|
`.trim();
|
|
26
47
|
function render(config) {
|
|
27
48
|
const eyebrow = config.eyebrow;
|
|
@@ -29,20 +50,32 @@ function render(config) {
|
|
|
29
50
|
const subhead = config.subhead;
|
|
30
51
|
const align = config.align;
|
|
31
52
|
const cta = config.cta;
|
|
53
|
+
const imageUrl = sanitizeUrl(config.image);
|
|
54
|
+
const hasImg = imageUrl !== '#' && !!config.image;
|
|
55
|
+
const overlay = ['scrim', 'dark', 'light', 'none'].includes(config.overlay) ? config.overlay : 'scrim';
|
|
56
|
+
const minHeight = ['auto', 'sm', 'md', 'lg', 'full'].includes(config.minHeight) ? config.minHeight : 'auto';
|
|
57
|
+
const classes = ['blk-hero'];
|
|
58
|
+
if (hasImg)
|
|
59
|
+
classes.push('has-image', `overlay-${overlay}`);
|
|
60
|
+
if (minHeight !== 'auto')
|
|
61
|
+
classes.push(`mh-${minHeight}`);
|
|
62
|
+
const banner = hasImg
|
|
63
|
+
? `<img class="bg" src="${escapeAttr(imageUrl)}" alt="" aria-hidden="true">\n <div class="scrim"></div>\n `
|
|
64
|
+
: '';
|
|
32
65
|
const parts = [
|
|
33
66
|
eyebrow ? `<p class="eyebrow">${escapeHtml(eyebrow)}</p>` : '',
|
|
34
67
|
`<h1>${escapeHtml(headline)}</h1>`,
|
|
35
68
|
subhead ? `<p>${escapeHtml(subhead)}</p>` : '',
|
|
36
69
|
cta.label ? `<a class="cta" href="${escapeAttr(sanitizeUrl(cta.href))}">${escapeHtml(cta.label)}</a>` : '',
|
|
37
70
|
].filter(Boolean).join('\n ');
|
|
38
|
-
return `<section class="
|
|
39
|
-
<div class="wrap">
|
|
71
|
+
return `<section class="${classes.join(' ')}" data-align="${escapeAttr(align)}" aria-label="Hero">
|
|
72
|
+
${banner}<div class="wrap">
|
|
40
73
|
${parts}
|
|
41
74
|
</div>
|
|
42
75
|
</section>`;
|
|
43
76
|
}
|
|
44
77
|
export const hero = {
|
|
45
78
|
type: 'hero',
|
|
46
|
-
description: 'Top-of-page
|
|
79
|
+
description: 'Top-of-page hero: a big headline with optional eyebrow, subheading, and CTA — optionally over a full-bleed background image banner with a legibility overlay.',
|
|
47
80
|
schema, css, render,
|
|
48
81
|
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `profile-header` — the résumé/CV header: avatar, name, role/headline, location,
|
|
3
|
+
* a contact + social row (brand icons), an optional short summary, and optional
|
|
4
|
+
* **Download-PDF** (print) and **Share** action buttons. The buttons are wired by
|
|
5
|
+
* the `resume` island (print → `window.print()` with the engine's print styles;
|
|
6
|
+
* share → Web Share API + copy-link fallback) and are hidden from the printout.
|
|
7
|
+
* Static markup; island only ships when an action button is enabled.
|
|
8
|
+
*/
|
|
9
|
+
import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
|
|
10
|
+
import { BRAND_ICON_PATHS } from './brandIcons.js';
|
|
11
|
+
// Generic (non-brand) contact glyphs — filled, 24×24.
|
|
12
|
+
const GENERIC = {
|
|
13
|
+
email: 'M2 6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v.5l-10 6.25L2 6.5V6zm0 2.85V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8.85l-9.47 5.92a1 1 0 0 1-1.06 0L2 8.85z',
|
|
14
|
+
phone: 'M6.62 2.53c.5-.2 1.08.02 1.36.49l1.7 3.01c.24.42.18.95-.15 1.3L8.06 9.03a12 12 0 0 0 6.9 6.9l1.7-1.47c.35-.33.88-.39 1.3-.15l3.01 1.7c.47.28.69.86.49 1.36l-.98 2.4c-.22.55-.79.9-1.4.83C10.7 20.1 3.9 13.3 3.1 5.9c-.07-.6.28-1.18.83-1.4l2.69-1.97z',
|
|
15
|
+
website: 'M14 3h7v7h-2V6.41l-8.29 8.3-1.42-1.42L17.59 5H14V3zM5 5h5v2H5v12h12v-5h2v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z',
|
|
16
|
+
location: 'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5A2.5 2.5 0 1 1 12 6.5a2.5 2.5 0 0 1 0 5z',
|
|
17
|
+
};
|
|
18
|
+
const CONTACT_TYPES = ['email', 'phone', 'website', 'location', 'linkedin', 'github', 'twitter', 'x', 'custom'];
|
|
19
|
+
const schema = {
|
|
20
|
+
name: { kind: 'string', required: true, default: '', max: 120 },
|
|
21
|
+
headline: { kind: 'string', default: '', max: 160 }, // role / tagline
|
|
22
|
+
location: { kind: 'string', default: '', max: 120 },
|
|
23
|
+
avatar: { kind: 'string', default: '', max: 500 },
|
|
24
|
+
summary: { kind: 'string', default: '', max: 400 },
|
|
25
|
+
contacts: {
|
|
26
|
+
kind: 'array', max: 10,
|
|
27
|
+
of: {
|
|
28
|
+
kind: 'object',
|
|
29
|
+
fields: {
|
|
30
|
+
type: { kind: 'enum', values: CONTACT_TYPES, default: 'custom' },
|
|
31
|
+
value: { kind: 'string', required: true, default: '', max: 300 }, // email / phone / url / text
|
|
32
|
+
label: { kind: 'string', default: '', max: 80 }, // shown text (defaults to value)
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
showDownload: { kind: 'boolean', default: false },
|
|
37
|
+
downloadLabel: { kind: 'string', default: 'Download PDF', max: 40 },
|
|
38
|
+
showShare: { kind: 'boolean', default: false },
|
|
39
|
+
shareLabel: { kind: 'string', default: 'Share', max: 40 },
|
|
40
|
+
};
|
|
41
|
+
const css = `
|
|
42
|
+
.blk-profile-header{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
|
|
43
|
+
.blk-profile-header .wrap{max-width:900px;margin:0 auto;display:flex;gap:calc(var(--space)*1.4);align-items:center;flex-wrap:wrap}
|
|
44
|
+
.blk-profile-header .avatar,.blk-profile-header .initials{width:112px;height:112px;border-radius:999px;flex:0 0 auto;object-fit:cover;display:flex;align-items:center;justify-content:center}
|
|
45
|
+
.blk-profile-header .initials{background:color-mix(in srgb,var(--primary) 18%,var(--surface));color:var(--primary);font-weight:800;font-size:var(--fs-xl)}
|
|
46
|
+
.blk-profile-header .info{flex:1;min-width:min(280px,100%)}
|
|
47
|
+
.blk-profile-header h1{margin:0;font-size:var(--fs-xl);font-weight:800;line-height:1.1}
|
|
48
|
+
.blk-profile-header .role{margin:.2em 0 0;color:var(--primary);font-weight:600;font-size:var(--fs-lg)}
|
|
49
|
+
.blk-profile-header .summary{margin:.5em 0 0;color:var(--muted)}
|
|
50
|
+
.blk-profile-header .contacts{display:flex;flex-wrap:wrap;gap:.4em 1.1em;margin:.8em 0 0}
|
|
51
|
+
.blk-profile-header .contacts a,.blk-profile-header .contacts span.c{display:inline-flex;align-items:center;gap:.4em;color:var(--muted);text-decoration:none;font-size:var(--fs-base)}
|
|
52
|
+
.blk-profile-header .contacts a:hover{color:var(--primary)}
|
|
53
|
+
.blk-profile-header .contacts svg{width:1.05em;height:1.05em;flex:0 0 auto}
|
|
54
|
+
.blk-profile-header .actions{display:flex;gap:.6em;flex-wrap:wrap;margin:calc(var(--space)*1.1) 0 0;flex-basis:100%}
|
|
55
|
+
.blk-profile-header .btn{font:inherit;font-weight:700;cursor:pointer;border:0;border-radius:var(--radius);padding:.6em 1.2em;background:var(--primary);color:var(--on-primary)}
|
|
56
|
+
.blk-profile-header .btn.ghost{background:transparent;color:var(--primary);border:1px solid var(--primary)}
|
|
57
|
+
@media(max-width:560px){.blk-profile-header .wrap{justify-content:center;text-align:center}.blk-profile-header .contacts{justify-content:center}}
|
|
58
|
+
`.trim();
|
|
59
|
+
function svg(path) {
|
|
60
|
+
return `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="${path}"></path></svg>`;
|
|
61
|
+
}
|
|
62
|
+
function initials(name) {
|
|
63
|
+
return name.trim().split(/\s+/).slice(0, 2).map((w) => w[0] ?? '').join('').toUpperCase() || '?';
|
|
64
|
+
}
|
|
65
|
+
function contact(c) {
|
|
66
|
+
const type = CONTACT_TYPES.includes(c.type) ? c.type : 'custom';
|
|
67
|
+
const value = c.value;
|
|
68
|
+
if (!value)
|
|
69
|
+
return '';
|
|
70
|
+
const text = escapeHtml(c.label || value);
|
|
71
|
+
const iconPath = type === 'twitter' || type === 'x' ? BRAND_ICON_PATHS.x
|
|
72
|
+
: (BRAND_ICON_PATHS[type] ?? GENERIC[type]);
|
|
73
|
+
const icon = iconPath ? svg(iconPath) : '';
|
|
74
|
+
if (type === 'location')
|
|
75
|
+
return `<span class="c">${icon}${text}</span>`;
|
|
76
|
+
let href = value;
|
|
77
|
+
if (type === 'email')
|
|
78
|
+
href = `mailto:${value}`;
|
|
79
|
+
else if (type === 'phone')
|
|
80
|
+
href = `tel:${value.replace(/[^0-9+]/g, '')}`;
|
|
81
|
+
const safe = sanitizeUrl(href);
|
|
82
|
+
if (safe === '#')
|
|
83
|
+
return `<span class="c">${icon}${text}</span>`;
|
|
84
|
+
return `<a href="${escapeAttr(safe)}" rel="noopener">${icon}${text}</a>`;
|
|
85
|
+
}
|
|
86
|
+
function render(config) {
|
|
87
|
+
const name = config.name;
|
|
88
|
+
const headline = config.headline;
|
|
89
|
+
const summary = config.summary;
|
|
90
|
+
const avatarUrl = sanitizeUrl(config.avatar);
|
|
91
|
+
const hasAvatar = avatarUrl !== '#' && !!config.avatar;
|
|
92
|
+
const contacts = (config.contacts ?? []).map(contact).filter(Boolean);
|
|
93
|
+
const showDownload = config.showDownload === true;
|
|
94
|
+
const showShare = config.showShare === true;
|
|
95
|
+
const avatar = hasAvatar
|
|
96
|
+
? `<img class="avatar" src="${escapeAttr(avatarUrl)}" alt="${escapeAttr(name)}">`
|
|
97
|
+
: `<div class="initials" aria-hidden="true">${escapeHtml(initials(name))}</div>`;
|
|
98
|
+
const actions = (showDownload || showShare)
|
|
99
|
+
? `<div class="actions" data-wl-noprint>
|
|
100
|
+
${showDownload ? `<button class="btn" type="button" data-wl-print>${escapeHtml(config.downloadLabel || 'Download PDF')}</button>` : ''}
|
|
101
|
+
${showShare ? `<button class="btn ghost" type="button" data-wl-share>${escapeHtml(config.shareLabel || 'Share')}</button>` : ''}
|
|
102
|
+
</div>`
|
|
103
|
+
: '';
|
|
104
|
+
return `<section class="blk-profile-header" aria-label="${escapeAttr(name || 'Profile')}">
|
|
105
|
+
<div class="wrap">
|
|
106
|
+
${avatar}
|
|
107
|
+
<div class="info">
|
|
108
|
+
<h1>${escapeHtml(name)}</h1>
|
|
109
|
+
${headline ? `<p class="role">${escapeHtml(headline)}</p>` : ''}
|
|
110
|
+
${summary ? `<p class="summary">${escapeHtml(summary)}</p>` : ''}
|
|
111
|
+
${contacts.length ? `<div class="contacts">${contacts.join('')}</div>` : ''}
|
|
112
|
+
</div>
|
|
113
|
+
${actions}
|
|
114
|
+
</div>
|
|
115
|
+
</section>`;
|
|
116
|
+
}
|
|
117
|
+
export const profileHeader = {
|
|
118
|
+
type: 'profile-header',
|
|
119
|
+
description: 'A résumé/CV header: avatar, name, role, location, a contact + social row, and optional Download-PDF and Share buttons.',
|
|
120
|
+
schema,
|
|
121
|
+
css,
|
|
122
|
+
render,
|
|
123
|
+
island: 'resume',
|
|
124
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `skills` — grouped skills shown as **tags** (chips, with optional proficiency
|
|
3
|
+
* dots) or **bars** (labelled level bars). Doubles for Languages, Tools, etc.
|
|
4
|
+
* Static brick.
|
|
5
|
+
*/
|
|
6
|
+
import { escapeAttr, escapeHtml } from '../schema.js';
|
|
7
|
+
const schema = {
|
|
8
|
+
title: { kind: 'string', default: 'Skills', max: 120 },
|
|
9
|
+
display: { kind: 'enum', values: ['tags', 'bars'], default: 'tags' },
|
|
10
|
+
groups: {
|
|
11
|
+
kind: 'array', max: 10,
|
|
12
|
+
of: {
|
|
13
|
+
kind: 'object',
|
|
14
|
+
fields: {
|
|
15
|
+
name: { kind: 'string', default: '', max: 60 }, // e.g. "Languages"
|
|
16
|
+
items: {
|
|
17
|
+
kind: 'array', max: 40,
|
|
18
|
+
of: {
|
|
19
|
+
kind: 'object',
|
|
20
|
+
fields: {
|
|
21
|
+
label: { kind: 'string', required: true, default: '', max: 60 },
|
|
22
|
+
level: { kind: 'int', min: 0, max: 5, default: 0 }, // 0 = unrated
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
const css = `
|
|
31
|
+
.blk-skills{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
|
|
32
|
+
.blk-skills .wrap{max-width:820px;margin:0 auto}
|
|
33
|
+
.blk-skills h2{font-size:var(--fs-xl);margin:0 0 var(--space);font-weight:800}
|
|
34
|
+
.blk-skills .group{margin-bottom:calc(var(--space)*1.1)}
|
|
35
|
+
.blk-skills .group:last-child{margin-bottom:0}
|
|
36
|
+
.blk-skills .gname{font-weight:700;font-size:var(--fs-base);margin:0 0 .5em;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
|
|
37
|
+
.blk-skills .tags{display:flex;flex-wrap:wrap;gap:.5em}
|
|
38
|
+
.blk-skills .tag{display:inline-flex;align-items:center;gap:.5em;padding:.4em .8em;border-radius:999px;background:var(--surface);border:1px solid color-mix(in srgb,var(--text) 14%,transparent);font-size:var(--fs-base);font-weight:600}
|
|
39
|
+
.blk-skills .dots{display:inline-flex;gap:2px}
|
|
40
|
+
.blk-skills .dots i{width:6px;height:6px;border-radius:999px;background:color-mix(in srgb,var(--text) 25%,transparent)}
|
|
41
|
+
.blk-skills .dots i.on{background:var(--primary)}
|
|
42
|
+
.blk-skills .bars{display:grid;gap:.6em}
|
|
43
|
+
.blk-skills .bar{display:grid;grid-template-columns:minmax(90px,180px) 1fr;gap:.8em;align-items:center}
|
|
44
|
+
.blk-skills .bar .blabel{font-weight:600;font-size:var(--fs-base)}
|
|
45
|
+
.blk-skills .track{height:8px;border-radius:999px;background:color-mix(in srgb,var(--text) 12%,transparent);overflow:hidden}
|
|
46
|
+
.blk-skills .fill{height:100%;background:var(--primary);border-radius:999px}
|
|
47
|
+
@media(max-width:480px){.blk-skills .bar{grid-template-columns:1fr}}
|
|
48
|
+
`.trim();
|
|
49
|
+
function dots(level) {
|
|
50
|
+
const n = Math.max(0, Math.min(5, level | 0));
|
|
51
|
+
if (!n)
|
|
52
|
+
return '';
|
|
53
|
+
return `<span class="dots" aria-hidden="true">${Array.from({ length: 5 }, (_v, i) => `<i class="${i < n ? 'on' : ''}"></i>`).join('')}</span>`;
|
|
54
|
+
}
|
|
55
|
+
function group(g, display) {
|
|
56
|
+
const items = (g.items ?? []).filter((s) => s && s.label);
|
|
57
|
+
if (!items.length)
|
|
58
|
+
return '';
|
|
59
|
+
let body;
|
|
60
|
+
if (display === 'bars') {
|
|
61
|
+
body = `<div class="bars">${items.map((s) => {
|
|
62
|
+
const pct = Math.max(0, Math.min(5, s.level | 0)) * 20;
|
|
63
|
+
return `<div class="bar"><span class="blabel">${escapeHtml(s.label)}</span><span class="track"><span class="fill" style="width:${pct}%"></span></span></div>`;
|
|
64
|
+
}).join('')}</div>`;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
body = `<div class="tags">${items.map((s) => `<span class="tag">${escapeHtml(s.label)}${dots(s.level)}</span>`).join('')}</div>`;
|
|
68
|
+
}
|
|
69
|
+
return `<div class="group">
|
|
70
|
+
${g.name ? `<p class="gname">${escapeHtml(g.name)}</p>` : ''}
|
|
71
|
+
${body}
|
|
72
|
+
</div>`;
|
|
73
|
+
}
|
|
74
|
+
function render(config) {
|
|
75
|
+
const title = config.title;
|
|
76
|
+
const display = config.display === 'bars' ? 'bars' : 'tags';
|
|
77
|
+
const groups = (config.groups ?? []).map((g) => group(g, display)).filter(Boolean);
|
|
78
|
+
return `<section class="blk-skills" aria-label="${escapeAttr(title || 'Skills')}">
|
|
79
|
+
<div class="wrap">
|
|
80
|
+
${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
|
|
81
|
+
${groups.join('\n ')}
|
|
82
|
+
</div>
|
|
83
|
+
</section>`;
|
|
84
|
+
}
|
|
85
|
+
export const skills = {
|
|
86
|
+
type: 'skills',
|
|
87
|
+
description: 'Grouped skills shown as tags (with optional proficiency dots) or labelled level bars; also for languages and tools.',
|
|
88
|
+
schema,
|
|
89
|
+
css,
|
|
90
|
+
render,
|
|
91
|
+
};
|
|
@@ -1,44 +1,94 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* `social-links` — one row/grid of links to social or contact profiles.
|
|
3
|
+
*
|
|
4
|
+
* A single generic block (not one per network): you add only the accounts you
|
|
5
|
+
* have, so unset ones are simply absent. Each link's `platform` selects a
|
|
6
|
+
* built-in monochrome brand icon (from simple-icons, themed via `currentColor`)
|
|
7
|
+
* and a default accessible label; `custom` (or an unknown platform) falls back to
|
|
8
|
+
* your own emoji/glyph `icon`. Layout adapts via `layout` (row/grid) and
|
|
9
|
+
* `variant` (labeled/icon-only). Static brick.
|
|
10
|
+
*/
|
|
2
11
|
import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
|
|
12
|
+
import { BRAND_ICON_PATHS } from './brandIcons.js';
|
|
13
|
+
const PLATFORMS = [
|
|
14
|
+
'x', 'twitter', 'instagram', 'facebook', 'linkedin', 'youtube', 'github', 'tiktok',
|
|
15
|
+
'whatsapp', 'telegram', 'discord', 'mastodon', 'rss', 'email', 'website', 'phone', 'custom',
|
|
16
|
+
];
|
|
17
|
+
/** Default accessible label per platform (author `label` overrides). */
|
|
18
|
+
const LABELS = {
|
|
19
|
+
x: 'X', twitter: 'X', instagram: 'Instagram', facebook: 'Facebook', linkedin: 'LinkedIn',
|
|
20
|
+
youtube: 'YouTube', github: 'GitHub', tiktok: 'TikTok', whatsapp: 'WhatsApp',
|
|
21
|
+
telegram: 'Telegram', discord: 'Discord', mastodon: 'Mastodon', rss: 'RSS',
|
|
22
|
+
email: 'Email', website: 'Website', phone: 'Phone',
|
|
23
|
+
};
|
|
24
|
+
/** Simple monochrome (non-brand) contact icons — filled, 24×24. */
|
|
25
|
+
const GENERIC_ICONS = {
|
|
26
|
+
email: 'M2 6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v.5l-10 6.25L2 6.5V6zm0 2.85V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8.85l-9.47 5.92a1 1 0 0 1-1.06 0L2 8.85z',
|
|
27
|
+
website: 'M14 3h7v7h-2V6.41l-8.29 8.3-1.42-1.42L17.59 5H14V3zM5 5h5v2H5v12h12v-5h2v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z',
|
|
28
|
+
phone: 'M6.62 2.53c.5-.2 1.08.02 1.36.49l1.7 3.01c.24.42.18.95-.15 1.3L8.06 9.03a12 12 0 0 0 6.9 6.9l1.7-1.47c.35-.33.88-.39 1.3-.15l3.01 1.7c.47.28.69.86.49 1.36l-.98 2.4c-.22.55-.79.9-1.4.83C10.7 20.1 3.9 13.3 3.1 5.9c-.07-.6.28-1.18.83-1.4l2.69-1.97z',
|
|
29
|
+
};
|
|
3
30
|
const schema = {
|
|
4
31
|
title: { kind: 'string', default: '', max: 120 },
|
|
32
|
+
layout: { kind: 'enum', values: ['row', 'grid'], default: 'row' },
|
|
33
|
+
variant: { kind: 'enum', values: ['labeled', 'icon'], default: 'labeled' },
|
|
34
|
+
align: { kind: 'enum', values: ['start', 'center', 'end'], default: 'center' },
|
|
5
35
|
links: {
|
|
6
|
-
kind: 'array', max:
|
|
36
|
+
kind: 'array', max: 20,
|
|
7
37
|
of: {
|
|
8
38
|
kind: 'object',
|
|
9
39
|
fields: {
|
|
10
|
-
|
|
40
|
+
platform: { kind: 'enum', values: PLATFORMS, default: 'custom' },
|
|
11
41
|
href: { kind: 'string', required: true, default: '', max: 500 },
|
|
12
|
-
|
|
42
|
+
label: { kind: 'string', default: '', max: 60 }, // overrides the platform default
|
|
43
|
+
icon: { kind: 'string', default: '', max: 8 }, // emoji/glyph for `custom`
|
|
13
44
|
},
|
|
14
45
|
},
|
|
15
46
|
},
|
|
16
47
|
};
|
|
17
48
|
const css = `
|
|
18
|
-
.blk-social-links{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)
|
|
19
|
-
.blk-social-links .wrap{max-width:
|
|
20
|
-
.blk-social-links h2{font-size:var(--fs-lg);font-weight:700;margin:0 0 var(--space)}
|
|
21
|
-
.blk-social-links .
|
|
22
|
-
.blk-social-links
|
|
23
|
-
.blk-social-links
|
|
24
|
-
.blk-social-links .
|
|
49
|
+
.blk-social-links{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
|
|
50
|
+
.blk-social-links .wrap{max-width:900px;margin:0 auto}
|
|
51
|
+
.blk-social-links h2{text-align:center;font-size:var(--fs-lg);font-weight:700;margin:0 0 var(--space)}
|
|
52
|
+
.blk-social-links .items{display:flex;flex-wrap:wrap;gap:.7em}
|
|
53
|
+
.blk-social-links.layout-grid .items{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}
|
|
54
|
+
.blk-social-links.align-center .items{justify-content:center}
|
|
55
|
+
.blk-social-links.align-end .items{justify-content:flex-end}
|
|
56
|
+
.blk-social-links .s-item{display:inline-flex;align-items:center;justify-content:center;gap:.5em;padding:.55em .95em;border-radius:var(--radius);text-decoration:none;color:var(--text);background:var(--surface);border:1px solid color-mix(in srgb,var(--text) 14%,transparent);font-size:var(--fs-base);font-weight:600;transition:color var(--motion),border-color var(--motion),transform var(--motion)}
|
|
57
|
+
.blk-social-links .s-item:hover,.blk-social-links .s-item:focus-visible{color:var(--primary);border-color:var(--primary);transform:translateY(-1px)}
|
|
58
|
+
.blk-social-links .s-item.icon-only{padding:0;width:2.9em;height:2.9em;border-radius:999px}
|
|
59
|
+
.blk-social-links .ico{flex:0 0 auto;width:1.2em;height:1.2em;display:block}
|
|
60
|
+
.blk-social-links .ico.emoji{font-size:1.15em;line-height:1;width:auto;height:auto}
|
|
25
61
|
`.trim();
|
|
26
|
-
function
|
|
62
|
+
function iconMarkup(platform, emoji) {
|
|
63
|
+
const path = platform === 'twitter' ? BRAND_ICON_PATHS.x : (BRAND_ICON_PATHS[platform] ?? GENERIC_ICONS[platform]);
|
|
64
|
+
if (path)
|
|
65
|
+
return `<svg class="ico" viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="${path}"></path></svg>`;
|
|
66
|
+
return emoji ? `<span class="ico emoji" aria-hidden="true">${escapeHtml(emoji)}</span>` : '';
|
|
67
|
+
}
|
|
68
|
+
function item(l, iconOnly) {
|
|
27
69
|
const href = sanitizeUrl(l.href);
|
|
28
|
-
if (
|
|
29
|
-
return '';
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
70
|
+
if (href === '#' || !l.href)
|
|
71
|
+
return ''; // no destination → drop (unset stays hidden)
|
|
72
|
+
const platform = String(l.platform || 'custom');
|
|
73
|
+
const label = l.label || LABELS[platform] || 'Link';
|
|
74
|
+
const icon = iconMarkup(platform, l.icon);
|
|
75
|
+
// icon-only mode needs the label as the accessible name; labeled mode shows it.
|
|
76
|
+
return `<a class="s-item${iconOnly ? ' icon-only' : ''}" href="${escapeAttr(href)}" rel="noopener"${iconOnly ? ` aria-label="${escapeAttr(label)}"` : ''}>
|
|
77
|
+
${icon}
|
|
78
|
+
${iconOnly ? '' : `<span class="lbl">${escapeHtml(label)}</span>`}
|
|
33
79
|
</a>`;
|
|
34
80
|
}
|
|
35
81
|
function render(config) {
|
|
36
82
|
const title = config.title;
|
|
37
|
-
const
|
|
38
|
-
|
|
83
|
+
const layout = config.layout === 'grid' ? 'grid' : 'row';
|
|
84
|
+
const variant = config.variant === 'icon' ? 'icon' : 'labeled';
|
|
85
|
+
const align = ['start', 'center', 'end'].includes(config.align) ? config.align : 'center';
|
|
86
|
+
const iconOnly = variant === 'icon';
|
|
87
|
+
const links = (config.links ?? []).map((l) => item(l, iconOnly)).filter(Boolean);
|
|
88
|
+
return `<section class="blk-social-links layout-${layout} align-${align}" aria-label="${escapeAttr(title || 'Social links')}">
|
|
39
89
|
<div class="wrap">
|
|
40
90
|
${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
|
|
41
|
-
<div class="
|
|
91
|
+
<div class="items">
|
|
42
92
|
${links.join('\n ')}
|
|
43
93
|
</div>
|
|
44
94
|
</div>
|
|
@@ -46,7 +96,7 @@ function render(config) {
|
|
|
46
96
|
}
|
|
47
97
|
export const socialLinks = {
|
|
48
98
|
type: 'social-links',
|
|
49
|
-
description: 'A
|
|
99
|
+
description: 'A row or grid of links to social/contact profiles; each link picks a built-in brand icon by platform (or a custom emoji), shown labeled or icon-only.',
|
|
50
100
|
schema,
|
|
51
101
|
css,
|
|
52
102
|
render,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `video-gallery` — a grid or carousel of **click-to-play video cards**
|
|
3
|
+
* (YouTube / Vimeo / self-hosted). Each card is a lightweight *facade*: a
|
|
4
|
+
* thumbnail + play button, so no heavy player iframe loads until the visitor
|
|
5
|
+
* presses play — then the `video` island swaps in the real player inline.
|
|
6
|
+
*
|
|
7
|
+
* Progressive enhancement: with no JavaScript each card is a link that opens the
|
|
8
|
+
* video on its platform; with the island, it plays inline. YouTube thumbnails are
|
|
9
|
+
* derived from the id automatically (give a `poster` for Vimeo/file). Static-first
|
|
10
|
+
* brick — the island only adds inline playback.
|
|
11
|
+
*/
|
|
12
|
+
import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
|
|
13
|
+
import { youtubeId, vimeoId, youtubeThumb, youtubeEmbed, youtubeWatch, vimeoEmbed, vimeoWatch, PLAY_ICON, } from './videoUtil.js';
|
|
14
|
+
const schema = {
|
|
15
|
+
title: { kind: 'string', default: '', max: 120 },
|
|
16
|
+
layout: { kind: 'enum', values: ['grid', 'carousel'], default: 'grid' },
|
|
17
|
+
columns: { kind: 'int', oneOf: [2, 3, 4], default: 3 },
|
|
18
|
+
items: {
|
|
19
|
+
kind: 'array', max: 24,
|
|
20
|
+
of: {
|
|
21
|
+
kind: 'object',
|
|
22
|
+
fields: {
|
|
23
|
+
provider: { kind: 'enum', values: ['youtube', 'vimeo', 'file'], default: 'youtube' },
|
|
24
|
+
src: { kind: 'string', required: true, default: '', max: 500 }, // id or url
|
|
25
|
+
title: { kind: 'string', default: '', max: 160 },
|
|
26
|
+
poster: { kind: 'string', default: '', max: 500 }, // thumbnail (auto for youtube)
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
const css = `
|
|
32
|
+
.blk-video-gallery{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
|
|
33
|
+
.blk-video-gallery .wrap{max-width:1120px;margin:0 auto}
|
|
34
|
+
.blk-video-gallery h2{font-size:var(--fs-xl);margin:0 0 var(--space-lg);font-weight:800;text-align:center}
|
|
35
|
+
.blk-video-gallery .grid{display:grid;grid-template-columns:repeat(var(--cols,3),1fr);gap:calc(var(--space)*1.2)}
|
|
36
|
+
.blk-video-gallery.layout-carousel .grid{display:flex;grid-template-columns:none;overflow-x:auto;scroll-snap-type:x mandatory;padding-bottom:.5em}
|
|
37
|
+
.blk-video-gallery.layout-carousel .v-card{flex:0 0 min(88%,440px);scroll-snap-align:center}
|
|
38
|
+
.blk-video-gallery .v-card{display:block;text-decoration:none;color:inherit;background:var(--surface);border-radius:var(--radius);overflow:hidden;transition:transform var(--motion)}
|
|
39
|
+
.blk-video-gallery .v-card:hover{transform:translateY(-3px)}
|
|
40
|
+
.blk-video-gallery .v-media{position:relative;display:block;aspect-ratio:16/9;background:#000}
|
|
41
|
+
.blk-video-gallery .v-media img{width:100%;height:100%;object-fit:cover;display:block}
|
|
42
|
+
.blk-video-gallery .v-play{position:absolute;inset:0;margin:auto;width:62px;height:62px;display:flex;align-items:center;justify-content:center;border-radius:999px;background:rgba(0,0,0,.55);color:#fff;transition:background var(--motion),transform var(--motion)}
|
|
43
|
+
.blk-video-gallery .v-card:hover .v-play{background:var(--primary);color:var(--on-primary);transform:scale(1.06)}
|
|
44
|
+
.blk-video-gallery .v-play svg{margin-left:3px}
|
|
45
|
+
.blk-video-gallery .v-title{display:block;padding:.7em .9em;font-weight:600;font-size:var(--fs-base)}
|
|
46
|
+
.blk-video-gallery .v-frame{position:absolute;inset:0;width:100%;height:100%;border:0}
|
|
47
|
+
@media(max-width:720px){.blk-video-gallery .grid{grid-template-columns:1fr 1fr}}
|
|
48
|
+
@media(max-width:440px){.blk-video-gallery .grid{grid-template-columns:1fr}}
|
|
49
|
+
`.trim();
|
|
50
|
+
function card(it) {
|
|
51
|
+
const provider = ['youtube', 'vimeo', 'file'].includes(it.provider) ? it.provider : 'youtube';
|
|
52
|
+
const title = it.title;
|
|
53
|
+
const posterUrl = sanitizeUrl(it.poster);
|
|
54
|
+
const poster = (posterUrl !== '#' && it.poster) ? posterUrl : '';
|
|
55
|
+
let thumb = '', embed = '', watch = '';
|
|
56
|
+
if (provider === 'youtube') {
|
|
57
|
+
const id = youtubeId(it.src);
|
|
58
|
+
if (!id)
|
|
59
|
+
return '';
|
|
60
|
+
thumb = poster || youtubeThumb(id);
|
|
61
|
+
embed = youtubeEmbed(id, true);
|
|
62
|
+
watch = youtubeWatch(id);
|
|
63
|
+
}
|
|
64
|
+
else if (provider === 'vimeo') {
|
|
65
|
+
const id = vimeoId(it.src);
|
|
66
|
+
if (!id)
|
|
67
|
+
return '';
|
|
68
|
+
thumb = poster;
|
|
69
|
+
embed = vimeoEmbed(id, true);
|
|
70
|
+
watch = vimeoWatch(id);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
const url = sanitizeUrl(it.src);
|
|
74
|
+
if (url === '#' || !it.src)
|
|
75
|
+
return '';
|
|
76
|
+
thumb = poster;
|
|
77
|
+
embed = url;
|
|
78
|
+
watch = url;
|
|
79
|
+
}
|
|
80
|
+
const media = `<span class="v-media">
|
|
81
|
+
${thumb ? `<img src="${escapeAttr(thumb)}" alt="${escapeAttr(title)}" loading="lazy">` : ''}
|
|
82
|
+
<span class="v-play" aria-hidden="true">${PLAY_ICON}</span>
|
|
83
|
+
</span>`;
|
|
84
|
+
return `<a class="v-card" href="${escapeAttr(watch)}" target="_blank" rel="noopener" data-wl-video data-wl-provider="${provider}" data-wl-embed="${escapeAttr(embed)}" data-wl-title="${escapeAttr(title || 'Video')}" aria-label="Play video: ${escapeAttr(title || 'video')}">
|
|
85
|
+
${media}
|
|
86
|
+
${title ? `<span class="v-title">${escapeHtml(title)}</span>` : ''}
|
|
87
|
+
</a>`;
|
|
88
|
+
}
|
|
89
|
+
function render(config) {
|
|
90
|
+
const title = config.title;
|
|
91
|
+
const layout = config.layout === 'carousel' ? 'carousel' : 'grid';
|
|
92
|
+
const columns = Number.isInteger(config.columns) ? config.columns : 3;
|
|
93
|
+
const items = (config.items ?? []).map(card).filter(Boolean);
|
|
94
|
+
return `<section class="blk-video-gallery layout-${layout}" aria-label="${escapeAttr(title || 'Videos')}">
|
|
95
|
+
<div class="wrap">
|
|
96
|
+
${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
|
|
97
|
+
<div class="grid" style="--cols:${columns}">
|
|
98
|
+
${items.join('\n ')}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</section>`;
|
|
102
|
+
}
|
|
103
|
+
export const videoGallery = {
|
|
104
|
+
type: 'video-gallery',
|
|
105
|
+
description: 'A grid or carousel of click-to-play video cards (YouTube/Vimeo/file); each loads its player inline on click (a lightweight facade — no heavy iframes up front).',
|
|
106
|
+
schema,
|
|
107
|
+
css,
|
|
108
|
+
render,
|
|
109
|
+
island: 'video',
|
|
110
|
+
};
|