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