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