Compare commits

...

11 Commits

Author SHA1 Message Date
e084372b44
feat: add page system behing navbar items and skeletons for loading 2024-08-06 21:19:24 +02:00
898ee32f9a Merge pull request 'Interface - Implémentation d'une première ébauche technique' (#40) from feat/8_implement_main_ui into main
Cette PR met concrètement en place une première interface combinant les technologies front choisies pour le projet.

- Setup de tailwindcss (+ documentation basique sur son usage)
- Implémentation d'un composant "complexe" de barre de navigation
- Gestion du responsive
- Combinaison de Askama (Jinja) + HTMX
- Usage de AlpineJS pour les micro-interactions (affichage des menus)

À suivre, dans la PR #42 : une interface moins "random" et plus orientée vers nos besoins

Contribue à #8

Reviewed-on: #40
Reviewed-by: kosssi <simon@p4pillon.org>
Reviewed-by: theo <theo.lettermann@gmail.com>
2024-08-06 21:12:31 +02:00
f3495b8fb4 fixup! feat: make Nav and Profile menu dynamic 2024-08-06 21:11:04 +02:00
06e03011d8 fixup! feat: Setup Tailwind CSS 2024-08-06 21:11:04 +02:00
78bf81c301 feat: make Nav and Profile menu dynamic 2024-08-06 21:11:04 +02:00
aba6c101cb feat: replace vanilla JS by AlpineJS 2024-08-06 21:11:04 +02:00
23f85c5e92 feat: add navbar layout with some JS controls (with Alpine.js) 2024-08-06 21:11:04 +02:00
e057889403 refacto: nest axum templates routes 2024-08-06 21:11:04 +02:00
4a27dacd8e feat: Setup Tailwind CSS 2024-08-06 21:11:04 +02:00
a365e9206f Merge pull request 'docs: Add Tauri version' (#41) from tauri_version into main
### Détails

Spécifie la version de Tauri à installer dans la documentation

### Pourquoi ?

Il faut la bonne version de Tauri pour que ça fonctionne et que l'on soit raccord dans l'équipe

Reviewed-on: #41
Reviewed-by: florian_briand <florian.briand@digital-engine.info>
2024-08-06 09:39:20 +02:00
Simon C
4b96e6348e docs: Add Tauri version 2024-08-06 00:15:51 +02:00
39 changed files with 1782 additions and 42 deletions

1
Cargo.lock generated
View File

@ -69,6 +69,7 @@ dependencies = [
"askama",
"askama_axum",
"axum",
"serde",
"tokio",
"tower-http",
]

View File

@ -12,23 +12,40 @@ Logiciel de Pharmacie libre et open-source.
### Pré-requis
#### Tauri CLI
La CLI Tauri est nécessaire au lancement du client `desktop`. Elle peut être installée via Cargo :
```bash
cargo install tauri-cli
cargo install tauri-cli --version "^2.0.0-beta"
```
### Exécution de l'application cliente desktop
#### Tailwindcss CLI
Le CLI Tailwindcss est nécessaire pour la génération du fichier `crates/app/assets/css/style.css`.
La documentation d'installation est disponible sur le site officiel de Tailwindcss : https://tailwindcss.com/blog/standalone-cli
La version actuellement utilisée est la [`v3.4.7`](https://github.com/tailwindlabs/tailwindcss/releases/tag/v3.4.7)
#### SESAM-Vitale
La crate `sesam-vitale` nécessite la présence des librairies dynamiques fournies par le package FSV et la CryptolibCPS. Les instructions d'installation sont disponibles dans le [README](crates/sesam-vitale/README.md) de la crate `sesam-vitale`.
### Lancement
Le logiciel dans sa globalité peut être lancé via la commande suivante :
```bash
cargo tauri dev
```
### Exécution du serveur web `app` en mode endpoint
/!\ Attention, le lancement du client `desktop` ne génère pas le fichier `crates/app/assets/css/style.css` automatiquement pour le moment. En cas de modification des interfaces web, il est donc nécessaire de procéder à sa génération comme indiqué dans le [README](crates/app/README.md) de la crate `app`.
```bash
cargo run --bin app
```
Si vous souhaitez lancer les composants séparément, les indications de lancement sont disponibles dans les README des différents crates.
- [app](crates/app/README.md)
- [sesam-vitale](crates/sesam-vitale/README.md)
## Build

View File

@ -1 +1,4 @@
/target
# Tailwind CSS CLI
tailwindcss

View File

@ -7,6 +7,7 @@ edition = "2021"
askama = "0.12.1"
askama_axum = "0.4.0"
axum = "0.7.5"
serde = { version = "1.0.204", features = ["derive"] }
tokio = { version = "1.39.1", features = ["macros", "rt-multi-thread"] }
tower-http = { version = "0.5.2", features = ["fs"] }

15
crates/app/README.md Normal file
View File

@ -0,0 +1,15 @@
## Pré-requis
- Récupérer le binaire TailwindCSS : https://tailwindcss.com/blog/standalone-cli
## Exécution
- Lancer tailwindcss en mode watch dans un terminal :
```bash
./tailwindcss -i css/input.css -o assets/css/style.css --watch
```
- Lancer le serveur web dans un autre terminal :
```bash
cargo run --bin app
```

File diff suppressed because it is too large Load Diff

3
crates/app/css/input.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -1,27 +1,30 @@
mod pages;
mod templates;
use std::path::Path;
use askama::Template;
use askama_axum::IntoResponse;
use templates::{hello::HelloResponse, index::GetIndexResponse};
use axum::http::{StatusCode, Uri};
use tower_http::services::ServeDir;
async fn root() -> impl IntoResponse {
return GetIndexResponse {}.into_response();
async fn fallback(uri: Uri) -> (StatusCode, String) {
(StatusCode::NOT_FOUND, format!("No route for {uri}"))
}
async fn hello() -> impl IntoResponse {
return HelloResponse {
name: "Theo".to_string(),
}
.into_response();
#[derive(Template)]
#[template(path = "index.html")]
pub struct GetIndexResponse;
async fn root() -> impl IntoResponse {
GetIndexResponse {}.into_response()
}
pub fn get_router(assets_path: &Path) -> axum::Router {
let router = axum::Router::new()
axum::Router::new()
.nest_service("/assets", ServeDir::new(assets_path))
.route("/", axum::routing::get(root))
.route("/hello", axum::routing::get(hello));
router
.nest("/pages", pages::get_routes())
.merge(templates::get_routes())
.fallback(fallback)
}

View File

@ -9,6 +9,7 @@ async fn main() {
let router = get_router(assets_path.as_path());
// TODO: select port based on available port (or ask in CLI)
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
let listener = tokio::net::TcpListener::bind("localhost:3000").await.unwrap();
println!("Listening on: http://{}", listener.local_addr().unwrap());
axum::serve(listener, router).await.unwrap();
}

View File

@ -0,0 +1,10 @@
use askama::Template;
use askama_axum::IntoResponse;
#[derive(Template)]
#[template(path = "pages/cps.html")]
struct CpsResponse;
pub async fn cps() -> impl IntoResponse {
CpsResponse.into_response()
}

View File

@ -0,0 +1,10 @@
use askama::Template;
use askama_axum::IntoResponse;
#[derive(Template)]
#[template(path = "pages/home.html")]
struct HomeResponse;
pub async fn home() -> impl IntoResponse {
HomeResponse.into_response()
}

View File

@ -0,0 +1,10 @@
use axum::{routing, Router};
mod cps;
mod home;
pub fn get_routes() -> Router {
Router::new()
.route("/home", routing::get(home::home))
.route("/cps", routing::get(cps::cps))
}

View File

@ -1,7 +1,20 @@
use askama::Template;
use askama_axum::IntoResponse;
use axum::{routing, Router};
#[derive(Template)]
#[template(path = "hello.html")]
pub struct HelloResponse {
struct HelloResponse {
pub name: String,
}
async fn hello() -> impl IntoResponse {
HelloResponse {
name: "Theo".to_string(),
}.into_response()
}
pub fn get_routes() -> Router {
Router::new()
.route("/", routing::get(hello))
}

View File

@ -1,5 +0,0 @@
use askama::Template;
#[derive(Template)]
#[template(path = "index.html")]
pub struct GetIndexResponse;

View File

@ -1,2 +1,12 @@
pub mod hello;
pub mod index;
use axum::Router;
mod hello;
mod nav;
mod profile;
pub fn get_routes() -> Router {
Router::new()
.nest("/hello", hello::get_routes())
.nest("/nav", nav::get_routes())
.nest("/profile", profile::get_routes())
}

View File

@ -0,0 +1,60 @@
use askama::Template;
use askama_axum::IntoResponse;
use axum::{extract::Query, routing, Router};
use serde::Deserialize;
struct MenuItem {
label: String,
href: String,
current: bool,
}
#[derive(Deserialize)]
struct MenuParameters {
mobile: bool,
}
#[derive(Template)]
#[template(path = "layout/nav/nav-menu-items.html")]
struct MenuResponse {
mobile: bool,
items: Vec<MenuItem>,
}
impl MenuResponse {
fn get_classes(&self, is_current_item: &bool) -> String {
let common_classes = match self.mobile {
true => "block border-l-4 py-2 pl-3 pr-4 text-base font-medium".to_string(),
false => "inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium".to_string(),
};
match (self.mobile, is_current_item) {
(true, true) => common_classes + " border-indigo-500 bg-indigo-50 text-indigo-700",
(true, false) => common_classes + " border-transparent text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-800",
(false, true) => common_classes + " border-indigo-500 text-gray-900",
(false, false) => common_classes + " border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
}
}
}
async fn menu(Query(params): Query<MenuParameters>) -> impl IntoResponse {
MenuResponse {
mobile: params.mobile,
items: vec![
MenuItem {
label: "Accueil".to_string(),
href: "/pages/home".to_string(),
current: true,
},
MenuItem {
label: "CPS".to_string(),
href: "/pages/cps".to_string(),
current: false,
},
],
}.into_response()
}
pub fn get_routes() -> Router {
Router::new()
.route("/menu", routing::get(menu))
}

View File

@ -0,0 +1,65 @@
use askama::Template;
use askama_axum::IntoResponse;
use axum::{extract::Query, routing, Router};
use serde::Deserialize;
struct MenuItem {
label: String,
id: String,
current: bool,
}
#[derive(Deserialize)]
struct MenuParameters {
mobile: bool,
}
#[derive(Template)]
#[template(path = "layout/nav/profile-menu-items.html")]
struct MenuResponse {
mobile: bool,
items: Vec<MenuItem>,
}
impl MenuResponse {
fn get_classes(&self, is_current_item: &bool) -> String {
let common_classes = match self.mobile {
true => "block px-4 py-2 text-base font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-800".to_string(),
false => "block px-4 py-2 text-sm text-gray-700".to_string(),
};
match (self.mobile, is_current_item) {
(true, true) => common_classes + "", // ???
(true, false) => common_classes + "",
(false, true) => common_classes + " bg-gray-100",
(false, false) => common_classes + "",
}
}
}
async fn menu(Query(params): Query<MenuParameters>) -> impl IntoResponse {
MenuResponse {
mobile: params.mobile,
items: vec![
MenuItem {
label: "Votre profil".to_string(),
id: "profile".to_string(),
current: false,
},
MenuItem {
label: "Paramètres".to_string(),
id: "settings".to_string(),
current: false,
},
MenuItem {
label: "Déconnexion".to_string(),
id: "logout".to_string(),
current: false,
},
],
}.into_response()
}
pub fn get_routes() -> Router {
Router::new()
.route("/menu", routing::get(menu))
}

View File

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./templates/**/*.html',
'./css/**/*.css',
],
theme: {
extend: {},
},
plugins: [],
}

View File

@ -1,13 +1,23 @@
<!doctype html>
<html lang="en">
<html lang="fr" class="h-full">
<head>
<title>{% block title %}{{ title }}{% endblock %}</title>
<script src="/assets/js/htmx.min.js"></script>
<script src="//unpkg.com/alpinejs" defer></script>
<link href="/assets/css/style.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/flowbite@2.5.1/dist/flowbite.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.5.1/dist/flowbite.min.js"></script>
{% block head %}{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
<body class="h-full">
<div class="min-h-full">
{% block nav %}
{% include "layout/nav.html" %}
{% endblock %}
{% block body %}{% endblock %}
</div>
</body>
</html>

View File

@ -1,22 +1,33 @@
{% extends "base.html" %}
{% block title %}Pharma Libre{% endblock %}
{% block body %}
<div>
{% block title %}Pharma Libre{% endblock %}
{% block body %}
<div class="py-10">
<header>
<h1>Pharma Libre</h1>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<h1
id="page-title"
class="text-3xl font-bold leading-tight tracking-tight text-gray-900"
>
{% include "skeletons/page-title.html" %}
</h1>
</div>
</header>
<main>
<div
id="hello"
hx-get="/hello"
hx-target="this"
hx-trigger="load"
hx-swap="outerHTML"
id="main-container"
class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8"
>
Loading...
<!-- Your content -->
<div
hx-get="/pages/home"
hx-target="this"
hx-trigger="load"
hx-swap="outerHTML"
>
{% include "skeletons/card.html" %}
</div>
</div>
</main>
</div>

View File

@ -0,0 +1,41 @@
<nav
class="border-b border-gray-200 bg-white"
x-data="{ menuOpen: false }"
>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<div class="flex">
{% include "layout/nav/logo.html" %}
<div class="hidden sm:-my-px sm:ml-6 sm:flex sm:space-x-8">
{% include "layout/nav/desktop/menu-items.html" %}
</div>
</div>
<div class="hidden sm:ml-6 sm:flex sm:items-center">
{% include "layout/nav/desktop/notifications-button.html" %}
{% include "layout/nav/desktop/profile.html" %}
</div>
<div class="-mr-2 flex items-center sm:hidden">
{% include "layout/nav/mobile/menu-button.html" %}
</div>
</div>
</div>
<!-- Mobile menu, show/hide based on menu state. -->
<div
class="sm:hidden" id="mobile-menu"
x-show="menuOpen"
x-cloak
>
<div class="space-y-1 pb-3 pt-2">
{% include "layout/nav/mobile/menu-items.html" %}
</div>
<div class="border-t border-gray-200 pb-3 pt-4">
<div class="flex items-center px-4">
{% include "layout/nav/mobile/profile.html" %}
{% include "layout/nav/mobile/notifications-button.html" %}
</div>
<div class="mt-3 space-y-1">
{% include "layout/nav/mobile/profile-items.html" %}
</div>
</div>
</div>
</nav>

View File

@ -0,0 +1,9 @@
<div
id="nav-menu-desktop"
hx-get="/nav/menu?mobile=false"
hx-target="this"
hx-trigger="load"
hx-swap="outerHTML"
>
{% include "skeletons/menu-items.html" %}
</div>

View File

@ -0,0 +1,6 @@
<button
type="button"
class="relative rounded-full bg-white p-1 text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
{% include "layout/nav/notifications-icon.html" %}
</button>

View File

@ -0,0 +1,22 @@
<div
class="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
role="menu"
id="profile-dropdown"
aria-orientation="vertical"
aria-labelledby="user-menu-button"
tabindex="-1"
x-show="profileOpen"
x-on:click.outside="profileOpen = false"
x-cloak
x-transition
>
<div
id="profile-menu-desktop"
hx-get="/profile/menu?mobile=false"
hx-target="this"
hx-trigger="load"
hx-swap="outerHTML"
>
Chargement ...
</div>
</div>

View File

@ -0,0 +1,27 @@
<!-- Profile dropdown -->
<div
class="relative ml-3"
x-data="{ profileOpen: false }"
>
<div>
<button
type="button"
class="relative flex max-w-xs items-center rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
id="user-menu-button"
aria-controls="profile-dropdown"
aria-expanded="false"
aria-haspopup="menu"
x-on:click="profileOpen = ! profileOpen"
>
<span class="absolute -inset-1.5"></span>
<span class="sr-only">Open user menu</span>
<img
class="h-8 w-8 rounded-full"
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
alt=""
/>
</button>
</div>
{% include "layout/nav/desktop/profile-dropdown.html" %}
</div>

View File

@ -0,0 +1,4 @@
<div class="flex flex-shrink-0 items-center">
<img class="block h-8 w-auto lg:hidden" src="https://tailwindui.com/img/logos/mark.svg?color=indigo&shade=600" alt="Your Company">
<img class="hidden h-8 w-auto lg:block" src="https://tailwindui.com/img/logos/mark.svg?color=indigo&shade=600" alt="Your Company">
</div>

View File

@ -0,0 +1,43 @@
<!-- Mobile menu button -->
<button
type="button"
class="relative inline-flex items-center justify-center rounded-md bg-white p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
aria-controls="mobile-menu"
x-on:click="menuOpen = ! menuOpen"
x-bind:aria-expanded="menuOpen"
>
<span class="absolute -inset-0.5"></span>
<span class="sr-only">Open main menu</span>
<!-- Menu open: "hidden", Menu closed: "block" -->
<svg
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
x-bind:class="menuOpen ? 'hidden' : 'block'"
x-bind:aria-hidden="menuOpen"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
<!-- Menu open: "block", Menu closed: "hidden" -->
<svg
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
x-bind:class="menuOpen ? 'block' : 'hidden'"
x-bind:aria-hidden="! menuOpen"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>

View File

@ -0,0 +1,9 @@
<div
id="nav-menu-mobile"
hx-get="/nav/menu?mobile=true"
hx-target="this"
hx-trigger="load"
hx-swap="outerHTML"
>
Chargement ...
</div>

View File

@ -0,0 +1,6 @@
<button
type="button"
class="relative ml-auto flex-shrink-0 rounded-full bg-white p-1 text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
{% include "layout/nav/notifications-icon.html" %}
</button>

View File

@ -0,0 +1,9 @@
<div
id="profile-menu-mobile"
hx-get="/profile/menu?mobile=true"
hx-target="this"
hx-trigger="load"
hx-swap="outerHTML"
>
Chargement ...
</div>

View File

@ -0,0 +1,11 @@
<div class="flex-shrink-0">
<img
class="h-10 w-10 rounded-full"
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
alt=""
/>
</div>
<div class="ml-3">
<div class="text-base font-medium text-gray-800">Tom Cook</div>
<div class="text-sm font-medium text-gray-500">tom@example.com</div>
</div>

View File

@ -0,0 +1,13 @@
{% for item in items %}
<a
href=""
hx-get="{{ item.href }}"
hx-trigger="click"
hx-target="#main-container"
hx-swap="innerHTML"
class="{{ Self::get_classes(self, item.current) }}"
aria-current="{% if item.current %}page{% endif %}"
>
{{ item.label }}
</a>
{% endfor %}

View File

@ -0,0 +1,16 @@
<span class="absolute -inset-1.5"></span>
<span class="sr-only">View notifications</span>
<svg
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"
/>
</svg>

View File

@ -0,0 +1,11 @@
{% for item in items %}
<a
href="#{{ item.id }}"
class="{{ Self::get_classes(self, item.current) }}"
role="menuitem"
tabindex="-1"
id="{{ item.id }}"
>
{{ item.label }}
</a>
{% endfor %}

View File

@ -0,0 +1,52 @@
<h3 id="page-title" hx-swap-oob="textContent">
CPS
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<div
class="border-2 border-dashed border-gray-300 rounded-lg dark:border-gray-600 h-32 md:h-64"
></div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-32 md:h-64"
></div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-32 md:h-64"
></div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-32 md:h-64"
></div>
</div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-96 mb-4"
></div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-48 md:h-72"
></div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-48 md:h-72"
></div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-48 md:h-72"
></div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-48 md:h-72"
></div>
</div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-96 mb-4"
></div>
<div class="grid grid-cols-2 gap-4">
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-48 md:h-72"
></div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-48 md:h-72"
></div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-48 md:h-72"
></div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-48 md:h-72"
></div>
</div>

View File

@ -0,0 +1,52 @@
<h3 id="page-title" hx-swap-oob="textContent">
Accueil
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<div
class="border-2 border-dashed border-gray-300 rounded-lg dark:border-gray-600 h-32 md:h-64"
></div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-32 md:h-64"
></div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-32 md:h-64"
></div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-32 md:h-64"
></div>
</div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-96 mb-4"
></div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-48 md:h-72"
></div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-48 md:h-72"
></div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-48 md:h-72"
></div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-48 md:h-72"
></div>
</div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-96 mb-4"
></div>
<div class="grid grid-cols-2 gap-4">
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-48 md:h-72"
></div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-48 md:h-72"
></div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-48 md:h-72"
></div>
<div
class="border-2 border-dashed rounded-lg border-gray-300 dark:border-gray-600 h-48 md:h-72"
></div>
</div>

View File

@ -0,0 +1,22 @@
<div role="status" class="animate-pulse max-w-sm p-4 border border-gray-200 rounded shadow md:p-6 dark:border-gray-700">
<div class="flex items-center justify-center h-48 mb-4 bg-gray-300 rounded dark:bg-gray-700">
<svg class="w-10 h-10 text-gray-200 dark:text-gray-600" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 20">
<path d="M14.066 0H7v5a2 2 0 0 1-2 2H0v11a1.97 1.97 0 0 0 1.934 2h12.132A1.97 1.97 0 0 0 16 18V2a1.97 1.97 0 0 0-1.934-2ZM10.5 6a1.5 1.5 0 1 1 0 2.999A1.5 1.5 0 0 1 10.5 6Zm2.221 10.515a1 1 0 0 1-.858.485h-8a1 1 0 0 1-.9-1.43L5.6 10.039a.978.978 0 0 1 .936-.57 1 1 0 0 1 .9.632l1.181 2.981.541-1a.945.945 0 0 1 .883-.522 1 1 0 0 1 .879.529l1.832 3.438a1 1 0 0 1-.031.988Z"/>
<path d="M5 5V.13a2.96 2.96 0 0 0-1.293.749L.879 3.707A2.98 2.98 0 0 0 .13 5H5Z"/>
</svg>
</div>
<div class="h-2.5 bg-gray-200 rounded-full dark:bg-gray-700 w-48 mb-4"></div>
<div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 mb-2.5"></div>
<div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 mb-2.5"></div>
<div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
<div class="flex items-center mt-4">
<svg class="w-10 h-10 me-3 text-gray-200 dark:text-gray-700" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 0a10 10 0 1 0 10 10A10.011 10.011 0 0 0 10 0Zm0 5a3 3 0 1 1 0 6 3 3 0 0 1 0-6Zm0 13a8.949 8.949 0 0 1-4.951-1.488A3.987 3.987 0 0 1 9 13h2a3.987 3.987 0 0 1 3.951 3.512A8.949 8.949 0 0 1 10 18Z"/>
</svg>
<div>
<div class="h-2.5 bg-gray-200 rounded-full dark:bg-gray-700 w-32 mb-2"></div>
<div class="w-48 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
</div>
</div>
<span class="sr-only">Loading...</span>
</div>

View File

@ -0,0 +1,4 @@
<div role="status" class="animate-pulse flex items-center justify-center h-full">
<div class="w-32 h-4 bg-gray-200 rounded-full dark:bg-gray-700 me-3"></div>
<div class="w-32 h-4 bg-gray-200 rounded-full dark:bg-gray-700"></div>
</div>

View File

@ -0,0 +1 @@
<div role="status" class="animate-pulse h-7 bg-gray-200 rounded-full dark:bg-gray-700 w-48 mt-3"></div>