Git Product home page Git Product logo

mairies-sveltekit's Introduction

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

showcase.mp4

🛠️

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

tinacms.mp4
  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.

pagination.mp4

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.

pagefind.mp4
  1. Installed Pagefind through NPM
npx pagefind --site "public"
  1. I'm not using the pre-built search UI provided by Pagefind but rather accessing directly the Pagefind API /frontend/src/components/SearchDialog.svelte
<script>
	let query = "";
  const handleSearch = async () => {
	  const pagefind = await import("/pagefind/pagefind.js");//appelle l'objet pagefind
    const r = await pagefind.search(query);//tape l'API "search" de Pagefind (on fait remonter la query de l'utilisateur en string via bind:value={query})

    console.log(r);//donne un array avec 4 objets parmis lesquels l'objet "results"
    for (const result of r.results) {
      console.log(await result.data());//obligé d'await result.data pour bien avoir les résultats de la query
    }
  };
</script>

<form>
	<label class="sr-only" for="search">Rechercher</label>
	<input on:keyup={() => handleSearch()} bind:value={query} type="text"
name="search" id="search" placeholder="Rechercher"/>
</form>

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

contactform.mp4
  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: '[email protected]',
      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();
  1. 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: '[email protected]',
      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,
		},
    }
}
  1. /frontend/src/routes/.layout.js
export const prerender = true

Ops

Frontend deployed on Vercel Backend deployed on fly.io

mairies-sveltekit's People

Contributors

teotimepacreau avatar tina-cloud-app[bot] avatar

Stargazers

 avatar

Watchers

 avatar

Forkers

bayaderpack

mairies-sveltekit's Issues

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.