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