This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / web / src / lib / components / shell / Footer.svelte
12 kB 451 lines
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>&copy; 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 &copy; 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 &copy; 2026 Tangled Labs Oy. All rights reserved. 444 </div> 445 </div> 446 </div> 447 </div> 448 </div> 449 </div> 450 </footer> 451{/if}