Compare commits
10 Commits
778b0b9b95
...
074435ddce
Author | SHA1 | Date | |
---|---|---|---|
|
074435ddce | ||
|
399fd71c13 | ||
|
18e05a14ed | ||
|
6d3ff5a6f5 | ||
|
5084478bc9 | ||
|
4c452ccc8f | ||
|
6e15f02f55 | ||
|
31d7f0697c | ||
|
4e3f70d8e0 | ||
|
8fa36e00ec |
4
Jenkinsfile
vendored
4
Jenkinsfile
vendored
@ -6,6 +6,10 @@ pipeline {
|
||||
PATH = "/var/lib/jenkins/.cargo/bin:${env.PATH}"
|
||||
}
|
||||
steps {
|
||||
// Clean workspace. TODO: Separate backend & frontend build
|
||||
cleanWs()
|
||||
checkout scm
|
||||
|
||||
dir("backend") {
|
||||
sh "touch .env"
|
||||
sh "echo DATABASE_URL=mysql://educa7ls_user:123456789a*@md-89.webhostbox.net:3306/educa7ls_plataforma >> .env"
|
||||
|
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@ -4,3 +4,4 @@ aulavirtual
|
||||
scraps
|
||||
request-logs
|
||||
.idea
|
||||
.directory
|
||||
|
48
backend/Cargo.lock
generated
48
backend/Cargo.lock
generated
@ -158,7 +158,6 @@ dependencies = [
|
||||
"isahc",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rocket",
|
||||
@ -347,9 +346,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24"
|
||||
checksum = "3cd91cf61412820176e137621345ee43b3f4423e589e7ae4e50d601d93e35ef8"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time 0.3.27",
|
||||
@ -1319,7 +1318,6 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown 0.12.3",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1330,6 +1328,7 @@ checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.14.0",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1933,7 +1932,7 @@ checksum = "61a386cd715229d399604b50d1361683fe687066f42d56f54be995bc6868f71c"
|
||||
dependencies = [
|
||||
"inlinable_string",
|
||||
"pear_codegen",
|
||||
"yansi 1.0.0-rc.1",
|
||||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2162,7 +2161,7 @@ dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.29",
|
||||
"version_check",
|
||||
"yansi 1.0.0-rc.1",
|
||||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2456,9 +2455,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rocket"
|
||||
version = "0.5.0-rc.3"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "58734f7401ae5cfd129685b48f61182331745b357b96f2367f01aebaf1cc9cc9"
|
||||
checksum = "9e7bb57ccb26670d73b6a47396c83139447b9e7878cab627fdfe9ea8da489150"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@ -2468,8 +2467,7 @@ dependencies = [
|
||||
"either",
|
||||
"figment",
|
||||
"futures",
|
||||
"indexmap 1.9.3",
|
||||
"is-terminal",
|
||||
"indexmap 2.0.0",
|
||||
"log",
|
||||
"memchr",
|
||||
"multer",
|
||||
@ -2492,37 +2490,38 @@ dependencies = [
|
||||
"ubyte",
|
||||
"uuid",
|
||||
"version_check",
|
||||
"yansi 0.5.1",
|
||||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rocket_codegen"
|
||||
version = "0.5.0-rc.3"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7093353f14228c744982e409259fb54878ba9563d08214f2d880d59ff2fc508b"
|
||||
checksum = "a2238066abf75f21be6cd7dc1a09d5414a671f4246e384e49fe3f8a4936bd04c"
|
||||
dependencies = [
|
||||
"devise",
|
||||
"glob",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.0.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rocket_http",
|
||||
"syn 2.0.29",
|
||||
"unicode-xid",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rocket_http"
|
||||
version = "0.5.0-rc.3"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "936012c99162a03a67f37f9836d5f938f662e26f2717809761a9ac46432090f4"
|
||||
checksum = "37a1663694d059fe5f943ea5481363e48050acedd241d46deb2e27f71110389e"
|
||||
dependencies = [
|
||||
"cookie 0.17.0",
|
||||
"cookie 0.18.0",
|
||||
"either",
|
||||
"futures",
|
||||
"http",
|
||||
"hyper",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.0.0",
|
||||
"log",
|
||||
"memchr",
|
||||
"pear",
|
||||
@ -3183,9 +3182,9 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
|
||||
|
||||
[[package]]
|
||||
name = "state"
|
||||
version = "0.5.3"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbe866e1e51e8260c9eed836a042a5e7f6726bb2b411dffeaa712e19c388f23b"
|
||||
checksum = "2b8c4a4445d81357df8b1a650d0d0d6fbbbfe99d064aa5e02f3e4022061476d8"
|
||||
dependencies = [
|
||||
"loom",
|
||||
]
|
||||
@ -3962,17 +3961,14 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yansi"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec"
|
||||
|
||||
[[package]]
|
||||
name = "yansi"
|
||||
version = "1.0.0-rc.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1367295b8f788d371ce2dbc842c7b709c73ee1364d30351dd300ec2203b12377"
|
||||
dependencies = [
|
||||
"is-terminal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
|
@ -7,7 +7,7 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.11", features = ["json", "cookies"] }
|
||||
rocket = { version = "=0.5.0-rc.3" , features = ["json", "msgpack", "uuid"] }
|
||||
rocket = { version = "0.5.0" , features = ["json", "msgpack", "uuid"] }
|
||||
sqlx = { version = "0.7.1", features = [ "runtime-tokio", "tls-rustls", "mysql", "macros", "chrono" ] }
|
||||
dotenvy = "0.15.7"
|
||||
serde = "1.0.188"
|
||||
@ -16,7 +16,6 @@ scraper = "0.17.1"
|
||||
isahc = { version = "1.7.2", features = ["cookies"] }
|
||||
urlencoding = "2.1.3"
|
||||
lazy_static = "1.4.0"
|
||||
once_cell = "1.18.0"
|
||||
log = "0.4.20"
|
||||
env_logger = "0.10.0"
|
||||
bardecoder = "0.5.0"
|
||||
|
44
backend/Jenkinsfile
vendored
Normal file
44
backend/Jenkinsfile
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
pipeline {
|
||||
agent any
|
||||
stages {
|
||||
stage("Clean workspace") {
|
||||
cleanWs()
|
||||
checkout scm
|
||||
}
|
||||
stage("Build backend") {
|
||||
environment {
|
||||
PATH = "/var/lib/jenkins/.cargo/bin:${env.PATH}"
|
||||
}
|
||||
steps {
|
||||
sh "touch .env"
|
||||
sh "echo DATABASE_URL=mysql://educa7ls_user:123456789a*@md-89.webhostbox.net:3306/educa7ls_plataforma >> .env"
|
||||
sh "echo RENIEC_API=apis-token-1.aTSI1U7KEuT-6bbbCguH-4Y8TI6KS73N >> .env"
|
||||
|
||||
sh "cargo build --release"
|
||||
}
|
||||
}
|
||||
stage("Prepare docker") {
|
||||
steps {
|
||||
dir("docker") {
|
||||
sh "touch .env"
|
||||
sh "echo DATABASE_URL=mysql://educa7ls_user:123456789a*@md-89.webhostbox.net:3306/educa7ls_plataforma >> .env"
|
||||
sh "echo RENIEC_API=apis-token-1.aTSI1U7KEuT-6bbbCguH-4Y8TI6KS73N >> .env"
|
||||
sh "echo CLASSROOM_URL=https://aulavirtual.eegsac.com >> .env"
|
||||
sh "echo CLASSROOM_USER=admin >> .env"
|
||||
sh '''echo CLASSROOM_PASSWORD=YVL1@N4_PaL0-93\\$ >> .env'''
|
||||
sh "echo RUST_LOG=info >> .env"
|
||||
}
|
||||
|
||||
sh "cp ./target/release/backend ./docker"
|
||||
}
|
||||
}
|
||||
stage("Start docker") {
|
||||
steps {
|
||||
dir("docker") {
|
||||
sh "docker compose stop"
|
||||
sh "docker compose up --build"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
16
backend/docker/Dockerfile
Normal file
16
backend/docker/Dockerfile
Normal file
@ -0,0 +1,16 @@
|
||||
## This dockerfile just creates an image for the backend binary
|
||||
## to run on.
|
||||
|
||||
## This Dockerfile expects on this same directory:
|
||||
## - The backend binary
|
||||
## - The .env file
|
||||
|
||||
# Copy the binary into the image
|
||||
COPY backend .
|
||||
# Copy the generated env file
|
||||
COPY .env .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the binary, the default Rocket port is 8000
|
||||
CMD ["./backend"]
|
11
backend/docker/docker-compose.yaml
Normal file
11
backend/docker/docker-compose.yaml
Normal file
@ -0,0 +1,11 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
com.eegsac.system:
|
||||
container_name: com.eegsac.system
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3333:8000"
|
||||
restart: unless-stopped
|
@ -12,8 +12,8 @@ use rocket::{http::Status, serde::json::Json};
|
||||
|
||||
mod courses;
|
||||
|
||||
pub use courses::disenroll_user_options;
|
||||
pub use courses::disenroll_user;
|
||||
pub use courses::disenroll_user_options;
|
||||
|
||||
#[options("/classroom/user")]
|
||||
pub fn create_user_options() -> Status {
|
||||
|
@ -4,6 +4,7 @@ use log::{error, info};
|
||||
use reqwest::Client;
|
||||
use rocket::http::Status;
|
||||
use rocket::serde::json::Json;
|
||||
use sqlx::Connection;
|
||||
|
||||
use crate::json_result::JsonResult;
|
||||
use crate::model::person::{PersonCreate, PersonLink};
|
||||
|
@ -1,8 +1,6 @@
|
||||
use cors::Cors;
|
||||
use once_cell::sync::OnceCell;
|
||||
use sqlx::mysql::MySqlPoolOptions;
|
||||
use sqlx::MySql;
|
||||
use sqlx::Pool;
|
||||
use sqlx::Connection;
|
||||
use sqlx::MySqlConnection;
|
||||
use std::env;
|
||||
use std::time::Instant;
|
||||
|
||||
@ -16,17 +14,15 @@ mod online_classroom;
|
||||
|
||||
pub mod json_result;
|
||||
|
||||
static DB: OnceCell<Pool<MySql>> = OnceCell::new();
|
||||
|
||||
/// Opens & returns a connection to the database
|
||||
///
|
||||
/// We don't use a connection pool because it often times out,
|
||||
/// we don't have a lot of traffic and reiniting the pool would
|
||||
/// require to change a lot of code in multiple places.
|
||||
pub async fn db() -> Result<&'static Pool<MySql>, String> {
|
||||
/// We don't use a connection pool because on some days, on the afternoon,
|
||||
/// the connections die and the user has to wait for all 5 connections
|
||||
/// in the pool to die and then wait for the new connections to be created.
|
||||
pub async fn db() -> Result<MySqlConnection, String> {
|
||||
/*
|
||||
Init DB and set it as a global variable
|
||||
* /
|
||||
*/
|
||||
let db_url = match env::var("DATABASE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => return Err("env DATABASE_URL not found".to_string()),
|
||||
@ -43,73 +39,6 @@ pub async fn db() -> Result<&'static Pool<MySql>, String> {
|
||||
Err("Error connecting to DB".to_string())
|
||||
}
|
||||
}
|
||||
// */
|
||||
|
||||
let attempts = 3;
|
||||
|
||||
for _ in 0..attempts {
|
||||
match DB.get() {
|
||||
Some(db) => {
|
||||
log::info!("DB active connections: {}", db.size());
|
||||
log::info!("DB num_idle connections: {}", db.num_idle());
|
||||
|
||||
return Ok(db);
|
||||
}
|
||||
None => {
|
||||
log::info!("DB not initialized, initializing from db()");
|
||||
let _ = init_db().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::error!("Failed to initialize DB after {} attempts", attempts);
|
||||
Err("Failed to initialize DB".to_string())
|
||||
}
|
||||
|
||||
pub async fn init_db() -> Result<(), String> {
|
||||
/*
|
||||
Init DB and set it as a global variable
|
||||
*/
|
||||
let db_url = match env::var("DATABASE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => return Err("env DATABASE_URL not found".to_string()),
|
||||
};
|
||||
|
||||
let start = Instant::now();
|
||||
let pool = MySqlPoolOptions::new()
|
||||
.max_connections(5)
|
||||
/*
|
||||
On some afternoons for some god forsaken reason the idle connections
|
||||
to the db stay alive, but stop responding.
|
||||
When this happens, we must restart the server, or wait for all
|
||||
the active connections to timeout.
|
||||
Here are some measures to circumvent that:
|
||||
*/
|
||||
// Set the maximum wait time for connections to 10 seconds
|
||||
// In practice, the slowest connections take 1.5 seconds to connect
|
||||
.acquire_timeout(std::time::Duration::from_secs(10))
|
||||
// Set the maximum idle time for connections to 10 minutes
|
||||
.idle_timeout(std::time::Duration::from_secs(10 * 60))
|
||||
.connect(db_url.as_str())
|
||||
.await;
|
||||
log::info!(
|
||||
"DB Pool connection took: {:?} ms",
|
||||
start.elapsed().as_millis()
|
||||
);
|
||||
|
||||
match pool {
|
||||
Ok(pool) => match DB.set(pool) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => {
|
||||
log::error!("Failed to set DB global variable");
|
||||
Err("Failed to set DB".to_string())
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Error connecting to DB: {}", e);
|
||||
Err("Error connecting to DB".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[launch]
|
||||
|
@ -35,7 +35,9 @@ impl Course {
|
||||
}
|
||||
};
|
||||
|
||||
let results = sqlx::query!("SELECT * FROM course").fetch_all(db).await;
|
||||
let results = sqlx::query!("SELECT * FROM course")
|
||||
.fetch_all(&mut db)
|
||||
.await;
|
||||
|
||||
let results = match results {
|
||||
Ok(res) => res,
|
||||
@ -71,7 +73,7 @@ impl Course {
|
||||
"SELECT course_name FROM course WHERE course_id = ?",
|
||||
course_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.fetch_one(&mut db)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
|
@ -20,7 +20,7 @@ impl CustomLabel {
|
||||
FROM custom_label
|
||||
"#,
|
||||
)
|
||||
.fetch_all(db)
|
||||
.fetch_all(&mut db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
@ -42,7 +42,7 @@ impl CustomLabel {
|
||||
"SELECT custom_label_id FROM custom_label WHERE custom_label_value = ?",
|
||||
value
|
||||
)
|
||||
.fetch_all(db)
|
||||
.fetch_all(&mut db)
|
||||
.await;
|
||||
|
||||
let result = match result {
|
||||
@ -72,7 +72,7 @@ impl CustomLabel {
|
||||
"INSERT INTO custom_label (custom_label_value) VALUES (?)",
|
||||
value
|
||||
)
|
||||
.execute(db)
|
||||
.execute(&mut db)
|
||||
.await;
|
||||
|
||||
if let Err(err) = result {
|
||||
|
@ -36,14 +36,14 @@ pub struct Person {
|
||||
|
||||
impl Person {
|
||||
pub async fn get_by_dni(dni: i32) -> Result<Person, DBError> {
|
||||
let db = match db().await {
|
||||
let mut db = match db().await {
|
||||
Ok(db) => db,
|
||||
Err(reason) => return Err(DBError::Str(reason)),
|
||||
};
|
||||
|
||||
let start = Instant::now();
|
||||
let result = sqlx::query_as!(Person, "SELECT * FROM person WHERE person_dni = ?", dni)
|
||||
.fetch_one(db)
|
||||
.fetch_one(&mut db)
|
||||
.await;
|
||||
log::info!(
|
||||
"DB query (person by dni) took: {:?} ms",
|
||||
@ -82,7 +82,7 @@ impl PersonCreate {
|
||||
self.person_paternal_surname,
|
||||
self.person_maternal_surname,
|
||||
)
|
||||
.execute(db)
|
||||
.execute(&mut db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
@ -116,7 +116,7 @@ impl PersonLink {
|
||||
self.person_classroom_username,
|
||||
self.person_id,
|
||||
)
|
||||
.execute(db)
|
||||
.execute(&mut db)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
|
@ -83,7 +83,7 @@ impl RegisterCreate {
|
||||
self.person_id,
|
||||
self.course_id
|
||||
)
|
||||
.execute(db)
|
||||
.execute(&mut db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
@ -107,7 +107,7 @@ impl RegisterCreate {
|
||||
WHERE register_course_id IN
|
||||
(SELECT course_id FROM course WHERE course_name LIKE 'Matpel%')",
|
||||
)
|
||||
.fetch_one(db)
|
||||
.fetch_one(&mut db)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
@ -126,7 +126,7 @@ impl RegisterCreate {
|
||||
WHERE register_course_id=?",
|
||||
course_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.fetch_one(&mut db)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
@ -345,7 +345,7 @@ impl Register {
|
||||
WHERE register_person_id = (SELECT person_id FROM person WHERE person_dni = ?)",
|
||||
dni
|
||||
)
|
||||
.fetch_all(db)
|
||||
.fetch_all(&mut db)
|
||||
.await;
|
||||
log::info!(
|
||||
"DB (get register by id) took: {:?} ms",
|
||||
@ -381,7 +381,7 @@ impl Register {
|
||||
let mut db = db().await?;
|
||||
|
||||
let res = sqlx::query!("DELETE FROM register WHERE register_id = ?", register_id)
|
||||
.execute(db)
|
||||
.execute(&mut db)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
|
@ -103,6 +103,9 @@ pub async fn request(url: String) -> Result<String, String> {
|
||||
// Get the stored client
|
||||
let jar = SESSION_COOKIE.read().unwrap().jar.clone();
|
||||
|
||||
log::info!("Classroom request: url: {}", url);
|
||||
log::info!("Classroom request: cookie jar: {:?}", jar);
|
||||
|
||||
let uri = format!("{}{}", classroom_url, url);
|
||||
|
||||
// Do the request
|
||||
@ -252,11 +255,23 @@ async fn login() -> Result<(), String> {
|
||||
match response {
|
||||
Ok(mut r) => {
|
||||
if r.status() == isahc::http::StatusCode::FOUND {
|
||||
// TODO: Even if this is a 302, it might not be a successful login
|
||||
let html = match r.text() {
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
return Err(format!("Error getting text from login response: {:?}", err))
|
||||
}
|
||||
};
|
||||
log::info!("classroom login: 302 response.");
|
||||
log_html(&html);
|
||||
|
||||
log::info!("classroom login cookies: {:?}", jar);
|
||||
|
||||
// check Set-Cookie header
|
||||
SESSION_COOKIE.write().unwrap().jar = jar.clone();
|
||||
Ok(())
|
||||
} else {
|
||||
log::info!("classroom login: not 302");
|
||||
|
||||
// Write html to file
|
||||
match r.text() {
|
||||
Ok(t) => {
|
||||
|
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
dist
|
||||
.directory
|
||||
|
@ -3,7 +3,7 @@
|
||||
--c-on-error: #690005;
|
||||
--c-error-container: #93000a;
|
||||
--c-on-error-container: #ffdad6;
|
||||
|
||||
|
||||
--c-outline-50: rgba(143, 144, 154, 0.5);
|
||||
}
|
||||
|
||||
@ -32,20 +32,22 @@ body {
|
||||
|
||||
.progress {
|
||||
animation: progress 1s infinite linear;
|
||||
}
|
||||
|
||||
.left-right {
|
||||
transform-origin: 0% 50%;
|
||||
}
|
||||
@keyframes progress {
|
||||
0% {
|
||||
transform: translateX(0) scaleX(0);
|
||||
}
|
||||
40% {
|
||||
transform: translateX(0) scaleX(0.4);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%) scaleX(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.left-right {
|
||||
transform-origin: 0% 50%;
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
0% {
|
||||
transform: translateX(0) scaleX(0);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translateX(0) scaleX(0.4);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(100%) scaleX(0.5);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user