@hyvor/design 2.1.8-beta.1 → 2.1.8

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.
@@ -1,18 +1,17 @@
1
1
  <script lang="ts">
2
- import type { Snippet } from 'svelte';
3
2
  import Container from '../Container/Container.svelte';
4
- import IconEnvelope from '@hyvor/icons/IconEnvelope';
5
3
  import IconCopy from '@hyvor/icons/IconCopy';
6
4
  import IconDiscord from '@hyvor/icons/IconDiscord';
5
+ import IconEnvelope from '@hyvor/icons/IconEnvelope';
7
6
  import IconGithub from '@hyvor/icons/IconGithub';
8
7
  import IconLinkedin from '@hyvor/icons/IconLinkedin';
9
8
  import IconTwitterX from '@hyvor/icons/IconTwitterX';
10
9
  import IconYoutube from '@hyvor/icons/IconYoutube';
11
- import IconBluesky from '@hyvor/icons/IconBluesky';
12
- import IconLockFill from '@hyvor/icons/IconLockFill';
10
+ import Link from '../../components/Link/Link.svelte';
13
11
  import IconButton from '../../components/IconButton/IconButton.svelte';
14
12
  import Tooltip from '../../components/Tooltip/Tooltip.svelte';
15
13
  import LanguageToggle from '../../components/Internationalization/LanguageToggle.svelte';
14
+ import IconBluesky from '@hyvor/icons/IconBluesky';
16
15
  import { SOCIAL_LINKS, type Socials } from '../social.js';
17
16
  import Affiliate from '../Affiliate/Affiliate.svelte';
18
17
  import RecordVisit from './RecordVisit.svelte';
@@ -20,207 +19,104 @@
20
19
  const year = new Date().getFullYear();
21
20
 
22
21
  interface Props {
23
- name?: string;
24
- subname?: string;
25
- product?: string;
26
- instance?: string;
27
- logo?: string;
28
- background?: string;
29
- backgroundDark?: string;
30
- card?: boolean;
31
22
  email?: string | null;
32
23
  social?: Partial<Socials>;
33
- languageToggle?: boolean;
34
- gdpr?: boolean;
24
+ center?: import('svelte').Snippet;
35
25
  affiliate?: boolean;
36
26
  recordVisit?: boolean;
37
- center?: Snippet;
38
27
  max?: boolean;
39
28
  }
40
29
 
41
30
  let {
42
- name = 'HYVOR',
43
- subname,
44
- product,
45
- instance = 'https://hyvor.com',
46
- logo,
47
- background,
48
- backgroundDark,
49
- card = false,
50
31
  email = null,
51
- social = {},
52
- languageToggle = true,
53
- gdpr = true,
32
+ social = $bindable({} as Record<string, string | null>),
33
+ center,
54
34
  affiliate = true,
55
35
  recordVisit = true,
56
- center,
57
36
  max = false
58
37
  }: Props = $props();
59
38
 
60
- // same "explicit logo, else derive from product" pattern as Header — pass
61
- // `product` (e.g. "blogs") for the mascot to follow that product's logo
62
- // automatically, or `logo` to override it directly. Neither given means
63
- // no mascot.
64
- const mascotLogo = $derived(
65
- logo || (product ? `${instance}/api/public/logo/${product}.svg` : undefined)
66
- );
67
-
68
- const socialLinks = $derived({ ...SOCIAL_LINKS, ...social });
69
-
70
- const SOCIAL_PLATFORMS = [
71
- { key: 'x', icon: IconTwitterX, label: 'X (Twitter)' },
72
- { key: 'github', icon: IconGithub, label: 'GitHub' },
73
- { key: 'discord', icon: IconDiscord, label: 'Discord' },
74
- { key: 'linkedin', icon: IconLinkedin, label: 'LinkedIn' },
75
- { key: 'youtube', icon: IconYoutube, label: 'YouTube' },
76
- { key: 'bluesky', icon: IconBluesky, label: 'Bluesky' }
77
- ] as const satisfies { key: keyof Socials; icon: unknown; label: string }[];
39
+ social = {
40
+ ...SOCIAL_LINKS,
41
+ ...social
42
+ };
78
43
 
79
44
  let emailCopied = $state(false);
80
- function handleCopyEmail() {
81
- if (!email) return;
82
- navigator.clipboard.writeText(email);
83
- emailCopied = true;
84
- setTimeout(() => (emailCopied = false), 1000);
85
- }
86
45
 
87
- // the floating mascot (only shown when a `logo` is given) tilts/fades in
88
- // once it's scrolled into view, instead of just appearing on page load
89
- let mascotInView = $state(false);
90
- function onView(node: HTMLElement, callback: () => void) {
91
- const observer = new IntersectionObserver(
92
- (entries) => {
93
- if (entries[0]?.isIntersecting) {
94
- setTimeout(callback, 400);
95
- observer.disconnect();
96
- }
97
- },
98
- { threshold: 0.3 }
99
- );
100
- observer.observe(node);
101
- return {
102
- destroy() {
103
- observer.disconnect();
104
- }
105
- };
106
- }
107
-
108
- // a single 5-pointed star (outer/inner vertices alternating), centered on
109
- // its own origin — reused via `transform="translate(...)"` per position
110
- // below, rather than a plain dot, for the EU ring on the GDPR badge
111
- function starPath(outerR: number, innerR: number) {
112
- const points: string[] = [];
113
- for (let i = 0; i < 10; i++) {
114
- const angle = -Math.PI / 2 + (i * Math.PI) / 5;
115
- const r = i % 2 === 0 ? outerR : innerR;
116
- points.push(`${(r * Math.cos(angle)).toFixed(2)},${(r * Math.sin(angle)).toFixed(2)}`);
117
- }
118
- return `M${points.join('L')}Z`;
46
+ function handleCopy() {
47
+ navigator.clipboard.writeText(email!);
48
+ emailCopied = true;
49
+ setTimeout(() => {
50
+ emailCopied = false;
51
+ }, 1000);
119
52
  }
120
- const gdprStarPath = starPath(1.7, 0.68);
121
-
122
- // positions for the 12-star EU ring on the small GDPR badge
123
- const gdprStars = Array.from({ length: 12 }, (_, i) => {
124
- const angle = (i * 30 * Math.PI) / 180;
125
- return {
126
- x: 16 + 12 * Math.cos(angle),
127
- y: 16 + 12 * Math.sin(angle)
128
- };
129
- });
130
53
  </script>
131
54
 
132
- <div class="footer-outer">
133
- {#if mascotLogo}
134
- <div class="mascot-wrap" use:onView={() => (mascotInView = true)}>
135
- <img
136
- src={mascotLogo}
137
- alt="{name} Logo"
138
- width="100"
139
- height="100"
140
- class:in-view={mascotInView}
141
- />
142
- </div>
143
- {/if}
55
+ <footer>
56
+ <Container {max}>
57
+ <div class="footer-top">
58
+ <div class="footer-top-left">
59
+ {#if email}
60
+ <div class="email-wrap">
61
+ <Link href="mailto:{email}" underline={false} color="text" rel="nofollow">
62
+ {#snippet start()}
63
+ <IconEnvelope />
64
+ {/snippet}
65
+ {email}
66
+ </Link>
67
+ <Tooltip text={emailCopied ? 'Copied!' : 'Copy email'} position="top">
68
+ <IconButton
69
+ size="small"
70
+ variant="invisible"
71
+ on:click={handleCopy}
72
+ on:mouseleave={() => (emailCopied = false)}
73
+ >
74
+ <IconCopy size={12} />
75
+ </IconButton>
76
+ </Tooltip>
77
+ </div>
78
+ {/if}
144
79
 
145
- <footer class:card style:--footer-bg={background} style:--footer-bg-dark={backgroundDark}>
146
- <Container {max}>
147
- <div class="top-row">
148
- <div class="brand">
149
- <span>{name}</span>
150
- {#if subname}
151
- <div class="subname">{subname}</div>
80
+ <div class="social-media">
81
+ {#if social.github}
82
+ <a href={social.github} target="_blank" rel="nofollow"><IconGithub /></a>
152
83
  {/if}
153
- </div>
154
-
155
- <div class="top-row-right">
156
- {#if email}
157
- <div class="email-wrap">
158
- <a class="email" href="mailto:{email}">
159
- <IconEnvelope size={14} />
160
- {email}
161
- </a>
162
- <Tooltip text={emailCopied ? 'Copied!' : 'Copy email'} position="top">
163
- <IconButton
164
- size="small"
165
- variant="invisible"
166
- onclick={handleCopyEmail}
167
- onmouseleave={() => (emailCopied = false)}
168
- >
169
- <IconCopy size={12} />
170
- </IconButton>
171
- </Tooltip>
172
- </div>
84
+ {#if social.discord}
85
+ <a href={social.discord} target="_blank" rel="nofollow"><IconDiscord /></a>
173
86
  {/if}
174
-
175
- <div class="socials">
176
- {#each SOCIAL_PLATFORMS as platform (platform.key)}
177
- {@const href = socialLinks[platform.key]}
178
- {#if href}
179
- <a {href} target="_blank" rel="nofollow" aria-label={platform.label}>
180
- <platform.icon size={16} />
181
- </a>
182
- {/if}
183
- {/each}
184
- </div>
185
-
186
- {#if languageToggle}
187
- <span class="language-toggle-wrap">
188
- <LanguageToggle align="end" position="top" staticPage />
189
- </span>
87
+ {#if social.blueksy}
88
+ <a href={social.blueksy} target="_blank" rel="nofollow"><IconBluesky /></a>
89
+ {/if}
90
+ {#if social.x}
91
+ <a href={social.x} target="_blank" rel="nofollow"><IconTwitterX /></a>
92
+ {/if}
93
+ {#if social.youtube}
94
+ <a href={social.youtube} target="_blank" rel="nofollow"><IconYoutube /></a>
95
+ {/if}
96
+ {#if social.linkedin}
97
+ <a href={social.linkedin} target="_blank" rel="nofollow"><IconLinkedin /></a>
190
98
  {/if}
191
99
  </div>
192
100
  </div>
193
101
 
194
- <div class="footer-center">
195
- {@render center?.()}
102
+ <div class="footer-top-right">
103
+ <span class="language-toggle-wrap">
104
+ <LanguageToggle align="end" position="top" staticPage />
105
+ </span>
196
106
  </div>
107
+ </div>
197
108
 
198
- <div class="bottom-bar">
199
- <div>HYVOR &copy; {year}</div>
200
-
201
- {#if gdpr}
202
- <div class="bottom-center">
203
- <a class="gdpr-chip" href="https://hyvor.com/compliance" target="_blank">
204
- <span class="gdpr-chip-icon">
205
- <svg class="ring" viewBox="0 0 32 32" aria-hidden="true">
206
- <circle cx="16" cy="16" r="16" fill="#173a8a" />
207
- {#each gdprStars as s}
208
- <path d={gdprStarPath} fill="#ffcd3c" transform="translate({s.x}, {s.y})" />
209
- {/each}
210
- </svg>
211
- <span class="lock"><IconLockFill size={10} /></span>
212
- </span>
213
- <span class="gdpr-chip-text">GDPR Compliant</span>
214
- </a>
215
- </div>
216
- {/if}
109
+ <div class="footer-center">
110
+ {@render center?.()}
111
+ </div>
217
112
 
218
- <div class="bottom-right">
219
- <div class="france">From France <span class="flag">🇫🇷</span></div>
220
- </div>
113
+ <div class="footer-bottom">
114
+ <div class="footer-bottom-left">
115
+ HYVOR © {year}
221
116
  </div>
222
- </Container>
223
- </footer>
117
+ <div class="footer-bottom-right">From France 🇫🇷</div>
118
+ </div>
119
+ </Container>
224
120
 
225
121
  {#if affiliate}
226
122
  <Affiliate />
@@ -229,252 +125,57 @@
229
125
  {#if recordVisit}
230
126
  <RecordVisit />
231
127
  {/if}
232
- </div>
128
+ </footer>
233
129
 
234
130
  <style>
235
- .footer-outer {
236
- position: relative;
237
- margin-top: 100px;
238
- }
239
-
240
- .mascot-wrap {
241
- position: absolute;
242
- z-index: 10;
243
- display: flex;
244
- justify-content: center;
245
- pointer-events: none;
246
- left: 0;
247
- bottom: 100%;
248
- transform: translate(-35%, 45%) rotate(20deg);
249
- }
250
-
251
- .mascot-wrap img {
252
- opacity: 0;
253
- }
254
-
255
- .mascot-wrap img.in-view {
256
- animation: mascot-tilt-in 0.8s ease forwards;
257
- }
258
-
259
- @keyframes mascot-tilt-in {
260
- 0% {
261
- opacity: 0;
262
- transform: rotate(-14deg) scale(0.9) translateY(16px);
263
- }
264
- 55% {
265
- opacity: 1;
266
- transform: rotate(10deg) scale(1.06) translateY(-4px);
267
- }
268
- 100% {
269
- opacity: 1;
270
- transform: rotate(0deg) scale(1) translateY(0);
271
- }
272
- }
273
-
274
- @media (prefers-reduced-motion: reduce) {
275
- .mascot-wrap img {
276
- opacity: 1;
277
- }
278
- .mascot-wrap img.in-view {
279
- animation: none;
280
- }
281
- }
282
-
283
131
  footer {
284
- --footer-text: var(--text);
285
- --footer-text-strong: var(--text);
286
- --footer-muted: var(--text-light);
287
- --footer-border: var(--border);
288
- --footer-hover: var(--accent);
289
-
290
- position: relative;
291
- z-index: 1;
292
- background: var(--footer-bg, transparent);
293
- color: var(--footer-text);
294
- padding-top: 50px;
295
- }
296
-
297
- /*
298
- `backgroundDark` only when needed for dark mode - not complusory
299
- */
300
- :global(:root.dark) footer {
301
- background: var(--footer-bg-dark, var(--footer-bg, transparent));
132
+ border-top: 1px solid var(--border);
302
133
  }
303
134
 
304
- footer.card {
305
- --footer-text: rgba(255, 255, 255, 0.75);
306
- --footer-text-strong: #fff;
307
- --footer-muted: rgba(255, 255, 255, 0.45);
308
- --footer-border: rgba(255, 255, 255, 0.08);
309
- --footer-hover: #fff;
310
- }
311
-
312
- .top-row {
135
+ .footer-top {
136
+ padding-top: 60px;
313
137
  display: flex;
314
- align-items: center;
315
- justify-content: space-between;
316
- flex-wrap: wrap;
317
- gap: 20px;
318
- padding-bottom: 40px;
319
- border-bottom: 1px solid var(--footer-border);
138
+ align-items: flex-start;
320
139
  }
321
-
322
- .brand {
323
- font-size: 16px;
324
- font-weight: 700;
325
- color: var(--footer-text-strong);
140
+ .footer-center {
141
+ padding-top: 50px;
326
142
  }
327
-
328
- .subname {
329
- font-size: 13px;
330
- font-weight: 400;
331
- color: var(--footer-muted);
143
+ .footer-top-left {
144
+ flex: 1;
332
145
  }
333
-
334
- .top-row-right {
146
+ .footer-top-right {
335
147
  display: flex;
336
- align-items: center;
337
- gap: 24px;
338
- flex-wrap: wrap;
339
148
  }
340
-
341
149
  .email-wrap {
342
150
  display: inline-flex;
343
151
  align-items: center;
344
- gap: 2px;
152
+ gap: 4px;
153
+ margin-bottom: 15px;
345
154
  }
346
-
347
- .email {
348
- display: inline-flex;
349
- align-items: center;
350
- gap: 6px;
351
- font-size: 13px;
352
- color: var(--footer-text);
155
+ .social-media a {
156
+ display: inline-block;
157
+ padding: 3px;
158
+ margin-right: 8px;
159
+ color: var(--text-light);
353
160
  }
354
-
355
- .email:hover {
356
- color: var(--footer-hover);
161
+ .social-media a:first-child {
162
+ padding-left: 0;
357
163
  }
358
-
359
- .socials {
360
- display: flex;
361
- align-items: center;
362
- gap: 14px;
164
+ .social-media a:hover {
165
+ color: var(--accent);
363
166
  }
364
167
 
365
- .socials a {
366
- color: var(--footer-text);
168
+ .footer-bottom {
169
+ padding-top: 50px;
170
+ padding-bottom: 30px;
367
171
  display: flex;
368
172
  }
369
-
370
- .socials a:hover {
371
- color: var(--footer-hover);
173
+ .footer-bottom-left {
174
+ flex: 1;
372
175
  }
373
176
 
374
177
  .language-toggle-wrap {
178
+ margin-left: 8px;
375
179
  font-size: 18px;
376
180
  }
377
-
378
- .footer-center:not(:empty) {
379
- padding: 48px 0;
380
- }
381
-
382
- /* only if there is no center element - to prevent double border apperance */
383
- .footer-center:not(:empty) + .bottom-bar {
384
- border-top: 1px solid var(--footer-border);
385
- }
386
-
387
- .bottom-bar {
388
- display: flex;
389
- align-items: center;
390
- justify-content: space-between;
391
- flex-wrap: wrap;
392
- gap: 10px;
393
- padding: 24px 0 32px;
394
- font-size: 13px;
395
- color: var(--footer-muted);
396
- }
397
-
398
- .bottom-right {
399
- display: flex;
400
- align-items: center;
401
- gap: 16px;
402
- flex-wrap: wrap;
403
- }
404
-
405
- .france {
406
- display: inline-flex;
407
- align-items: center;
408
- gap: 6px;
409
- }
410
-
411
- .gdpr-chip {
412
- display: inline-flex;
413
- align-items: center;
414
- gap: 8px;
415
- padding: 5px 12px 5px 5px;
416
- border-radius: 100px;
417
- background: #1f2f57;
418
- color: #fff;
419
- opacity: 0.9;
420
- transition: opacity 0.15s ease;
421
- }
422
-
423
- .gdpr-chip:hover {
424
- opacity: 1;
425
- }
426
-
427
- .gdpr-chip-icon {
428
- position: relative;
429
- width: 22px;
430
- height: 22px;
431
- flex-shrink: 0;
432
- }
433
-
434
- .gdpr-chip-icon .ring {
435
- position: absolute;
436
- inset: 0;
437
- width: 100%;
438
- height: 100%;
439
- display: block;
440
- }
441
-
442
- .gdpr-chip-icon .lock {
443
- position: absolute;
444
- inset: 0;
445
- display: flex;
446
- align-items: center;
447
- justify-content: center;
448
- color: #fff;
449
- }
450
-
451
- .gdpr-chip-text {
452
- font-size: 11px;
453
- font-weight: 800;
454
- letter-spacing: 0.02em;
455
- color: #fff;
456
- }
457
-
458
- /* emoji glyphs are pre-colored — never let the surrounding muted text
459
- color dim or filter them */
460
- .flag {
461
- color: initial;
462
- filter: none;
463
- }
464
-
465
- @media (max-width: 560px) {
466
- .mascot-wrap {
467
- /* keep it clear of the brand wordmark that sits right below it */
468
- margin-bottom: -40px;
469
- }
470
-
471
- footer {
472
- padding-top: 130px;
473
- }
474
-
475
- .top-row {
476
- flex-direction: column;
477
- align-items: flex-start;
478
- }
479
- }
480
181
  </style>
@@ -1,23 +1,12 @@
1
- import type { Snippet } from 'svelte';
2
1
  import { type Socials } from '../social.js';
3
2
  interface Props {
4
- name?: string;
5
- subname?: string;
6
- product?: string;
7
- instance?: string;
8
- logo?: string;
9
- background?: string;
10
- backgroundDark?: string;
11
- card?: boolean;
12
3
  email?: string | null;
13
4
  social?: Partial<Socials>;
14
- languageToggle?: boolean;
15
- gdpr?: boolean;
5
+ center?: import('svelte').Snippet;
16
6
  affiliate?: boolean;
17
7
  recordVisit?: boolean;
18
- center?: Snippet;
19
8
  max?: boolean;
20
9
  }
21
- declare const Footer: import("svelte").Component<Props, {}, "">;
10
+ declare const Footer: import("svelte").Component<Props, {}, "social">;
22
11
  type Footer = ReturnType<typeof Footer>;
23
12
  export default Footer;
@@ -17,16 +17,12 @@
17
17
  <style>
18
18
  .link-list {
19
19
  flex: 1;
20
- min-width: 150px;
21
20
  }
22
21
 
23
22
  .title {
24
- font-size: 12px;
25
- font-weight: 700;
23
+ font-weight: 600;
26
24
  text-transform: uppercase;
27
- letter-spacing: 0.05em;
28
- color: var(--footer-muted, var(--text-light));
29
- margin-bottom: 14px;
25
+ margin-bottom: 10px;
30
26
  }
31
27
 
32
28
  .links {
@@ -36,12 +32,9 @@
36
32
  }
37
33
 
38
34
  .links :global(a) {
39
- font-size: 14px;
40
- color: var(--footer-text, var(--text-light));
41
- margin-top: 10px;
35
+ margin-top: 12px;
42
36
  }
43
37
  .links :global(a:hover) {
44
- color: var(--footer-text-strong, var(--text));
45
38
  text-decoration: underline;
46
39
  }
47
40
  </style>