Implementing the Professional Profile Feature

last updated by ewan datetime loading...

(datetime loading...)

View on WhiteWind or see the record at atproto.at

7 min read • 1,216 words


Ever wondered how my professional profile page comes together? Let's explore the technical bits that make it tick, from fetching the data to displaying it neatly on the page.

The Blueprint: Defining the Data with ProfessionalInfo

At the heart of the professional profile is the data itself. We need a clear structure to hold all the important details like my name, a bit about what I do, my skills, and how to get in touch. This structure is defined by the ProfessionalInfo interface, which you can find tucked away in src/lib/components/profile/profile.ts. Think of it as the blueprint that ensures we know exactly what information to expect and where everything should go.

export interface ProfessionalInfo {
  displayName?: string;
  description?: string;
  avatar?: {
    image: {
      $type: string;
      ref: {
        $link: string;
      };
      mimeType: string;
      size: number;
    };
    alt: string;
    aspectRatio?: { width: number; height: number };
  };
  headline?: string;
  websiteUrl?: string;
  contactEmail?: string;
  country?: string;
  skills?: string[];
}

Gathering the Goods: The getProfessionalInfo Function

Now that we have the blueprint, how do we get the actual professional information? This is where the getProfessionalInfo function comes in. Located in the same src/lib/components/profile/profile.ts file, this function is responsible for fetching the data from my Personal Data Server (PDS). It first grabs some basic profile info, then sends a request to the PDS specifically asking for the professional profile record. If it finds the record, it brings it back, ready to be used.

/**
 * Fetches professional information from the user's PDS.
 * @returns A Promise that resolves to ProfessionalInfo or null if not found or an error occurs.
 */
export async function getProfessionalInfo(): Promise<ProfessionalInfo | null> {
  try {
    const profile: Profile = await getProfile(); // Assuming getProfile is available and returns the user's profile with PDS and DID
    const rawResponse = await fetch(
      `${profile.pds}/xrpc/com.atproto.repo.listRecords?repo=${profile.did}&collection=uk.ewancroft.pro.info&rkey=self`
    );
    const response = await rawResponse.json();

    if (response && response.records && response.records.length > 0) {
      // Assuming the record structure matches the ProfessionalInfo interface
      return response.records[0].value as ProfessionalInfo;
    } else {
      console.log("No professional info record found.");
      return null;
    }
  } catch (error) {
    console.error("Error fetching professional info:", error);
    return null;
  }
}

The Rulebook: Defining the Lexicon

To ensure the professional information is structured correctly and can be validated on the PDS, we use a custom lexicon. This lexicon, found at static/lexicons/professional/info.json, acts as a rulebook. It specifies exactly what properties the professional profile record should have, what type of data they should contain, and any limitations on things like text length. This helps keep the data consistent and reliable.

{
  "lexicon": 1,
  "id": "uk.ewancroft.pro.info",
  "defs": {
    "main": {
      "type": "record",
      "description": "A declaration of a professional portfolio profile.",
      "key": "literal:self",
      "record": {
        "type": "object",
        "properties": {
          "displayName": {
            "type": "string",
            "description": "The display name of the professional or entity.",
            "maxGraphemes": 64,
            "maxLength": 640
          },
          "description": {
            "type": "string",
            "description": "A detailed professional summary, bio, or statement.",
            "maxGraphemes": 512,
            "maxLength": 5120
          },
          "avatar": {
            "type": "object",
            "required": ["image", "alt"],
            "properties": {
              "image": {
                "type": "blob",
                "accept": ["image/png", "image/jpeg"],
                "maxSize": 1000000
              },
              "alt": {
                "type": "string",
                "description": "Alt text description of the image, for accessibility."
              },
              "aspectRatio": {
                "type": "object",
                "description": "Recommended aspect ratio for the image. For a square image, width and height would be equal (e.g., 1:1).",
                "properties": {
                  "width": {
                    "type": "integer",
                    "description": "The width component of the aspect ratio (e.g., 1 for a square)."
                  },
                  "height": {
                    "type": "integer",
                    "description": "The height component of the aspect ratio (e.g., 1 for a square)."
                  }
                },
                "required": ["width", "height"]
              }
            }
          },
          "headline": {
            "type": "string",
            "description": "A short professional headline or tagline.",
            "maxGraphemes": 128,
            "maxLength": 1280
          },
          "websiteUrl": {
            "type": "string",
            "format": "uri",
            "description": "Link to the user's primary website, portfolio, or professional page."
          },
          "contactEmail": {
            "type": "string",
            "format": "email",
            "description": "A contact email address for professional inquiries."
          },\
          "country": {
            "type": "string",
            "description": "The country where the professional is primarily based or operates from.",
            "maxGraphemes": 64,
            "maxLength": 640
          },
          "skills": {
            "type": "array",
            "description": "A list of key skills, technologies, or areas of expertise.",
            "items": {
              "type": "string",
              "maxGraphemes": 50,
              "maxLength": 500
            },
            "maxLength": 50
          }
        }
      }
    }
  }
}

Putting it on Show: The Professional Route

Finally, we need to display this information on the website. This is handled by the Svelte component for the professional route, located at src/routes/professional/+page.svelte. This component takes the fetched professional data and renders it nicely on the page, showing things like the display name, headline, description, and skills. It also takes care of displaying the avatar image, making sure it fetches the correct image from the PDS.

<script lang="ts">
  import { page } from "$app/stores";
    import type { ProfessionalInfo, Profile } from "$lib/components/profile/profile";

  // Define the type for the page data
  interface PageData {
    professionalInfo: ProfessionalInfo | null;
    profile: Profile;
    pdsUrl: string;
    did: string;
  }

  // Access data from layout
  let { data } = $props();
  let professionalInfo: ProfessionalInfo | null = data.professionalInfo;
  console.log(professionalInfo);

  // Construct the full avatar image URL
  let avatarImageUrl: string | undefined = $state(undefined);
  $effect(() => {
    if (professionalInfo?.avatar?.image?.ref?.$link && data.pdsUrl && data.did) {
      avatarImageUrl = `${data.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${data.did}&cid=${professionalInfo?.avatar.image.ref.$link}`;
    } else {
      avatarImageUrl = undefined;
    }
  });

  // State to track if the avatar image failed to load
  let imageLoadError = $state(false);

  // Handle image load error
  function handleImageError() {
    imageLoadError = true;
  }
</script>

<svelte:head>
  <title>Professional - Ewan's Corner</title>
  <meta
    name="description"
    content="Explore professional insights and experiences at Ewan's Corner."
  />
  <meta
    name="keywords"
    content="Ewan, professional, insights, experiences, career, skills"
  />

  <!-- Open Graph / Facebook -->
  <meta property="og:type" content="website" />
  <meta property="og:url" content={$page.url.origin + $page.url.pathname} />
  <meta property="og:title" content="Professional - Ewan's Corner" />
  <meta
    property="og:description"
    content="Explore professional insights and experiences at Ewan's Corner."
  />
  <meta property="og:site_name" content="Professional - Ewan's Corner" />
  <meta property="og:image" content={$page.url.origin + "/embed/professional.png"} />
  <meta property="og:image:width" content="1200" />
  <meta property="og:image:height" content="630" />

  <!-- Twitter -->
  <meta name="twitter:card" content="summary_large_image" />
  <meta name="twitter:url" content={$page.url.origin + $page.url.pathname} />
  <meta name="twitter:title" content="Professional - Ewan's Corner" />
  <meta
    name="twitter:description"
    content="Explore professional insights and experiences at Ewan's Corner."
  />
  <meta name="twitter:image" content={$page.url.origin + "/embed/professional.png"} />
</svelte:head>


{#if !professionalInfo}
  <div
    class="flex flex-col items-center justify-center min-h-[200px] text-lg text-[var(--text-color)] opacity-70 text-center"
  >
    <p>No professional information found.</p>
    <p class="mt-2 text-sm">Please check back later for updates.</p>
  </div>
{:else}
  <div class="container mx-auto px-4 py-12 professional-info">
    <div class="text-center mb-8">
      {#if professionalInfo?.avatar}
        {#if !imageLoadError}
          <img
            src={avatarImageUrl}
            alt={professionalInfo?.avatar.alt}
            class="rounded-full mx-auto mb-4 w-32 h-32 object-cover shadow-lg"
            style="aspect-ratio: {professionalInfo?.avatar.aspectRatio?.width} / {professionalInfo?.avatar.aspectRatio?.height};"
            onerror={handleImageError}
          />
        {/if}
      {/if}
      {#if professionalInfo?.displayName}
      <h1 class="text-4xl font-bold mb-2">{professionalInfo?.displayName}</h1>
      {/if}
      {#if professionalInfo?.headline}
      <p class="text-md text-[var(--text-color)] opacity-80 mb-4">{professionalInfo?.headline}</p>
      {/if}
      {#if professionalInfo?.description}
      <p class="text-lg text-[var(--text-color)] opacity-90">{professionalInfo?.description}</p>
      {/if}
      {#if professionalInfo?.country}
        <p class="text-md text-[var(--text-color)] opacity-80 mt-2">Country: {professionalInfo?.country}</p>
      {/if}
      {#if professionalInfo?.contactEmail}
        <p class="text-md text-[var(--text-color)] opacity-80">Contact: <a href="mailto:{professionalInfo?.contactEmail}" class="text-[var(--link-color)] hover:underline">{professionalInfo?.contactEmail}</a></p>
      {/if}
      {#if professionalInfo?.websiteUrl}
        <p class="text-md text-[var(--text-color)] opacity-80">Website: <a href="{professionalInfo?.websiteUrl}" class="text-[var(--link-color)] hover:underline" target="_blank" rel="noopener noreferrer">{professionalInfo?.websiteUrl ? professionalInfo.websiteUrl.replace(/^(https?://)/, '') : ''}</a></p>
      {/if}
    </div>

    {#if professionalInfo?.skills && professionalInfo?.skills.length > 0}
      <div class="mt-8">
        <h2 class="text-2xl font-semibold mb-4 text-center">Skills</h2>
        <ul class="flex flex-wrap justify-center gap-3">
          {#each professionalInfo?.skills as skill}
            <li class="bg-[var(--card-bg)] text-[var(--text-color)] px-4 py-2 rounded-full shadow-md">
              {skill}
            </li>
          {/each}
        </ul>
      </div>
    {/if}
  </div>
{/if}

By combining these components – the ProfessionalInfo interface for data structure, the getProfessionalInfo function for data fetching, the custom lexicon for data definition and validation, and the Svelte component for presentation – the professional profile feature seamlessly integrates into the website, providing a dedicated space to showcase professional details.