@hyvor/design 2.1.7 → 2.1.8-beta.2

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