This repository has no description
1<script lang="ts">
2 import { resolve } from '$app/paths';
3 import LogotypeSmall from '../ui/LogotypeSmall.svelte';
4
5 interface Props {
6 variant?: 'full' | 'minimal';
7 }
8
9 type FooterIcon =
10 | 'terms'
11 | 'privacy'
12 | 'blog'
13 | 'docs'
14 | 'source'
15 | 'brand'
16 | 'discord'
17 | 'irc'
18 | 'bluesky'
19 | 'twitter'
20 | 'email'
21 | 'security';
22 type FooterLayout = 'desktop' | 'mobile' | 'minimal';
23
24 interface FooterLinkAttributes {
25 target?: '_blank';
26 }
27
28 interface FooterLink {
29 label: string;
30 href: string;
31 icon: FooterIcon;
32 internal?: true;
33 minimalLabel?: string;
34 minimalOrder?: number;
35 attributes?: FooterLinkAttributes;
36 desktopAttributes?: FooterLinkAttributes;
37 }
38
39 interface FooterSection {
40 title: string;
41 links: FooterLink[];
42 }
43
44 let { variant = 'full' }: Props = $props();
45
46 const appPath = (path: string) => resolve(path as '/');
47 const headerStyle = 'text-fg font-bold text-sm tracking-wide mb-1';
48 const mobileHeaderStyle = 'text-fg font-bold text-xs tracking-wide mb-1';
49 const linkStyle = 'inline-flex items-center gap-1 text-fg-subtle hover:text-fg hover:underline';
50 const minimalLinkStyle = 'text-fg-subtle hover:text-fg hover:underline';
51 const externalLinkAttributes = { target: '_blank' } as const;
52
53 const footerSections: FooterSection[] = [
54 {
55 title: 'Legal',
56 links: [
57 {
58 label: 'Terms of service',
59 minimalLabel: 'Terms',
60 href: '/terms',
61 internal: true,
62 icon: 'terms',
63 minimalOrder: 8
64 },
65 {
66 label: 'Privacy policy',
67 minimalLabel: 'Privacy',
68 href: '/privacy',
69 internal: true,
70 icon: 'privacy',
71 minimalOrder: 9
72 }
73 ]
74 },
75 {
76 title: 'Resources',
77 links: [
78 {
79 label: 'Blog',
80 href: 'https://blog.tangled.org',
81 icon: 'blog',
82 minimalOrder: 1,
83 desktopAttributes: externalLinkAttributes
84 },
85 {
86 label: 'Docs',
87 href: 'https://docs.tangled.org',
88 icon: 'docs',
89 minimalOrder: 2
90 },
91 {
92 label: 'Source',
93 href: 'https://tangled.org/@tangled.org/core',
94 icon: 'source',
95 minimalOrder: 3
96 },
97 {
98 label: 'Brand',
99 href: 'https://tangled.org/brand',
100 icon: 'brand',
101 minimalOrder: 4
102 }
103 ]
104 },
105 {
106 title: 'Social',
107 links: [
108 {
109 label: 'Discord',
110 href: 'https://chat.tangled.org',
111 icon: 'discord',
112 minimalOrder: 5,
113 attributes: externalLinkAttributes
114 },
115 {
116 label: 'IRC',
117 href: 'https://web.libera.chat/#tangled',
118 icon: 'irc',
119 attributes: externalLinkAttributes
120 },
121 {
122 label: 'Bluesky',
123 href: 'https://bsky.app/profile/tangled.org',
124 icon: 'bluesky',
125 minimalOrder: 6,
126 attributes: externalLinkAttributes
127 },
128 {
129 label: 'Twitter (X)',
130 href: 'https://x.com/tangled_org',
131 icon: 'twitter',
132 minimalOrder: 7,
133 attributes: externalLinkAttributes
134 }
135 ]
136 },
137 {
138 title: 'Contact',
139 links: [
140 {
141 label: 'team@tangled.org',
142 href: 'mailto:team@tangled.org',
143 icon: 'email'
144 },
145 {
146 label: 'security@tangled.org',
147 href: 'mailto:security@tangled.org',
148 icon: 'security'
149 }
150 ]
151 }
152 ];
153
154 const minimalFooterLinks = footerSections
155 .flatMap((section) => section.links)
156 .filter((link) => typeof link.minimalOrder === 'number')
157 .sort((a, b) => (a.minimalOrder ?? 0) - (b.minimalOrder ?? 0));
158
159 const linkAttributes = (link: FooterLink, layout: FooterLayout) =>
160 layout === 'desktop' ? (link.desktopAttributes ?? link.attributes) : link.attributes;
161 const linkTarget = (link: FooterLink, layout: FooterLayout) =>
162 linkAttributes(link, layout)?.target;
163</script>
164
165{#snippet footerIcon(icon: FooterIcon)}
166 {#if icon === 'terms'}
167 <svg
168 class="size-4 shrink-0"
169 viewBox="0 0 24 24"
170 fill="none"
171 stroke="currentColor"
172 stroke-width="2"
173 stroke-linecap="round"
174 stroke-linejoin="round"
175 >
176 <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
177 <path d="M14 2v4a2 2 0 0 0 2 2h4" />
178 <path d="M10 9H8" />
179 <path d="M16 13H8" />
180 <path d="M16 17H8" />
181 </svg>
182 {:else if icon === 'privacy'}
183 <svg
184 class="size-4 shrink-0"
185 viewBox="0 0 24 24"
186 fill="none"
187 stroke="currentColor"
188 stroke-width="2"
189 stroke-linecap="round"
190 stroke-linejoin="round"
191 >
192 <path
193 d="M20 13c0 5-3.5 7.5-7.66 9.7a1 1 0 0 1-.68 0C7.5 20.5 4 18 4 13V6a1 1 0 0 1 .76-.97l8-2a1 1 0 0 1 .48 0l8 2c.47.12.76.54.76.97Z"
194 />
195 </svg>
196 {:else if icon === 'blog'}
197 <svg
198 class="size-4 shrink-0"
199 viewBox="0 0 24 24"
200 fill="none"
201 stroke="currentColor"
202 stroke-width="2"
203 stroke-linecap="round"
204 stroke-linejoin="round"
205 >
206 <path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
207 <path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
208 </svg>
209 {:else if icon === 'docs'}
210 <svg
211 class="size-4 shrink-0"
212 viewBox="0 0 24 24"
213 fill="none"
214 stroke="currentColor"
215 stroke-width="2"
216 stroke-linecap="round"
217 stroke-linejoin="round"
218 >
219 <path
220 d="M4 19.5V15a2 2 0 0 1 2-2h2M20 19.5V5a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2z"
221 />
222 </svg>
223 {:else if icon === 'source'}
224 <svg
225 class="size-4 shrink-0"
226 viewBox="0 0 24 24"
227 fill="none"
228 stroke="currentColor"
229 stroke-width="2"
230 stroke-linecap="round"
231 stroke-linejoin="round"
232 >
233 <polyline points="16 18 22 12 16 6" />
234 <polyline points="8 6 2 12 8 18" />
235 </svg>
236 {:else if icon === 'brand'}
237 <svg
238 class="size-4 shrink-0"
239 viewBox="0 0 24 24"
240 fill="none"
241 stroke="currentColor"
242 stroke-width="2"
243 stroke-linecap="round"
244 stroke-linejoin="round"
245 >
246 <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" />
247 <path
248 d="M7.5 10.5c.828 0 1.5-.672 1.5-1.5s-.672-1.5-1.5-1.5-1.5.672-1.5 1.5.672 1.5 1.5 1.5z"
249 />
250 <path
251 d="M11.5 7.5c.828 0 1.5-.672 1.5-1.5S12.328 4.5 11.5 4.5 10 5.172 10 6s.672 1.5 1.5 1.5z"
252 />
253 <path
254 d="M16.5 9.5c.828 0 1.5-.672 1.5-1.5s-.672-1.5-1.5-1.5-1.5.672-1.5 1.5.672 1.5 1.5 1.5z"
255 />
256 <path d="M6 14c0-2 2-3 4-3 2.5 0 4.5 1.5 4.5 3.5S12.5 18 10 18c-2.5 0-4-2-4-4z" />
257 </svg>
258 {:else if icon === 'discord'}
259 <svg
260 class="size-4 shrink-0"
261 viewBox="0 0 24 24"
262 fill="none"
263 stroke="currentColor"
264 stroke-width="2"
265 stroke-linecap="round"
266 stroke-linejoin="round"
267 >
268 <path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
269 </svg>
270 {:else if icon === 'irc'}
271 <svg
272 class="size-4 shrink-0"
273 viewBox="0 0 24 24"
274 fill="none"
275 stroke="currentColor"
276 stroke-width="2"
277 stroke-linecap="round"
278 stroke-linejoin="round"
279 >
280 <line x1="4" y1="9" x2="20" y2="9" />
281 <line x1="4" y1="15" x2="20" y2="15" />
282 <line x1="10" y1="3" x2="8" y2="21" />
283 <line x1="16" y1="3" x2="14" y2="21" />
284 </svg>
285 {:else if icon === 'bluesky'}
286 <svg
287 class="size-4 shrink-0"
288 viewBox="0 0 24 24"
289 fill="none"
290 stroke-dasharray="none"
291 stroke-width="2"
292 stroke-linecap="round"
293 stroke-linejoin="round"
294 >
295 <path d="M12 21a9 9 0 0 0 9-9 9 9 0 0 0-9-9 9 9 0 0 0-9 9 9 9 0 0 0 9 9Z" />
296 <path d="M12 12c2-3 5-3 5 0s-3 3-5 0Zm0 0c-2-3-5-3-5 0s3 3 5 0Z" />
297 </svg>
298 {:else if icon === 'twitter'}
299 <svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="currentColor">
300 <path
301 d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
302 />
303 </svg>
304 {:else if icon === 'email'}
305 <svg
306 class="size-4 shrink-0"
307 viewBox="0 0 24 24"
308 fill="none"
309 stroke="currentColor"
310 stroke-width="2"
311 stroke-linecap="round"
312 stroke-linejoin="round"
313 >
314 <rect width="20" height="16" x="2" y="4" rx="2" />
315 <path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
316 </svg>
317 {:else if icon === 'security'}
318 <svg
319 class="size-4 shrink-0"
320 viewBox="0 0 24 24"
321 fill="none"
322 stroke="currentColor"
323 stroke-width="2"
324 stroke-linecap="round"
325 stroke-linejoin="round"
326 >
327 <path
328 d="M20 13c0 5-3.5 7.5-7.66 9.7a1 1 0 0 1-.68 0C7.5 20.5 4 18 4 13V6a1 1 0 0 1 .76-.97l8-2a1 1 0 0 1 .48 0l8 2c.47.12.76.54.76.97Z"
329 />
330 <path d="m9 12 2 2 4-4" />
331 </svg>
332 {/if}
333{/snippet}
334
335{#if variant === 'minimal'}
336 <footer
337 class="w-full border-t border-border-subtle bg-surface px-6 py-4 pb-[calc(env(safe-area-inset-bottom)+1rem)]"
338 >
339 <div
340 class="mx-auto flex max-w-screen-lg flex-wrap items-center justify-center gap-x-4 gap-y-2 text-sm text-fg-subtle"
341 >
342 <div
343 class="order-last flex w-full items-center justify-center gap-x-2 sm:order-first sm:w-auto"
344 >
345 <a href={resolve('/')} class="flex items-center no-underline hover:no-underline">
346 <img src="/logos/dolly.svg" alt="tangled" class="size-5 opacity-70" />
347 </a>
348 <span>© 2026 Tangled Labs Oy.</span>
349 </div>
350 {#each minimalFooterLinks as link (link.href)}
351 {#if link.internal}
352 <a href={appPath(link.href)} class={minimalLinkStyle}>{link.minimalLabel ?? link.label}</a
353 >
354 {:else}
355 <a
356 href={link.href}
357 class={minimalLinkStyle}
358 target={linkTarget(link, 'minimal')}
359 rel="external noopener noreferrer">{link.minimalLabel ?? link.label}</a
360 >
361 {/if}
362 {/each}
363 </div>
364 </footer>
365{:else}
366 <footer class="z-10 pb-[env(safe-area-inset-bottom)] select-none">
367 <div class="w-full bg-surface p-8">
368 <div class="mx-auto px-4">
369 <div class="flex flex-col gap-8 text-fg-subtle">
370 <div
371 class="hidden lg:grid lg:grid-cols-[1fr_minmax(0,1024px)_1fr] lg:items-start lg:gap-8"
372 >
373 <div>
374 <a href={resolve('/')} class="flex gap-2 no-underline hover:no-underline">
375 <LogotypeSmall />
376 </a>
377 </div>
378
379 <div class="grid grid-cols-4 gap-2">
380 {#each footerSections as section (section.title)}
381 <div class="flex flex-col gap-1">
382 <div class={headerStyle}>{section.title}</div>
383 {#each section.links as link (link.href)}
384 {#if link.internal}
385 <a href={appPath(link.href)} class={linkStyle}>
386 {@render footerIcon(link.icon)}
387 <span>{link.label}</span>
388 </a>
389 {:else}
390 <a
391 href={link.href}
392 class={linkStyle}
393 target={linkTarget(link, 'desktop')}
394 rel="external noopener noreferrer"
395 >
396 {@render footerIcon(link.icon)}
397 <span>{link.label}</span>
398 </a>
399 {/if}
400 {/each}
401 </div>
402 {/each}
403 </div>
404
405 <div class="text-right">
406 <div class="text-xs">© 2026 Tangled Labs Oy. All rights reserved.</div>
407 </div>
408 </div>
409
410 <div class="flex flex-col gap-8 lg:hidden">
411 <div class="mb-4 md:mb-0">
412 <a href={resolve('/')} class="flex gap-2 no-underline hover:no-underline">
413 <LogotypeSmall />
414 </a>
415 </div>
416
417 <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-4 md:gap-2">
418 {#each footerSections as section (section.title)}
419 <div class="flex flex-col gap-1">
420 <div class={mobileHeaderStyle}>{section.title}</div>
421 {#each section.links as link (link.href)}
422 {#if link.internal}
423 <a href={appPath(link.href)} class={linkStyle}>{link.label}</a>
424 {:else}
425 <a
426 href={link.href}
427 class={linkStyle}
428 target={linkTarget(link, 'mobile')}
429 rel="external noopener noreferrer">{link.label}</a
430 >
431 {/if}
432 {/each}
433 </div>
434 {/each}
435 </div>
436
437 <div class="text-center">
438 <div class="text-xs">© 2026 Tangled Labs Oy. All rights reserved.</div>
439 </div>
440 </div>
441 </div>
442 </div>
443 </div>
444 </footer>
445{/if}