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 446 lines
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>&copy; 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">&copy; 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">&copy; 2026 Tangled Labs Oy. All rights reserved.</div> 440 </div> 441 </div> 442 </div> 443 </div> 444 </div> 445 </footer> 446{/if}