Frontend : HTML, CSS, Tailwind, JS, SvelteKit Backend: TinaCMS, Pagefind, Fastify
+page.js
rather than +page.server.js
and contraryIn 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
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
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)
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");
}
}
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} />
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.
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>
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.
npx pagefind --site "public"
/frontend/src/components/SearchDialog.svelte
```
```
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>
package.json
script to see pagefind functioning"postbuildservepagefind": "tinacms build && vite build && pagefind --site build --serve"
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
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();
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: '[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, }, } }
2. `/frontend/src/routes/.layout.js`
export const prerender = true
# Ops
Frontend deployed on Vercel
Backend deployed on fly.io