"Site de Mairie" : my fullstack project with SvelteKit frontend + Fastify backend

https://github.com/teotimepacreau/Mairies-Sveltekit/assets/95431443/f5b7c952-d9f2-4851-8484-ae6b4b984012

🛠️

Frontend : HTML, CSS, Tailwind, JS, SvelteKit Backend: TinaCMS, Pagefind, Fastify

Functionalities

  • user-friendly administration to create/update/delete every page on the website
  • contact form automatically sending an email containing form to the admin
  • inner search function
  • articles pagination
  • smooth page transitions

What I learned :

  • using Svelte and SvelteKit
  • implement a headless CMS in order to allow non tech users to admin the website
  • querying a GraphQL API
  • using Tailwind and work with an existing Design System
  • splitting recursive elements in Svelte components
  • customizing Svelte to generate a static website, and allowing also SSR for certain pages
  • applying shared layouts to pages through Svelte, escaping layouts
  • knowing when to use +page.js rather than +page.server.js and contrary
  • converting Markdown files to HTML through Svelte

In-depth details of the project :

Progressive enhancement on hamburger menu

In order to not require JS to open and close the hamburger menu, I used the Popover API. It's a new HTML feature.

<button class="show-btn" popovertarget="mobile-navigation" popovertargetaction="show">
<nav popover id="mobile-navigation">
    <button class="close-btn" popovertarget="mobile-navigation" popovertargetaction="hide">
</nav>

/frontend/src/components/MobileNav.svelte

Allowing non-tech admins to manage the whole website easily

I wanted to allow non-tech admins to create/update/delete articles but also be capable to create/update/delete all pages of the website. I choosed a headless Git based CMS in order to don't have the hussle to manage a Database and prioritize simplicity : TinaCMS. /frontend/tina/config.js

https://github.com/teotimepacreau/Mairies-Sveltekit/assets/95431443/c4887666-0eee-49cb-8ed6-8b6094fd689a

  1. First defining a schema for the data with 2 collections : article (for news) and pages (for general page content), then defining each field with data types and requirements.
    schema: {
     collections: [
       {
         name: "article",
         label: "Articles",
         path: "src/articles",
         fields: [
           {
             type: "string",
             name: "titre",
             label: "Titre",
             isTitle: true,
             required: true,
           },
           {
             type: "string",
             name: "desc",
             label: "Description",
             required: true,
           },
           {
             type: "datetime",
             name: "date",
             label: "Date",
             required: true,
           },
           {
             type: "image",
             name: "image",
             label: "Image",
           },
           {
             type: "string",
             name: "imagealt",
             label: "Description de l'image",
           },
           {
             type: "rich-text",
             name: "body",
             label: "Corps de texte",
             isBody: true, //⚠️ bien penser à mettre isBody: true au champ dont on souhaite qu’il souhaite render non pas en frontmatter mais bien en corps de texte markdown
           },
         ],
       },
       {
         name: "pages",
         label: "Pages",
         path: "src/pages",
         fields: [
           {
             type: "string",
             name: "titre",
             label: "Titre",
             isTitle: true,
             required: true,
           },
           {
             type: "string",
             label: "Catégorie",
             name: "categorie",
             list: true,
             required: true,
             options: [
               {
                 label: "Mairie",
                 value: "mairie",
               },
               {
                 label: "Vie Locale",
                 value: "vie locale",
               },
               {
                 label: "DĂ©marches",
                 value: "demarches",
               }
             ],
           },
           {
             type: "string",
             label: "IcĂ´ne",
             name: "emoji",
             description: "Emoji qui servira d'icĂ´ne dans le menu de navigation",
             required: true,
           },
           {
             type: "rich-text",
             name: "contenu",
             label: "Contenu",
             required: true,
             isBody: true, //bien penser à mettre isBody: true au champ dont on souhaite qu’il souhaite render non pas en frontmatter mais bien en corps de texte markdown
           },
         ],
       },
     ],
      },
    

Result is having a dedicated admin page, and pages for each collection (Articles, Pages)

  1. Querying the generated GraphQL API in Svelte files in order to get data
    import { client } from "@tina/__generated__/client";
    async function fillArrayOfNavLinks() {
     const result = await client.queries.pagesConnection();
     try {
       const {
         data: {
           pagesConnection: { edges },
         },
       } = result;
       arrayOfNavLinks = edges;
       return arrayOfNavLinks;
     } catch (e) {
       console.error(500, "Could not find articles on the server");
     }
      }
    

Articles generation

  1. Markdown files are processed through MDSVEX in order to generate HTML pages.
  2. Accessing articles through the SvelteKit load native function in /frontend/src/routes/(content)/actualites/[slug]/+page.js : if the slug matches then content and metadata of the articles are passed to the +page.svelte through data variable. Then capturing data.meta.titre, data.meta.image... and rendering body of the article with <svelte:component this={data.content} />

Articles pagination through Shadcn-UI

I wanted to limit displayed articles to 3, and then needed a Pagination item to see hidden articles. I used Shadcn-Svelte a UI component library.

https://github.com/teotimepacreau/Mairies-Sveltekit/assets/95431443/a3ccc618-21f2-4f73-b572-18df9925e156

frontend/src/components/ActuItemsListAndPagination.svelte

import * as Pagination from "@sveltecomponents/pagination";

// IIII. RENDRE CHAQUE BTN DE PAGINATION INTERACTIF POUR METTRE A JOUR LA VARIABLE currentPage puis appeler la fonction getSubsetOfArticlesForPagination() pour mettre Ă  jour le subset
  onMount(async () => {
    await getSubsetOfArticlesForPagination(); //la fonction de création du subset est appelée pour la 1ère fois ici
    // IIII.1 intéractivité des btns n° de pa&0ge
    let paginationNumberButtons = document.querySelectorAll(
      "[data-melt-pagination-page]"
    );
    paginationNumberButtons.forEach((button) => {
      button.addEventListener("click", () => {
        currentPage = button.dataset.value;
        getSubsetOfArticlesForPagination();
      });
    });

    // IIII.2 intéractivité du btn "suivant"
    let paginationNextButton = document.querySelector(
      "[data-melt-pagination-next]"
    );
    paginationNextButton.addEventListener("click", () => {
      currentPage++;
      getSubsetOfArticlesForPagination();
    });

    // IIII.3 intéractivité du btn "précédent"
    let paginationPrevButton = document.querySelector(
      "[data-melt-pagination-prev]"
    );
    paginationPrevButton.addEventListener("click", () => {
      currentPage--;
      getSubsetOfArticlesForPagination();
    });
  });

<Pagination.Root
    count={arrayOfArticles.length}
    perPage={itemsPerPage}
    let:pages
    let:currentPage
  >
    <!-- count c'est nombre total d'item -->
    <!-- perPage c'est le nombre d'item qu'on souhaite afficher par page-->

    <Pagination.Content>
      <Pagination.Item>
        <Pagination.PrevButton />
      </Pagination.Item>
      {#each pages as page (page.key)}
        {#if page.type === "ellipsis"}
          <Pagination.Item>
            <Pagination.Ellipsis />
          </Pagination.Item>
        {:else}
          <Pagination.Item isVisible={currentPage == page.value}>
            <Pagination.Link {page} isActive={currentPage == page.value}>
              {page.value}
            </Pagination.Link>
          </Pagination.Item>
        {/if}
      {/each}
      <Pagination.Item>
        <Pagination.NextButton />
      </Pagination.Item>
    </Pagination.Content>
  </Pagination.Root>

Inner search function

Website search function is powered with Pagefind. It builds an index for static pages, and works only after the build because it analysis all HTML pages. It works only for static websites.

https://github.com/teotimepacreau/Mairies-Sveltekit/assets/95431443/ced8b1c9-0b45-46fa-b89f-8f046739b489

  1. Installed Pagefind through NPM
    npx pagefind --site "public"
    
  2. I'm not using the pre-built search UI provided by Pagefind but rather accessing directly the Pagefind API /frontend/src/components/SearchDialog.svelte ```

    handleSearch()} bind:value={query} type="text" name="search" id="search" placeholder="Rechercher"/>
    ```

    Then displaying the resuts via VanillaJS 3. I select what pages are searchable thanks to the data-pagefind-body attribute

    <article data-pagefind-body>
        {{ content | safe }}
    </article>
    
    1. package.json script to see pagefind functioning
      "postbuildservepagefind": "tinacms build && vite build && pagefind --site build --serve"
      

    Contact form & email notification

    Toast notification confirms if form is successully sent and received by the server. If form is not successfully received server-side, a toast notification informs the user. Then an email is sent to the admin's mail containing the form

    https://github.com/teotimepacreau/Mairies-Sveltekit/assets/95431443/33b64b90-ea8c-4a0c-85f7-18804d375678

    1. Set up a backend server through Fastify to receive form submission backend/src/server.js ``` import fastify from "fastify"; import cors from "@fastify/cors";

    const app = fastify();

    // CORS await app.register(cors, { origin: "*",//to modify before prod to not allow every origin methods: "GET, POST", });

    app.post("/api", async (req, res) => { //it's the handler function try { let receivedForm = { nom: req.body.nom, prenom: req.body.prenom, telephone: req.body.telephone, email: req.body.email, messagecontent: req.body.messagecontent, }; console.log(receivedForm);

    let msg = {
      to: 'teotime.pac@outlook.fr',
      from: process.env.FROM_EMAIL,
      subject: 'Demande de contact - site mairie',
      html: `<strong>${receivedForm.prenom}</strong></br><strong>${receivedForm.nom}</strong></br><span>${receivedForm.email}</span></br><span>${receivedForm.telephone}</span></br><p>${receivedForm.messagecontent}</p>`,
    };
    

    } })

    //fonction qui permet de démarrer notre serveur const start = async () => { try { await app.listen({ port: 3000 }); } catch (err) { console.error(err); process.exit(1); //permet de finir le processus en cas d'erreur avec le code erreur 1 } }; // appel de la fonction pour démarrer serveur start();

    2. Set up a mail sender on backend server to transfer the form to the admin's mail
    added to `backend/src/server.js`
    

    import fastify from "fastify"; import cors from "@fastify/cors"; import sgMail from "@sendgrid/mail"

    // SECRET ENREGISTRE VIA NODE20.0 5node package.json : --env-file=.env sgMail.setApiKey(process.env.SENDGRID_API_KEY);

    const app = fastify();

    // ROUTES

    app.get("/", async (req, res) => { res.send({ message: "Hello from the route handler!" }); });

    app.post("/api", async (req, res) => { //it's the handler function try { let receivedForm = { nom: req.body.nom, prenom: req.body.prenom, telephone: req.body.telephone, email: req.body.email, messagecontent: req.body.messagecontent, }; console.log(receivedForm);

    let msg = {
      to: 'blabla@gmail.com',
      from: process.env.FROM_EMAIL,
      subject: 'Demande de contact - site mairie',
      html: `<strong>${receivedForm.prenom}</strong></br><strong>${receivedForm.nom}</strong></br><span>${receivedForm.email}</span></br><span>${receivedForm.telephone}</span></br><p>${receivedForm.messagecontent}</p>`,
    };
    
    (async () => {
      try {
        await sgMail.send(msg);
      } catch (error) {
        console.error(error);
    
        if (error.response) {
          console.error(error.response.body)
        }
      }
    })();
    
    res
      .status(201)
      .send({ confmessage: "Form received on backend with success" });
    

    } catch (error) { res .status(500) .send({ errMessage: "Erreur côté serveur suite à la soumission de votre formulaire", }); } });

    
    ## Layouts
    1. SvelteKit layout logic to apply style to all subpages with `+layout.svelte`
    2. Create shared layout only within a subfolder but without affecting url thanks to `(content)` : `/frontend/src/routes/(content)/+layout.svelte`
    ## View Transitions
    Using View Transitions API to create seamless navigation with the native fade-in animation
    `/frontend/src/routes/+layout.svelte`
    

    import { onNavigate } from "$app/navigation";

    onNavigate((navigation) => { if (!document.startViewTransition) return;

    return new Promise((resolve) => {
      document.startViewTransition(async () => {
        resolve();
        await navigation.complete;
      });
    });
    

    });

    ## Components
    Created several components from scratch :
    1. Generate dynamically list of actu items by making a request to the CMS and paginate it (pagination is from Shadcn pre-made component) `/frontend/src/components/ActuItemsListAndPagination.svelte`
    2. Dynamic Breadcrumbs adapting path and elements on each page `/frontend/src/components/Breadcrumb.svelte`
    3.  Generating navigation links by making a request to the CMS and ordering nav elements following their category `/frontend/src/components/Nav.svelte`
    4. Search Button opening a `dialog` `/frontend/src/components/SearchButton.svelte`
    5. Search Dialog allowing user to input the searched elements `/frontend/src/components/SearchDialog.svelte`
    
    ## Static Site Generation
    I wanted the website to be as performant as possible so rendering the major part of the website as Static seemed important.
    1. `/frontend/svelte.config.js`
    

    import adapter from '@sveltejs/adapter-static' const config = { kit: { prerender: { crawl: true, }, } }

    2. `/frontend/src/routes/.layout.js`
    

    export const prerender = true

    
    # Ops
    Frontend deployed on Vercel
    Backend deployed on fly.io
    

Top categories

Loading Svelte Themes