Compare changes

Choose any two refs to compare.

Changed files
+194 -203
.vscode
src
lib
components
routes
+1
.gitignore
··· 185 185 .vercel 186 186 187 187 # End of https://www.toptal.com/developers/gitignore/api/node,macos,svelte,vercel 188 + git-diff.txt
+17 -1
.vscode/settings.json
··· 3 3 "css.validate": false, 4 4 "tailwindCSS.includeLanguages": { 5 5 "svelte": "html" 6 - } 6 + }, 7 + "cSpell.words": [ 8 + "atproto", 9 + "Decentralised", 10 + "diddoc", 11 + "Dids", 12 + "ewan", 13 + "ewanc", 14 + "Linkat", 15 + "mkizka", 16 + "pdsurl", 17 + "prerender", 18 + "prerendering", 19 + "rkey", 20 + "utilises", 21 + "xrpc" 22 + ] 7 23 }
+43 -46
src/lib/components/layout/footer/Main.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from "svelte"; 3 - import { env } from "$env/dynamic/public"; 4 - import TidClock from "./TidClock.svelte"; 5 3 6 4 export let profile: any; 7 - export const posts: any = undefined; 5 + let showDetails = false; 8 6 9 7 onMount(() => { 10 8 const copyrightYearElement = document.getElementById("copyright-year"); ··· 12 10 copyrightYearElement.textContent = new Date().getFullYear().toString(); 13 11 } 14 12 }); 13 + 14 + function toggleDetails() { 15 + showDetails = !showDetails; 16 + } 15 17 </script> 16 18 17 - <footer class="text-center py-8 text-primary text-sm opacity-60"> 19 + <footer class="text-center py-6 text-primary text-sm opacity-60"> 18 20 <div class="max-w-2xl mx-auto px-4"> 19 - <!-- Main attribution line --> 20 - <div class="mb-4"> 21 + <!-- Main footer line --> 22 + <div class="mb-3"> 21 23 <span>&copy; <span id="copyright-year"></span></span> 22 - <span class="mx-2">•</span> 23 24 {#if profile?.handle} 25 + <span class="mx-2">•</span> 24 26 <a 25 27 href="https://bsky.app/profile/{profile.did}" 26 28 class="text-[var(--link-color)] hover:text-[var(--link-hover-color)] transition-colors" 27 29 > 28 30 @{profile.handle} 29 31 </a> 30 - {:else} 31 - <span>{profile?.displayName || profile?.did}</span> 32 32 {/if} 33 33 <span class="mx-2">•</span> 34 - <span>powered by 35 - <a 36 - class="text-[var(--link-color)] hover:text-[var(--link-hover-color)] transition-colors" 37 - href="https://atproto.com/guides/glossary#at-protocol" 38 - > 39 - atproto 40 - </a> 41 - </span> 34 + <button 35 + class="text-[var(--link-color)] hover:text-[var(--link-hover-color)] transition-colors underline bg-none border-none cursor-pointer text-sm" 36 + on:click={toggleDetails} 37 + > 38 + {showDetails ? 'Hide details' : 'About'} 39 + </button> 42 40 </div> 43 41 44 - <!-- Project info --> 45 - <div class="mb-4 text-xs opacity-75 leading-relaxed"> 46 - <div class="mb-2"> 47 - Linkat Directory made by 48 - <a 49 - class="text-[var(--link-color)] hover:text-[var(--link-hover-color)] transition-colors" 50 - href="https://bsky.app/profile/did:plc:ofrbh253gwicbkc5nktqepol" 51 - > 52 - ewan 53 - </a> 54 - </div> 55 - <div> 56 - <a 57 - class="text-[var(--link-color)] hover:text-[var(--link-hover-color)] transition-colors" 58 - href="https://github.com/ewanc26/linkat-directory" 59 - > 60 - Open source 61 - </a> 62 - and free to use under AGPL-3.0. Not affiliated with 63 - <a 64 - class="text-[var(--link-color)] hover:text-[var(--link-hover-color)] transition-colors" 65 - href="https://linkat.blue" 66 - > 67 - Linkat 68 - </a> 42 + <!-- Collapsible details --> 43 + {#if showDetails} 44 + <div class="text-xs opacity-75 leading-relaxed space-y-2 transition-all duration-200"> 45 + <div> 46 + Linkat Directory made by 47 + <a 48 + class="text-[var(--link-color)] hover:text-[var(--link-hover-color)] transition-colors" 49 + href="https://bsky.app/profile/did:plc:ofrbh253gwicbkc5nktqepol" 50 + > 51 + ewan 52 + </a> 53 + </div> 54 + <div> 55 + <a 56 + class="text-[var(--link-color)] hover:text-[var(--link-hover-color)] transition-colors" 57 + href="https://github.com/ewanc26/linkat-directory" 58 + > 59 + Open source 60 + </a> 61 + and free to use under AGPL-3.0. Not affiliated with 62 + <a 63 + class="text-[var(--link-color)] hover:text-[var(--link-hover-color)] transition-colors" 64 + href="https://linkat.blue" 65 + > 66 + Linkat 67 + </a> 68 + </div> 69 69 </div> 70 - </div> 71 - 72 - <!-- Clock --> 73 - <TidClock /> 70 + {/if} 74 71 </div> 75 72 </footer>
-96
src/lib/components/layout/footer/TidClock.svelte
··· 1 - <script lang="ts"> 2 - import { onMount, onDestroy } from "svelte"; 3 - 4 - let currentTID = ''; 5 - let interval: NodeJS.Timeout; 6 - let isRunning = true; 7 - 8 - // Base32-sortable character set for TID encoding 9 - const BASE32_SORTABLE = '234567abcdefghijklmnopqrstuvwxyz'; 10 - 11 - /** 12 - * Generate a random 10-bit clock identifier 13 - */ 14 - function generateClockId(): number { 15 - return Math.floor(Math.random() * 1024); // 2^10 = 1024 16 - } 17 - 18 - /** 19 - * Convert a number to base32-sortable encoding 20 - */ 21 - function toBase32Sortable(num: bigint): string { 22 - if (num === 0n) { 23 - return '2222222222222'; 24 - } 25 - 26 - let result = ''; 27 - while (num > 0n) { 28 - result = BASE32_SORTABLE[Number(num % 32n)] + result; 29 - num = num / 32n; 30 - } 31 - 32 - // Pad to 13 characters for consistent TID length 33 - return result.padStart(13, '2'); 34 - } 35 - 36 - /** 37 - * Generate a TID for the current timestamp 38 - */ 39 - function generateTID(): string { 40 - // Get current timestamp in microseconds since UNIX epoch 41 - const nowMs = Date.now(); 42 - const nowMicroseconds = BigInt(nowMs * 1000); // Convert to microseconds 43 - 44 - // Generate random clock identifier (10 bits) 45 - const clockId = generateClockId(); 46 - 47 - // Combine timestamp (53 bits) and clock identifier (10 bits) 48 - // The top bit is always 0, so we have 63 bits in total 49 - const tidBigInt = (nowMicroseconds << 10n) | BigInt(clockId); 50 - 51 - return toBase32Sortable(tidBigInt); 52 - } 53 - 54 - /** 55 - * Update the TID display value 56 - */ 57 - function updateTID() { 58 - if (isRunning) { 59 - currentTID = generateTID(); 60 - } 61 - } 62 - 63 - /** 64 - * Copy the TID to the clipboard 65 - */ 66 - async function copyTID() { 67 - try { 68 - await navigator.clipboard.writeText(currentTID); 69 - console.log('TID copied to clipboard:', currentTID); 70 - } catch (err) { 71 - console.error('Failed to copy TID:', err); 72 - } 73 - } 74 - 75 - onMount(() => { 76 - // Generate initial TID 77 - updateTID(); 78 - 79 - // Update every 100ms for a smooth display 80 - interval = setInterval(updateTID, 100); 81 - }); 82 - 83 - onDestroy(() => { 84 - if (interval) { 85 - clearInterval(interval); 86 - } 87 - }); 88 - </script> 89 - 90 - <button 91 - class="inline-block bg-none border-none text-[var(--link-color)] font-mono text-xs cursor-pointer px-0.5 py-0 rounded-md transition-all duration-200 ease-in-out hover:text-[var(--link-hover-color)] hover:bg-[var(--button-bg)]" 92 - on:click={copyTID} 93 - title="Click to copy TID" 94 - > 95 - {currentTID} 96 - </button>
+1 -1
src/routes/+layout.svelte
··· 12 12 <div class="box-border mx-auto px-4 sm:px-8 max-w-[1000px] pb-8"> 13 13 {@render children()} 14 14 15 - <Footer profile={data.profile} posts={data.posts} /> 15 + <Footer profile={data.profile} /> 16 16 </div> 17 17 </div>
+76 -50
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { onMount } from "svelte"; 3 2 import { getStores } from "$app/stores"; 4 - const { page } = getStores(); 3 + import { env } from "$env/dynamic/public"; 5 4 import UserDirectory from "$lib/components/archive/UserDirectory.svelte"; 6 5 import DynamicHead from "$lib/components/layout/DynamicHead.svelte"; 6 + import { getProfile } from "$lib/components/profile/profile"; 7 7 8 + const { page } = getStores(); 8 9 let { data } = $props(); 10 + 11 + // Environment variable for directory owner 12 + let directoryOwner = env.DIRECTORY_OWNER ?? ""; 13 + 14 + // Profile state for directory owner - initialize with data.profile if available 15 + let ownerProfile = $state<{ displayName?: string; handle?: string } | null>( 16 + data.profile || null 17 + ); 18 + 19 + // Load the directory owner's profile only if we don't already have it 20 + $effect(() => { 21 + if (directoryOwner && !ownerProfile) { 22 + const loadOwner = async () => { 23 + try { 24 + const result = await getProfile(fetch); 25 + ownerProfile = result; 26 + } catch (err) { 27 + console.error("Could not fetch owner profile:", err); 28 + ownerProfile = null; 29 + } 30 + }; 31 + loadOwner(); 32 + } 33 + }); 34 + 35 + // Derived reactive values for user display options 9 36 let displayUserBanner = $derived(data.displayUserBanner); 10 37 let displayUserDescription = $derived(data.displayUserDescription); 11 38 ··· 17 44 function shuffleArray<T>(array: T[]): T[] { 18 45 let currentIndex = array.length, randomIndex; 19 46 20 - // While there remain elements to shuffle. 21 47 while (currentIndex !== 0) { 22 - // Pick a remaining element. 23 48 randomIndex = Math.floor(Math.random() * currentIndex); 24 49 currentIndex--; 25 50 26 - // And swap it with the current element. 27 51 [array[currentIndex], array[randomIndex]] = [ 28 52 array[randomIndex], 29 53 array[currentIndex], ··· 32 56 return array; 33 57 } 34 58 35 - // State to track if locale has been properly loaded 36 - let localeLoaded = $state(false); 59 + const getDisplayName = (p: { displayName?: string; handle?: string } | null | undefined) => 60 + p?.displayName || p?.handle || null; 37 61 38 - onMount(() => { 39 - // Set a brief timeout to ensure the browser has time to determine locale 40 - setTimeout(() => { 41 - localeLoaded = true; 42 - }, 10); 62 + // Computed title that prioritizes display name, then handle, then DID 63 + const pageTitle = $derived(() => { 64 + if (!directoryOwner) return "Linkat Directory"; 65 + 66 + const displayName = getDisplayName(ownerProfile); 67 + if (displayName) { 68 + return `${displayName}'s Linkat Directory`; 69 + } 70 + 71 + // Fallback to directoryOwner (DID) while loading 72 + return `${directoryOwner}'s Linkat Directory`; 43 73 }); 44 74 45 - import { getProfile } from "$lib/components/profile/profile"; 46 - let profile = $state<{ displayName?: string; handle?: string } | null>(null); 47 - let loading = $state(true); 48 - let error = $state<string | null>(null); 75 + const pageDescription = $derived(() => { 76 + if (!directoryOwner) return "Discover amazing users curated by the Linkat community"; 77 + 78 + const displayName = getDisplayName(ownerProfile) || directoryOwner; 79 + return `Discover users' links curated by ${displayName} in ${displayName}'s Linkat Directory`; 80 + }); 49 81 50 - $effect(() => { 51 - if (import.meta.env.DIRECTORY_OWNER) { 52 - loading = true; 53 - getProfile(fetch) 54 - .then((p) => { 55 - profile = p; 56 - error = null; 57 - }) 58 - .catch((err) => { 59 - console.error('Failed to load profile:', err); 60 - error = err.message; 61 - profile = null; 62 - }) 63 - .finally(() => { 64 - loading = false; 65 - }); 66 - } else { 67 - loading = false; 68 - } 82 + const pageKeywords = $derived(() => { 83 + const baseKeywords = "Linkat, directory, links, Bluesky, community, curation"; 84 + if (!directoryOwner) return baseKeywords; 85 + 86 + const displayName = getDisplayName(ownerProfile) || directoryOwner; 87 + return `${baseKeywords}, ${displayName}`; 69 88 }); 70 89 </script> 71 90 72 91 <DynamicHead 73 - title={profile?.displayName || "Linkat Directory"} 74 - description={profile?.displayName ? `Discover users' links curated by ${profile.displayName}` : "Discover amazing users curated by the Linkat community"} 75 - keywords={`Linkat, directory, links, Bluesky, community, curation${profile?.displayName ? `, ${profile.displayName}` : ''}`} 76 - ogTitle={profile?.displayName || "Linkat Directory"} 77 - ogDescription={profile?.displayName ? `Discover users' links curated by ${profile.displayName}` : "Discover amazing users' links curated by the Linkat community"} 78 - twitterTitle={profile?.displayName || "Linkat Directory"} 79 - twitterDescription={profile?.displayName ? `Discover users' links curated by ${profile.displayName}` : "Discover amazing users' links curated by the Linkat community"} 92 + title={pageTitle()} 93 + description={pageDescription()} 94 + keywords={pageKeywords()} 95 + ogTitle={pageTitle()} 96 + ogDescription={pageDescription()} 97 + twitterTitle={pageTitle()} 98 + twitterDescription={pageDescription()} 80 99 /> 81 100 82 101 <div class="container mx-auto px-4 py-8"> 83 - {#if data.noUsersConfigured} 102 + {#if data.noUsersConfigured} 84 103 <div class="text-center py-12"> 85 - <div class="max-w-md mx-auto"> 104 + <div class="max-w-4xl mx-auto px-4"> 86 105 <p class="text-lg mb-4 opacity-75"> 87 106 Welcome to Linkat Directory! No users are currently configured. 88 107 </p> 89 - <div class="bg-[var(--muted-bg)] rounded-lg p-6 text-left"> 108 + <div class="bg-[var(--muted-bg)] rounded-lg p-6 text-left overflow-hidden"> 90 109 <h3 class="font-semibold mb-2">To get started:</h3> 91 110 <ol class="list-decimal list-inside space-y-2 text-sm"> 92 - <li>Create a <code>.env</code> file in your project root</li> 93 - <li>Add your user DID: <code>DIRECTORY_OWNER=did:plc:your-did-here</code></li> 94 - <li>Or add multiple users: <code>PUBLIC_LINKAT_USERS=did:plc:user1,did:web:user2</code></li> 95 - <li>Restart the development server</li> 111 + <li class="break-words">Copy <code class="break-all bg-[var(--card-bg)] px-1 py-0.5 rounded text-xs">. env.example</code> to <code class="break-all bg-[var(--card-bg)] px-1 py-0.5 rounded text-xs">.env</code></li> 112 + <li class="break-words">Set your DID: <code class="break-all bg-[var(--card-bg)] px-1 py-0.5 rounded text-xs">DIRECTORY_OWNER=did:plc:your-did-here</code></li> 113 + <li class="break-words">Set the origin: <code class="break-all bg-[var(--card-bg)] px-1 py-0.5 rounded text-xs">PUBLIC_ORIGIN=http://localhost:5713</code></li> 114 + <li class="break-words">Optionally add more users: <code class="break-all bg-[var(--card-bg)] px-1 py-0.5 rounded text-xs">PUBLIC_LINKAT_USERS=did:plc:user1,did:plc:user2</code></li> 115 + <li class="break-words">Restart the development server</li> 96 116 </ol> 97 117 </div> 98 118 </div> 99 119 </div> 100 120 {:else} 101 - <UserDirectory users={shuffleArray([...data.linkatUsers]).map(did => ({ did }))} primaryUserDid={data.primaryUserDid} userLinkBoards={data.userLinkBoards} displayBanner={displayUserBanner} displayDescription={displayUserDescription} /> 121 + <UserDirectory 122 + users={shuffleArray([...data.linkatUsers]).map(did => ({ did }))} 123 + primaryUserDid={directoryOwner} 124 + userLinkBoards={data.userLinkBoards} 125 + displayBanner={displayUserBanner} 126 + displayDescription={displayUserDescription} 127 + /> 102 128 {/if} 103 129 </div>
+56 -9
src/routes/user/[did]/+page.svelte
··· 2 2 import DynamicLinks from "$lib/components/layout/main/DynamicLinks.svelte"; 3 3 import DynamicHead from "$lib/components/layout/DynamicHead.svelte"; 4 4 import { getStores } from "$app/stores"; 5 + import { env } from "$env/dynamic/public"; 6 + import { getProfile } from "$components/profile/profile"; 7 + 5 8 const { page } = getStores(); 6 - 7 9 let { data } = $props(); 8 - 10 + 9 11 let profile = $derived(data.profile); 10 12 let dynamicLinks = $derived(data.dynamicLinks); 11 13 let error = $derived(data.error); 12 14 let did = $derived(data.did); 15 + 16 + let directoryOwner = env.DIRECTORY_OWNER; 17 + let ownerProfile = $state<{ displayName?: string; handle?: string } | null>(null); 18 + 19 + $effect(() => { 20 + if (directoryOwner) { 21 + const loadOwner = async () => { 22 + try { 23 + const result = await getProfile(fetch); 24 + ownerProfile = result; 25 + } catch (err) { 26 + console.error("Could not fetch owner profile:", err); 27 + ownerProfile = null; 28 + } 29 + }; 30 + loadOwner(); 31 + } 32 + }); 33 + 34 + const getDisplayName = (p: { displayName?: string; handle?: string } | null | undefined) => 35 + p?.displayName || p?.handle || null; 13 36 </script> 14 37 15 38 <DynamicHead 16 - title={profile?.displayName || did + " - Linkat Directory"} 17 - description={"View " + (profile?.displayName || did) + "'s curated Linkat links"} 18 - ogTitle={profile?.displayName || did + " - Linkat Directory"} 19 - ogDescription={"View " + (profile?.displayName || did) + "'s curated Linkat links"} 20 - twitterTitle={profile?.displayName || did + " - Linkat Directory"} 21 - twitterDescription={"View " + (profile?.displayName || did) + "'s curated Linkat links"} 22 - keywords={`Linkat, directory, links, Bluesky, curation, ${profile?.displayName || did}`} 39 + title={ 40 + directoryOwner 41 + ? `${getDisplayName(profile) || did} – ${getDisplayName(ownerProfile) || directoryOwner}'s Linkat Directory` 42 + : `${getDisplayName(profile) || did} – Linkat Directory` 43 + } 44 + description={ 45 + directoryOwner 46 + ? `View ${getDisplayName(profile) || did}'s curated links in ${getDisplayName(ownerProfile) || directoryOwner}'s Linkat Directory` 47 + : `View ${getDisplayName(profile) || did}'s curated links in the Linkat Directory` 48 + } 49 + ogTitle={ 50 + directoryOwner 51 + ? `${getDisplayName(profile) || did} – ${getDisplayName(ownerProfile) || directoryOwner}'s Linkat Directory` 52 + : `${getDisplayName(profile) || did} – Linkat Directory` 53 + } 54 + ogDescription={ 55 + directoryOwner 56 + ? `View ${getDisplayName(profile) || did}'s curated links in ${getDisplayName(ownerProfile) || directoryOwner}'s Linkat Directory` 57 + : `View ${getDisplayName(profile) || did}'s curated links in the Linkat Directory` 58 + } 59 + twitterTitle={ 60 + directoryOwner 61 + ? `${getDisplayName(profile) || did} – ${getDisplayName(ownerProfile) || directoryOwner}'s Linkat Directory` 62 + : `${getDisplayName(profile) || did} – Linkat Directory` 63 + } 64 + twitterDescription={ 65 + directoryOwner 66 + ? `View ${getDisplayName(profile) || did}'s curated links in ${getDisplayName(ownerProfile) || directoryOwner}'s Linkat Directory` 67 + : `View ${getDisplayName(profile) || did}'s curated links in the Linkat Directory` 68 + } 69 + keywords={`Linkat, directory, links, Bluesky, curation, ${getDisplayName(profile) || did}, ${getDisplayName(ownerProfile) || directoryOwner}`} 23 70 /> 24 71 25 72 <div class="container mx-auto px-4 py-8">