Compare commits

..

8 Commits

Author SHA1 Message Date
06701ea7be feat: require subcommand 2023-04-03 22:48:49 +02:00
502b9023d3 feat: add favicon and add plausible 2023-04-03 22:27:24 +02:00
079a076925 feat: fix ci 2023-04-03 14:57:07 +02:00
9565aa03b8 Merge pull request #1 from kjuulh/renovate/configure
Configure Renovate
2023-03-08 08:47:43 +01:00
Renovate Bot
044878d78e Add renovate.json 2023-03-07 22:57:51 +00:00
e39be808a7 feat: add github action 2023-03-07 23:35:43 +01:00
b60410276f feat: absolute path run 2023-03-07 23:30:56 +01:00
2053220d01 feat: add ci 2023-03-07 23:20:06 +01:00
17 changed files with 1832 additions and 332 deletions

37
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: release
on:
pull_request:
push:
branches:
- "main"
env:
CARGO_TERM_COLOR: always
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
_EXPERIMENTAL_DAGGER_CACHE_CONFIG: type=gha;mode=max
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Update rust toolchain
run: rustup update stable && rustup default stable
- name: Set up cargo cache
uses: actions/cache@v3
continue-on-error: false
with:
path: "~/.cargo/bin/\n~/.cargo/registry/index/\n~/.cargo/registry/cache/\n~/.cargo/git/db/\ntarget/"
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Run dagger [CI]
run: cargo run -p ci

1
.gitignore vendored
View File

@@ -11,3 +11,4 @@ node_modules/
test-results/ test-results/
end2end/playwright-report/ end2end/playwright-report/
playwright/.cache/ playwright/.cache/
.env

1701
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
[workspace] [workspace]
members = [".", "crates/services", "crates/domain", "crates/biteme"] members = [".", "crates/services", "crates/domain", "crates/biteme", "ci"]
[workspace.dependencies] [workspace.dependencies]
domain = { path = "crates/domain" } domain = { path = "crates/domain" }
@@ -11,7 +11,7 @@ chrono = { version = "0.4.23", features = ["serde"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
[package] [package]
name = "ssr_modes_axum" name = "ssr_modes"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
@@ -35,6 +35,8 @@ tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.3.4", features = ["fs"], optional = true } tower-http = { version = "0.3.4", features = ["fs"], optional = true }
tokio = { version = "1", features = ["time"], optional = true } tokio = { version = "1", features = ["time"], optional = true }
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
tracing-subscriber = { version = "0.3.16", optional = true }
tracing = { version = "0.1.37", features = ["log"], optional = true }
serde = { workspace = true } serde = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
@@ -55,6 +57,8 @@ ssr = [
"leptos_router/ssr", "leptos_router/ssr",
"dep:leptos_axum", "dep:leptos_axum",
"dep:services", "dep:services",
"dep:tracing-subscriber",
"dep:tracing",
] ]
[package.metadata.leptos] [package.metadata.leptos]

View File

@@ -30,3 +30,8 @@ command = "cargo"
args = ["install", "--path", "crates/biteme"] args = ["install", "--path", "crates/biteme"]
workspace = false workspace = false
install_crate = "cargo-all-features" install_crate = "cargo-all-features"
[tasks.ci]
command = "cargo"
args = ["run", "-p", "ci"]
workspace = false

View File

@@ -7,7 +7,7 @@ description: |
God gammeldags oksesteg med en intens og fyldig brun sauce. Gammeldags oksesteg God gammeldags oksesteg med en intens og fyldig brun sauce. Gammeldags oksesteg
er rigtig simremad som gør de fleste glade. Så server en gammeldags oksesteg for er rigtig simremad som gør de fleste glade. Så server en gammeldags oksesteg for
din gæster... både de unge og de gamle. din gæster... både de unge og de gamle.
time: 2023-03-06 time: 2025-03-06
--- ---
Some article Some article

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

14
ci/Cargo.toml Normal file
View File

@@ -0,0 +1,14 @@
[package]
name = "ci"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
dagger-sdk = "0.2.20"
eyre = "0.6.8"
color-eyre = "0.6.2"
tokio = { version = "1.27.0", features = ["full"] }
chrono.workspace = true
dotenv = "0.15.0"

197
ci/src/main.rs Normal file
View File

@@ -0,0 +1,197 @@
#[tokio::main]
async fn main() -> eyre::Result<()> {
let _ = dotenv::dotenv();
let rust_image = "docker.io/rustlang/rust:nightly";
let client = dagger_sdk::connect().await?;
let workdir = client.host().directory_opts(
".",
dagger_sdk::HostDirectoryOpts {
exclude: Some(vec!["target/", ".git/"]),
include: None,
},
);
let minio_url = "https://github.com/mozilla/sccache/releases/download/v0.3.3/sccache-v0.3.3-x86_64-unknown-linux-musl.tar.gz";
let sccache_download_cache = client.cache_volume("sccache_download");
let cargo_cache = client.cache_volume("cargo_cache");
// Main container
let rust_base = client
.container()
.from(rust_image)
.with_exec(vec!["apt-get", "update"])
.with_exec(vec!["apt-get", "install", "--yes", "libpq-dev", "wget"])
.with_exec(vec!["mkdir", "-p", "/src/downloads"])
.with_workdir("/src/downloads")
.with_mounted_cache("/src/downloads", sccache_download_cache.id().await?)
.with_exec(vec!["wget", minio_url])
.with_exec(vec![
"tar",
"xzf",
"sccache-v0.3.3-x86_64-unknown-linux-musl.tar.gz",
])
.with_exec(vec![
"mv",
"sccache-v0.3.3-x86_64-unknown-linux-musl/sccache",
"/usr/local/bin/sccache",
])
.with_exec(vec!["chmod", "+x", "/usr/local/bin/sccache"])
.with_env_variable("RUSTC_WRAPPER", "/usr/local/bin/sccache")
.with_env_variable(
"AWS_ACCESS_KEY_ID",
std::env::var("AWS_ACCESS_KEY_ID").unwrap_or("".into()),
)
.with_env_variable(
"AWS_SECRET_ACCESS_KEY",
std::env::var("AWS_SECRET_ACCESS_KEY").unwrap_or("".into()),
)
.with_env_variable("SCCACHE_BUCKET", "sccache")
.with_env_variable("SCCACHE_REGION", "auto")
.with_env_variable("SCCACHE_ENDPOINT", "https://api-minio.front.kjuulh.io")
.with_mounted_cache("~/.cargo", cargo_cache.id().await?)
.with_exec(vec!["rustup", "target", "add", "wasm32-unknown-unknown"])
.with_exec(vec!["cargo", "install", "cargo-chef"])
.with_exec(vec!["cargo", "install", "cargo-leptos"])
.with_workdir("/app");
let exit_code = rust_base.exit_code().await?;
if exit_code != 0 {
eyre::bail!("could not build base");
}
let target_cache = client.cache_volume("target_cache");
let rust_prepare = rust_base
.with_mounted_directory(".", workdir.id().await?)
.with_mounted_cache("target", target_cache.id().await?)
.with_exec(vec![
"cargo",
"chef",
"prepare",
"--recipe-path",
"recipe.json",
]);
let exit_code = rust_prepare.exit_code().await?;
if exit_code != 0 {
eyre::bail!("could not build prepare");
}
let target_rust_cache = client.cache_volume("target_rust_cache");
let rust_cacher = rust_base
.with_exec(vec!["apt", "update"])
.with_exec(vec![
"apt",
"install",
"pkg-config",
"openssl",
"libssl-dev",
"-y",
])
.with_exec(vec!["rustup", "target", "add", "wasm32-unknown-unknown"])
.with_file(
"/recipe.json",
rust_prepare.file("./recipe.json").id().await?,
)
.with_mounted_cache("target", target_rust_cache.id().await?)
.with_exec(vec![
"cargo",
"chef",
"cook",
"--release",
"--recipe-path",
"/recipe.json",
])
.with_mounted_directory(".", workdir.id().await?);
let exit_code = rust_cacher.exit_code().await?;
if exit_code != 0 {
eyre::bail!("could not build cacher");
}
let nodejs_cacher = client.cache_volume("node");
// something
let rust_pre_builder = rust_cacher
.with_exec(vec!["mkdir", "-p", "node"])
.with_mounted_cache("node", nodejs_cacher.id().await?)
.with_exec(vec![
"curl",
"-sL",
"https://deb.nodesource.com/setup_12.x",
"-o",
"node/node_12.txt",
])
.with_exec(vec!["chmod", "+x", "node/node_12.txt"])
.with_exec(vec!["bash", "-c", "node/node_12.txt"])
.with_exec(vec!["apt-get", "update"])
.with_exec(vec!["apt-get", "install", "nodejs"])
.with_exec(vec!["npm", "install", "-g", "sass"]);
let exit_code = rust_pre_builder.exit_code().await?;
if exit_code != 0 {
eyre::bail!("could not build rust_pre_builder");
}
let rust_builder = rust_pre_builder
.with_env_variable("LEPTOS_BROWSERQUERY", "defaults")
.with_exec(vec!["cargo", "leptos", "build", "--release"]);
let exit_code = rust_builder.exit_code().await?;
if exit_code != 0 {
eyre::bail!("could not build builder");
}
let tag = chrono::Utc::now().timestamp();
let prod_image = "debian:bullseye";
let prod = client
.container()
.from(prod_image)
.with_exec(vec!["apt", "update"])
.with_exec(vec!["apt", "install", "-y", "zlib1g", "git"])
.with_workdir("/app")
.with_file(
"/app/ssr_modes",
rust_builder
.file("/app/target/server/release/ssr_modes")
.id()
.await?,
)
.with_directory(
"/app/site",
rust_builder.directory("/app/target/site").id().await?,
)
.with_env_variable("LEPTOS_OUTPUT_NAME", "ssr_modes")
.with_env_variable("LEPTOS_SITE_ROOT", "site")
.with_env_variable("LEPTOS_SITE_PKG_DIR", "pkg")
.with_env_variable("LEPTOS_SITE_ADDR", "0.0.0.0:3000")
.with_env_variable("LEPTOS_RELOAD_PORT", "3001")
.with_entrypoint(vec!["/app/ssr_modes"]);
let image_tag = format!("docker.io/kasperhermansen/bitebuds:{tag}");
prod.publish(&image_tag).await?;
let update_deployment = client
.container()
.from("docker.io/kasperhermansen/update-deployment:1680548342")
.with_env_variable("GIT_USERNAME", "kjuulh")
.with_env_variable("GIT_PASSWORD", std::env::var("GIT_PASSWORD").unwrap())
.with_exec(vec![
"update-deployment",
"--repo",
"https://git.front.kjuulh.io/kjuulh/bitebuds-deployment.git",
"--service",
"bitebuds",
"--image",
&image_tag,
])
.exit_code()
.await?;
Ok(())
}

View File

@@ -1,3 +1,5 @@
use std::path::PathBuf;
use domain::{Event, Image}; use domain::{Event, Image};
use inquire::validator::ValueRequiredValidator; use inquire::validator::ValueRequiredValidator;
use regex::Regex; use regex::Regex;
@@ -9,7 +11,12 @@ async fn main() -> eyre::Result<()> {
color_eyre::install()?; color_eyre::install()?;
let cli = clap::Command::new("biteme") let cli = clap::Command::new("biteme")
.subcommand(clap::Command::new("generate").subcommand(clap::Command::new("article"))); .subcommand_required(true)
.subcommand(
clap::Command::new("generate")
.subcommand_required(true)
.subcommand(clap::Command::new("article")),
);
let args = std::env::args(); let args = std::env::args();
@@ -89,7 +96,10 @@ async fn generate_article() -> eyre::Result<()> {
description.unwrap_or("".into()) description.unwrap_or("".into())
); );
tokio::fs::write(format!("articles/events/{}.md", slug), contents).await?; let mut vault_path = PathBuf::from(std::env::var("BITEME_ROOT").unwrap());
vault_path.push(format!("areas/food/events/{}.md", slug));
tokio::fs::write(vault_path, contents).await?;
Ok(()) Ok(())
} }

View File

@@ -9,6 +9,7 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
gitevents_sdk = { git = "https://github.com/kjuulh/gitevents.git", branch = "main" }
cached = "0.42.0" cached = "0.42.0"
chrono = { version = "0.4.23", features = ["serde"] } chrono = { version = "0.4.23", features = ["serde"] }
domain = { path = "../domain" } domain = { path = "../domain" }
@@ -18,3 +19,4 @@ serde_json = "1.0.94"
serde_yaml = "0.9.19" serde_yaml = "0.9.19"
tokio = { version = "1.26.0", features = ["full"] } tokio = { version = "1.26.0", features = ["full"] }
uuid = { version = "1.3.0", features = ["v4", "serde"] } uuid = { version = "1.3.0", features = ["v4", "serde"] }
tracing = { version = "0.1.37", features = ["log"] }

View File

@@ -1,15 +1,10 @@
use cached::proc_macro::once;
use domain::{Event, Image, Metadata};
use gitevents_sdk::events::EventResponse;
use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use cached::proc_macro::{cached, once};
use domain::{Event, Image, Metadata};
use serde::{Deserialize, Serialize};
pub struct EventStore {
pub path: PathBuf,
events: Arc<tokio::sync::RwLock<Vec<Event>>>,
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RawImage { pub struct RawImage {
pub url: String, pub url: String,
@@ -81,28 +76,94 @@ impl From<RawImage> for Image {
} }
} }
struct InnerEventStore {
url: Option<String>,
pub path: PathBuf,
events: Arc<tokio::sync::RwLock<Vec<Event>>>,
url_path: Option<String>,
}
#[derive(Clone)]
pub struct EventStore {
inner: Arc<InnerEventStore>,
}
impl EventStore { impl EventStore {
pub fn new(path: PathBuf) -> Self { pub fn new(path: PathBuf) -> Self {
let article_repo_url = std::env::var("BITE_ARTICLE_REPO_URL")
.map(|a| (a != "").then(|| a))
.unwrap_or(None);
let article_repo_path = std::env::var("BITE_ARTICLE_REPO_PATH")
.map(|a| (a != "").then(|| a))
.unwrap_or(None);
Self { Self {
inner: Arc::new(InnerEventStore {
url: article_repo_url,
url_path: article_repo_path,
path, path,
events: Default::default(), events: Default::default(),
}),
} }
} }
pub async fn bootstrap(&self) -> eyre::Result<()> {
tracing::info!("boostrapping event_store");
//let mut event_path = self.inner.path.clone();
//event_path.push("events");
//let events = fetch_events(event_path.clone()).await?;
//let mut e = self.inner.events.write().await;
//*e = events;
if let Some(repo_url) = self.inner.url.clone() {
tracing::info!(repo_url = repo_url, "subscribing to repo");
let inner = self.inner.clone();
tokio::task::spawn(async move {
gitevents_sdk::builder::Builder::new()
.set_generic_git_url(repo_url)
.set_scheduler_opts(&gitevents_sdk::cron::SchedulerOpts {
duration: std::time::Duration::from_secs(30),
})
.action(move |req| {
let inner = inner.clone();
async move {
tracing::info!("updating articles");
let mut event_path = req.git.path.clone();
event_path.push(inner.url_path.as_ref().unwrap());
tracing::debug!(
path = event_path.display().to_string(),
"reading from"
);
let events = fetch_events(event_path).await.unwrap();
let mut e = inner.events.write().await;
*e = events.clone();
Ok(EventResponse {})
}
})
.execute()
.await
.unwrap();
});
}
Ok(())
}
pub async fn get_upcoming_events(&self) -> eyre::Result<Vec<Event>> { pub async fn get_upcoming_events(&self) -> eyre::Result<Vec<Event>> {
let mut event_path = self.path.clone(); let events = self.inner.events.read().await.clone();
event_path.push("events");
let events = fetch_events(event_path).await?;
let mut e = self.events.write().await;
*e = events.clone();
Ok(events) Ok(events)
} }
pub async fn get_event(&self, event_id: uuid::Uuid) -> eyre::Result<Option<Event>> { pub async fn get_event(&self, event_id: uuid::Uuid) -> eyre::Result<Option<Event>> {
let events = self.events.read().await; let events = self.inner.events.read().await;
let event = events.iter().find(|e| e.id == event_id); let event = events.iter().find(|e| e.id == event_id);
@@ -110,7 +171,6 @@ impl EventStore {
} }
} }
#[once(time = 60, result = true, sync_writes = true)]
pub async fn fetch_events(event_path: PathBuf) -> eyre::Result<Vec<Event>> { pub async fn fetch_events(event_path: PathBuf) -> eyre::Result<Vec<Event>> {
let mut dir = tokio::fs::read_dir(event_path).await?; let mut dir = tokio::fs::read_dir(event_path).await?;
@@ -140,8 +200,12 @@ pub async fn fetch_events(event_path: PathBuf) -> eyre::Result<Vec<Event>> {
impl Default for EventStore { impl Default for EventStore {
fn default() -> Self { fn default() -> Self {
Self { Self {
inner: Arc::new(InnerEventStore {
url: Default::default(),
path: PathBuf::from("articles"), path: PathBuf::from("articles"),
events: Default::default(), events: Default::default(),
url_path: Some("articles/events".into()),
}),
} }
} }
} }

3
renovate.json Normal file
View File

@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}

View File

@@ -1,3 +1,5 @@
use std::path::PathBuf;
use cfg_if::cfg_if; use cfg_if::cfg_if;
use leptos::*; use leptos::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -10,21 +12,19 @@ cfg_if! {
use lazy_static::lazy_static; use lazy_static::lazy_static;
lazy_static! { lazy_static! {
static ref EVENTSTORE: EventStore = EventStore::default(); static ref EVENTSTORE: EventStore = EventStore::new(PathBuf::from("articles"));
} }
async fn get_upcoming_events_fn() -> Result<UpcomingEventsOverview, ServerFnError> { async fn get_upcoming_events_fn() -> Result<UpcomingEventsOverview, ServerFnError> {
let current_time = chrono::Utc::now();
let mut events: Vec<EventOverview> = EVENTSTORE let mut events: Vec<EventOverview> = EVENTSTORE
.get_upcoming_events() .get_upcoming_events()
.await .await
.map_err(|e| ServerFnError::ServerError(e.to_string()))? .map_err(|e| ServerFnError::ServerError(e.to_string()))?
.iter() .iter()
.filter(|d| d.time.gt(&chrono::Utc::now().date_naive())) .filter(|d| d.time.ge(&chrono::Utc::now().date_naive()))
.map(|data| data.clone().into()) .map(|data| data.clone().into())
.collect(); .collect();
events.sort_by(|a, b| a.time.cmp(&b.time)); events.sort_by(|a, b| a.time.cmp(&b.time)) ;
Ok(UpcomingEventsOverview { events }) Ok(UpcomingEventsOverview { events })
} }
@@ -33,9 +33,12 @@ cfg_if! {
.get_event(event_id) .get_event(event_id)
.await .await
.map_err(|e| ServerFnError::ServerError(e.to_string()))?; .map_err(|e| ServerFnError::ServerError(e.to_string()))?;
Ok(event) Ok(event)
} }
pub async fn boostrap() -> Result<(), ServerFnError> {
EVENTSTORE.bootstrap().await.map_err(|e| ServerFnError::ServerError(e.to_string()))
}
} }
} }

View File

@@ -13,6 +13,8 @@ pub fn App(cx: Scope) -> impl IntoView {
<Stylesheet id="leptos" href="/pkg/ssr_modes.css" /> <Stylesheet id="leptos" href="/pkg/ssr_modes.css" />
<Title text="Bitebuds" /> <Title text="Bitebuds" />
<script defer="true" data-domain="bitebuds.front.kjuulh.io" src="https://plausible.front.kjuulh.io/js/script.js"/>
<Router> <Router>
<div class="app grid lg:grid-cols-[25%,50%,25%] sm:grid-cols-[10%,80%,10%] grid-cols-[5%,90%,5%]"> <div class="app grid lg:grid-cols-[25%,50%,25%] sm:grid-cols-[10%,80%,10%] grid-cols-[5%,90%,5%]">
<main class="main col-start-2"> <main class="main col-start-2">

View File

@@ -1,16 +1,25 @@
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
use axum::{ use axum::{extract::Extension, routing::post, Router};
extract::{Extension, Path},
routing::{get, post},
Router,
};
use leptos::*; use leptos::*;
use leptos_axum::{generate_route_list, LeptosRoutes}; use leptos_axum::{generate_route_list, LeptosRoutes};
use ssr_modes_axum::app::*; use ssr_modes::app::*;
use ssr_modes_axum::fallback::file_and_error_handler; use ssr_modes::fallback::file_and_error_handler;
use std::sync::Arc; use std::sync::Arc;
use tracing_subscriber::EnvFilter;
std::env::set_var(
"BITE_ARTICLE_REPO_URL",
"git@git.front.kjuulh.io:kjuulh/obsidian.git",
);
std::env::set_var("BITE_ARTICLE_REPO_PATH", "areas/food/events");
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
ssr_modes::api::events::boostrap().await.unwrap();
let conf = get_configuration(None).await.unwrap(); let conf = get_configuration(None).await.unwrap();
let addr = conf.leptos_options.site_addr; let addr = conf.leptos_options.site_addr;
@@ -18,7 +27,7 @@ async fn main() {
// Generate the list of routes in your Leptos App // Generate the list of routes in your Leptos App
let routes = generate_route_list(|cx| view! { cx, <App/> }).await; let routes = generate_route_list(|cx| view! { cx, <App/> }).await;
ssr_modes_axum::api::register(); ssr_modes::api::register();
let app = Router::new() let app = Router::new()
.route("/api/*fn_name", post(leptos_axum::handle_server_fns)) .route("/api/*fn_name", post(leptos_axum::handle_server_fns))

View File

@@ -655,22 +655,6 @@ video {
flex-grow: 1; flex-grow: 1;
} }
@keyframes bounce {
0%, 100% {
transform: translateY(-25%);
animation-timing-function: cubic-bezier(0.8,0,1,1);
}
50% {
transform: none;
animation-timing-function: cubic-bezier(0,0,0.2,1);
}
}
.animate-bounce {
animation: bounce 1s infinite;
}
@keyframes pulse { @keyframes pulse {
50% { 50% {
opacity: .5; opacity: .5;
@@ -769,20 +753,6 @@ video {
border-top-right-radius: 1rem; border-top-right-radius: 1rem;
} }
.border {
border-width: 1px;
}
.border-gray-400 {
--tw-border-opacity: 1;
border-color: rgb(156 163 175 / var(--tw-border-opacity));
}
.border-gray-300 {
--tw-border-opacity: 1;
border-color: rgb(209 213 219 / var(--tw-border-opacity));
}
.bg-gray-200 { .bg-gray-200 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(229 231 235 / var(--tw-bg-opacity)); background-color: rgb(229 231 235 / var(--tw-bg-opacity));