Merge pull request 'Ajout tags' (#7) from refactor-tags into main
All checks were successful
deploy / docker (push) Successful in 13m27s
All checks were successful
deploy / docker (push) Successful in 13m27s
Reviewed-on: https://gitea.mrdev023.fr/florian.richer/portfolio/pulls/7
This commit is contained in:
commit
60a36f0eb8
17 changed files with 436 additions and 387 deletions
467
Cargo.lock
generated
467
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
20
Cargo.toml
20
Cargo.toml
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "portfolio"
|
||||
version = "0.2.3"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
@ -10,10 +10,9 @@ crate-type = ["cdylib", "rlib"]
|
|||
|
||||
[dependencies]
|
||||
leptos = { version = "0.5", features = ["nightly"] }
|
||||
leptos_actix = { version = "0.5", optional = true }
|
||||
leptos_meta = { version = "0.5", features = ["nightly"] }
|
||||
leptos_router = { version = "0.5", features = ["nightly"] }
|
||||
gloo-net = { version = "0.4", features = ["http"] }
|
||||
gloo-net = { version = "0.5", features = ["http"] }
|
||||
log = "0.4"
|
||||
cfg-if = "1.0"
|
||||
serde = "1.0"
|
||||
|
@ -30,16 +29,20 @@ leptos_icons = { version = "0.1.0", features = [
|
|||
|
||||
# dependecies for client (enable when csr or hydrate set)
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
console_log = { version = "1", optional = true }
|
||||
console_log = { version = "1.0", optional = true }
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
|
||||
# dependecies for server (enable when ssr set)
|
||||
actix-files = { version = "0.6", optional = true }
|
||||
actix-web = { version = "4", features = ["macros"], optional = true }
|
||||
actix-web = { version = "4.4", features = ["macros"], optional = true }
|
||||
leptos_actix = { version = "0.5", optional = true }
|
||||
futures = { version = "0.3", optional = true }
|
||||
simple_logger = { version = "4.2", optional = true }
|
||||
simple_logger = { version = "4.3", optional = true }
|
||||
pulldown-cmark = { version = "0.9", optional = true } # Markdown parser
|
||||
gray_matter = { version = "0.2", optional = true } # frontmatter parser
|
||||
serde_yaml = { version = "0.9", optional = true }
|
||||
anyhow = { version = "1.0", optional = true }
|
||||
thiserror = { version = "1.0", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["csr"]
|
||||
|
@ -70,6 +73,9 @@ ssr = [
|
|||
"dep:simple_logger",
|
||||
"dep:pulldown-cmark",
|
||||
"dep:gray_matter",
|
||||
"dep:serde_yaml",
|
||||
"dep:anyhow",
|
||||
"dep:thiserror"
|
||||
]
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
|
@ -94,7 +100,9 @@ opt-level = 'z'
|
|||
name = "portfolio"
|
||||
site-root = "target/site"
|
||||
site-pkg-dir = "pkg"
|
||||
style-file = "style/main.scss" # Important for Hot Reload to work properly (File must exist)
|
||||
tailwind-input-file = "style/portfolio.css"
|
||||
watch-additional-files = ["data_src"]
|
||||
assets-dir = "public"
|
||||
site-addr = "127.0.0.1:3000"
|
||||
reload-port = 3001
|
||||
|
|
|
@ -33,11 +33,11 @@ COPY --from=builder /app/target/site /app/site
|
|||
# Copy Cargo.toml if it’s needed at runtime
|
||||
COPY --from=builder /app/Cargo.toml /app/
|
||||
# Copy all data files
|
||||
COPY --from=builder /app/markdowns /app/markdowns
|
||||
COPY --from=builder /app/data_src /app/data_src
|
||||
WORKDIR /app
|
||||
|
||||
# Set any required env variables and
|
||||
ENV RUST_LOG="info"
|
||||
ENV RUST_LOG="warn"
|
||||
ENV APP_ENVIRONMENT="production"
|
||||
ENV LEPTOS_SITE_ADDR="0.0.0.0:8080"
|
||||
ENV LEPTOS_SITE_ROOT="site"
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
---
|
||||
image_path: "https://upload.wikimedia.org/wikipedia/commons/thumb/1/11/Test-Logo.svg/783px-Test-Logo.svg.png?20150906031702"
|
||||
slug: test_layout
|
||||
title: Testing layout
|
||||
date: 2023-11-26
|
||||
description: Testing the layout of the site.
|
||||
project_link: none
|
||||
draft: true
|
||||
tags:
|
||||
- test
|
||||
---
|
||||
|
||||
# Heading 1
|
|
@ -31,4 +31,4 @@ mod title;
|
|||
|
||||
pub use title::Title;
|
||||
|
||||
pub use mon_parcours::{MonParcours};
|
||||
pub use mon_parcours::MonParcours;
|
||||
|
|
|
@ -62,7 +62,7 @@ const GRADLE_TAG : Lang<'static> = Lang { lang: "Gradle", url: "https://gradle.o
|
|||
const BABYLONJS_TAG : Lang<'static> = Lang { lang: "BabylonJS", url: "https://www.babylonjs.com/" };
|
||||
const ROCKET_RS_TAG : Lang<'static> = Lang { lang: "Rocket", url: "https://rocket.rs/" };
|
||||
const ACTIX_WEB_TAG : Lang<'static> = Lang { lang: "Actix Web", url: "https://actix.rs/" };
|
||||
const LEPTOS_TAG : Lang<'static> = Lang { lang: "Leptos", url: "https://leptos.dev/" };
|
||||
const _LEPTOS_TAG : Lang<'static> = Lang { lang: "Leptos", url: "https://leptos.dev/" };
|
||||
|
||||
|
||||
#[component]
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
use leptos::*;
|
||||
use leptos_router::A;
|
||||
|
||||
#[component]
|
||||
pub fn Title(
|
||||
|
@ -8,7 +9,7 @@ pub fn Title(
|
|||
) -> impl IntoView {
|
||||
view! {
|
||||
<header>
|
||||
<a href=href>r"< Retour"</a>
|
||||
<A href=href>r"< Retour"</A>
|
||||
<h1>{title}</h1>
|
||||
<span></span>
|
||||
</header>
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
pub mod components;
|
||||
|
||||
pub mod models;
|
||||
|
||||
mod pages;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
mod utils;
|
||||
|
||||
use leptos::*;
|
||||
use leptos_meta::*;
|
||||
use leptos_router::*;
|
||||
|
|
|
@ -1,3 +1,50 @@
|
|||
mod post;
|
||||
|
||||
pub use post::Post;
|
||||
pub use post::Post;
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct Data {
|
||||
pub posts: Vec<Arc<Post>>,
|
||||
pub posts_by_slug: HashMap<String, Arc<Post>>,
|
||||
pub posts_by_tag: HashMap<String, Vec<Arc<Post>>>,
|
||||
}
|
||||
|
||||
impl Data {
|
||||
#[allow(dead_code)] // Use in main.rs
|
||||
pub fn new() -> anyhow::Result<Self> {
|
||||
let posts = crate::app::utils::data_src::get_all::<Post>("posts")?
|
||||
.into_iter()
|
||||
.map(Arc::new)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut posts_by_slug : HashMap<String, Arc<Post>> = HashMap::new();
|
||||
let mut posts_by_tag : HashMap<String, Vec<Arc<Post>>> = HashMap::new();
|
||||
|
||||
for post in &posts {
|
||||
posts_by_slug.insert(post.metadata.slug.clone(), post.clone());
|
||||
|
||||
for tag in &post.metadata.tags {
|
||||
let tag = tag.to_lowercase();
|
||||
let posts = posts_by_tag.entry(tag).or_default();
|
||||
posts.push(post.clone());
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Loaded {} posts", posts.len());
|
||||
log::info!("Found {} tags", posts_by_tag.len());
|
||||
|
||||
Ok(Self {
|
||||
posts,
|
||||
posts_by_slug,
|
||||
posts_by_tag,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
|
@ -1,34 +1,43 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct PostMetadata {
|
||||
pub slug: String,
|
||||
pub image_path: String,
|
||||
pub title: String,
|
||||
pub date: String,
|
||||
pub description: String,
|
||||
pub project_link: String,
|
||||
pub draft: bool
|
||||
pub draft: bool,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Post {
|
||||
pub slug : String,
|
||||
pub metadata: PostMetadata,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
impl Post {
|
||||
fn from_content(content: String) -> Option<Self> {
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PostDeserializationError {
|
||||
#[error("Invalid front matter")]
|
||||
InvalidFrontMatter,
|
||||
#[error("Invalid markdown")]
|
||||
InvalidMarkdown,
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Post {
|
||||
type Error = PostDeserializationError;
|
||||
|
||||
fn try_from(content: String) -> Result<Self, Self::Error> {
|
||||
use gray_matter::{Matter, engine::YAML};
|
||||
let matter = Matter::<YAML>::new();
|
||||
|
||||
let post_data = matter
|
||||
.parse_with_struct::<PostMetadata>(&content)?;
|
||||
.parse_with_struct::<PostMetadata>(&content)
|
||||
.ok_or_else(|| PostDeserializationError::InvalidFrontMatter)?;
|
||||
|
||||
let metadata = post_data.data;
|
||||
let slug = format!("{}_{}", metadata.date, metadata.title.to_lowercase().replace(' ', "_"));
|
||||
let content = post_data.content;
|
||||
|
||||
use pulldown_cmark::{Parser, Options, html};
|
||||
|
@ -36,49 +45,14 @@ cfg_if::cfg_if! {
|
|||
let mut html_output = String::new();
|
||||
html::push_html(&mut html_output, parser);
|
||||
|
||||
Some(Self {
|
||||
slug,
|
||||
metadata,
|
||||
content: html_output,
|
||||
})
|
||||
}
|
||||
|
||||
fn from_path(path: &std::path::Path) -> Option<Self> {
|
||||
let content = std::fs::read_to_string(path);
|
||||
let content = match content {
|
||||
Ok(content) => content,
|
||||
Err(e) => {
|
||||
eprintln!("{:?}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Self::from_content(content)
|
||||
}
|
||||
|
||||
pub fn get_all(folder: &str) -> Result<Vec<Self>, String> {
|
||||
use std::{path::Path, fs::read_dir};
|
||||
|
||||
let mut posts: Vec<Self> = Vec::new();
|
||||
|
||||
let folder_path = Path::new("markdowns").join(folder);
|
||||
let paths = read_dir(folder_path).map_err(|e| e.to_string())?;
|
||||
|
||||
for path_result in paths {
|
||||
let path = match path_result {
|
||||
Ok(path) => path.path(),
|
||||
Err(e) => {
|
||||
eprintln!("{:?}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(post) = Self::from_path(&path) {
|
||||
posts.push(post);
|
||||
}
|
||||
if html_output.is_empty() {
|
||||
return Err(PostDeserializationError::InvalidMarkdown);
|
||||
}
|
||||
|
||||
Ok(posts)
|
||||
Ok(Self {
|
||||
metadata: post_data.data,
|
||||
content: html_output,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,64 +9,113 @@ use crate::app::{
|
|||
};
|
||||
|
||||
#[server]
|
||||
pub async fn get_posts() -> Result<Vec<Post>, ServerFnError> {
|
||||
let posts = Post::get_all("posts")
|
||||
.map_err(|e| ServerFnError::ServerError(e.to_string()))?;
|
||||
|
||||
Ok(posts)
|
||||
pub async fn get_posts(
|
||||
tag: Option<String>
|
||||
) -> Result<Vec<Post>, ServerFnError> {
|
||||
leptos_actix::extract(
|
||||
|data: actix_web::web::Data<crate::app::models::Data>| async move {
|
||||
let data = data.into_inner();
|
||||
let default = vec![];
|
||||
let posts = match tag {
|
||||
Some(tag) => data.posts_by_tag.get(&tag).unwrap_or(&default),
|
||||
None => &data.posts
|
||||
};
|
||||
posts.iter()
|
||||
.map(|post| {
|
||||
Post {
|
||||
metadata: post.metadata.clone(),
|
||||
content: post.content.clone(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>, >()
|
||||
},
|
||||
).await
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn get_post(
|
||||
slug: String
|
||||
) -> Result<Post, ServerFnError> {
|
||||
let posts = Post::get_all("posts")
|
||||
.map_err(|e| ServerFnError::ServerError(e.to_string()))?;
|
||||
leptos_actix::extract(
|
||||
|data: actix_web::web::Data<crate::app::models::Data>| async move {
|
||||
let data = data.into_inner();
|
||||
data.posts_by_slug.get(&slug)
|
||||
.and_then(|post| Some(Post {
|
||||
metadata: post.metadata.clone(),
|
||||
content: post.content.clone(),
|
||||
}))
|
||||
},
|
||||
)
|
||||
.await
|
||||
.and_then(|post| post.ok_or_else(|| ServerFnError::ServerError("Post not found".to_string())))
|
||||
}
|
||||
|
||||
let post = posts.into_iter().find(|post| post.slug == slug)
|
||||
.ok_or(ServerFnError::ServerError("Post not found".to_string()))?;
|
||||
#[component]
|
||||
pub fn PostTags(
|
||||
tags: Vec<String>
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="tags_list">
|
||||
{
|
||||
tags.into_iter().map(|tag| view! { <A class="tag" href=format!("/posts?tag={}", tag)>{tag}</A>}).collect_view()
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
Ok(post)
|
||||
#[component]
|
||||
pub fn PostListCard(
|
||||
post: Post
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div>
|
||||
<img src={post.metadata.image_path.clone()} alt=format!("Image {}", post.metadata.title)/>
|
||||
|
||||
{
|
||||
if post.metadata.draft {
|
||||
Some(view!{
|
||||
<div class="warning">
|
||||
<Icon icon=Icon::from(IoIcon::IoConstruct)/>
|
||||
</div>
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
<div>
|
||||
<PostTags tags=post.metadata.tags.clone()/>
|
||||
<h2><A href=format!("/posts/{}", post.metadata.slug.clone())>{post.metadata.title.clone()}</A></h2>
|
||||
<p>{post.metadata.description.clone()}</p>
|
||||
<span>{post.metadata.date.clone()}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn PostList() -> impl IntoView {
|
||||
let posts = create_resource(|| (), |_| get_posts());
|
||||
let query = use_query_map();
|
||||
let tag = move || query.with(|query| query.get("tag").cloned());
|
||||
let posts = create_resource(move || tag(), move |_| get_posts(tag()));
|
||||
|
||||
let posts_view = move || {
|
||||
posts.and_then(|posts| {
|
||||
posts.iter()
|
||||
.map(|post| view! {
|
||||
<a href=format!("posts/{}", post.slug.clone())>
|
||||
<img src={post.metadata.image_path.clone()} alt=format!("Image {}", post.metadata.title)/>
|
||||
|
||||
{
|
||||
if post.metadata.draft {
|
||||
Some(view!{
|
||||
<div class="warning">
|
||||
<Icon icon=Icon::from(IoIcon::IoConstruct)/>
|
||||
</div>
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
<div>
|
||||
<h2>{post.metadata.title.clone()}</h2>
|
||||
<p>{post.metadata.description.clone()}</p>
|
||||
<span>{post.metadata.date.clone()}</span>
|
||||
</div>
|
||||
</a>
|
||||
})
|
||||
.map(|post| view! { <PostListCard post=post.clone() /> })
|
||||
.collect_view()
|
||||
})
|
||||
};
|
||||
|
||||
let title = move || match tag() {
|
||||
Some(tag) => view! { <Title href="/posts".to_string() title=format!("Posts for {}", tag)/> },
|
||||
None => view! { <Title href="/".to_string() title="Posts".to_string()/> }
|
||||
};
|
||||
|
||||
view! {
|
||||
<Suspense fallback=move || view! { <Loading title="Chargement des posts...".to_string() /> }>
|
||||
<main class="posts">
|
||||
<Title href="/".to_string() title="Posts".to_string()/>
|
||||
{ title }
|
||||
|
||||
<div class="posts__cards">{posts_view}</div>
|
||||
</main>
|
||||
|
@ -86,6 +135,7 @@ pub fn PostElement() -> impl IntoView {
|
|||
view! {
|
||||
<>
|
||||
<Title href="/posts".to_string() title=post.metadata.title.clone()/>
|
||||
<PostTags tags=post.metadata.tags.clone()/>
|
||||
{
|
||||
if post.metadata.draft {
|
||||
Some(view!{
|
||||
|
|
33
src/app/utils/data_src.rs
Normal file
33
src/app/utils/data_src.rs
Normal file
|
@ -0,0 +1,33 @@
|
|||
#[allow(dead_code)] // Use in main.rs
|
||||
pub fn get_all<T>(folder: &str) -> anyhow::Result<Vec<T>>
|
||||
where
|
||||
T: TryFrom<String>,
|
||||
{
|
||||
use std::{path::Path, fs::read_dir};
|
||||
|
||||
let mut datas: Vec<T> = Vec::new();
|
||||
|
||||
let folder_path = Path::new("data_src").join(folder);
|
||||
let paths = read_dir(folder_path)?;
|
||||
|
||||
for path_result in paths {
|
||||
let data : Result<T, String> = path_result
|
||||
.and_then(|path| std::fs::File::open(path.path()))
|
||||
.map_err(|e| format!("Open file Error: {:?}", e))
|
||||
.and_then(|mut file| {
|
||||
use std::io::Read;
|
||||
let mut content = String::new();
|
||||
file.read_to_string(&mut content)
|
||||
.map_err(|e| format!("Read file Error: {:?}", e))?;
|
||||
T::try_from(content)
|
||||
.map_err(|_e| "Parse file Error".to_string())
|
||||
});
|
||||
|
||||
match data {
|
||||
Ok(data) => datas.push(data),
|
||||
Err(e) => log::warn!("{}", e),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(datas)
|
||||
}
|
1
src/app/utils/mod.rs
Normal file
1
src/app/utils/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub(crate) mod data_src;
|
|
@ -12,10 +12,10 @@ cfg_if! {
|
|||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
logging::log!("hydrate mode - hydrating");
|
||||
logging::log!("Portfolio version : {}", env!("CARGO_PKG_VERSION"));
|
||||
log::info!("hydrate mode - hydrating");
|
||||
log::info!("Portfolio version : {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
leptos::mount_to_body(|| {
|
||||
mount_to_body(|| {
|
||||
view! { <App/> }
|
||||
});
|
||||
}
|
||||
|
@ -30,7 +30,8 @@ cfg_if! {
|
|||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
logging::log!("csr mode - mounting to body");
|
||||
log::info!("csr mode - mounting to body");
|
||||
log::info!("Portfolio version : {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
mount_to_body(|| {
|
||||
view! { <App /> }
|
||||
|
|
19
src/main.rs
19
src/main.rs
|
@ -8,9 +8,26 @@ cfg_if! {
|
|||
use leptos::*;
|
||||
use crate::app::*;
|
||||
use leptos_actix::{generate_route_list, LeptosRoutes};
|
||||
use simple_logger::SimpleLogger;
|
||||
|
||||
#[get("/style.css")]
|
||||
async fn css() -> impl Responder {
|
||||
actix_files::NamedFile::open_async("./style/output.css").await
|
||||
}
|
||||
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
// Configure logger
|
||||
SimpleLogger::new()
|
||||
.with_level(log::LevelFilter::Info)
|
||||
.env()
|
||||
.init()
|
||||
.unwrap();
|
||||
|
||||
// Load required data
|
||||
let data = web::Data::<models::Data>::new(models::Data::new().unwrap());
|
||||
|
||||
// Setting this to None means we'll be using cargo-leptos and its env vars.
|
||||
let conf = get_configuration(None).await.unwrap();
|
||||
|
||||
|
@ -24,8 +41,10 @@ cfg_if! {
|
|||
let site_root = &leptos_options.site_root;
|
||||
let routes = &routes;
|
||||
App::new()
|
||||
.app_data(web::Data::clone(&data)) // Must panic if data can't be loaded
|
||||
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
|
||||
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), || view! { <App/> })
|
||||
.service(css)
|
||||
.service(Files::new("/", site_root))
|
||||
.wrap(middleware::Compress::default())
|
||||
})
|
||||
|
|
0
style/main.scss
Normal file
0
style/main.scss
Normal file
|
@ -42,13 +42,21 @@
|
|||
& ol li {
|
||||
@apply list-decimal list-inside;
|
||||
}
|
||||
|
||||
& blockquote {
|
||||
@apply border-l-4 border-primary/50 dark:border-dark_primary/50 pl-3 my-3;
|
||||
}
|
||||
|
||||
& .tags_list {
|
||||
@apply flex flex-row flex-wrap gap-2;
|
||||
}
|
||||
}
|
||||
|
||||
.posts {
|
||||
& > .posts__cards {
|
||||
@apply grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 m-5;
|
||||
|
||||
& > a {
|
||||
& > div {
|
||||
@apply rounded-xl overflow-hidden relative;
|
||||
@apply bg-primary/10 dark:bg-dark_primary/10;
|
||||
@apply shadow-md shadow-primary/5 dark:shadow-dark_primary/5;
|
||||
|
|
Loading…
Add table
Reference in a new issue