├── migrations ├── .keep ├── 2022-10-23-160940_create_users_table │ ├── down.sql │ └── up.sql ├── 2022-10-23-193539_create_api_tokens_table │ ├── down.sql │ └── up.sql ├── 2022-10-27-193452_create_plugins_table │ ├── down.sql │ └── up.sql ├── 2022-10-27-204919_create_versions_table │ ├── down.sql │ └── up.sql └── 00000000000000_diesel_initial_setup │ ├── down.sql │ └── up.sql ├── .dockerignore ├── volts-core ├── src │ ├── db │ │ ├── api.rs │ │ ├── mod.rs │ │ ├── schema.rs │ │ └── models.rs │ ├── util │ │ ├── mod.rs │ │ └── rfc3339.rs │ └── lib.rs └── Cargo.toml ├── volts-cli ├── src │ ├── bin │ │ └── volts.rs │ ├── lib.rs │ └── commands.rs └── Cargo.toml ├── nginx ├── volt.png ├── default.conf ├── index.html ├── nginx.conf └── mime.types ├── .gitignore ├── volts-front ├── src │ ├── components │ │ ├── mod.rs │ │ ├── navbar.rs │ │ ├── token.rs │ │ └── plugin.rs │ └── lib.rs ├── Trunk.toml ├── index.html ├── Cargo.toml ├── dist │ └── index.html ├── tailwind.config.js └── assets │ └── tailwind.css ├── volts-back ├── src │ ├── bin │ │ └── server.rs │ ├── lib.rs │ ├── util.rs │ ├── token.rs │ ├── github.rs │ ├── state.rs │ ├── router.rs │ ├── db.rs │ └── plugin.rs └── Cargo.toml ├── diesel.toml ├── Cargo.toml ├── fly.toml └── Dockerfile /migrations/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /target 2 | fly.toml -------------------------------------------------------------------------------- /volts-core/src/db/api.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /volts-core/src/util/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod rfc3339; 2 | -------------------------------------------------------------------------------- /volts-cli/src/bin/volts.rs: -------------------------------------------------------------------------------- 1 | pub fn main() { 2 | volts::cli(); 3 | } 4 | -------------------------------------------------------------------------------- /nginx/volt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lapce/lapce-volts/HEAD/nginx/volt.png -------------------------------------------------------------------------------- /volts-core/src/db/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod api; 2 | pub mod models; 3 | pub mod schema; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .env 3 | .DS_Store 4 | 5 | volts-front/dist 6 | 7 | .vscode 8 | .lapce -------------------------------------------------------------------------------- /volts-front/src/components/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod navbar; 2 | pub(crate) mod plugin; 3 | pub(crate) mod token; 4 | -------------------------------------------------------------------------------- /volts-back/src/bin/server.rs: -------------------------------------------------------------------------------- 1 | #[tokio::main] 2 | async fn main() { 3 | volts_back::start_server().await; 4 | } 5 | -------------------------------------------------------------------------------- /migrations/2022-10-23-160940_create_users_table/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | drop table users; -------------------------------------------------------------------------------- /migrations/2022-10-23-193539_create_api_tokens_table/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | drop table api_tokens; -------------------------------------------------------------------------------- /migrations/2022-10-27-193452_create_plugins_table/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | drop table plugins; 3 | -------------------------------------------------------------------------------- /migrations/2022-10-27-204919_create_versions_table/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | drop table versions; 3 | -------------------------------------------------------------------------------- /volts-front/Trunk.toml: -------------------------------------------------------------------------------- 1 | [watch] 2 | watch = ["src/"] 3 | 4 | [serve] 5 | address = "127.0.0.1" 6 | port = 3000 7 | open = false 8 | 9 | [[proxy]] 10 | rewrite = "/api/" 11 | backend = "http://localhost:8080/api" 12 | -------------------------------------------------------------------------------- /diesel.toml: -------------------------------------------------------------------------------- 1 | # For documentation on how to configure this file, 2 | # see https://diesel.rs/guides/configuring-diesel-cli 3 | 4 | [print_schema] 5 | file = "src/db/schema.rs" 6 | 7 | [migrations_directory] 8 | dir = "migrations" 9 | -------------------------------------------------------------------------------- /migrations/00000000000000_diesel_initial_setup/down.sql: -------------------------------------------------------------------------------- 1 | -- This file was automatically created by Diesel to setup helper functions 2 | -- and other internal bookkeeping. This file is safe to edit, any future 3 | -- changes will be added to existing projects as new migrations. 4 | 5 | DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); 6 | DROP FUNCTION IF EXISTS diesel_set_updated_at(); 7 | -------------------------------------------------------------------------------- /migrations/2022-10-23-160940_create_users_table/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | create table users ( 3 | id SERIAL PRIMARY KEY, 4 | gh_access_token VARCHAR NOT NULL, 5 | gh_login VARCHAR NOT NULL, 6 | gh_id INTEGER NOT NULL 7 | ); 8 | 9 | CREATE UNIQUE INDEX users_gh_id ON users (gh_id); 10 | CREATE INDEX users_gh_login ON users (gh_login); 11 | -------------------------------------------------------------------------------- /volts-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "volts-core" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | chrono = "0.4.22" 10 | diesel = { version = "2.0.2", features = ["postgres", "chrono"] } 11 | anyhow = "1.0.66" 12 | url = "2.3.1" 13 | serde = { version = "1.0", features = ["derive"] } 14 | -------------------------------------------------------------------------------- /volts-front/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Yew App 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /nginx/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 3000; 3 | server_name _; 4 | 5 | location / { 6 | root /app/static; 7 | try_files $uri /index.html; 8 | } 9 | 10 | location /static/ { 11 | root /app/; 12 | add_header Cache-Control "public, max-age=86400"; 13 | } 14 | 15 | location /api { 16 | proxy_pass http://127.0.0.1:8080; 17 | } 18 | 19 | client_max_body_size 100M; 20 | } 21 | -------------------------------------------------------------------------------- /migrations/2022-10-23-193539_create_api_tokens_table/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | CREATE TABLE "public"."api_tokens" ( 3 | "id" SERIAL PRIMARY KEY, 4 | "user_id" INTEGER NOT NULL, 5 | "token" bytea NOT NULL, 6 | "name" varchar NOT NULL, 7 | "created_at" timestamp NOT NULL DEFAULT now(), 8 | "last_used_at" timestamp, 9 | "revoked" bool NOT NULL DEFAULT false, 10 | CONSTRAINT "api_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") 11 | ); 12 | 13 | CREATE UNIQUE INDEX api_tokens_token ON api_tokens (token) -------------------------------------------------------------------------------- /volts-back/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::net::SocketAddr; 2 | 3 | pub(crate) mod db; 4 | pub mod github; 5 | pub(crate) mod plugin; 6 | pub mod router; 7 | pub mod state; 8 | pub mod token; 9 | pub mod util; 10 | 11 | #[macro_use] 12 | extern crate diesel; 13 | 14 | pub async fn start_server() { 15 | dotenvy::dotenv().ok(); 16 | let router = crate::router::build_router(); 17 | let addr = SocketAddr::from(([0, 0, 0, 0], 8080)); 18 | axum::Server::bind(&addr) 19 | .serve(router.into_make_service()) 20 | .await 21 | .unwrap(); 22 | } 23 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lapce-volts" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | tokio = { version = "1.21.2", features = ["full"] } 8 | volts-front = { path = "./volts-front" } 9 | volts-back = { path = "./volts-back" } 10 | volts = { path = "./volts-cli" } 11 | 12 | [[bin]] 13 | name = "volts-server" 14 | path = "volts-back/src/bin/server.rs" 15 | 16 | [[bin]] 17 | name = "volts" 18 | path = "volts-cli/src/bin/volts.rs" 19 | 20 | [workspace] 21 | members = [ 22 | "volts-core", 23 | "volts-front", 24 | "volts-back", 25 | "volts-cli", 26 | ] -------------------------------------------------------------------------------- /volts-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "volts" 3 | description = "Cli tool for publishing and managing Lapce plugins" 4 | license = "Apache-2.0" 5 | version = "0.2.1" 6 | edition = "2021" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [dependencies] 11 | reqwest = { version = "0.11.12", features = ["blocking"] } 12 | lapce-rpc = "0.2.1" 13 | tempfile = "3.3.0" 14 | tar = "0.4.38" 15 | toml_edit = { version = "0.14.4", features = ["easy"] } 16 | serde = { version = "1.0", features = ["derive"] } 17 | clap = { version = "4.0", features = ["derive"] } 18 | keyring = { version = "1.2.0" } 19 | zstd = "0.11" -------------------------------------------------------------------------------- /migrations/2022-10-27-204919_create_versions_table/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | create table versions ( 3 | id SERIAL PRIMARY KEY, 4 | plugin_id INTEGER NOT NULL, 5 | updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 | num VARCHAR NOT NULL, 8 | downloads INTEGER NOT NULL DEFAULT 0, 9 | yanked bool NOT NULL DEFAULT false, 10 | CONSTRAINT "versions_plugin_id_fkey" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") 11 | ); 12 | 13 | CREATE UNIQUE INDEX versions_plugin_id_num ON versions (plugin_id, num); 14 | -------------------------------------------------------------------------------- /volts-front/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "volts-front" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | pulldown-cmark = "0.9.2" 10 | url = "2.3.1" 11 | gloo-net = "0.2.4" 12 | web-sys = { version = "0.3.60", features = ["HtmlImageElement"] } 13 | wasm-bindgen = "0.2.83" 14 | wasm-bindgen-futures = "0.4.33" 15 | gloo-timers = { version = "0.2.3", features = ["futures"] } 16 | console_error_panic_hook = "0.1.7" 17 | sycamore-router = "0.8.0" 18 | sycamore = { version = "0.8.2", features = ["suspense"] } 19 | volts-core = { path = "../volts-core" } 20 | 21 | [lib] 22 | crate-type = ["cdylib", "rlib"] 23 | -------------------------------------------------------------------------------- /nginx/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Lapce Plugins 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for volts on 2022-10-26T20:03:53+01:00 2 | 3 | app = "volts" 4 | kill_signal = "SIGINT" 5 | kill_timeout = 5 6 | processes = [] 7 | 8 | [env] 9 | 10 | [experimental] 11 | allowed_public_ports = [] 12 | auto_rollback = true 13 | 14 | [[services]] 15 | http_checks = [] 16 | internal_port = 3000 17 | processes = ["app"] 18 | protocol = "tcp" 19 | script_checks = [] 20 | 21 | [services.concurrency] 22 | hard_limit = 100 23 | soft_limit = 25 24 | type = "connections" 25 | 26 | [[services.ports]] 27 | force_https = false 28 | handlers = ["http"] 29 | port = 80 30 | 31 | [[services.ports]] 32 | handlers = ["tls", "http"] 33 | port = 443 34 | 35 | [[services.tcp_checks]] 36 | grace_period = "1s" 37 | interval = "15s" 38 | restart_limit = 0 39 | timeout = "2s" 40 | -------------------------------------------------------------------------------- /migrations/2022-10-27-193452_create_plugins_table/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | create table plugins ( 3 | id SERIAL PRIMARY KEY, 4 | name VARCHAR NOT NULL, 5 | user_id INTEGER NOT NULL, 6 | updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 | created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | display_name VARCHAR NOT NULL, 9 | description VARCHAR NOT NULL, 10 | downloads INTEGER NOT NULL DEFAULT 0, 11 | repository VARCHAR, 12 | wasm bool NOT NULL DEFAULT true, 13 | CONSTRAINT "plugins_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") 14 | ); 15 | 16 | CREATE UNIQUE INDEX plugins_user_id_name ON plugins (user_id, name); 17 | CREATE INDEX plugins_downloads ON plugins (downloads); 18 | -------------------------------------------------------------------------------- /volts-back/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "volts-back" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | semver = "1.0.14" 10 | rust-s3 = { version = "0.32.3", features = ["with-tokio"] } 11 | tempfile = "3.3.0" 12 | tar = "0.4.38" 13 | sha2 = "0.10.6" 14 | rand = "0.8.5" 15 | chrono = "0.4.22" 16 | diesel = { version = "2.0.2", features = ["postgres", "chrono"] } 17 | diesel-async = { version = "0.1.1", features = ["postgres", "deadpool"] } 18 | async-session = "3.0.0" 19 | headers = "0.3" 20 | axum = { version = "0.6.0-rc.4", features = ["headers"] } 21 | reqwest = { version = "0.11.12", features = ["json"] } 22 | oauth2 = "4.2.3" 23 | anyhow = "1.0.66" 24 | serde_json = "1.0.87" 25 | serde = { version = "1.0", features = ["derive"] } 26 | futures = "0.3" 27 | tokio-util = { version = "0.7", features = ["io"] } 28 | tokio = { version = "1.21.2", features = ["full"] } 29 | dotenvy = "0.15.6" 30 | volts-core = { path = "../volts-core" } 31 | toml_edit = { version = "0.14.4", features = ["easy"] } 32 | lapce-rpc = "0.2.1" 33 | zstd = { version = "0.11" } -------------------------------------------------------------------------------- /volts-back/src/util.rs: -------------------------------------------------------------------------------- 1 | use rand::{distributions::Uniform, rngs::OsRng, Rng}; 2 | use sha2::{Digest, Sha256}; 3 | 4 | pub struct SecureToken { 5 | plaintext: String, 6 | token: Vec, 7 | } 8 | 9 | impl SecureToken { 10 | pub fn new_token() -> Self { 11 | let plaintext = generate_secure_alphanumeric_string(32); 12 | let token = Sha256::digest(plaintext.as_bytes()).as_slice().to_vec(); 13 | Self { plaintext, token } 14 | } 15 | 16 | pub fn parse(plaintext: &str) -> Self { 17 | let token = Sha256::digest(plaintext.as_bytes()).as_slice().to_vec(); 18 | Self { 19 | plaintext: plaintext.to_string(), 20 | token, 21 | } 22 | } 23 | 24 | pub fn plaintext(&self) -> &str { 25 | &self.plaintext 26 | } 27 | 28 | pub fn token(&self) -> &[u8] { 29 | &self.token 30 | } 31 | } 32 | 33 | fn generate_secure_alphanumeric_string(len: usize) -> String { 34 | const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 35 | 36 | OsRng 37 | .sample_iter(Uniform::from(0..CHARS.len())) 38 | .map(|idx| CHARS[idx] as char) 39 | .take(len) 40 | .collect() 41 | } 42 | -------------------------------------------------------------------------------- /nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | user www-data; 2 | worker_processes auto; 3 | pid /run/nginx.pid; 4 | include /etc/nginx/modules-enabled/*.conf; 5 | 6 | events { 7 | worker_connections 768; 8 | # multi_accept on; 9 | } 10 | 11 | http { 12 | 13 | ## 14 | # Basic Settings 15 | ## 16 | 17 | sendfile on; 18 | tcp_nopush on; 19 | types_hash_max_size 2048; 20 | # server_tokens off; 21 | 22 | # server_names_hash_bucket_size 64; 23 | # server_name_in_redirect off; 24 | 25 | include /etc/nginx/mime.types; 26 | default_type application/octet-stream; 27 | 28 | ## 29 | # SSL Settings 30 | ## 31 | 32 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE 33 | ssl_prefer_server_ciphers on; 34 | 35 | ## 36 | # Logging Settings 37 | ## 38 | 39 | access_log /var/log/nginx/access.log; 40 | error_log /var/log/nginx/error.log; 41 | 42 | ## 43 | # Gzip Settings 44 | ## 45 | 46 | gzip on; 47 | 48 | # gzip_vary on; 49 | # gzip_proxied any; 50 | # gzip_comp_level 6; 51 | # gzip_buffers 16 8k; 52 | # gzip_http_version 1.1; 53 | # gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; 54 | 55 | ## 56 | # Virtual Host Configs 57 | ## 58 | 59 | include /etc/nginx/conf.d/*.conf; 60 | include /etc/nginx/sites-enabled/*; 61 | } 62 | -------------------------------------------------------------------------------- /migrations/00000000000000_diesel_initial_setup/up.sql: -------------------------------------------------------------------------------- 1 | -- This file was automatically created by Diesel to setup helper functions 2 | -- and other internal bookkeeping. This file is safe to edit, any future 3 | -- changes will be added to existing projects as new migrations. 4 | 5 | 6 | 7 | 8 | -- Sets up a trigger for the given table to automatically set a column called 9 | -- `updated_at` whenever the row is modified (unless `updated_at` was included 10 | -- in the modified columns) 11 | -- 12 | -- # Example 13 | -- 14 | -- ```sql 15 | -- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); 16 | -- 17 | -- SELECT diesel_manage_updated_at('users'); 18 | -- ``` 19 | CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ 20 | BEGIN 21 | EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s 22 | FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); 23 | END; 24 | $$ LANGUAGE plpgsql; 25 | 26 | CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ 27 | BEGIN 28 | IF ( 29 | NEW IS DISTINCT FROM OLD AND 30 | NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at 31 | ) THEN 32 | NEW.updated_at := current_timestamp; 33 | END IF; 34 | RETURN NEW; 35 | END; 36 | $$ LANGUAGE plpgsql; 37 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:latest as builder 2 | 3 | RUN cargo install wasm-pack 4 | 5 | WORKDIR /build 6 | COPY . . 7 | RUN --mount=type=cache,target=/usr/local/cargo/registry \ 8 | # --mount=type=cache,target=/build/target \ 9 | cd ./volts-front && \ 10 | wasm-pack build --target web && \ 11 | cd .. && \ 12 | cargo build --bin volts-server --release 13 | 14 | # Runtime image 15 | FROM debian:bookworm 16 | 17 | RUN apt-get update 18 | RUN apt install -y postgresql-common 19 | RUN /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -i -v 14 20 | RUN apt-get -y install postgresql-client-14 21 | RUN apt-get -y install nginx 22 | RUN apt-get install ca-certificates -y 23 | RUN update-ca-certificates 24 | 25 | WORKDIR /app 26 | RUN mkdir /app/static 27 | 28 | COPY ./nginx/nginx.conf /etc/nginx/nginx.conf 29 | COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf 30 | COPY ./nginx/mime.types /etc/nginx/mime.types 31 | COPY ./nginx/index.html /app/static/index.html 32 | COPY ./nginx/volt.png /app/static/volt.png 33 | COPY ./volts-front/assets/tailwind.css /app/static/main.css 34 | 35 | # Get compiled binaries from builder's cargo install directory 36 | COPY --from=builder /build/volts-front/pkg/volts_front.js /app/static/main.js 37 | COPY --from=builder /build/volts-front/pkg/volts_front_bg.wasm /app/static/main.wasm 38 | COPY --from=builder /build/target/release/volts-server /app/volts-server 39 | 40 | CMD nginx && /app/volts-server 41 | -------------------------------------------------------------------------------- /volts-core/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod db; 2 | pub mod util; 3 | 4 | #[macro_use] 5 | extern crate diesel; 6 | 7 | use chrono::NaiveDateTime; 8 | use db::models::ApiToken; 9 | use serde::{Deserialize, Serialize}; 10 | 11 | #[derive(Serialize, Deserialize)] 12 | pub struct MeUser { 13 | pub login: String, 14 | } 15 | 16 | #[derive(Serialize, Deserialize)] 17 | pub struct NewSessionResponse { 18 | pub url: String, 19 | pub state: String, 20 | } 21 | 22 | #[derive(Serialize, Deserialize, Clone)] 23 | pub struct ApiTokenList { 24 | pub api_tokens: Vec, 25 | } 26 | 27 | #[derive(Serialize, Deserialize)] 28 | pub struct EncodeApiToken { 29 | pub token: ApiToken, 30 | pub plaintext: String, 31 | } 32 | 33 | #[derive(Serialize, Deserialize)] 34 | pub struct NewTokenPayload { 35 | pub name: String, 36 | } 37 | 38 | #[derive(Serialize, Deserialize, PartialEq, Eq, Clone)] 39 | pub struct EncodePlugin { 40 | pub id: i32, 41 | pub name: String, 42 | pub author: String, 43 | pub version: String, 44 | pub display_name: String, 45 | pub description: String, 46 | pub downloads: i32, 47 | pub repository: Option, 48 | pub updated_at_ts: i64, 49 | pub updated_at: String, 50 | pub released_at: String, 51 | pub wasm: bool, 52 | } 53 | 54 | #[derive(Serialize, Deserialize)] 55 | pub struct PluginList { 56 | pub total: i64, 57 | pub limit: usize, 58 | pub offset: usize, 59 | pub plugins: Vec, 60 | } 61 | -------------------------------------------------------------------------------- /volts-core/src/db/schema.rs: -------------------------------------------------------------------------------- 1 | // @generated automatically by Diesel CLI. 2 | 3 | diesel::table! { 4 | api_tokens (id) { 5 | id -> Int4, 6 | user_id -> Int4, 7 | token -> Bytea, 8 | name -> Varchar, 9 | created_at -> Timestamp, 10 | last_used_at -> Nullable, 11 | revoked -> Bool, 12 | } 13 | } 14 | 15 | diesel::table! { 16 | plugins (id) { 17 | id -> Int4, 18 | name -> Varchar, 19 | user_id -> Int4, 20 | updated_at -> Timestamp, 21 | created_at -> Timestamp, 22 | display_name -> Varchar, 23 | description -> Varchar, 24 | downloads -> Int4, 25 | repository -> Nullable, 26 | wasm -> Bool, 27 | } 28 | } 29 | 30 | diesel::table! { 31 | users (id) { 32 | id -> Int4, 33 | gh_access_token -> Varchar, 34 | gh_login -> Varchar, 35 | gh_id -> Int4, 36 | } 37 | } 38 | 39 | diesel::table! { 40 | versions (id) { 41 | id -> Int4, 42 | plugin_id -> Int4, 43 | updated_at -> Timestamp, 44 | created_at -> Timestamp, 45 | num -> Varchar, 46 | yanked -> Bool, 47 | downloads -> Int4, 48 | } 49 | } 50 | 51 | diesel::joinable!(api_tokens -> users (user_id)); 52 | diesel::joinable!(plugins -> users (user_id)); 53 | diesel::joinable!(versions -> plugins (plugin_id)); 54 | 55 | diesel::allow_tables_to_appear_in_same_query!(api_tokens, plugins, users, versions,); 56 | -------------------------------------------------------------------------------- /volts-core/src/util/rfc3339.rs: -------------------------------------------------------------------------------- 1 | //! Convenience functions for serializing and deserializing times in RFC 3339 format. 2 | //! Used for returning time values in JSON API responses. 3 | //! Example: `2012-02-22T14:53:18+00:00`. 4 | 5 | use chrono::{DateTime, NaiveDateTime, Utc}; 6 | use serde::{self, Deserialize, Deserializer, Serializer}; 7 | 8 | pub fn serialize(dt: &NaiveDateTime, serializer: S) -> Result 9 | where 10 | S: Serializer, 11 | { 12 | let s = DateTime::::from_utc(*dt, Utc).to_rfc3339(); 13 | serializer.serialize_str(&s) 14 | } 15 | pub fn deserialize<'de, D>(deserializer: D) -> Result 16 | where 17 | D: Deserializer<'de>, 18 | { 19 | let s = String::deserialize(deserializer)?; 20 | let dt = DateTime::parse_from_rfc3339(&s).map_err(serde::de::Error::custom)?; 21 | Ok(dt.naive_utc()) 22 | } 23 | 24 | /// Wrapper for dealing with Option 25 | pub mod option { 26 | use chrono::NaiveDateTime; 27 | use serde::{Deserializer, Serializer}; 28 | 29 | pub fn serialize(dt: &Option, serializer: S) -> Result 30 | where 31 | S: Serializer, 32 | { 33 | match *dt { 34 | Some(dt) => super::serialize(&dt, serializer), 35 | None => serializer.serialize_none(), 36 | } 37 | } 38 | 39 | pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> 40 | where 41 | D: Deserializer<'de>, 42 | { 43 | match super::deserialize(deserializer) { 44 | Ok(dt) => Ok(Some(dt)), 45 | Err(_) => Ok(None), 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /volts-core/src/db/models.rs: -------------------------------------------------------------------------------- 1 | use chrono::NaiveDateTime; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::db::schema::{api_tokens, plugins, users, versions}; 5 | use crate::util::rfc3339; 6 | 7 | #[derive( 8 | Queryable, Debug, Identifiable, Associations, Serialize, Deserialize, Clone, PartialEq, Eq, 9 | )] 10 | #[diesel(belongs_to(User))] 11 | pub struct ApiToken { 12 | pub id: i32, 13 | #[serde(skip)] 14 | pub user_id: i32, 15 | #[serde(skip)] 16 | pub token: Vec, 17 | pub name: String, 18 | #[serde(with = "rfc3339")] 19 | pub created_at: NaiveDateTime, 20 | #[serde(with = "rfc3339::option")] 21 | pub last_used_at: Option, 22 | #[serde(skip)] 23 | pub revoked: bool, 24 | } 25 | 26 | #[derive(Queryable, Debug, Identifiable)] 27 | pub struct User { 28 | pub id: i32, 29 | pub gh_access_token: String, 30 | pub gh_login: String, 31 | pub gh_id: i32, 32 | } 33 | 34 | #[derive(Queryable, Debug, Identifiable, Associations)] 35 | #[diesel(belongs_to(User))] 36 | pub struct Plugin { 37 | pub id: i32, 38 | pub name: String, 39 | pub user_id: i32, 40 | pub updated_at: NaiveDateTime, 41 | pub created_at: NaiveDateTime, 42 | pub display_name: String, 43 | pub description: String, 44 | pub downloads: i32, 45 | pub repository: Option, 46 | pub wasm: bool, 47 | } 48 | 49 | #[derive(Queryable, Debug, Identifiable, Associations)] 50 | #[diesel(belongs_to(Plugin))] 51 | pub struct Version { 52 | pub id: i32, 53 | pub plugin_id: i32, 54 | pub updated_at: NaiveDateTime, 55 | pub created_at: NaiveDateTime, 56 | pub num: String, 57 | pub yanked: bool, 58 | pub downloads: i32, 59 | } 60 | -------------------------------------------------------------------------------- /volts-front/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | Yew App 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /volts-back/src/token.rs: -------------------------------------------------------------------------------- 1 | use async_session::{MemoryStore, SessionStore}; 2 | use axum::{ 3 | extract::{Path, State}, 4 | response::IntoResponse, 5 | Json, TypedHeader, 6 | }; 7 | use volts_core::{ApiTokenList, NewTokenPayload}; 8 | 9 | use crate::{ 10 | db::{find_user, insert_token, list_tokens, revoke_token, DbPool}, 11 | router::authenticated_user, 12 | state::SESSION_COOKIE_NAME, 13 | }; 14 | 15 | pub async fn list( 16 | State(store): State, 17 | State(db_pool): State, 18 | TypedHeader(cookies): TypedHeader, 19 | ) -> impl IntoResponse { 20 | let cookie = cookies.get(SESSION_COOKIE_NAME).unwrap(); 21 | let session = store 22 | .load_session(cookie.to_string()) 23 | .await 24 | .unwrap() 25 | .unwrap(); 26 | let user_id: i32 = session.get("user_id").unwrap(); 27 | 28 | let mut conn = db_pool.read.get().await.unwrap(); 29 | let user = find_user(&mut conn, user_id).await.unwrap(); 30 | let tokens = list_tokens(&mut conn, &user).await.unwrap(); 31 | Json(ApiTokenList { api_tokens: tokens }) 32 | } 33 | 34 | pub async fn new( 35 | State(store): State, 36 | State(db_pool): State, 37 | TypedHeader(cookies): TypedHeader, 38 | Json(payload): Json, 39 | ) -> impl IntoResponse { 40 | let user = authenticated_user(State(store), State(db_pool.clone()), TypedHeader(cookies)) 41 | .await 42 | .unwrap(); 43 | let mut conn = db_pool.write.get().await.unwrap(); 44 | 45 | let token = insert_token(&mut conn, &user, &payload.name).await.unwrap(); 46 | Json(token) 47 | } 48 | 49 | pub async fn revoke( 50 | State(store): State, 51 | State(db_pool): State, 52 | TypedHeader(cookies): TypedHeader, 53 | Path(id): Path, 54 | ) -> impl IntoResponse { 55 | let user = authenticated_user(State(store), State(db_pool.clone()), TypedHeader(cookies)) 56 | .await 57 | .unwrap(); 58 | 59 | let mut conn = db_pool.write.get().await.unwrap(); 60 | revoke_token(&mut conn, &user, id).await.unwrap(); 61 | } 62 | -------------------------------------------------------------------------------- /volts-back/src/github.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use oauth2::AccessToken; 3 | use reqwest::{header, Client}; 4 | use serde::{de::DeserializeOwned, Deserialize}; 5 | 6 | const GITHUB_API_ENDPOINT: &str = "https://api.github.com"; 7 | 8 | const VOLTS_USER_AGENT: &str = "volts (https://plugins.lapce.dev)"; 9 | 10 | #[derive(Debug, Deserialize)] 11 | pub struct GithubUser { 12 | pub avatar_url: Option, 13 | pub email: Option, 14 | pub id: i32, 15 | pub login: String, 16 | pub name: Option, 17 | } 18 | 19 | #[derive(Clone)] 20 | pub struct GithubClient { 21 | base_url: String, 22 | client: Client, 23 | } 24 | 25 | impl Default for GithubClient { 26 | fn default() -> Self { 27 | GithubClient::new() 28 | } 29 | } 30 | 31 | impl GithubClient { 32 | pub fn new() -> Self { 33 | let client = reqwest::Client::new(); 34 | Self { 35 | base_url: GITHUB_API_ENDPOINT.to_string(), 36 | client, 37 | } 38 | } 39 | 40 | /// Does all the nonsense for sending a GET to Github. 41 | pub async fn request(&self, url: &str, auth: &AccessToken) -> Result 42 | where 43 | T: DeserializeOwned, 44 | { 45 | let url = format!("{}{}", self.base_url, url); 46 | 47 | let result = self 48 | .client() 49 | .get(&url) 50 | .header(header::ACCEPT, "application/vnd.github.v3+json") 51 | .header(header::AUTHORIZATION, format!("token {}", auth.secret())) 52 | .header(header::USER_AGENT, VOLTS_USER_AGENT) 53 | .send() 54 | .await? 55 | .json() 56 | .await?; 57 | 58 | Ok(result) 59 | } 60 | 61 | /// Returns a client for making HTTP requests to upload crate files. 62 | /// 63 | /// The client will go through a proxy if the application was configured via 64 | /// `TestApp::with_proxy()`. 65 | /// 66 | /// # Panics 67 | /// 68 | /// Panics if the application was not initialized with a client. This should only occur in 69 | /// tests that were not properly initialized. 70 | fn client(&self) -> &Client { 71 | &self.client 72 | } 73 | 74 | pub async fn current_user(&self, auth: &AccessToken) -> Result { 75 | self.request("/user", auth).await 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /volts-cli/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod commands; 2 | 3 | use std::{collections::HashMap, io::stdin}; 4 | 5 | use clap::{Parser, Subcommand}; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | #[derive(Serialize, Deserialize)] 9 | #[serde(rename_all = "kebab-case")] 10 | struct IconTheme { 11 | pub icon_theme: IconThemeConfig, 12 | } 13 | 14 | #[derive(Serialize, Deserialize)] 15 | struct IconThemeConfig { 16 | pub ui: HashMap, 17 | pub foldername: HashMap, 18 | pub filename: HashMap, 19 | pub extension: HashMap, 20 | } 21 | 22 | #[derive(Parser)] 23 | #[clap(version, name = "Volts")] 24 | struct Cli { 25 | #[command(subcommand)] 26 | command: Commands, 27 | 28 | /// Registry API authentication token 29 | #[clap(long, action)] 30 | token: Option, 31 | } 32 | 33 | #[derive(Subcommand)] 34 | enum Commands { 35 | /// Publish plugin to registry 36 | Publish {}, 37 | /// Yank version from registry 38 | Yank { name: String, version: String }, 39 | /// Undo yanking version from registry 40 | Unyank { name: String, version: String }, 41 | } 42 | 43 | pub fn cli() { 44 | let cli = Cli::parse(); 45 | 46 | match &cli.command { 47 | Commands::Publish {} => commands::publish(&cli), 48 | Commands::Yank { name, version } => commands::yank(&cli, name, version), 49 | Commands::Unyank { name, version } => commands::unyank(&cli, name, version), 50 | } 51 | } 52 | 53 | fn auth_token(cli: &Cli) -> String { 54 | if let Some(token) = &cli.token { 55 | token.to_owned() 56 | } else { 57 | let api_credential = keyring::Entry::new("lapce-volts", "registry-api"); 58 | if let Ok(token) = api_credential.get_password() { 59 | return token; 60 | } 61 | 62 | println!("Please paste the API Token you created on https://plugins.lapce.dev/"); 63 | let mut token = String::new(); 64 | stdin().read_line(&mut token).unwrap(); 65 | 66 | token = token.trim().to_string(); 67 | if token.is_empty() { 68 | eprintln!("Token cannot be empty"); 69 | std::process::exit(1); 70 | } 71 | 72 | if let Err(why) = api_credential.set_password(&token) { 73 | eprintln!("Failed to save token in system credential store: {why}"); 74 | }; 75 | 76 | token 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /volts-back/src/state.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | use async_session::MemoryStore; 4 | use axum::extract::FromRef; 5 | use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, TokenUrl}; 6 | use s3::{creds::Credentials, Bucket, Region}; 7 | 8 | use crate::{db::DbPool, github::GithubClient}; 9 | 10 | const GITHUB_OAUTH_AUTHORIZE_ENDPOINT: &str = "https://github.com/login/oauth/authorize"; 11 | const GITHUB_OAUTH_TOKEN_ENDPOINT: &str = "https://github.com/login/oauth/access_token"; 12 | 13 | pub const SESSION_COOKIE_NAME: &str = "session"; 14 | 15 | #[derive(Clone)] 16 | pub struct AppState { 17 | store: MemoryStore, 18 | /// The GitHub OAuth2 configuration 19 | pub github_oauth: BasicClient, 20 | github_client: GithubClient, 21 | db_pool: DbPool, 22 | bucket: Bucket, 23 | } 24 | 25 | impl FromRef for MemoryStore { 26 | fn from_ref(state: &AppState) -> Self { 27 | state.store.clone() 28 | } 29 | } 30 | 31 | impl FromRef for BasicClient { 32 | fn from_ref(state: &AppState) -> Self { 33 | state.github_oauth.clone() 34 | } 35 | } 36 | 37 | impl FromRef for GithubClient { 38 | fn from_ref(state: &AppState) -> Self { 39 | state.github_client.clone() 40 | } 41 | } 42 | 43 | impl FromRef for DbPool { 44 | fn from_ref(state: &AppState) -> Self { 45 | state.db_pool.clone() 46 | } 47 | } 48 | 49 | impl FromRef for Bucket { 50 | fn from_ref(state: &AppState) -> Self { 51 | state.bucket.clone() 52 | } 53 | } 54 | 55 | impl Default for AppState { 56 | fn default() -> Self { 57 | AppState::new() 58 | } 59 | } 60 | 61 | impl AppState { 62 | pub fn new() -> Self { 63 | let github_client_id = ClientId::new( 64 | env::var("GITHUB_CLIENT_ID") 65 | .expect("Missing the GITHUB_CLIENT_ID environment variable."), 66 | ); 67 | let github_client_secret = ClientSecret::new( 68 | env::var("GITHUB_CLIENT_SECRET") 69 | .expect("Missing the GITHUB_CLIENT_SECRET environment variable."), 70 | ); 71 | let auth_url = AuthUrl::new(GITHUB_OAUTH_AUTHORIZE_ENDPOINT.to_string()) 72 | .expect("Invalid authorization endpoint URL"); 73 | let token_url = TokenUrl::new(GITHUB_OAUTH_TOKEN_ENDPOINT.to_string()) 74 | .expect("Invalid token endpoint URL"); 75 | 76 | // Set up the config for the Github OAuth2 process. 77 | let github_oauth = BasicClient::new( 78 | github_client_id, 79 | Some(github_client_secret), 80 | auth_url, 81 | Some(token_url), 82 | ); 83 | let store = MemoryStore::new(); 84 | let github_client = GithubClient::new(); 85 | let db_pool = crate::db::DbPool::new(); 86 | let bucket = Bucket::new( 87 | "lapce-plugins", 88 | Region::R2 { 89 | account_id: env::var("R2_ACCOUNT_ID").unwrap(), 90 | }, 91 | Credentials::from_env().unwrap(), 92 | ) 93 | .unwrap() 94 | .with_path_style(); 95 | Self { 96 | store, 97 | github_oauth, 98 | github_client, 99 | db_pool, 100 | bucket, 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /volts-front/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod components; 2 | 3 | use components::{ 4 | navbar::Navbar, 5 | plugin::{PluginList, PluginSearch, PluginSearchIndex, PluginView}, 6 | token::TokenList, 7 | }; 8 | use gloo_net::http::Request; 9 | use sycamore::{ 10 | component, 11 | prelude::view, 12 | reactive::{create_signal, provide_context_ref, ReadSignal, Scope}, 13 | view::View, 14 | web::Html, 15 | }; 16 | use sycamore_router::{HistoryIntegration, Route, Router}; 17 | use volts_core::MeUser; 18 | use wasm_bindgen::prelude::wasm_bindgen; 19 | 20 | #[derive(Route)] 21 | enum AppRoutes { 22 | #[to("/")] 23 | Index, 24 | #[to("/account")] 25 | Account, 26 | #[to("/plugins//")] 27 | Plugin { author: String, name: String }, 28 | #[to("/search/")] 29 | Search { query: String }, 30 | #[to("/search")] 31 | SearchIndex, 32 | #[not_found] 33 | NotFound, 34 | } 35 | 36 | #[derive(Clone, PartialEq, Eq, Default)] 37 | pub struct AppContext { 38 | pub login: Option, 39 | } 40 | 41 | #[component] 42 | fn Index(cx: Scope) -> View { 43 | view! { cx, 44 | PluginList {} 45 | } 46 | } 47 | 48 | #[component] 49 | fn Account(cx: Scope) -> View { 50 | view! { cx, 51 | TokenList {} 52 | } 53 | } 54 | 55 | #[wasm_bindgen(start)] 56 | pub fn start_front() { 57 | console_error_panic_hook::set_once(); 58 | sycamore::render(|cx| { 59 | view! { cx, 60 | Router( 61 | integration=HistoryIntegration::new(), 62 | view=|cx, route: &ReadSignal| { 63 | let ctx = create_signal(cx, AppContext::default()); 64 | provide_context_ref(cx, ctx); 65 | 66 | { 67 | let req = Request::get("/api/v1/me").send(); 68 | sycamore::futures::spawn_local_scoped(cx, async move { 69 | let resp = req.await.unwrap(); 70 | let resp: MeUser = resp.json().await.unwrap(); 71 | let mut new_ctx = (*ctx.get()).clone(); 72 | new_ctx.login = Some(resp.login); 73 | ctx.set(new_ctx); 74 | }); 75 | } 76 | 77 | view! { cx, 78 | Navbar {} 79 | div { 80 | (match route.get().as_ref() { 81 | AppRoutes::Index => view! {cx, 82 | Index 83 | }, 84 | AppRoutes::Account => view! {cx, 85 | Account 86 | }, 87 | AppRoutes::Plugin { author, name } => view! {cx, 88 | PluginView(author=author.clone(), name=name.clone()) 89 | }, 90 | AppRoutes::Search { query } => view! {cx, 91 | PluginSearch(query=query.clone()) 92 | }, 93 | AppRoutes::SearchIndex => view! { cx, 94 | PluginSearchIndex 95 | }, 96 | AppRoutes::NotFound => view! {cx, 97 | p(class="text-lg") { 98 | "404 Not Found" 99 | } 100 | }, 101 | }) 102 | } 103 | } 104 | } 105 | ) 106 | } 107 | }) 108 | } 109 | -------------------------------------------------------------------------------- /nginx/mime.types: -------------------------------------------------------------------------------- 1 | 2 | types { 3 | text/html html htm shtml; 4 | text/css css; 5 | text/xml xml; 6 | image/gif gif; 7 | image/jpeg jpeg jpg; 8 | application/javascript js; 9 | application/atom+xml atom; 10 | application/rss+xml rss; 11 | 12 | text/mathml mml; 13 | text/plain txt; 14 | text/vnd.sun.j2me.app-descriptor jad; 15 | text/vnd.wap.wml wml; 16 | text/x-component htc; 17 | 18 | image/png png; 19 | image/tiff tif tiff; 20 | image/vnd.wap.wbmp wbmp; 21 | image/x-icon ico; 22 | image/x-jng jng; 23 | image/x-ms-bmp bmp; 24 | image/svg+xml svg svgz; 25 | image/webp webp; 26 | 27 | application/font-woff woff; 28 | application/java-archive jar war ear; 29 | application/json json; 30 | application/mac-binhex40 hqx; 31 | application/msword doc; 32 | application/pdf pdf; 33 | application/postscript ps eps ai; 34 | application/rtf rtf; 35 | application/vnd.apple.mpegurl m3u8; 36 | application/vnd.ms-excel xls; 37 | application/vnd.ms-fontobject eot; 38 | application/vnd.ms-powerpoint ppt; 39 | application/vnd.wap.wmlc wmlc; 40 | application/vnd.google-earth.kml+xml kml; 41 | application/vnd.google-earth.kmz kmz; 42 | application/x-7z-compressed 7z; 43 | application/x-cocoa cco; 44 | application/x-java-archive-diff jardiff; 45 | application/x-java-jnlp-file jnlp; 46 | application/x-makeself run; 47 | application/x-perl pl pm; 48 | application/x-pilot prc pdb; 49 | application/x-rar-compressed rar; 50 | application/x-redhat-package-manager rpm; 51 | application/x-sea sea; 52 | application/x-shockwave-flash swf; 53 | application/x-stuffit sit; 54 | application/x-tcl tcl tk; 55 | application/x-x509-ca-cert der pem crt; 56 | application/x-xpinstall xpi; 57 | application/xhtml+xml xhtml; 58 | application/xspf+xml xspf; 59 | application/zip zip; 60 | application/wasm wasm; 61 | 62 | application/octet-stream bin exe dll; 63 | application/octet-stream deb; 64 | application/octet-stream dmg; 65 | application/octet-stream iso img; 66 | application/octet-stream msi msp msm; 67 | 68 | application/vnd.openxmlformats-officedocument.wordprocessingml.document docx; 69 | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx; 70 | application/vnd.openxmlformats-officedocument.presentationml.presentation pptx; 71 | 72 | audio/midi mid midi kar; 73 | audio/mpeg mp3; 74 | audio/ogg ogg; 75 | audio/x-m4a m4a; 76 | audio/x-realaudio ra; 77 | 78 | video/3gpp 3gpp 3gp; 79 | video/mp2t ts; 80 | video/mp4 mp4; 81 | video/mpeg mpeg mpg; 82 | video/quicktime mov; 83 | video/webm webm; 84 | video/x-flv flv; 85 | video/x-m4v m4v; 86 | video/x-mng mng; 87 | video/x-ms-asf asx asf; 88 | video/x-ms-wmv wmv; 89 | video/x-msvideo avi; 90 | } 91 | -------------------------------------------------------------------------------- /volts-front/src/components/navbar.rs: -------------------------------------------------------------------------------- 1 | use gloo_net::http::Request; 2 | use sycamore::{ 3 | component, 4 | prelude::view, 5 | reactive::{create_effect, create_signal, use_context, Scope, Signal}, 6 | view::View, 7 | web::Html, 8 | }; 9 | use volts_core::NewSessionResponse; 10 | 11 | use crate::AppContext; 12 | 13 | #[component] 14 | pub fn Navbar(cx: Scope) -> View { 15 | let context = use_context::>(cx); 16 | let is_logged_in = create_signal(cx, false); 17 | let login = create_signal(cx, "".to_string()); 18 | create_effect(cx, move || { 19 | if let Some(l) = context.get().login.as_ref() { 20 | login.set(l.to_string()); 21 | is_logged_in.set(true); 22 | } else { 23 | is_logged_in.set(false); 24 | } 25 | }); 26 | 27 | let handle_login = move |_| { 28 | let req = Request::get("/api/private/session").send(); 29 | sycamore::futures::spawn_local(async move { 30 | let resp = req.await.unwrap(); 31 | let resp: NewSessionResponse = resp.json().await.unwrap(); 32 | 33 | web_sys::window() 34 | .unwrap() 35 | .location() 36 | .set_href(&resp.url) 37 | .unwrap(); 38 | }); 39 | }; 40 | 41 | let handle_logout = move |_| { 42 | let req = Request::delete("/api/private/session"); 43 | sycamore::futures::spawn_local(async move { 44 | req.send().await.unwrap(); 45 | 46 | web_sys::window().unwrap().location().set_href("/").unwrap(); 47 | }); 48 | }; 49 | 50 | view! { cx, 51 | div(class="relative bg-coolGray-50 overflow-hidden", 52 | style="background-image: linear-gradient(to right top, #4264af, #4f70ba, #5b7dc4, #688acf, #7597d9, #6ca0e0, #63a9e6, #5ab2eb, #2eb9e7, #00bfdd, #00c3cd, #10c6ba);" 53 | ) { 54 | nav(class="flex flex-wrap items-center justify-between container m-auto py-6 px-3") { 55 | div(class="flex items-center"){ 56 | a( 57 | class="flex items-center", 58 | href="/", 59 | ) { 60 | img( 61 | class="h-10 mr-2", 62 | src="https://raw.githubusercontent.com/lapce/lapce/master/extra/images/logo.png", 63 | ) {} 64 | a(class="text-blue-50") { 65 | "Lapce Plugins" 66 | } 67 | } 68 | } 69 | div(class="flex items-center"){ 70 | svg( 71 | class="h-4 w-4 mr-2", 72 | width=512, 73 | height=512, 74 | viewBox="0 0 512 512", 75 | fill="white", 76 | xmlns="http://www.w3.org/2000/svg", 77 | ) { 78 | path( 79 | d="M256 0C114.61 0 0 114.61 0 256c0 113.1 73.345 209.05 175.07 242.91 12.81 2.35 17.48-5.56 17.48-12.35 0-6.06-.22-22.17-.35-43.53-71.21 15.46-86.23-34.32-86.23-34.32-11.645-29.58-28.429-37.45-28.429-37.45-23.244-15.88 1.76-15.56 1.76-15.56 25.699 1.8 39.209 26.38 39.209 26.38 22.84 39.12 59.92 27.82 74.51 21.27 2.32-16.54 8.93-27.82 16.25-34.22-56.84-6.45-116.611-28.43-116.611-126.52 0-27.94 9.981-50.8 26.351-68.7-2.64-6.47-11.42-32.5 2.5-67.74 0 0 21.5-6.889 70.41 26.24 20.41-5.69 42.32-8.52 64.09-8.61 21.73.1 43.64 2.92 64.09 8.61 48.87-33.129 70.32-26.24 70.32-26.24 13.97 35.24 5.19 61.27 2.55 67.74 16.41 17.9 26.32 40.76 26.32 68.7 0 98.35-59.86 119.99-116.89 126.32 9.19 7.91 17.38 23.53 17.38 47.41 0 34.22-.31 61.83-.31 70.22 0 6.85 4.6 14.82 17.6 12.32C438.72 464.96 512 369.08 512 256 512 114.61 397.37 0 255.98 0" 80 | ) {} 81 | } 82 | (if *is_logged_in.get() { 83 | view! { cx, 84 | a( 85 | class="text-blue-50", 86 | href="/account" 87 | ) { (login.get()) } 88 | button( 89 | class="text-blue-50 ml-4", 90 | on:click=handle_logout, 91 | ) { 92 | "logout" 93 | } 94 | } 95 | } else { 96 | view ! { cx, 97 | button( 98 | class="text-blue-50", 99 | on:click=handle_login, 100 | ) { 101 | "login" 102 | } 103 | } 104 | }) 105 | } 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /volts-cli/src/commands.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashSet, 3 | fs::{self, File}, 4 | path::PathBuf, 5 | }; 6 | 7 | use lapce_rpc::plugin::VoltMetadata; 8 | use reqwest::{Method, StatusCode}; 9 | use tar::Builder; 10 | use toml_edit::easy as toml; 11 | use zstd::Encoder; 12 | 13 | use crate::{auth_token, Cli, IconTheme}; 14 | 15 | pub(crate) fn publish(cli: &Cli) { 16 | let token = auth_token(cli); 17 | 18 | let temp_dir = tempfile::tempdir().unwrap(); 19 | let archive_path = temp_dir.path().join("plugin.volt"); 20 | 21 | { 22 | let archive = File::create(&archive_path).unwrap(); 23 | let encoder = Encoder::new(archive, 0).unwrap().auto_finish(); 24 | let mut tar = Builder::new(encoder); 25 | 26 | let volt_path = PathBuf::from("volt.toml"); 27 | if !volt_path.exists() { 28 | eprintln!("volt.toml doesn't exist"); 29 | return; 30 | } 31 | 32 | let s = fs::read_to_string(&volt_path).unwrap(); 33 | let volt: VoltMetadata = match toml::from_str(&s) { 34 | Ok(volt) => volt, 35 | Err(e) => { 36 | eprintln!("volt.toml format invalid: {e}"); 37 | return; 38 | } 39 | }; 40 | 41 | tar.append_path(&volt_path).unwrap(); 42 | 43 | if let Some(wasm) = volt.wasm.as_ref() { 44 | let wasm_path = PathBuf::from(wasm); 45 | if !wasm_path.exists() { 46 | eprintln!("wasm {wasm} not found"); 47 | return; 48 | } 49 | 50 | tar.append_path(&wasm_path).unwrap(); 51 | } else if let Some(themes) = volt.color_themes.as_ref() { 52 | if themes.is_empty() { 53 | eprintln!("no color theme provided"); 54 | return; 55 | } 56 | for theme in themes { 57 | let theme_path = PathBuf::from(theme); 58 | if !theme_path.exists() { 59 | eprintln!("color theme {theme} not found"); 60 | return; 61 | } 62 | 63 | tar.append_path(&theme_path).unwrap(); 64 | } 65 | } else if let Some(themes) = volt.icon_themes.as_ref() { 66 | if themes.is_empty() { 67 | eprintln!("no icon theme provided"); 68 | return; 69 | } 70 | for theme in themes { 71 | let theme_path = PathBuf::from(theme); 72 | if !theme_path.exists() { 73 | eprintln!("icon theme {theme} not found"); 74 | return; 75 | } 76 | 77 | tar.append_path(&theme_path).unwrap(); 78 | 79 | let s = fs::read_to_string(&theme_path).unwrap(); 80 | let theme_config: IconTheme = match toml::from_str(&s) { 81 | Ok(config) => config, 82 | Err(_) => { 83 | eprintln!("icon theme {theme} format invalid"); 84 | return; 85 | } 86 | }; 87 | 88 | let mut icons = HashSet::new(); 89 | icons.extend(theme_config.icon_theme.ui.values()); 90 | icons.extend(theme_config.icon_theme.filename.values()); 91 | icons.extend(theme_config.icon_theme.foldername.values()); 92 | icons.extend(theme_config.icon_theme.extension.values()); 93 | 94 | let cwd = PathBuf::from("."); 95 | 96 | for icon in icons { 97 | let icon_path = theme_path.parent().unwrap_or(&cwd).join(icon); 98 | if !icon_path.exists() { 99 | eprintln!("icon {icon} not found"); 100 | return; 101 | } 102 | tar.append_path(&icon_path).unwrap(); 103 | } 104 | } 105 | } else { 106 | eprintln!("not a valid plugin"); 107 | return; 108 | } 109 | 110 | let readme_path = PathBuf::from("README.md"); 111 | if readme_path.exists() { 112 | tar.append_path(&readme_path).unwrap(); 113 | } 114 | 115 | if let Some(icon) = volt.icon.as_ref() { 116 | let icon_path = PathBuf::from(icon); 117 | if !icon_path.exists() { 118 | eprintln!("icon not found at the specified path"); 119 | return; 120 | } 121 | tar.append_path(&icon_path).unwrap(); 122 | } 123 | tar.finish().unwrap(); 124 | } 125 | 126 | let resp = reqwest::blocking::Client::new() 127 | .request(Method::PUT, "https://plugins.lapce.dev/api/v1/plugins/new") 128 | .bearer_auth(token.trim()) 129 | .body(File::open(&archive_path).unwrap()) 130 | .send() 131 | .unwrap(); 132 | if resp.status() == StatusCode::OK { 133 | println!("plugin published successfully"); 134 | return; 135 | } 136 | 137 | eprintln!("{}", resp.text().unwrap()); 138 | } 139 | 140 | pub(crate) fn yank(cli: &Cli, name: &String, version: &String) { 141 | let token = auth_token(cli); 142 | 143 | let resp = reqwest::blocking::Client::new() 144 | .request( 145 | Method::PUT, 146 | format!("https://plugins.lapce.dev/api/v1/plugins/me/{name}/{version}/yank"), 147 | ) 148 | .bearer_auth(token.trim()) 149 | .send() 150 | .unwrap(); 151 | if resp.status() == StatusCode::OK { 152 | println!("plugin version yanked successfully"); 153 | } else { 154 | eprintln!("failed to yank plugin version: {}", resp.text().unwrap()); 155 | } 156 | } 157 | 158 | pub(crate) fn unyank(cli: &Cli, name: &String, version: &String) { 159 | let token = auth_token(cli); 160 | 161 | let resp = reqwest::blocking::Client::new() 162 | .request( 163 | Method::PUT, 164 | format!("https://plugins.lapce.dev/api/v1/plugins/me/{name}/{version}/unyank"), 165 | ) 166 | .bearer_auth(token.trim()) 167 | .send() 168 | .unwrap(); 169 | if resp.status() == StatusCode::OK { 170 | println!("plugin version yanked successfully"); 171 | } else { 172 | eprintln!("failed to yank plugin version: {}", resp.text().unwrap()); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /volts-back/src/router.rs: -------------------------------------------------------------------------------- 1 | use async_session::{MemoryStore, Session, SessionStore}; 2 | use axum::{ 3 | extract::{Query, State}, 4 | http::{header::SET_COOKIE, HeaderMap, StatusCode}, 5 | response::{IntoResponse, Redirect}, 6 | routing::{delete, get, post, put}, 7 | Json, Router, TypedHeader, 8 | }; 9 | use oauth2::{ 10 | basic::BasicClient, reqwest::async_http_client, AuthorizationCode, Scope, TokenResponse, 11 | }; 12 | use serde::Deserialize; 13 | use volts_core::{db::models::User, MeUser, NewSessionResponse}; 14 | 15 | use crate::{ 16 | db::{find_user, DbPool, NewUser}, 17 | github::GithubClient, 18 | plugin, 19 | state::{AppState, SESSION_COOKIE_NAME}, 20 | token, 21 | }; 22 | 23 | pub fn build_router() -> Router { 24 | let state = AppState::new(); 25 | 26 | let private_routes = Router::with_state(state.clone()) 27 | .route("/session", get(new_session)) 28 | .route("/session", delete(logout)) 29 | .route("/session/authorize", get(session_authorize)); 30 | 31 | let user_routes = Router::with_state(state.clone()) 32 | .route("/", get(me)) 33 | .route("/tokens", get(token::list)) 34 | .route("/tokens", post(token::new)) 35 | .route("/tokens/:id", delete(token::revoke)); 36 | 37 | let plugins_routes = Router::with_state(state.clone()) 38 | .route("/", get(plugin::search)) 39 | .route("/new", put(plugin::publish)) 40 | .route("/me/:name/:version/yank", put(plugin::yank)) 41 | .route("/me/:name/:version/unyank", put(plugin::unyank)) 42 | .route("/:author/:name/:version", get(plugin::meta)) 43 | .route("/:author/:name/:version/download", get(plugin::download)) 44 | .route("/:author/:name/:version/readme", get(plugin::readme)) 45 | .route("/:author/:name/:version/icon", get(plugin::icon)); 46 | 47 | let v1 = Router::with_state(state.clone()) 48 | .nest("/me", user_routes) 49 | .nest("/plugins", plugins_routes); 50 | 51 | let api = Router::with_state(state.clone()) 52 | .nest("/private", private_routes) 53 | .nest("/v1", v1); 54 | 55 | Router::with_state(state).nest("/api", api) 56 | } 57 | 58 | async fn me( 59 | State(store): State, 60 | State(db_pool): State, 61 | TypedHeader(cookies): TypedHeader, 62 | ) -> impl IntoResponse { 63 | let cookie = cookies.get(SESSION_COOKIE_NAME).unwrap(); 64 | let session = store 65 | .load_session(cookie.to_string()) 66 | .await 67 | .unwrap() 68 | .unwrap(); 69 | let user_id: i32 = session.get("user_id").unwrap(); 70 | let mut conn = db_pool.read.get().await.unwrap(); 71 | let user = find_user(&mut conn, user_id).await.unwrap(); 72 | Json(MeUser { 73 | login: user.gh_login, 74 | }) 75 | } 76 | 77 | async fn new_session( 78 | State(store): State, 79 | State(github_oauth): State, 80 | ) -> impl IntoResponse { 81 | let (url, state) = github_oauth 82 | .authorize_url(oauth2::CsrfToken::new_random) 83 | .add_scope(Scope::new("read:user".to_string())) 84 | .url(); 85 | let state = state.secret().to_string(); 86 | 87 | let mut session = Session::new(); 88 | let _ = session.insert("github_oauth_state", state.clone()); 89 | let cookie = store.store_session(session).await.unwrap().unwrap(); 90 | let cookie = format!("{SESSION_COOKIE_NAME}={cookie}; Path=/"); 91 | 92 | let mut headers = HeaderMap::new(); 93 | headers.insert(SET_COOKIE, cookie.parse().unwrap()); 94 | 95 | ( 96 | headers, 97 | Json(NewSessionResponse { 98 | url: url.as_str().to_string(), 99 | state, 100 | }), 101 | ) 102 | } 103 | 104 | #[derive(Debug, Deserialize)] 105 | struct AuthRequest { 106 | code: String, 107 | state: String, 108 | } 109 | 110 | async fn session_authorize( 111 | Query(query): Query, 112 | State(store): State, 113 | State(github_oauth): State, 114 | State(github_client): State, 115 | State(db_pool): State, 116 | TypedHeader(cookies): TypedHeader, 117 | ) -> impl IntoResponse { 118 | let cookie = cookies.get(SESSION_COOKIE_NAME).unwrap(); 119 | let mut session = store 120 | .load_session(cookie.to_string()) 121 | .await 122 | .unwrap() 123 | .unwrap(); 124 | let session_state = session.get("github_oauth_state"); 125 | println!("session state is {session_state:?}"); 126 | session.remove("github_oauth_state"); 127 | if session_state != Some(query.state) { 128 | return (StatusCode::BAD_REQUEST, "invalid state parameter").into_response(); 129 | } 130 | 131 | // Fetch the access token from GitHub using the code we just got 132 | let code = AuthorizationCode::new(query.code); 133 | let token = github_oauth 134 | .exchange_code(code) 135 | .request_async(async_http_client) 136 | .await 137 | .unwrap(); 138 | let token = token.access_token(); 139 | 140 | let ghuser = github_client.current_user(token).await.unwrap(); 141 | 142 | let mut conn = db_pool.write.get().await.unwrap(); 143 | 144 | let user = NewUser::new(ghuser.id, &ghuser.login, token.secret()) 145 | .create_or_update(&mut conn) 146 | .await 147 | .unwrap(); 148 | 149 | session.insert("user_id", user.id).unwrap(); 150 | 151 | println!("redirect to home page"); 152 | Redirect::temporary("/account/").into_response() 153 | } 154 | 155 | async fn logout( 156 | State(store): State, 157 | TypedHeader(cookies): TypedHeader, 158 | ) -> impl IntoResponse { 159 | let cookie = cookies.get(SESSION_COOKIE_NAME).unwrap(); 160 | let mut session = store 161 | .load_session(cookie.to_string()) 162 | .await 163 | .unwrap() 164 | .unwrap(); 165 | session.remove("user_id"); 166 | } 167 | 168 | pub async fn authenticated_user( 169 | State(store): State, 170 | State(db_pool): State, 171 | TypedHeader(cookies): TypedHeader, 172 | ) -> Option { 173 | let cookie = cookies.get(SESSION_COOKIE_NAME)?; 174 | let session = store.load_session(cookie.to_string()).await.ok()??; 175 | let user_id: i32 = session.get("user_id")?; 176 | let mut conn = db_pool.read.get().await.ok()?; 177 | find_user(&mut conn, user_id).await.ok() 178 | } 179 | -------------------------------------------------------------------------------- /volts-back/src/db.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use anyhow::Result; 4 | use diesel::BelongingToDsl; 5 | use diesel::ExpressionMethods; 6 | use diesel::NullableExpressionMethods; 7 | use diesel::QueryDsl; 8 | use diesel_async::RunQueryDsl; 9 | use diesel_async::{pooled_connection::deadpool::Pool, AsyncPgConnection}; 10 | use volts_core::db::models::Plugin; 11 | use volts_core::db::models::{ApiToken, User, Version}; 12 | use volts_core::db::schema::{api_tokens, plugins, users, versions}; 13 | use volts_core::EncodeApiToken; 14 | 15 | #[derive(Clone)] 16 | pub struct DbPool { 17 | pub write: Pool, 18 | pub read: Pool, 19 | } 20 | 21 | impl Default for DbPool { 22 | fn default() -> Self { 23 | Self::new() 24 | } 25 | } 26 | 27 | impl DbPool { 28 | pub fn new() -> Self { 29 | let manager = diesel_async::pooled_connection::AsyncDieselConnectionManager::new( 30 | std::env::var("DATABASE_URL").unwrap(), 31 | ); 32 | let write = Pool::builder(manager).build().unwrap(); 33 | 34 | let manager = diesel_async::pooled_connection::AsyncDieselConnectionManager::new( 35 | std::env::var("READ_DATABASE_URL") 36 | .unwrap_or_else(|_| std::env::var("DATABASE_URL").unwrap()), 37 | ); 38 | let read = Pool::builder(manager).build().unwrap(); 39 | 40 | Self { write, read } 41 | } 42 | } 43 | 44 | /// Represents a new user record insertable to the `users` table 45 | #[derive(Insertable, Debug, Default)] 46 | #[diesel(table_name = users)] 47 | pub struct NewUser<'a> { 48 | pub gh_id: i32, 49 | pub gh_login: &'a str, 50 | pub gh_access_token: Cow<'a, str>, 51 | } 52 | 53 | impl<'a> NewUser<'a> { 54 | pub fn new(gh_id: i32, gh_login: &'a str, gh_access_token: &'a str) -> Self { 55 | NewUser { 56 | gh_id, 57 | gh_login, 58 | gh_access_token: Cow::Borrowed(gh_access_token), 59 | } 60 | } 61 | 62 | /// Inserts the user into the database, or updates an existing one. 63 | pub async fn create_or_update(&self, conn: &mut AsyncPgConnection) -> Result { 64 | use diesel::pg::upsert::excluded; 65 | use volts_core::db::schema::users::dsl::*; 66 | 67 | let user: User = diesel::insert_into(users) 68 | .values(self) 69 | .on_conflict(gh_id) 70 | .do_update() 71 | .set(( 72 | gh_login.eq(excluded(gh_login)), 73 | gh_access_token.eq(excluded(gh_access_token)), 74 | )) 75 | .get_result(conn) 76 | .await?; 77 | Ok(user) 78 | } 79 | } 80 | 81 | pub async fn find_user(conn: &mut AsyncPgConnection, id: i32) -> Result { 82 | let user = users::table.find(id).first(conn).await?; 83 | Ok(user) 84 | } 85 | 86 | pub async fn find_user_by_gh_login(conn: &mut AsyncPgConnection, gh_login: &str) -> Result { 87 | let user = users::table 88 | .filter(users::gh_login.eq(gh_login)) 89 | .first(conn) 90 | .await?; 91 | Ok(user) 92 | } 93 | 94 | pub async fn list_tokens(conn: &mut AsyncPgConnection, user: &User) -> Result> { 95 | let tokens: Vec = ApiToken::belonging_to(&user) 96 | .filter(api_tokens::revoked.eq(false)) 97 | .order(api_tokens::created_at.desc()) 98 | .load(conn) 99 | .await?; 100 | Ok(tokens) 101 | } 102 | 103 | pub async fn insert_token( 104 | conn: &mut AsyncPgConnection, 105 | user: &User, 106 | name: &str, 107 | ) -> Result { 108 | let token = crate::util::SecureToken::new_token(); 109 | 110 | let model: ApiToken = diesel::insert_into(api_tokens::table) 111 | .values(( 112 | api_tokens::user_id.eq(user.id), 113 | api_tokens::name.eq(name), 114 | api_tokens::token.eq(token.token()), 115 | )) 116 | .get_result(conn) 117 | .await?; 118 | 119 | Ok(EncodeApiToken { 120 | token: model, 121 | plaintext: token.plaintext().into(), 122 | }) 123 | } 124 | 125 | pub async fn revoke_token(conn: &mut AsyncPgConnection, user: &User, id: i32) -> Result<()> { 126 | diesel::update(ApiToken::belonging_to(&user).find(id)) 127 | .set(api_tokens::revoked.eq(true)) 128 | .execute(conn) 129 | .await?; 130 | Ok(()) 131 | } 132 | 133 | pub async fn find_api_token(conn: &mut AsyncPgConnection, api_token: &str) -> Result { 134 | use diesel::{dsl::now, update}; 135 | use volts_core::db::schema::api_tokens::dsl::*; 136 | 137 | let token_ = crate::util::SecureToken::parse(api_token); 138 | 139 | let tokens = api_tokens 140 | .filter(revoked.eq(false)) 141 | .filter(token.eq(token_.token())); 142 | 143 | let token_ = update(tokens) 144 | .set(last_used_at.eq(now.nullable())) 145 | .get_result(conn) 146 | .await?; 147 | 148 | Ok(token_) 149 | } 150 | 151 | #[derive(Insertable, Debug, Default)] 152 | #[diesel(table_name = plugins)] 153 | pub struct NewPlugin<'a> { 154 | pub name: &'a str, 155 | pub user_id: i32, 156 | pub display_name: &'a str, 157 | pub description: &'a str, 158 | pub repository: Option<&'a str>, 159 | pub downloads: i32, 160 | pub wasm: bool, 161 | } 162 | 163 | impl<'a> NewPlugin<'a> { 164 | pub fn new( 165 | name: &'a str, 166 | user_id: i32, 167 | display_name: &'a str, 168 | description: &'a str, 169 | repository: Option<&'a str>, 170 | wasm: bool, 171 | ) -> Self { 172 | NewPlugin { 173 | name, 174 | user_id, 175 | display_name, 176 | description, 177 | downloads: 0, 178 | repository, 179 | wasm, 180 | } 181 | } 182 | 183 | pub async fn create_or_update(&self, conn: &mut AsyncPgConnection) -> Result { 184 | use diesel::pg::upsert::excluded; 185 | use volts_core::db::schema::plugins::dsl::*; 186 | 187 | let plugin: Plugin = diesel::insert_into(plugins) 188 | .values(self) 189 | .on_conflict((user_id, name)) 190 | .do_update() 191 | .set(( 192 | display_name.eq(excluded(display_name)), 193 | description.eq(excluded(description)), 194 | repository.eq(excluded(repository)), 195 | wasm.eq(excluded(wasm)), 196 | updated_at.eq(chrono::Utc::now().naive_utc()), 197 | )) 198 | .get_result(conn) 199 | .await?; 200 | Ok(plugin) 201 | } 202 | } 203 | 204 | #[derive(Insertable, Debug, Default)] 205 | #[diesel(table_name = versions)] 206 | pub struct NewVersion<'a> { 207 | pub plugin_id: i32, 208 | pub num: &'a str, 209 | pub yanked: bool, 210 | } 211 | 212 | impl<'a> NewVersion<'a> { 213 | pub fn new(plugin_id: i32, num: &'a str) -> Self { 214 | NewVersion { 215 | plugin_id, 216 | num, 217 | yanked: false, 218 | } 219 | } 220 | 221 | pub async fn create_or_update(&self, conn: &mut AsyncPgConnection) -> Result { 222 | use volts_core::db::schema::versions::dsl::*; 223 | 224 | let version: Version = diesel::insert_into(versions) 225 | .values(self) 226 | .on_conflict((plugin_id, num)) 227 | .do_update() 228 | .set(updated_at.eq(chrono::Utc::now().naive_utc())) 229 | .get_result(conn) 230 | .await?; 231 | Ok(version) 232 | } 233 | } 234 | 235 | pub async fn find_plugin(conn: &mut AsyncPgConnection, user: &User, name: &str) -> Result { 236 | let plugin = Plugin::belonging_to(user) 237 | .filter(plugins::name.eq(name)) 238 | .get_result(conn) 239 | .await?; 240 | Ok(plugin) 241 | } 242 | 243 | pub async fn find_plugin_version( 244 | conn: &mut AsyncPgConnection, 245 | plugin: &Plugin, 246 | num: &str, 247 | ) -> Result { 248 | let version = Version::belonging_to(plugin) 249 | .filter(versions::num.eq(num)) 250 | .get_result(conn) 251 | .await?; 252 | Ok(version) 253 | } 254 | 255 | pub async fn modify_plugin_version_yank( 256 | conn: &mut AsyncPgConnection, 257 | plugin: &Plugin, 258 | num: &str, 259 | is_yanked: bool, 260 | ) -> Result { 261 | let version = diesel::update(Version::belonging_to(plugin).filter(versions::num.eq(num))) 262 | .set(( 263 | versions::yanked.eq(is_yanked), 264 | versions::updated_at.eq(chrono::Utc::now().naive_utc()), 265 | )) 266 | .get_result(conn) 267 | .await?; 268 | Ok(version) 269 | } 270 | -------------------------------------------------------------------------------- /volts-front/src/components/token.rs: -------------------------------------------------------------------------------- 1 | use gloo_net::http::Request; 2 | use sycamore::{ 3 | component, 4 | prelude::{view, Keyed}, 5 | reactive::{create_effect, create_selector, create_signal, use_context, Scope, Signal}, 6 | view::View, 7 | web::Html, 8 | }; 9 | use volts_core::{db::models::ApiToken, ApiTokenList, EncodeApiToken, NewTokenPayload}; 10 | use wasm_bindgen::JsCast; 11 | use web_sys::{Event, HtmlInputElement}; 12 | 13 | use crate::AppContext; 14 | 15 | #[component(inline_props)] 16 | fn TokenItem<'a, G: Html>( 17 | cx: Scope<'a>, 18 | token: IndexedApiToken, 19 | tokens: &'a Signal>, 20 | ) -> View { 21 | let revoking = create_signal(cx, false); 22 | let handle_revoke_token = move |_| { 23 | let req = Request::delete(&format!("/api/v1/me/tokens/{}", token.token.id)); 24 | sycamore::futures::spawn_local_scoped(cx, async move { 25 | req.send().await.unwrap(); 26 | 27 | let mut new_tokens = (*tokens.get()).clone(); 28 | if let Some(i) = new_tokens.iter().position(|t| t.token.id == token.token.id) { 29 | new_tokens.remove(i); 30 | } 31 | tokens.set(new_tokens); 32 | }); 33 | revoking.set(true); 34 | }; 35 | view! { cx, 36 | li( 37 | class=( 38 | if token.last { 39 | "p-5" 40 | } else { 41 | "p-5 border-b" 42 | } 43 | ), 44 | ) { 45 | div(class="flex justify-between items-center") { 46 | p { 47 | (token.token.name) 48 | } 49 | (if *create_selector(cx, || *revoking.get()).get() { 50 | view! {cx, 51 | p(class="rounded-md p-2 border shadow") {"Revoking"} 52 | } 53 | } else { 54 | view! {cx, 55 | button( 56 | class="rounded-md p-2 border shadow", 57 | on:click=handle_revoke_token, 58 | ) { 59 | "Revoke" 60 | } 61 | } 62 | }) 63 | } 64 | (if let Some(text) = token.plaintext.clone() { 65 | view!{cx, 66 | p(class="text-lg") { 67 | "Make sure to copy your API token now. You won’t be able to see it again!" 68 | } 69 | p( 70 | class="text-lg mt-2 p-4 rounded-md bg-gray-500 text-blue-50" 71 | ) { 72 | (text) 73 | } 74 | } 75 | } else { 76 | view!{cx,} 77 | }) 78 | } 79 | } 80 | } 81 | 82 | #[derive(PartialEq, Eq, Clone)] 83 | struct IndexedApiToken { 84 | token: ApiToken, 85 | last: bool, 86 | plaintext: Option, 87 | } 88 | 89 | fn get_api_tokens<'a>(cx: Scope<'a>, api_tokens: &'a Signal>) { 90 | let req = Request::get("/api/v1/me/tokens").send(); 91 | sycamore::futures::spawn_local_scoped(cx, async move { 92 | let resp = req.await.unwrap(); 93 | let tokens: ApiTokenList = resp.json().await.unwrap(); 94 | let len = tokens.api_tokens.len(); 95 | let tokens = tokens 96 | .api_tokens 97 | .into_iter() 98 | .enumerate() 99 | .map(|(i, token)| IndexedApiToken { 100 | token, 101 | last: i + 1 == len, 102 | plaintext: None, 103 | }) 104 | .collect(); 105 | api_tokens.set(tokens); 106 | }); 107 | } 108 | 109 | #[component] 110 | pub fn TokenList(cx: Scope) -> View { 111 | let ctx = use_context::>(cx); 112 | let creating = create_signal(cx, false); 113 | 114 | let new_token_name = create_signal(cx, None); 115 | let handle_new_token = move |_| { 116 | new_token_name.set(Some("".to_string())); 117 | creating.set(false); 118 | }; 119 | 120 | let tokens = create_signal(cx, Vec::new()); 121 | get_api_tokens(cx, tokens); 122 | 123 | let is_logged_in = create_signal(cx, false); 124 | 125 | create_effect(cx, move || { 126 | if ctx.get().login.is_some() { 127 | is_logged_in.set(true); 128 | get_api_tokens(cx, tokens); 129 | } else { 130 | is_logged_in.set(false); 131 | } 132 | }); 133 | 134 | let is_new_token_show = create_selector(cx, || new_token_name.get().is_some()); 135 | 136 | let handle_input = move |event: Event| { 137 | let target: HtmlInputElement = event.target().unwrap().unchecked_into(); 138 | new_token_name.set(Some(target.value())); 139 | }; 140 | 141 | let handle_create_token = move |_| { 142 | if let Some(name) = new_token_name.get().as_ref() { 143 | let req = Request::post("/api/v1/me/tokens") 144 | .json(&NewTokenPayload { 145 | name: name.to_string(), 146 | }) 147 | .unwrap(); 148 | sycamore::futures::spawn_local_scoped(cx, async move { 149 | let resp: EncodeApiToken = req.send().await.unwrap().json().await.unwrap(); 150 | let mut new_tokens = vec![IndexedApiToken { 151 | token: resp.token, 152 | plaintext: Some(resp.plaintext), 153 | last: false, 154 | }]; 155 | new_tokens.extend((*tokens.get()).clone().into_iter()); 156 | tokens.set(new_tokens); 157 | creating.set(false); 158 | new_token_name.set(None); 159 | }); 160 | } 161 | creating.set(true); 162 | }; 163 | 164 | view! { cx, 165 | (if !*is_logged_in.get() { 166 | view! {cx, } 167 | } else { 168 | view! {cx, 169 | div(class="container m-auto px-3") { 170 | div(class="mt-5") { 171 | h1(class="flex justify-between") { 172 | span { "API Tokens" } 173 | button( 174 | class=( 175 | if *is_new_token_show.get() { 176 | "p-2 text-center text-blue-50 bg-blue-500 rounded-md shadow disabled:bg-gray-500" 177 | } else { 178 | "p-2 text-center text-blue-50 bg-blue-500 rounded-md shadow" 179 | } 180 | ), 181 | disabled=*is_new_token_show.get(), 182 | on:click=handle_new_token, 183 | ) { 184 | "New Token" 185 | } 186 | } 187 | p(class="py-1") { 188 | "You can use the API tokens generated on this page to manage your plugins." 189 | } 190 | p(class="py-1") { 191 | "They are stored in hashed form, so you can only download keys when you first create them." 192 | } 193 | } 194 | (if *is_new_token_show.get() { 195 | view! { cx, 196 | div(class="flex mt-5") { 197 | input( 198 | class="p-2 border rounded-md w-full", 199 | placeholder="New token name", 200 | disabled=*creating.get(), 201 | prop:value=(*new_token_name.get()).clone().unwrap_or_else(|| "".to_string()), 202 | on:input=handle_input, 203 | ) {} 204 | button( 205 | class=( 206 | if *creating.get() { 207 | "ml-6 p-2 w-20 text-blue-50 bg-blue-500 rounded-md shadow disabled:bg-gray-500" 208 | } else { 209 | "ml-6 p-2 w-20 text-blue-50 bg-blue-500 rounded-md shadow" 210 | } 211 | ), 212 | on:click=handle_create_token, 213 | disabled=*creating.get(), 214 | ) { 215 | (if *creating.get() { 216 | "Creating" 217 | } else { 218 | "Create" 219 | }) 220 | } 221 | } 222 | } 223 | } else { 224 | view! { cx, 225 | } 226 | }) 227 | ul(class="my-5 border rounded-md") { 228 | Keyed( 229 | iterable=tokens, 230 | view=move |cx, token| view! {cx, 231 | TokenItem(token=token, tokens=tokens) 232 | }, 233 | key=|token| token.token.id, 234 | ) 235 | } 236 | } 237 | } 238 | }) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /volts-front/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './**/*', 4 | ], 5 | 6 | theme: { 7 | screens: { 8 | sm: '640px', 9 | md: '768px', 10 | lg: '1024px', 11 | xl: '1170px', 12 | '2xl': '1170px', 13 | }, 14 | colors: { 15 | current: 'currentColor', 16 | transparent: 'transparent', 17 | 18 | black: '#000', 19 | white: '#fff', 20 | 21 | darkCoolGray: { 22 | 50: '#F5F6F7', 23 | 100: '#EBEDEF', 24 | 200: '#CED1D6', 25 | 300: '#B0B5BD', 26 | 400: '#757E8C', 27 | 500: '#3A475B', 28 | 600: '#344052', 29 | 700: '#2C3544', 30 | 800: '#232B37', 31 | 900: '#1C232D', 32 | }, 33 | coolGray: { 34 | 50: '#F7F8F9', 35 | 100: '#EEF0F3', 36 | 200: '#D5DAE1', 37 | 300: '#BBC3CF', 38 | 400: '#8896AB', 39 | 500: '#556987', 40 | 600: '#4D5F7A', 41 | 700: '#404F65', 42 | 800: '#333F51', 43 | 900: '#2A3342', 44 | }, 45 | indigo: { 46 | 50: '#F8F6FF', 47 | 100: '#F0EEFF', 48 | 200: '#DAD4FF', 49 | 300: '#C3B9FF', 50 | 400: '#9685FF', 51 | 500: '#6951FF', 52 | 600: '#5F49E6', 53 | 700: '#4F3DBF', 54 | 800: '#3F3199', 55 | 900: '#33287D', 56 | }, 57 | violet: { 58 | 50: '#FBF7FF', 59 | 100: '#F6EEFE', 60 | 200: '#E9D5FD', 61 | 300: '#DCBBFC', 62 | 400: '#C288F9', 63 | 500: '#A855F7', 64 | 600: '#974DDE', 65 | 700: '#7E40B9', 66 | 800: '#653394', 67 | 900: '#522A79', 68 | }, 69 | yellow: { 70 | 50: '#FFFAF3', 71 | 100: '#FEF5E7', 72 | 200: '#FDE7C2', 73 | 300: '#FBD89D', 74 | 400: '#F8BB54', 75 | 500: '#F59E0B', 76 | 600: '#DD8E0A', 77 | 700: '#B87708', 78 | 800: '#935F07', 79 | 900: '#784D05', 80 | }, 81 | red: { 82 | 50: '#FEF7F6', 83 | 100: '#FDEEEC', 84 | 200: '#FBD6D0', 85 | 300: '#F9BDB4', 86 | 400: '#F48B7C', 87 | 500: '#EF5844', 88 | 600: '#D7503D', 89 | 700: '#B34333', 90 | 800: '#8F3529', 91 | 900: '#752C21', 92 | }, 93 | green: { 94 | 50: '#F4FDF7', 95 | 100: '#EAFAF0', 96 | 200: '#CAF4D9', 97 | 300: '#AAEDC3', 98 | 400: '#6ADF95', 99 | 500: '#2AD167', 100 | 600: '#26BC5E', 101 | 700: '#209D4E', 102 | 800: '#197D3E', 103 | 900: '#156633', 104 | }, 105 | blue: { 106 | 50: '#F5F9FF', 107 | 100: '#EBF3FE', 108 | 200: '#CEE0FD', 109 | 300: '#B1CDFB', 110 | 400: '#76A8F9', 111 | 500: '#3B82F6', 112 | 600: '#3575DD', 113 | 700: '#2C62B9', 114 | 800: '#234E94', 115 | 900: '#1D4079', 116 | }, 117 | gray: { 118 | 50: '#f9fafb', 119 | 100: '#f3f4f6', 120 | 200: '#e5e7eb', 121 | 300: '#d1d5db', 122 | 400: '#9ca3af', 123 | 500: '#6b7280', 124 | 600: '#4b5563', 125 | 700: '#374151', 126 | 800: '#1f2937', 127 | 900: '#111827', 128 | }, 129 | }, 130 | spacing: { 131 | px: '1px', 132 | px: '1px', 133 | '0': '0px', 134 | '0.5': '0.125rem', 135 | '1': '0.25rem', 136 | '1.5': '0.375rem', 137 | '2': '0.5rem', 138 | '2.5': '0.625rem', 139 | '3': '0.75rem', 140 | '3.5': '0.875rem', 141 | '4': '1rem', 142 | '5': '1.25rem', 143 | '6': '1.5rem', 144 | '7': '1.75rem', 145 | '8': '2rem', 146 | '9': '2.25rem', 147 | '10': '2.5rem', 148 | '11': '2.75rem', 149 | '12': '3rem', 150 | '14': '3.5rem', 151 | '16': '4rem', 152 | '20': '5rem', 153 | '24': '6rem', 154 | '28': '7rem', 155 | '32': '8rem', 156 | '36': '9rem', 157 | '40': '10rem', 158 | '44': '11rem', 159 | '48': '12rem', 160 | '52': '13rem', 161 | '56': '14rem', 162 | '60': '15rem', 163 | '64': '16rem', 164 | '72': '18rem', 165 | '80': '20rem', 166 | '96': '24rem' 167 | }, 168 | backgroundColor: theme => ({ 169 | ...theme('colors'), 170 | body: '#fff', 171 | }), 172 | backgroundPosition: { 173 | bottom: 'bottom', 174 | center: 'center', 175 | left: 'left', 176 | 'left-bottom': 'left bottom', 177 | 'left-top': 'left top', 178 | right: 'right', 179 | 'right-bottom': 'right bottom', 180 | 'right-top': 'right top', 181 | top: 'top', 182 | }, 183 | backgroundSize: { 184 | auto: 'auto', 185 | cover: 'cover', 186 | contain: 'contain', 187 | }, 188 | borderColor: theme => ({ 189 | ...theme('colors'), 190 | DEFAULT: '#e5e7eb', 191 | }), 192 | borderRadius: { 193 | none: '0', 194 | sm: '0.125rem', 195 | DEFAULT: '0.25rem', 196 | md: '0.375rem', 197 | lg: '0.5rem', 198 | xl: '0.675rem', 199 | '2xl': '0.75rem', 200 | '3xl': '0.875rem', 201 | '4xl': '1rem', 202 | '5xl': '1.25rem', 203 | '6xl': '1.375rem', 204 | '7xl': '1.5rem', 205 | '8xl': '2rem', 206 | '9xl': '2.25rem', 207 | '10xl': '2.5rem', 208 | '11xl': '5rem', 209 | full: '9999px', 210 | }, 211 | borderWidth: { 212 | DEFAULT: '1px', 213 | '0': '0', 214 | '2': '2px', 215 | '4': '4px', 216 | '8': '8px', 217 | }, 218 | boxShadow: { 219 | sm: '0 1px 2px 0 rgba(105, 81, 255, 0.05)', 220 | DEFAULT: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)', 221 | md: '0 1px 2px 0 rgba(85, 105, 135, 0.1)', 222 | lg: '0px 1px 3px rgba(42, 51, 66, 0.06)', 223 | xl: '10px 14px 34px rgba(0, 0, 0, 0.04)', 224 | '2xl': '0px 32px 64px -12px rgba(85, 105, 135, 0.08)', 225 | '3xl': '0px 24px 48px -12px rgba(42, 51, 66, 0.06)', 226 | inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)', 227 | none: 'none', 228 | }, 229 | container: {}, 230 | cursor: { 231 | auto: 'auto', 232 | DEFAULT: 'default', 233 | pointer: 'pointer', 234 | wait: 'wait', 235 | text: 'text', 236 | move: 'move', 237 | 'not-allowed': 'not-allowed', 238 | }, 239 | fill: { 240 | current: 'currentColor', 241 | }, 242 | flex: { 243 | '1': '1 1 0%', 244 | auto: '1 1 auto', 245 | initial: '0 1 auto', 246 | none: 'none', 247 | }, 248 | flexGrow: { 249 | '0': '0', 250 | DEFAULT: '1', 251 | }, 252 | flexShrink: { 253 | '0': '0', 254 | DEFAULT: '1', 255 | }, 256 | fontFamily: { 257 | body: '"Poppins", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"', 258 | heading: '"Poppins", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"', 259 | sans: '"Poppins", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"', 260 | serif: 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif', 261 | mono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', 262 | }, 263 | fontSize: { 264 | xxs: '0.6875rem', 265 | xs: '0.75rem', 266 | sm: '0.875rem', 267 | base: '1rem', 268 | lg: '1.125rem', 269 | xl: '1.25rem', 270 | '2xl': '1.5rem', 271 | '3xl': '1.875rem', 272 | '4xl': '2.25rem', 273 | '5xl': '3rem', 274 | '6xl': '3.75rem', 275 | '7xl': '4.5rem', 276 | '8xl': '6rem', 277 | '9xl': '8rem' 278 | }, 279 | fontWeight: { 280 | hairline: '100', 281 | thin: '200', 282 | light: '300', 283 | normal: '400', 284 | medium: '500', 285 | semibold: '600', 286 | bold: '700', 287 | extrabold: '800', 288 | black: '900', 289 | }, 290 | height: theme => ({ 291 | auto: 'auto', 292 | ...theme('spacing'), 293 | full: '100%', 294 | screen: '100vh', 295 | }), 296 | inset: (theme, { negative }) => ({ 297 | auto: 'auto', 298 | ...theme('spacing'), 299 | ...negative(theme('spacing')), 300 | '1/2': '50%', 301 | '1/3': '33.333333%', 302 | '2/3': '66.666667%', 303 | '1/4': '25%', 304 | '2/4': '50%', 305 | '3/4': '75%', 306 | full: '100%', 307 | '-1/2': '-50%', 308 | '-1/3': '-33.333333%', 309 | '-2/3': '-66.666667%', 310 | '-1/4': '-25%', 311 | '-2/4': '-50%', 312 | '-3/4': '-75%', 313 | '-full': '-100%', 314 | }), 315 | letterSpacing: { 316 | tighter: '-0.02em', 317 | tight: '-1px', 318 | normal: '0em', 319 | wide: '0.03em', 320 | wider: '0.08em', 321 | widest: '0.1em', 322 | }, 323 | lineHeight: { 324 | none: '1', 325 | tight: '1.25', 326 | snug: '1.375', 327 | normal: '1.5', 328 | relaxed: '1.625', 329 | loose: '2', 330 | '3': '.75rem', 331 | '4': '1rem', 332 | '5': '1.25rem', 333 | '6': '1.5rem', 334 | '7': '1.75rem', 335 | '8': '2rem', 336 | '9': '2.25rem', 337 | '10': '2.5rem', 338 | }, 339 | listStyleType: { 340 | none: 'none', 341 | disc: 'disc', 342 | decimal: 'decimal', 343 | }, 344 | margin: (theme, { negative }) => ({ 345 | auto: 'auto', 346 | ...theme('spacing'), 347 | ...negative(theme('spacing')), 348 | }), 349 | maxHeight: { 350 | full: '100%', 351 | screen: '100vh', 352 | }, 353 | maxWidth: { 354 | none: 'none', 355 | xs: '20rem', 356 | sm: '24rem', 357 | md: '28rem', 358 | lg: '32rem', 359 | xl: '36rem', 360 | '2xl': '42rem', 361 | '3xl': '48rem', 362 | '4xl': '56rem', 363 | '5xl': '64rem', 364 | '6xl': '72rem', 365 | '7xl': '80rem', 366 | full: '100%', 367 | min: 'min-content', 368 | max: 'max-content', 369 | prose: '65ch', 370 | }, 371 | minHeight: { 372 | '0': '0', 373 | full: '100%', 374 | screen: '100vh', 375 | }, 376 | minWidth: { 377 | '0': '0', 378 | full: '100%', 379 | }, 380 | objectPosition: { 381 | bottom: 'bottom', 382 | center: 'center', 383 | left: 'left', 384 | 'left-bottom': 'left bottom', 385 | 'left-top': 'left top', 386 | right: 'right', 387 | 'right-bottom': 'right bottom', 388 | 'right-top': 'right top', 389 | top: 'top', 390 | }, 391 | opacity: { 392 | '0': '0', 393 | '5': '0.05', 394 | '10': '0.1', 395 | '20': '0.2', 396 | '25': '0.25', 397 | '30': '0.3', 398 | '40': '0.4', 399 | '50': '0.5', 400 | '60': '0.6', 401 | '70': '0.7', 402 | '75': '0.75', 403 | '80': '0.8', 404 | '90': '0.9', 405 | '95': '0.95', 406 | '100': '1', 407 | }, 408 | order: { 409 | first: '-9999', 410 | last: '9999', 411 | none: '0', 412 | '1': '1', 413 | '2': '2', 414 | '3': '3', 415 | '4': '4', 416 | '5': '5', 417 | '6': '6', 418 | '7': '7', 419 | '8': '8', 420 | '9': '9', 421 | '10': '10', 422 | '11': '11', 423 | '12': '12', 424 | }, 425 | padding: theme => theme('spacing'), 426 | placeholderColor: theme => theme('colors'), 427 | stroke: { 428 | current: 'currentColor', 429 | }, 430 | textColor: theme => ({ 431 | ...theme('colors'), 432 | body: '#2A3342', 433 | }), 434 | width: theme => ({ 435 | auto: 'auto', 436 | ...theme('spacing'), 437 | '1/2': '50%', 438 | '1/3': '33.333333%', 439 | '2/3': '66.666667%', 440 | '1/4': '25%', 441 | '2/4': '50%', 442 | '3/4': '75%', 443 | '1/5': '20%', 444 | '2/5': '40%', 445 | '3/5': '60%', 446 | '4/5': '80%', 447 | '1/6': '16.666667%', 448 | '2/6': '33.333333%', 449 | '3/6': '50%', 450 | '4/6': '66.666667%', 451 | '5/6': '83.333333%', 452 | '1/12': '8.333333%', 453 | '2/12': '16.666667%', 454 | '3/12': '25%', 455 | '4/12': '33.333333%', 456 | '5/12': '41.666667%', 457 | '6/12': '50%', 458 | '7/12': '58.333333%', 459 | '8/12': '66.666667%', 460 | '9/12': '75%', 461 | '10/12': '83.333333%', 462 | '11/12': '91.666667%', 463 | full: '100%', 464 | screen: '100vw', 465 | }), 466 | zIndex: { 467 | auto: 'auto', 468 | '0': '0', 469 | '10': '10', 470 | '20': '20', 471 | '30': '30', 472 | '40': '40', 473 | '50': '50', 474 | }, 475 | }, 476 | variants: { 477 | accessibility: ['responsive', 'focus-within', 'focus'], 478 | alignContent: ['responsive'], 479 | alignItems: ['responsive'], 480 | alignSelf: ['responsive'], 481 | animation: ['responsive'], 482 | appearance: ['responsive'], 483 | backgroundAttachment: ['responsive'], 484 | backgroundClip: ['responsive'], 485 | backgroundColor: ['responsive', 'dark', 'group-hover', 'focus-within', 'hover', 'focus'], 486 | backgroundImage: ['responsive'], 487 | backgroundOpacity: ['responsive', 'group-hover', 'focus-within', 'hover', 'focus'], 488 | backgroundPosition: ['responsive'], 489 | backgroundRepeat: ['responsive'], 490 | backgroundSize: ['responsive'], 491 | borderCollapse: ['responsive'], 492 | borderColor: ['responsive', 'dark', 'group-hover', 'focus-within', 'hover', 'focus'], 493 | borderOpacity: ['responsive', 'group-hover', 'focus-within', 'hover', 'focus'], 494 | borderRadius: ['responsive'], 495 | borderStyle: ['responsive'], 496 | borderWidth: ['responsive'], 497 | boxShadow: ['responsive', 'group-hover', 'focus-within', 'hover', 'focus'], 498 | boxSizing: ['responsive'], 499 | clear: ['responsive'], 500 | container: ['responsive'], 501 | cursor: ['responsive'], 502 | display: ['responsive'], 503 | divideColor: ['responsive', 'dark'], 504 | divideOpacity: ['responsive'], 505 | divideStyle: ['responsive'], 506 | divideWidth: ['responsive'], 507 | fill: ['responsive'], 508 | flex: ['responsive'], 509 | flexDirection: ['responsive'], 510 | flexGrow: ['responsive'], 511 | flexShrink: ['responsive'], 512 | flexWrap: ['responsive'], 513 | float: ['responsive'], 514 | fontFamily: ['responsive'], 515 | fontSize: ['responsive'], 516 | fontSmoothing: ['responsive'], 517 | fontStyle: ['responsive'], 518 | fontVariantNumeric: ['responsive'], 519 | fontWeight: ['responsive'], 520 | gap: ['responsive'], 521 | gradientColorStops: ['responsive', 'dark', 'hover', 'focus'], 522 | gridAutoColumns: ['responsive'], 523 | gridAutoFlow: ['responsive'], 524 | gridAutoRows: ['responsive'], 525 | gridColumn: ['responsive'], 526 | gridColumnEnd: ['responsive'], 527 | gridColumnStart: ['responsive'], 528 | gridRow: ['responsive'], 529 | gridRowEnd: ['responsive'], 530 | gridRowStart: ['responsive'], 531 | gridTemplateColumns: ['responsive'], 532 | gridTemplateRows: ['responsive'], 533 | height: ['responsive'], 534 | inset: ['responsive'], 535 | justifyContent: ['responsive'], 536 | justifyItems: ['responsive'], 537 | justifySelf: ['responsive'], 538 | letterSpacing: ['responsive'], 539 | lineHeight: ['responsive'], 540 | listStylePosition: ['responsive'], 541 | listStyleType: ['responsive'], 542 | margin: ['responsive'], 543 | maxHeight: ['responsive'], 544 | maxWidth: ['responsive'], 545 | minHeight: ['responsive'], 546 | minWidth: ['responsive'], 547 | objectFit: ['responsive'], 548 | objectPosition: ['responsive'], 549 | opacity: ['responsive', 'group-hover', 'focus-within', 'hover', 'focus'], 550 | order: ['responsive'], 551 | outline: ['responsive', 'focus-within', 'focus'], 552 | overflow: ['responsive'], 553 | overscrollBehavior: ['responsive'], 554 | padding: ['responsive'], 555 | placeContent: ['responsive'], 556 | placeItems: ['responsive'], 557 | placeSelf: ['responsive'], 558 | placeholderColor: ['responsive', 'dark', 'focus'], 559 | placeholderOpacity: ['responsive', 'focus'], 560 | pointerEvents: ['responsive'], 561 | position: ['responsive'], 562 | resize: ['responsive'], 563 | ringColor: ['responsive', 'dark', 'focus-within', 'focus'], 564 | ringOffsetColor: ['responsive', 'dark', 'focus-within', 'focus'], 565 | ringOffsetWidth: ['responsive', 'focus-within', 'focus'], 566 | ringOpacity: ['responsive', 'focus-within', 'focus'], 567 | ringWidth: ['responsive', 'focus-within', 'focus'], 568 | rotate: ['responsive', 'hover', 'focus'], 569 | scale: ['responsive', 'hover', 'focus'], 570 | skew: ['responsive', 'hover', 'focus'], 571 | space: ['responsive'], 572 | stroke: ['responsive'], 573 | strokeWidth: ['responsive'], 574 | tableLayout: ['responsive'], 575 | textAlign: ['responsive'], 576 | textColor: ['responsive', 'dark', 'group-hover', 'focus-within', 'hover', 'focus'], 577 | textDecoration: ['responsive', 'group-hover', 'focus-within', 'hover', 'focus'], 578 | textOpacity: ['responsive', 'group-hover', 'focus-within', 'hover', 'focus'], 579 | textOverflow: ['responsive'], 580 | textTransform: ['responsive'], 581 | transform: ['responsive'], 582 | transformOrigin: ['responsive'], 583 | transitionDelay: ['responsive'], 584 | transitionDuration: ['responsive'], 585 | transitionProperty: ['responsive'], 586 | transitionTimingFunction: ['responsive'], 587 | translate: ['responsive', 'hover', 'focus'], 588 | userSelect: ['responsive'], 589 | verticalAlign: ['responsive'], 590 | visibility: ['responsive'], 591 | whitespace: ['responsive'], 592 | width: ['responsive'], 593 | wordBreak: ['responsive'], 594 | zIndex: ['responsive', 'focus-within', 'focus'], 595 | }, 596 | corePlugins: {}, 597 | plugins: [ 598 | require('@tailwindcss/typography'), 599 | ], 600 | } -------------------------------------------------------------------------------- /volts-front/src/components/plugin.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use gloo_net::http::Request; 4 | use sycamore::{ 5 | component, 6 | prelude::{view, Keyed}, 7 | reactive::{create_effect, create_selector, create_signal, Scope, Signal}, 8 | view::View, 9 | web::Html, 10 | }; 11 | use volts_core::{EncodePlugin, PluginList}; 12 | use wasm_bindgen::{prelude::Closure, JsCast, JsValue}; 13 | use web_sys::{Event, KeyboardEvent}; 14 | 15 | #[derive(PartialEq, Eq, Clone)] 16 | struct IndexedPlugin { 17 | plugin: EncodePlugin, 18 | last: bool, 19 | } 20 | 21 | fn get_plugins<'a>( 22 | cx: Scope<'a>, 23 | q: Option<&str>, 24 | sort: Option<&str>, 25 | offset: Option<&str>, 26 | plugins: &'a Signal>, 27 | total: &'a Signal, 28 | loading: Option<&'a Signal>, 29 | ) { 30 | let quries = &[("q", q), ("sort", sort), ("offset", offset)] 31 | .iter() 32 | .filter_map(|(name, value)| { 33 | let value = (*value)?; 34 | Some(format!("{name}={value}")) 35 | }) 36 | .collect::>() 37 | .join("&"); 38 | let mut url = "/api/v1/plugins".to_string(); 39 | if !quries.is_empty() { 40 | url = format!("{url}?{quries}"); 41 | } 42 | 43 | let offset: usize = offset.unwrap_or("0").parse().unwrap(); 44 | let req = Request::get(&url).send(); 45 | sycamore::futures::spawn_local_scoped(cx, async move { 46 | let resp = req.await.unwrap(); 47 | let plugin_list: PluginList = resp.json().await.unwrap(); 48 | let len = plugin_list.plugins.len(); 49 | total.set(plugin_list.total); 50 | let plugin_list = plugin_list 51 | .plugins 52 | .into_iter() 53 | .enumerate() 54 | .map(|(i, plugin)| IndexedPlugin { 55 | plugin, 56 | last: i + 1 == len, 57 | }); 58 | let mut current_plugins = (*plugins.get()).clone(); 59 | current_plugins.truncate(offset); 60 | current_plugins.extend(plugin_list); 61 | plugins.set(current_plugins); 62 | if let Some(loading) = loading { 63 | loading.set(false); 64 | } 65 | }); 66 | } 67 | 68 | #[component(inline_props)] 69 | fn PluginItem<'a, G: Html>( 70 | cx: Scope<'a>, 71 | plugin: IndexedPlugin, 72 | plugins: &'a Signal>, 73 | ) -> View { 74 | let author = create_signal(cx, plugin.plugin.author.clone()); 75 | let name = create_signal(cx, plugin.plugin.name.clone()); 76 | let version = plugin.plugin.version.clone(); 77 | let updated_at = plugin.plugin.updated_at_ts; 78 | 79 | let handle_img_error = move |event: Event| { 80 | let target: web_sys::HtmlImageElement = event.target().unwrap().unchecked_into(); 81 | if target.src().ends_with("volt.png") { 82 | return; 83 | } 84 | target.set_src("/static/volt.png"); 85 | }; 86 | view! {cx, 87 | div(class="py-3") { 88 | a(href=format!("/plugins/{}/{}", author.get(), name.get())) { 89 | li( 90 | class="flex border rounded-md py-4 w-full" 91 | ) { 92 | img( 93 | class="m-4 h-16 w-16", 94 | src=format!("/api/v1/plugins/{}/{}/{}/icon?id={}", author.get(), name.get(), version, updated_at), 95 | on:error=handle_img_error, 96 | ) {} 97 | div(class="flex flex-col justify-between w-[calc(100%-6rem)] pr-4") { 98 | div { 99 | p( 100 | class="font-bold" 101 | ) { 102 | (plugin.plugin.display_name) 103 | } 104 | p( 105 | class="mt-1 text-ellipsis whitespace-nowrap overflow-hidden" 106 | ) { 107 | (plugin.plugin.description) 108 | } 109 | } 110 | div( 111 | class="flex justify-between text-sm text-gray-400 mt-3" 112 | ) { 113 | p { 114 | (plugin.plugin.author) 115 | } 116 | p { 117 | "Downloads: " (plugin.plugin.downloads) 118 | } 119 | } 120 | } 121 | } 122 | } 123 | } 124 | } 125 | } 126 | 127 | #[component(inline_props)] 128 | fn PluginColumn<'a, G: Html>(cx: Scope<'a>, plugins: &'a Signal>) -> View { 129 | view! {cx, 130 | ul { 131 | Keyed( 132 | iterable=plugins, 133 | view=move |cx, plugin| view! {cx, 134 | PluginItem(plugin=plugin, plugins=plugins) 135 | }, 136 | key=|plugin| plugin.plugin.id, 137 | ) 138 | } 139 | } 140 | } 141 | 142 | #[component(inline_props)] 143 | fn SearchInput<'a, G: Html>( 144 | cx: Scope<'a>, 145 | query: &'a Signal, 146 | plugins: &'a Signal>, 147 | total: &'a Signal, 148 | ) -> View { 149 | let jump_or_update = move || { 150 | if !web_sys::window() 151 | .unwrap() 152 | .location() 153 | .href() 154 | .unwrap() 155 | .as_str() 156 | .contains("/search") 157 | { 158 | web_sys::window() 159 | .unwrap() 160 | .location() 161 | .set_href(&format!("/search/{}", query.get())) 162 | .unwrap(); 163 | } else { 164 | web_sys::window() 165 | .unwrap() 166 | .history() 167 | .unwrap() 168 | .push_state_with_url( 169 | &JsValue::NULL, 170 | "search", 171 | Some(&format!("/search/{}", query.get())), 172 | ) 173 | .unwrap(); 174 | get_plugins(cx, Some(&query.get()), None, None, plugins, total, None); 175 | } 176 | }; 177 | 178 | let handle_keyup = move |event: Event| { 179 | let event: KeyboardEvent = event.unchecked_into(); 180 | if event.code() == "Enter" { 181 | jump_or_update(); 182 | } 183 | }; 184 | 185 | let handle_click = move |_| { 186 | jump_or_update(); 187 | }; 188 | 189 | view! {cx, 190 | div(class="w-[36rem] max-w-full flex items-center border rounded-md") { 191 | input( 192 | class="text-lg w-full p-2 pr-0", 193 | placeholder="search lapce plugins", 194 | on:keyup=handle_keyup, 195 | bind:value=query, 196 | ) { 197 | } 198 | button( 199 | class="p-3", 200 | on:click=handle_click, 201 | ) { 202 | svg( 203 | class="h-4 w-4", 204 | width=512, 205 | height=512, 206 | viewBox="0 0 512 512", 207 | fill="gray", 208 | xmlns="http://www.w3.org/2000/svg", 209 | ) { 210 | path( 211 | d="M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z" 212 | ) {} 213 | } 214 | } 215 | } 216 | } 217 | } 218 | 219 | #[component] 220 | pub fn PluginList(cx: Scope) -> View { 221 | let most_downloaded = create_signal(cx, Vec::new()); 222 | let new_plugins = create_signal(cx, Vec::new()); 223 | let recently_updated = create_signal(cx, Vec::new()); 224 | let most_downloaded_total = create_signal(cx, 0); 225 | let new_plugins_total = create_signal(cx, 0); 226 | let recently_updated_total = create_signal(cx, 0); 227 | get_plugins( 228 | cx, 229 | None, 230 | None, 231 | None, 232 | most_downloaded, 233 | most_downloaded_total, 234 | None, 235 | ); 236 | get_plugins( 237 | cx, 238 | None, 239 | Some("created"), 240 | None, 241 | new_plugins, 242 | new_plugins_total, 243 | None, 244 | ); 245 | get_plugins( 246 | cx, 247 | None, 248 | Some("updated"), 249 | None, 250 | recently_updated, 251 | recently_updated_total, 252 | None, 253 | ); 254 | 255 | let query = create_signal(cx, "".to_string()); 256 | 257 | view! {cx, 258 | div(class="container m-auto") { 259 | div(class="flex flex-col items-center mt-16 mb-10 text-center") { 260 | h1(class="text-3xl mb-4") { 261 | "Plugins for Lapce" 262 | } 263 | SearchInput(query=query, plugins=most_downloaded, total=most_downloaded_total) 264 | } 265 | div(class="flex flex-wrap") { 266 | div(class="w-full px-3 lg:w-1/3") { 267 | h1(class="mt-3 font-bold") { 268 | "Most Downloaded" 269 | } 270 | PluginColumn(plugins=most_downloaded) 271 | } 272 | 273 | div(class="w-full px-3 lg:w-1/3") { 274 | h1(class="mt-3 font-bold") { 275 | "New Plugins" 276 | } 277 | PluginColumn(plugins=new_plugins) 278 | } 279 | 280 | div(class="w-full px-3 lg:w-1/3") { 281 | h1(class="mt-3 font-bold") { 282 | "Recently updated" 283 | } 284 | PluginColumn(plugins=recently_updated) 285 | } 286 | } 287 | } 288 | } 289 | } 290 | 291 | #[component(inline_props)] 292 | pub fn ReadmeView<'a, G: Html>(cx: Scope<'a>, text: &'a Signal) -> View { 293 | let markdown_html = create_signal(cx, "".to_string()); 294 | create_effect(cx, || { 295 | let text = (*text.get()).to_string(); 296 | let parser = pulldown_cmark::Parser::new_ext(&text, pulldown_cmark::Options::all()); 297 | let mut html = "".to_string(); 298 | pulldown_cmark::html::push_html(&mut html, parser); 299 | markdown_html.set(html); 300 | }); 301 | view! { cx, 302 | (if text.get().is_empty() { 303 | view! {cx, 304 | p {"No Readme"} 305 | } 306 | } else { 307 | view! {cx, 308 | div( 309 | class="prose prose-neutral", 310 | dangerously_set_inner_html=&markdown_html.get(), 311 | ) 312 | } 313 | }) 314 | } 315 | } 316 | 317 | #[component(inline_props)] 318 | pub fn PluginView(cx: Scope, author: String, name: String) -> View { 319 | let plugin = create_signal(cx, None); 320 | let readme = create_signal(cx, "".to_string()); 321 | 322 | let req = Request::get(&format!("/api/v1/plugins/{author}/{name}/latest")).send(); 323 | sycamore::futures::spawn_local_scoped(cx, async move { 324 | let resp = req.await.unwrap(); 325 | let resp: EncodePlugin = resp.json().await.unwrap(); 326 | plugin.set(Some(resp.clone())); 327 | 328 | let req = Request::get(&format!( 329 | "/api/v1/plugins/{author}/{name}/{}/readme", 330 | resp.version 331 | )) 332 | .send(); 333 | let resp = req.await.unwrap(); 334 | if resp.status() == 200 { 335 | let resp = resp.text().await.unwrap(); 336 | readme.set(resp); 337 | } 338 | }); 339 | 340 | let handle_img_error = move |event: Event| { 341 | let target: web_sys::HtmlImageElement = event.target().unwrap().unchecked_into(); 342 | if target.src().ends_with("volt.png") { 343 | return; 344 | } 345 | target.set_src("/static/volt.png"); 346 | }; 347 | 348 | view! {cx, 349 | (if plugin.get().is_none() { 350 | view! {cx, 351 | } 352 | } else { 353 | view! {cx, 354 | div(class="container m-auto mt-10") { 355 | div(class="flex") { 356 | img( 357 | class="m-8 mt-2 h-24 w-24", 358 | src=format!("/api/v1/plugins/{}/{}/{}/icon?id={}", 359 | (*plugin.get()).as_ref().unwrap().author, 360 | (*plugin.get()).as_ref().unwrap().name, 361 | (*plugin.get()).as_ref().unwrap().version, 362 | (*plugin.get()).as_ref().unwrap().updated_at_ts), 363 | on:error=handle_img_error, 364 | ) {} 365 | div( 366 | class="w-[calc(100%-10rem)]" 367 | ) { 368 | div(class="flex items-baseline") { 369 | p(class="text-4xl font-bold") { 370 | ((*plugin.get()).as_ref().unwrap().display_name) 371 | } 372 | p(class="ml-4 px-2 rounded-md border bg-gray-200") { 373 | "v"((*plugin.get()).as_ref().unwrap().version) 374 | } 375 | } 376 | p(class="text-lg mt-1") { 377 | ((*plugin.get()).as_ref().unwrap().description) 378 | } 379 | div(class="flex mt-4 flex-wrap") { 380 | p { 381 | ((*plugin.get()).as_ref().unwrap().author) 382 | } 383 | p(class="ml-4") { 384 | "|" 385 | } 386 | p(class="ml-4") { 387 | ((*plugin.get()).as_ref().unwrap().downloads) " Downloads" 388 | } 389 | } 390 | } 391 | } 392 | hr(class="my-8 h-px bg-gray-200 border-0") {} 393 | div(class="flex flex-wrap") { 394 | div(class="w-full lg:w-2/3 px-10") { 395 | ReadmeView(text=readme) 396 | } 397 | div(class="w-full lg:w-1/3 mt-8 lg:mt-0 px-10 lg:px-4") { 398 | p(class="font-bold") { 399 | "Repository" 400 | } 401 | div(class="mt-2") { 402 | (if (*plugin.get()).as_ref().unwrap().repository.is_none() { 403 | view!{cx, p {""}} 404 | } else { 405 | view!{cx, 406 | a( 407 | class="text-blue-500 hover:text-blue-700", 408 | target="_blank", 409 | href=(*plugin.get()).as_ref().unwrap().repository.clone().unwrap(), 410 | ) { 411 | ((*plugin.get()).as_ref().unwrap().repository.clone().unwrap()) 412 | } 413 | } 414 | }) 415 | } 416 | 417 | p(class="font-bold mt-8") { 418 | "More Information" 419 | } 420 | p(class="mt-2") { 421 | table(class="table-auto") { 422 | tbody { 423 | tr { 424 | td { 425 | "Version" 426 | } 427 | td { 428 | ((*plugin.get()).as_ref().unwrap().version) 429 | } 430 | } 431 | tr { 432 | td { 433 | "Author" 434 | } 435 | td { 436 | ((*plugin.get()).as_ref().unwrap().author) 437 | } 438 | } 439 | tr { 440 | td(class="pr-4") { 441 | "Released" 442 | } 443 | td { 444 | ((*plugin.get()).as_ref().unwrap().released_at) 445 | } 446 | } 447 | tr { 448 | td(class="pr-4") { 449 | "Last Updated" 450 | } 451 | td { 452 | ((*plugin.get()).as_ref().unwrap().updated_at) 453 | } 454 | } 455 | } 456 | } 457 | } 458 | } 459 | } 460 | } 461 | } 462 | }) 463 | } 464 | } 465 | 466 | #[component(inline_props)] 467 | pub fn PluginSearch(cx: Scope, query: String) -> View { 468 | let query = create_signal(cx, query); 469 | let plugins = create_signal(cx, Vec::new()); 470 | let plugins_total = create_signal(cx, 0); 471 | get_plugins( 472 | cx, 473 | Some(&query.get()), 474 | None, 475 | None, 476 | plugins, 477 | plugins_total, 478 | None, 479 | ); 480 | 481 | let loading_more = create_signal(cx, false); 482 | 483 | let handle_scroll = move |event: Event| { 484 | if *loading_more.get() { 485 | return; 486 | } 487 | if plugins.get().len() == *plugins_total.get() as usize { 488 | return; 489 | } 490 | web_sys::console::log_1( 491 | &format!( 492 | "plugins len {}, total {}", 493 | plugins.get().len(), 494 | plugins_total.get() 495 | ) 496 | .into(), 497 | ); 498 | let target: web_sys::HtmlElement = event.target().unwrap().unchecked_into(); 499 | let scroll_height = target.scroll_height(); 500 | let scroll_top = target.scroll_top(); 501 | let client_height = target.client_height(); 502 | 503 | if scroll_height - scroll_top - client_height < 50 { 504 | loading_more.set(true); 505 | let offset = plugins.get().len().to_string(); 506 | get_plugins( 507 | cx, 508 | Some(&query.get()), 509 | None, 510 | Some(&offset), 511 | plugins, 512 | plugins_total, 513 | Some(loading_more), 514 | ); 515 | web_sys::console::log_1(&format!("loading more now").into()); 516 | } 517 | }; 518 | 519 | let is_plugins_empty = create_selector(cx, || plugins.get().is_empty()); 520 | 521 | view! { cx, 522 | div(class="container m-auto") { 523 | div(class="flex flex-col items-center mt-10 mb-6 text-center") { 524 | SearchInput(query=query, plugins=plugins, total=plugins_total) 525 | } 526 | (if *is_plugins_empty.get() { 527 | view! {cx, 528 | div(class="flex flex-col items-center mt-3 text-center") { 529 | p { 530 | "0 Plugins Found" 531 | } 532 | } 533 | } 534 | } else { 535 | view! { cx, 536 | div( 537 | class="overflow-y-scroll h-[calc(100vh-16rem)]", 538 | on:scroll=handle_scroll, 539 | ) { 540 | ul( 541 | class="px-3", 542 | ) { 543 | Keyed( 544 | iterable=plugins, 545 | view=move |cx, plugin| view! {cx, 546 | PluginItem(plugin=plugin, plugins=plugins) 547 | }, 548 | key=|plugin| plugin.plugin.id, 549 | ) 550 | } 551 | } 552 | } 553 | }) 554 | } 555 | } 556 | } 557 | 558 | #[component] 559 | pub fn PluginSearchIndex(cx: Scope) -> View { 560 | view! { cx, 561 | PluginSearch(query="".to_string()) 562 | } 563 | } 564 | -------------------------------------------------------------------------------- /volts-back/src/plugin.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | fs::File, 4 | }; 5 | 6 | use anyhow::Result; 7 | use axum::{ 8 | body::Bytes, 9 | extract::{BodyStream, Path, Query, State}, 10 | http::StatusCode, 11 | response::IntoResponse, 12 | BoxError, Json, TypedHeader, 13 | }; 14 | use diesel::{BelongingToDsl, BoolExpressionMethods, ExpressionMethods, GroupedBy}; 15 | use diesel::{PgTextExpressionMethods, QueryDsl}; 16 | use diesel_async::RunQueryDsl; 17 | use futures::{FutureExt, Stream, TryStreamExt}; 18 | use headers::authorization::Bearer; 19 | use lapce_rpc::plugin::VoltMetadata; 20 | use s3::Bucket; 21 | use serde::{Deserialize, Serialize}; 22 | use tar::Archive; 23 | use tokio_util::io::StreamReader; 24 | use toml_edit::easy as toml; 25 | use volts_core::{ 26 | db::{ 27 | models::{Plugin, User, Version}, 28 | schema::{plugins, users, versions}, 29 | }, 30 | EncodePlugin, PluginList, 31 | }; 32 | use zstd::{Decoder, Encoder}; 33 | 34 | use crate::db::{ 35 | find_api_token, find_plugin, find_plugin_version, find_user, find_user_by_gh_login, 36 | modify_plugin_version_yank, DbPool, NewPlugin, NewVersion, 37 | }; 38 | 39 | const VOLT_MANIFEST: &str = "volt.toml"; 40 | const VOLT_ARCHIVE: &str = "plugin.volt"; 41 | const OLD_VOLT_ARCHIVE: &str = "volt.tar.gz"; 42 | 43 | #[derive(Serialize, Deserialize)] 44 | #[serde(rename_all = "kebab-case")] 45 | struct IconTheme { 46 | pub icon_theme: IconThemeConfig, 47 | } 48 | 49 | #[derive(Serialize, Deserialize)] 50 | struct IconThemeConfig { 51 | pub ui: HashMap, 52 | pub foldername: HashMap, 53 | pub filename: HashMap, 54 | pub extension: HashMap, 55 | } 56 | 57 | #[derive(Debug, Deserialize)] 58 | pub struct SearchQuery { 59 | q: Option, 60 | sort: Option, 61 | limit: Option, 62 | offset: Option, 63 | } 64 | 65 | pub async fn search( 66 | Query(query): Query, 67 | State(db_pool): State, 68 | ) -> impl IntoResponse { 69 | let limit = query.limit.unwrap_or(10).min(100); 70 | let offset = query.offset.unwrap_or(0); 71 | let mut conn = db_pool.read.get().await.unwrap(); 72 | let mut sql_query = plugins::table 73 | .inner_join(users::dsl::users) 74 | .filter(diesel::expression::exists::exists( 75 | versions::table 76 | .filter(versions::plugin_id.eq(plugins::id)) 77 | .filter(versions::yanked.eq(false)), 78 | )) 79 | .into_boxed(); 80 | let mut total: i64 = 0; 81 | let mut had_query = false; 82 | if let Some(q) = query.q.as_ref() { 83 | if !q.is_empty() { 84 | let q = format!("%{q}%"); 85 | 86 | let filter = plugins::name 87 | .ilike(q.clone()) 88 | .or(plugins::description.ilike(q.clone())) 89 | .or(plugins::display_name.ilike(q)); 90 | sql_query = sql_query.filter(filter.clone()); 91 | 92 | had_query = true; 93 | total = plugins::table 94 | .filter(filter) 95 | .filter(diesel::expression::exists::exists( 96 | versions::table 97 | .filter(versions::plugin_id.eq(plugins::id)) 98 | .filter(versions::yanked.eq(false)), 99 | )) 100 | .count() 101 | .get_result(&mut conn) 102 | .await 103 | .unwrap(); 104 | } 105 | } 106 | if !had_query { 107 | total = plugins::table 108 | .filter(diesel::expression::exists::exists( 109 | versions::table 110 | .filter(versions::plugin_id.eq(plugins::id)) 111 | .filter(versions::yanked.eq(false)), 112 | )) 113 | .count() 114 | .get_result(&mut conn) 115 | .await 116 | .unwrap(); 117 | } 118 | 119 | sql_query = sql_query.offset(offset as i64).limit(limit as i64); 120 | match query.sort.as_deref() { 121 | Some("created") => { 122 | sql_query = sql_query.order(plugins::created_at.desc()); 123 | } 124 | Some("updated") => { 125 | sql_query = sql_query.order(plugins::updated_at.desc()); 126 | } 127 | _ => { 128 | sql_query = sql_query.order(plugins::downloads.desc()); 129 | } 130 | } 131 | let data: Vec<(Plugin, User)> = sql_query.load(&mut conn).await.unwrap(); 132 | 133 | let plugins = data.iter().map(|(p, u)| p).collect::>(); 134 | 135 | let versions: Vec = Version::belonging_to(plugins.as_slice()) 136 | .filter(versions::yanked.eq(false)) 137 | .load(&mut conn) 138 | .await 139 | .unwrap(); 140 | 141 | let versions = versions.grouped_by(&plugins).into_iter().map(|versions| { 142 | versions 143 | .into_iter() 144 | .filter_map(|v| Some((semver::Version::parse(&v.num).ok()?, v))) 145 | .max_by_key(|(v, _)| v.clone()) 146 | }); 147 | 148 | let plugins: Vec = versions 149 | .zip(data) 150 | .filter_map(|(v, (p, u))| { 151 | let version = v?.1; 152 | Some(EncodePlugin { 153 | id: p.id, 154 | name: p.name, 155 | author: u.gh_login, 156 | version: version.num, 157 | display_name: p.display_name, 158 | description: p.description, 159 | downloads: p.downloads, 160 | repository: p.repository, 161 | updated_at_ts: p.updated_at.timestamp(), 162 | updated_at: p.updated_at.format("%Y-%m-%d %H:%M:%S").to_string(), 163 | released_at: version.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), 164 | wasm: p.wasm, 165 | }) 166 | }) 167 | .collect(); 168 | 169 | Json(PluginList { 170 | total, 171 | limit, 172 | offset, 173 | plugins, 174 | }) 175 | } 176 | 177 | pub async fn meta( 178 | State(bucket): State, 179 | State(db_pool): State, 180 | Path((author, name, version)): Path<(String, String, String)>, 181 | ) -> impl IntoResponse { 182 | let mut conn = db_pool.read.get().await.unwrap(); 183 | let user = find_user_by_gh_login(&mut conn, &author).await.unwrap(); 184 | let name = name.to_lowercase(); 185 | let plugin = find_plugin(&mut conn, &user, &name).await.unwrap(); 186 | 187 | let version = if version == "latest" { 188 | let versions: Vec = Version::belonging_to(&plugin) 189 | .filter(versions::yanked.eq(false)) 190 | .load(&mut conn) 191 | .await 192 | .unwrap(); 193 | 194 | let max = versions 195 | .into_iter() 196 | .filter_map(|v| { 197 | semver::Version::parse(&v.num) 198 | .ok() 199 | .map(|version| (v, version)) 200 | }) 201 | .max_by_key(|(_, version)| version.clone()); 202 | max.unwrap().0 203 | } else { 204 | find_plugin_version(&mut conn, &plugin, &version) 205 | .await 206 | .unwrap() 207 | }; 208 | 209 | Json(EncodePlugin { 210 | id: plugin.id, 211 | name, 212 | author, 213 | version: version.num, 214 | display_name: plugin.display_name, 215 | description: plugin.description, 216 | downloads: plugin.downloads, 217 | repository: plugin.repository, 218 | updated_at_ts: plugin.updated_at.timestamp(), 219 | updated_at: plugin.updated_at.format("%Y-%m-%d %H:%M:%S").to_string(), 220 | released_at: version.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), 221 | wasm: plugin.wasm, 222 | }) 223 | } 224 | 225 | pub async fn download( 226 | State(bucket): State, 227 | State(db_pool): State, 228 | Path((author, name, version)): Path<(String, String, String)>, 229 | ) -> impl IntoResponse { 230 | let mut conn = db_pool.read.get().await.unwrap(); 231 | let user = find_user_by_gh_login(&mut conn, &author).await.unwrap(); 232 | let name = name.to_lowercase(); 233 | let plugin = find_plugin(&mut conn, &user, &name).await.unwrap(); 234 | let version = find_plugin_version(&mut conn, &plugin, &version) 235 | .await 236 | .unwrap(); 237 | { 238 | let mut conn = db_pool.write.get().await.unwrap(); 239 | diesel::update(plugins::dsl::plugins.find(plugin.id)) 240 | .set(plugins::downloads.eq(plugins::downloads + 1)) 241 | .execute(&mut conn) 242 | .await 243 | .unwrap(); 244 | diesel::update(versions::dsl::versions.find(version.id)) 245 | .set(versions::downloads.eq(versions::downloads + 1)) 246 | .execute(&mut conn) 247 | .await 248 | .unwrap(); 249 | } 250 | 251 | let s3_path = format!("{}/{}/{}/{VOLT_ARCHIVE}", user.gh_login, name, version.num); 252 | if bucket 253 | .head_object(&s3_path) 254 | .await 255 | .map(|(_, code)| code == 200) 256 | .unwrap_or(false) 257 | { 258 | bucket.presign_get(&s3_path, 60, None).unwrap() 259 | } else { 260 | let old_s3_path = format!( 261 | "{}/{}/{}/{OLD_VOLT_ARCHIVE}", 262 | user.gh_login, name, version.num 263 | ); 264 | bucket.presign_get(&old_s3_path, 60, None).unwrap() 265 | } 266 | } 267 | 268 | pub async fn readme( 269 | State(bucket): State, 270 | State(db_pool): State, 271 | Path((author, name, version)): Path<(String, String, String)>, 272 | ) -> impl IntoResponse { 273 | let mut conn = db_pool.read.get().await.unwrap(); 274 | let user = find_user_by_gh_login(&mut conn, &author).await.unwrap(); 275 | let name = name.to_lowercase(); 276 | let plugin = find_plugin(&mut conn, &user, &name).await.unwrap(); 277 | let version = find_plugin_version(&mut conn, &plugin, &version) 278 | .await 279 | .unwrap(); 280 | let s3_path = format!("{}/{}/{}/readme", user.gh_login, name, version.num); 281 | let result = bucket.get_object(&s3_path).await; 282 | let resp = match result { 283 | Ok(resp) => resp, 284 | Err(_) => { 285 | return ( 286 | axum::http::StatusCode::INTERNAL_SERVER_ERROR, 287 | "can't download readme", 288 | ) 289 | .into_response(); 290 | } 291 | }; 292 | if resp.status_code() != 200 { 293 | return ( 294 | axum::http::StatusCode::from_u16(resp.status_code()).unwrap(), 295 | "can't download readme", 296 | ) 297 | .into_response(); 298 | } 299 | 300 | resp.bytes().to_vec().into_response() 301 | } 302 | 303 | pub async fn icon( 304 | State(bucket): State, 305 | State(db_pool): State, 306 | Path((author, name, version)): Path<(String, String, String)>, 307 | ) -> axum::response::Response { 308 | let mut conn = db_pool.read.get().await.unwrap(); 309 | let user = find_user_by_gh_login(&mut conn, &author).await.unwrap(); 310 | let name = name.to_lowercase(); 311 | let plugin = find_plugin(&mut conn, &user, &name).await.unwrap(); 312 | let version = find_plugin_version(&mut conn, &plugin, &version) 313 | .await 314 | .unwrap(); 315 | let s3_path = format!("{}/{}/{}/icon", user.gh_login, name, version.num); 316 | let content_type = match bucket.head_object(&s3_path).await { 317 | Ok((head, _)) => head.content_type, 318 | Err(_) => { 319 | return ( 320 | axum::http::StatusCode::INTERNAL_SERVER_ERROR, 321 | "can't download icon", 322 | ) 323 | .into_response(); 324 | } 325 | }; 326 | 327 | let result = bucket.get_object(&s3_path).await; 328 | let resp = match result { 329 | Ok(resp) => resp, 330 | Err(_) => { 331 | return ( 332 | axum::http::StatusCode::INTERNAL_SERVER_ERROR, 333 | "can't download icon", 334 | ) 335 | .into_response(); 336 | } 337 | }; 338 | if resp.status_code() != 200 { 339 | return ( 340 | axum::http::StatusCode::from_u16(resp.status_code()).unwrap(), 341 | "can't download icon", 342 | ) 343 | .into_response(); 344 | } 345 | 346 | let mut res = axum::body::Full::from(resp.bytes().to_vec()).into_response(); 347 | res.headers_mut().insert( 348 | axum::http::header::CONTENT_TYPE, 349 | axum::http::header::HeaderValue::from_str( 350 | &content_type.unwrap_or_else(|| "image/*".to_string()), 351 | ) 352 | .unwrap(), 353 | ); 354 | res.headers_mut().insert( 355 | axum::http::header::CACHE_CONTROL, 356 | axum::http::header::HeaderValue::from_static("public, max-age=86400"), 357 | ); 358 | res 359 | } 360 | 361 | pub async fn publish( 362 | State(db_pool): State, 363 | State(bucket): State, 364 | TypedHeader(token): TypedHeader>, 365 | body: BodyStream, 366 | ) -> impl IntoResponse { 367 | let api_token = { 368 | let mut conn = db_pool.write.get().await.unwrap(); 369 | match find_api_token(&mut conn, token.token()).await { 370 | Ok(api_token) => api_token, 371 | Err(_) => { 372 | return (axum::http::StatusCode::UNAUTHORIZED, "API Token Invalid").into_response() 373 | } 374 | } 375 | }; 376 | 377 | let user = { 378 | let mut conn = db_pool.read.get().await.unwrap(); 379 | find_user(&mut conn, api_token.user_id).await.unwrap() 380 | }; 381 | 382 | let dir = tempfile::TempDir::new().unwrap(); 383 | let dest = tempfile::TempDir::new().unwrap(); 384 | let archive = dir.path().join(VOLT_ARCHIVE); 385 | stream_to_file(&archive, body).await.unwrap(); 386 | 387 | { 388 | let archive = archive.clone(); 389 | let dir_path = dir.path().to_path_buf(); 390 | tokio::task::spawn_blocking(move || { 391 | let archive = File::open(archive).unwrap(); 392 | let tar = Decoder::new(archive).unwrap(); 393 | let mut archive = Archive::new(tar); 394 | archive.unpack(dir_path).unwrap(); 395 | }) 396 | .await 397 | .unwrap(); 398 | } 399 | 400 | let volt_path = dir.path().join(VOLT_MANIFEST); 401 | if !volt_path.exists() { 402 | return ( 403 | StatusCode::BAD_REQUEST, 404 | format!("{VOLT_MANIFEST} doesn't exist"), 405 | ) 406 | .into_response(); 407 | } 408 | 409 | let s = tokio::fs::read_to_string(&volt_path).await.unwrap(); 410 | let volt = match toml::from_str::(&s) { 411 | Ok(mut volt) => { 412 | volt.author = user.gh_login.clone(); 413 | volt.name = volt.name.to_lowercase(); 414 | volt 415 | } 416 | Err(_) => { 417 | return ( 418 | StatusCode::BAD_REQUEST, 419 | format!("{VOLT_MANIFEST} format invalid"), 420 | ) 421 | .into_response() 422 | } 423 | }; 424 | 425 | if semver::Version::parse(&volt.version).is_err() { 426 | return (StatusCode::BAD_REQUEST, "version isn't valid").into_response(); 427 | } 428 | 429 | { 430 | let dest_volt_path = dest.path().join(VOLT_MANIFEST); 431 | tokio::fs::write( 432 | dest_volt_path, 433 | toml_edit::ser::to_string_pretty(&volt).unwrap(), 434 | ) 435 | .await 436 | .unwrap(); 437 | } 438 | 439 | let s3_folder = format!("{}/{}/{}", user.gh_login, volt.name, volt.version); 440 | 441 | let mut is_wasm = false; 442 | if let Some(wasm) = volt.wasm.as_ref() { 443 | let wasm_path = dir.path().join(wasm); 444 | if !wasm_path.exists() { 445 | return (StatusCode::BAD_REQUEST, "wasm {wasm} not found").into_response(); 446 | } 447 | 448 | let dest_wasm = dest.path().join(wasm); 449 | tokio::fs::create_dir_all(dest_wasm.parent().unwrap()) 450 | .await 451 | .unwrap(); 452 | tokio::fs::copy(wasm_path, dest_wasm).await.unwrap(); 453 | is_wasm = true; 454 | } else if let Some(themes) = volt.color_themes.as_ref() { 455 | if themes.is_empty() { 456 | return (StatusCode::BAD_REQUEST, "no color theme provided").into_response(); 457 | } 458 | for theme in themes { 459 | let theme_path = dir.path().join(theme); 460 | if !theme_path.exists() { 461 | return ( 462 | StatusCode::BAD_REQUEST, 463 | format!("color theme {theme} not found"), 464 | ) 465 | .into_response(); 466 | } 467 | 468 | let dest_theme = dest.path().join(theme); 469 | tokio::fs::create_dir_all(dest_theme.parent().unwrap()) 470 | .await 471 | .unwrap(); 472 | tokio::fs::copy(theme_path, dest_theme).await.unwrap(); 473 | } 474 | } else if let Some(themes) = volt.icon_themes.as_ref() { 475 | if themes.is_empty() { 476 | return (StatusCode::BAD_REQUEST, "no icon theme provided").into_response(); 477 | } 478 | for theme in themes { 479 | let theme_path = dir.path().join(theme); 480 | if !theme_path.exists() { 481 | return ( 482 | StatusCode::BAD_REQUEST, 483 | format!("icon theme {theme} not found"), 484 | ) 485 | .into_response(); 486 | } 487 | 488 | let dest_theme = dest.path().join(theme); 489 | tokio::fs::create_dir_all(dest_theme.parent().unwrap()) 490 | .await 491 | .unwrap(); 492 | tokio::fs::copy(&theme_path, &dest_theme).await.unwrap(); 493 | 494 | let s = tokio::fs::read_to_string(&theme_path).await.unwrap(); 495 | let theme_config: IconTheme = match toml::from_str(&s) { 496 | Ok(config) => config, 497 | Err(_) => { 498 | return ( 499 | StatusCode::BAD_REQUEST, 500 | format!("icon theme {theme} format invalid"), 501 | ) 502 | .into_response(); 503 | } 504 | }; 505 | 506 | let mut icons = HashSet::new(); 507 | icons.extend(theme_config.icon_theme.ui.values()); 508 | icons.extend(theme_config.icon_theme.filename.values()); 509 | icons.extend(theme_config.icon_theme.foldername.values()); 510 | icons.extend(theme_config.icon_theme.extension.values()); 511 | 512 | let cwd = theme_path.parent().unwrap(); 513 | let dest_cwd = dest_theme.parent().unwrap(); 514 | 515 | for icon in icons { 516 | let icon_path = cwd.join(icon); 517 | if !icon_path.exists() { 518 | return (StatusCode::BAD_REQUEST, format!("icon {icon} not found")) 519 | .into_response(); 520 | } 521 | 522 | let icon_content = tokio::fs::read(&icon_path).await.unwrap(); 523 | bucket 524 | .put_object( 525 | &format!("{}/{}/{}/icon", user.gh_login, volt.name, volt.version), 526 | &icon_content, 527 | ) 528 | .await 529 | .unwrap(); 530 | 531 | let dest_icon = dest_cwd.join(icon); 532 | tokio::fs::create_dir_all(dest_icon.parent().unwrap()) 533 | .await 534 | .unwrap(); 535 | tokio::fs::copy(icon_path, dest_icon).await.unwrap(); 536 | } 537 | } 538 | } else { 539 | return (StatusCode::BAD_REQUEST, "not a valid plugin").into_response(); 540 | } 541 | 542 | let readme_path = dir.path().join("README.md"); 543 | if readme_path.exists() { 544 | let readme = tokio::fs::read(&readme_path).await.unwrap(); 545 | bucket 546 | .put_object( 547 | &format!("{}/{}/{}/readme", user.gh_login, volt.name, volt.version), 548 | &readme, 549 | ) 550 | .await 551 | .unwrap(); 552 | tokio::fs::copy(readme_path, dest.path().join("README.md")) 553 | .await 554 | .unwrap(); 555 | } 556 | 557 | if let Some(icon) = volt.icon.as_ref() { 558 | let icon_path = dir.path().join(icon); 559 | if icon_path.exists() { 560 | let content_type = match icon_path.extension().and_then(|s| s.to_str()) { 561 | Some("png") => "image/png", 562 | Some("jpg") | Some("jpeg") => "image/jpeg", 563 | Some("svg") => "image/svg+xml", 564 | _ => "image/*", 565 | }; 566 | let icon_content = tokio::fs::read(&icon_path).await.unwrap(); 567 | bucket 568 | .put_object_with_content_type( 569 | &format!("{}/{}/{}/icon", user.gh_login, volt.name, volt.version), 570 | &icon_content, 571 | content_type, 572 | ) 573 | .await 574 | .unwrap(); 575 | 576 | let dest_icon = dest.path().join(icon); 577 | tokio::fs::create_dir_all(dest_icon.parent().unwrap()) 578 | .await 579 | .unwrap(); 580 | tokio::fs::copy(icon_path, dest_icon).await.unwrap(); 581 | } 582 | } 583 | 584 | let tmpdir = tempfile::TempDir::new().unwrap(); 585 | let dest_volt_archive = tmpdir.path().join(VOLT_ARCHIVE); 586 | { 587 | let volt_archive = dest_volt_archive.clone(); 588 | tokio::task::spawn_blocking(move || { 589 | let volt_archive = std::fs::File::create(volt_archive).unwrap(); 590 | let encoder = Encoder::new(volt_archive, 0).unwrap().auto_finish(); 591 | let mut tar = tar::Builder::new(encoder); 592 | tar.append_dir_all(".", dest.path()).unwrap(); 593 | }) 594 | .await 595 | .unwrap(); 596 | } 597 | 598 | let volt_content = tokio::fs::read(&dest_volt_archive).await.unwrap(); 599 | bucket 600 | .put_object_with_content_type( 601 | format!("{s3_folder}/{VOLT_ARCHIVE}"), 602 | &volt_content, 603 | "application/zstd", 604 | ) 605 | .await 606 | .unwrap(); 607 | 608 | let mut conn = db_pool.write.get().await.unwrap(); 609 | 610 | let result: Result<()> = conn 611 | .build_transaction() 612 | .run(|conn| { 613 | async move { 614 | let new_plugin = NewPlugin::new( 615 | &volt.name, 616 | user.id, 617 | &volt.display_name, 618 | &volt.description, 619 | volt.repository.as_deref(), 620 | is_wasm, 621 | ); 622 | let plugin = new_plugin.create_or_update(conn).await?; 623 | let new_version = NewVersion::new(plugin.id, &volt.version); 624 | new_version.create_or_update(conn).await?; 625 | Ok(()) 626 | } 627 | .boxed() 628 | }) 629 | .await; 630 | result.unwrap(); 631 | 632 | ().into_response() 633 | } 634 | 635 | async fn stream_to_file(path: &std::path::Path, stream: S) -> Result<()> 636 | where 637 | S: Stream>, 638 | E: Into, 639 | { 640 | let body_with_io_error = 641 | stream.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)); 642 | let body_reader = StreamReader::new(body_with_io_error); 643 | futures::pin_mut!(body_reader); 644 | 645 | let mut archive = tokio::fs::File::create(path).await?; 646 | tokio::io::copy(&mut body_reader, &mut archive).await?; 647 | Ok(()) 648 | } 649 | 650 | pub async fn yank( 651 | TypedHeader(token): TypedHeader>, 652 | State(db_pool): State, 653 | Path((name, version)): Path<(String, String)>, 654 | ) -> impl IntoResponse { 655 | let api_token = { 656 | let mut conn = db_pool.write.get().await.unwrap(); 657 | match find_api_token(&mut conn, token.token()).await { 658 | Ok(api_token) => api_token, 659 | Err(_) => { 660 | return (axum::http::StatusCode::UNAUTHORIZED, "API Token Invalid").into_response() 661 | } 662 | } 663 | }; 664 | 665 | let user = { 666 | let mut conn = db_pool.read.get().await.unwrap(); 667 | find_user(&mut conn, api_token.user_id).await.unwrap() 668 | }; 669 | 670 | if let Err(e) = modify_yank(&user, State(db_pool), Path((name, version)), true).await { 671 | return (axum::http::StatusCode::BAD_REQUEST, e.to_string()).into_response(); 672 | } 673 | 674 | ().into_response() 675 | } 676 | 677 | pub async fn unyank( 678 | TypedHeader(token): TypedHeader>, 679 | State(db_pool): State, 680 | Path((name, version)): Path<(String, String)>, 681 | ) -> impl IntoResponse { 682 | let api_token = { 683 | let mut conn = db_pool.write.get().await.unwrap(); 684 | match find_api_token(&mut conn, token.token()).await { 685 | Ok(api_token) => api_token, 686 | Err(_) => { 687 | return (axum::http::StatusCode::UNAUTHORIZED, "API Token Invalid").into_response() 688 | } 689 | } 690 | }; 691 | 692 | let user = { 693 | let mut conn = db_pool.read.get().await.unwrap(); 694 | find_user(&mut conn, api_token.user_id).await.unwrap() 695 | }; 696 | 697 | if let Err(e) = modify_yank(&user, State(db_pool), Path((name, version)), false).await { 698 | return (axum::http::StatusCode::BAD_REQUEST, e.to_string()).into_response(); 699 | } 700 | 701 | ().into_response() 702 | } 703 | 704 | async fn modify_yank( 705 | user: &User, 706 | State(db_pool): State, 707 | Path((name, version)): Path<(String, String)>, 708 | yanked: bool, 709 | ) -> Result<()> { 710 | let plugin = { 711 | let mut conn = db_pool.read.get().await?; 712 | find_plugin(&mut conn, user, &name).await? 713 | }; 714 | 715 | { 716 | let mut conn = db_pool.write.get().await?; 717 | modify_plugin_version_yank(&mut conn, &plugin, &version, yanked).await?; 718 | } 719 | 720 | Ok(()) 721 | } 722 | -------------------------------------------------------------------------------- /volts-front/assets/tailwind.css: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.0.23 | MIT License | https://tailwindcss.com 3 | */ 4 | 5 | /* 6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 8 | */ 9 | 10 | *, 11 | ::before, 12 | ::after { 13 | box-sizing: border-box; 14 | /* 1 */ 15 | border-width: 0; 16 | /* 2 */ 17 | border-style: solid; 18 | /* 2 */ 19 | border-color: #e5e7eb; 20 | /* 2 */ 21 | } 22 | 23 | ::before, 24 | ::after { 25 | --tw-content: ''; 26 | } 27 | 28 | /* 29 | 1. Use a consistent sensible line-height in all browsers. 30 | 2. Prevent adjustments of font size after orientation changes in iOS. 31 | 3. Use a more readable tab size. 32 | 4. Use the user's configured `sans` font-family by default. 33 | */ 34 | 35 | html { 36 | line-height: 1.5; 37 | /* 1 */ 38 | -webkit-text-size-adjust: 100%; 39 | /* 2 */ 40 | -moz-tab-size: 4; 41 | /* 3 */ 42 | -o-tab-size: 4; 43 | tab-size: 4; 44 | /* 3 */ 45 | font-family: "Poppins", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 46 | /* 4 */ 47 | } 48 | 49 | /* 50 | 1. Remove the margin in all browsers. 51 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 52 | */ 53 | 54 | body { 55 | margin: 0; 56 | /* 1 */ 57 | line-height: inherit; 58 | /* 2 */ 59 | } 60 | 61 | /* 62 | 1. Add the correct height in Firefox. 63 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 64 | 3. Ensure horizontal rules are visible by default. 65 | */ 66 | 67 | hr { 68 | height: 0; 69 | /* 1 */ 70 | color: inherit; 71 | /* 2 */ 72 | border-top-width: 1px; 73 | /* 3 */ 74 | } 75 | 76 | /* 77 | Add the correct text decoration in Chrome, Edge, and Safari. 78 | */ 79 | 80 | abbr:where([title]) { 81 | -webkit-text-decoration: underline dotted; 82 | text-decoration: underline dotted; 83 | } 84 | 85 | /* 86 | Remove the default font size and weight for headings. 87 | */ 88 | 89 | h1, 90 | h2, 91 | h3, 92 | h4, 93 | h5, 94 | h6 { 95 | font-size: inherit; 96 | font-weight: inherit; 97 | } 98 | 99 | /* 100 | Reset links to optimize for opt-in styling instead of opt-out. 101 | */ 102 | 103 | a { 104 | color: inherit; 105 | text-decoration: inherit; 106 | } 107 | 108 | /* 109 | Add the correct font weight in Edge and Safari. 110 | */ 111 | 112 | b, 113 | strong { 114 | font-weight: bolder; 115 | } 116 | 117 | /* 118 | 1. Use the user's configured `mono` font family by default. 119 | 2. Correct the odd `em` font sizing in all browsers. 120 | */ 121 | 122 | code, 123 | kbd, 124 | samp, 125 | pre { 126 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 127 | /* 1 */ 128 | font-size: 1em; 129 | /* 2 */ 130 | } 131 | 132 | /* 133 | Add the correct font size in all browsers. 134 | */ 135 | 136 | small { 137 | font-size: 80%; 138 | } 139 | 140 | /* 141 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 142 | */ 143 | 144 | sub, 145 | sup { 146 | font-size: 75%; 147 | line-height: 0; 148 | position: relative; 149 | vertical-align: baseline; 150 | } 151 | 152 | sub { 153 | bottom: -0.25em; 154 | } 155 | 156 | sup { 157 | top: -0.5em; 158 | } 159 | 160 | /* 161 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 162 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 163 | 3. Remove gaps between table borders by default. 164 | */ 165 | 166 | table { 167 | text-indent: 0; 168 | /* 1 */ 169 | border-color: inherit; 170 | /* 2 */ 171 | border-collapse: collapse; 172 | /* 3 */ 173 | } 174 | 175 | /* 176 | 1. Change the font styles in all browsers. 177 | 2. Remove the margin in Firefox and Safari. 178 | 3. Remove default padding in all browsers. 179 | */ 180 | 181 | button, 182 | input, 183 | optgroup, 184 | select, 185 | textarea { 186 | font-family: inherit; 187 | /* 1 */ 188 | font-size: 100%; 189 | /* 1 */ 190 | line-height: inherit; 191 | /* 1 */ 192 | color: inherit; 193 | /* 1 */ 194 | margin: 0; 195 | /* 2 */ 196 | padding: 0; 197 | /* 3 */ 198 | } 199 | 200 | /* 201 | Remove the inheritance of text transform in Edge and Firefox. 202 | */ 203 | 204 | button, 205 | select { 206 | text-transform: none; 207 | } 208 | 209 | /* 210 | 1. Correct the inability to style clickable types in iOS and Safari. 211 | 2. Remove default button styles. 212 | */ 213 | 214 | button, 215 | [type='button'], 216 | [type='reset'], 217 | [type='submit'] { 218 | -webkit-appearance: button; 219 | /* 1 */ 220 | background-color: transparent; 221 | /* 2 */ 222 | background-image: none; 223 | /* 2 */ 224 | } 225 | 226 | /* 227 | Use the modern Firefox focus style for all focusable elements. 228 | */ 229 | 230 | :-moz-focusring { 231 | outline: auto; 232 | } 233 | 234 | /* 235 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 236 | */ 237 | 238 | :-moz-ui-invalid { 239 | box-shadow: none; 240 | } 241 | 242 | /* 243 | Add the correct vertical alignment in Chrome and Firefox. 244 | */ 245 | 246 | progress { 247 | vertical-align: baseline; 248 | } 249 | 250 | /* 251 | Correct the cursor style of increment and decrement buttons in Safari. 252 | */ 253 | 254 | ::-webkit-inner-spin-button, 255 | ::-webkit-outer-spin-button { 256 | height: auto; 257 | } 258 | 259 | /* 260 | 1. Correct the odd appearance in Chrome and Safari. 261 | 2. Correct the outline style in Safari. 262 | */ 263 | 264 | [type='search'] { 265 | -webkit-appearance: textfield; 266 | /* 1 */ 267 | outline-offset: -2px; 268 | /* 2 */ 269 | } 270 | 271 | /* 272 | Remove the inner padding in Chrome and Safari on macOS. 273 | */ 274 | 275 | ::-webkit-search-decoration { 276 | -webkit-appearance: none; 277 | } 278 | 279 | /* 280 | 1. Correct the inability to style clickable types in iOS and Safari. 281 | 2. Change font properties to `inherit` in Safari. 282 | */ 283 | 284 | ::-webkit-file-upload-button { 285 | -webkit-appearance: button; 286 | /* 1 */ 287 | font: inherit; 288 | /* 2 */ 289 | } 290 | 291 | /* 292 | Add the correct display in Chrome and Safari. 293 | */ 294 | 295 | summary { 296 | display: list-item; 297 | } 298 | 299 | /* 300 | Removes the default spacing and border for appropriate elements. 301 | */ 302 | 303 | blockquote, 304 | dl, 305 | dd, 306 | h1, 307 | h2, 308 | h3, 309 | h4, 310 | h5, 311 | h6, 312 | hr, 313 | figure, 314 | p, 315 | pre { 316 | margin: 0; 317 | } 318 | 319 | fieldset { 320 | margin: 0; 321 | padding: 0; 322 | } 323 | 324 | legend { 325 | padding: 0; 326 | } 327 | 328 | ol, 329 | ul, 330 | menu { 331 | list-style: none; 332 | margin: 0; 333 | padding: 0; 334 | } 335 | 336 | /* 337 | Prevent resizing textareas horizontally by default. 338 | */ 339 | 340 | textarea { 341 | resize: vertical; 342 | } 343 | 344 | /* 345 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 346 | 2. Set the default placeholder color to the user's configured gray 400 color. 347 | */ 348 | 349 | input::-moz-placeholder, textarea::-moz-placeholder { 350 | opacity: 1; 351 | /* 1 */ 352 | color: #9ca3af; 353 | /* 2 */ 354 | } 355 | 356 | input:-ms-input-placeholder, textarea:-ms-input-placeholder { 357 | opacity: 1; 358 | /* 1 */ 359 | color: #9ca3af; 360 | /* 2 */ 361 | } 362 | 363 | input::placeholder, 364 | textarea::placeholder { 365 | opacity: 1; 366 | /* 1 */ 367 | color: #9ca3af; 368 | /* 2 */ 369 | } 370 | 371 | /* 372 | Set the default cursor for buttons. 373 | */ 374 | 375 | button, 376 | [role="button"] { 377 | cursor: pointer; 378 | } 379 | 380 | /* 381 | Make sure disabled buttons don't get the pointer cursor. 382 | */ 383 | 384 | :disabled { 385 | cursor: default; 386 | } 387 | 388 | /* 389 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 390 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 391 | This can trigger a poorly considered lint error in some tools but is included by design. 392 | */ 393 | 394 | img, 395 | svg, 396 | video, 397 | canvas, 398 | audio, 399 | iframe, 400 | embed, 401 | object { 402 | display: block; 403 | /* 1 */ 404 | vertical-align: middle; 405 | /* 2 */ 406 | } 407 | 408 | /* 409 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 410 | */ 411 | 412 | img, 413 | video { 414 | max-width: 100%; 415 | height: auto; 416 | } 417 | 418 | /* 419 | Ensure the default browser behavior of the `hidden` attribute. 420 | */ 421 | 422 | [hidden] { 423 | display: none; 424 | } 425 | 426 | *, ::before, ::after { 427 | --tw-translate-x: 0; 428 | --tw-translate-y: 0; 429 | --tw-rotate: 0; 430 | --tw-skew-x: 0; 431 | --tw-skew-y: 0; 432 | --tw-scale-x: 1; 433 | --tw-scale-y: 1; 434 | --tw-pan-x: ; 435 | --tw-pan-y: ; 436 | --tw-pinch-zoom: ; 437 | --tw-scroll-snap-strictness: proximity; 438 | --tw-ordinal: ; 439 | --tw-slashed-zero: ; 440 | --tw-numeric-figure: ; 441 | --tw-numeric-spacing: ; 442 | --tw-numeric-fraction: ; 443 | --tw-ring-inset: ; 444 | --tw-ring-offset-width: 0px; 445 | --tw-ring-offset-color: #fff; 446 | --tw-ring-color: rgb(59 130 246 / 0.5); 447 | --tw-ring-offset-shadow: 0 0 #0000; 448 | --tw-ring-shadow: 0 0 #0000; 449 | --tw-shadow: 0 0 #0000; 450 | --tw-shadow-colored: 0 0 #0000; 451 | --tw-blur: ; 452 | --tw-brightness: ; 453 | --tw-contrast: ; 454 | --tw-grayscale: ; 455 | --tw-hue-rotate: ; 456 | --tw-invert: ; 457 | --tw-saturate: ; 458 | --tw-sepia: ; 459 | --tw-drop-shadow: ; 460 | --tw-backdrop-blur: ; 461 | --tw-backdrop-brightness: ; 462 | --tw-backdrop-contrast: ; 463 | --tw-backdrop-grayscale: ; 464 | --tw-backdrop-hue-rotate: ; 465 | --tw-backdrop-invert: ; 466 | --tw-backdrop-opacity: ; 467 | --tw-backdrop-saturate: ; 468 | --tw-backdrop-sepia: ; 469 | } 470 | 471 | .container { 472 | width: 100%; 473 | } 474 | 475 | @media (min-width: 640px) { 476 | .container { 477 | max-width: 640px; 478 | } 479 | } 480 | 481 | @media (min-width: 768px) { 482 | .container { 483 | max-width: 768px; 484 | } 485 | } 486 | 487 | @media (min-width: 1024px) { 488 | .container { 489 | max-width: 1024px; 490 | } 491 | } 492 | 493 | @media (min-width: 1170px) { 494 | .container { 495 | max-width: 1170px; 496 | } 497 | } 498 | 499 | .prose { 500 | color: var(--tw-prose-body); 501 | max-width: 65ch; 502 | } 503 | 504 | .prose :where([class~="lead"]):not(:where([class~="not-prose"] *)) { 505 | color: var(--tw-prose-lead); 506 | font-size: 1.25em; 507 | line-height: 1.6; 508 | margin-top: 1.2em; 509 | margin-bottom: 1.2em; 510 | } 511 | 512 | .prose :where(a):not(:where([class~="not-prose"] *)) { 513 | color: var(--tw-prose-links); 514 | text-decoration: underline; 515 | font-weight: 500; 516 | } 517 | 518 | .prose :where(strong):not(:where([class~="not-prose"] *)) { 519 | color: var(--tw-prose-bold); 520 | font-weight: 600; 521 | } 522 | 523 | .prose :where(a strong):not(:where([class~="not-prose"] *)) { 524 | color: inherit; 525 | } 526 | 527 | .prose :where(blockquote strong):not(:where([class~="not-prose"] *)) { 528 | color: inherit; 529 | } 530 | 531 | .prose :where(thead th strong):not(:where([class~="not-prose"] *)) { 532 | color: inherit; 533 | } 534 | 535 | .prose :where(ol):not(:where([class~="not-prose"] *)) { 536 | list-style-type: decimal; 537 | margin-top: 1.25em; 538 | margin-bottom: 1.25em; 539 | padding-left: 1.625em; 540 | } 541 | 542 | .prose :where(ol[type="A"]):not(:where([class~="not-prose"] *)) { 543 | list-style-type: upper-alpha; 544 | } 545 | 546 | .prose :where(ol[type="a"]):not(:where([class~="not-prose"] *)) { 547 | list-style-type: lower-alpha; 548 | } 549 | 550 | .prose :where(ol[type="A" s]):not(:where([class~="not-prose"] *)) { 551 | list-style-type: upper-alpha; 552 | } 553 | 554 | .prose :where(ol[type="a" s]):not(:where([class~="not-prose"] *)) { 555 | list-style-type: lower-alpha; 556 | } 557 | 558 | .prose :where(ol[type="I"]):not(:where([class~="not-prose"] *)) { 559 | list-style-type: upper-roman; 560 | } 561 | 562 | .prose :where(ol[type="i"]):not(:where([class~="not-prose"] *)) { 563 | list-style-type: lower-roman; 564 | } 565 | 566 | .prose :where(ol[type="I" s]):not(:where([class~="not-prose"] *)) { 567 | list-style-type: upper-roman; 568 | } 569 | 570 | .prose :where(ol[type="i" s]):not(:where([class~="not-prose"] *)) { 571 | list-style-type: lower-roman; 572 | } 573 | 574 | .prose :where(ol[type="1"]):not(:where([class~="not-prose"] *)) { 575 | list-style-type: decimal; 576 | } 577 | 578 | .prose :where(ul):not(:where([class~="not-prose"] *)) { 579 | list-style-type: disc; 580 | margin-top: 1.25em; 581 | margin-bottom: 1.25em; 582 | padding-left: 1.625em; 583 | } 584 | 585 | .prose :where(ol > li):not(:where([class~="not-prose"] *))::marker { 586 | font-weight: 400; 587 | color: var(--tw-prose-counters); 588 | } 589 | 590 | .prose :where(ul > li):not(:where([class~="not-prose"] *))::marker { 591 | color: var(--tw-prose-bullets); 592 | } 593 | 594 | .prose :where(hr):not(:where([class~="not-prose"] *)) { 595 | border-color: var(--tw-prose-hr); 596 | border-top-width: 1px; 597 | margin-top: 3em; 598 | margin-bottom: 3em; 599 | } 600 | 601 | .prose :where(blockquote):not(:where([class~="not-prose"] *)) { 602 | font-weight: 500; 603 | font-style: italic; 604 | color: var(--tw-prose-quotes); 605 | border-left-width: 0.25rem; 606 | border-left-color: var(--tw-prose-quote-borders); 607 | quotes: "\201C""\201D""\2018""\2019"; 608 | margin-top: 1.6em; 609 | margin-bottom: 1.6em; 610 | padding-left: 1em; 611 | } 612 | 613 | .prose :where(blockquote p:first-of-type):not(:where([class~="not-prose"] *))::before { 614 | content: open-quote; 615 | } 616 | 617 | .prose :where(blockquote p:last-of-type):not(:where([class~="not-prose"] *))::after { 618 | content: close-quote; 619 | } 620 | 621 | .prose :where(h1):not(:where([class~="not-prose"] *)) { 622 | color: var(--tw-prose-headings); 623 | font-weight: 800; 624 | font-size: 2.25em; 625 | margin-top: 0; 626 | margin-bottom: 0.8888889em; 627 | line-height: 1.1111111; 628 | } 629 | 630 | .prose :where(h1 strong):not(:where([class~="not-prose"] *)) { 631 | font-weight: 900; 632 | color: inherit; 633 | } 634 | 635 | .prose :where(h2):not(:where([class~="not-prose"] *)) { 636 | color: var(--tw-prose-headings); 637 | font-weight: 700; 638 | font-size: 1.5em; 639 | margin-top: 2em; 640 | margin-bottom: 1em; 641 | line-height: 1.3333333; 642 | } 643 | 644 | .prose :where(h2 strong):not(:where([class~="not-prose"] *)) { 645 | font-weight: 800; 646 | color: inherit; 647 | } 648 | 649 | .prose :where(h3):not(:where([class~="not-prose"] *)) { 650 | color: var(--tw-prose-headings); 651 | font-weight: 600; 652 | font-size: 1.25em; 653 | margin-top: 1.6em; 654 | margin-bottom: 0.6em; 655 | line-height: 1.6; 656 | } 657 | 658 | .prose :where(h3 strong):not(:where([class~="not-prose"] *)) { 659 | font-weight: 700; 660 | color: inherit; 661 | } 662 | 663 | .prose :where(h4):not(:where([class~="not-prose"] *)) { 664 | color: var(--tw-prose-headings); 665 | font-weight: 600; 666 | margin-top: 1.5em; 667 | margin-bottom: 0.5em; 668 | line-height: 1.5; 669 | } 670 | 671 | .prose :where(h4 strong):not(:where([class~="not-prose"] *)) { 672 | font-weight: 700; 673 | color: inherit; 674 | } 675 | 676 | .prose :where(img):not(:where([class~="not-prose"] *)) { 677 | margin-top: 2em; 678 | margin-bottom: 2em; 679 | } 680 | 681 | .prose :where(figure > *):not(:where([class~="not-prose"] *)) { 682 | margin-top: 0; 683 | margin-bottom: 0; 684 | } 685 | 686 | .prose :where(figcaption):not(:where([class~="not-prose"] *)) { 687 | color: var(--tw-prose-captions); 688 | font-size: 0.875em; 689 | line-height: 1.4285714; 690 | margin-top: 0.8571429em; 691 | } 692 | 693 | .prose :where(code):not(:where([class~="not-prose"] *)) { 694 | color: var(--tw-prose-code); 695 | font-weight: 600; 696 | font-size: 0.875em; 697 | } 698 | 699 | .prose :where(code):not(:where([class~="not-prose"] *))::before { 700 | content: "`"; 701 | } 702 | 703 | .prose :where(code):not(:where([class~="not-prose"] *))::after { 704 | content: "`"; 705 | } 706 | 707 | .prose :where(a code):not(:where([class~="not-prose"] *)) { 708 | color: inherit; 709 | } 710 | 711 | .prose :where(h1 code):not(:where([class~="not-prose"] *)) { 712 | color: inherit; 713 | } 714 | 715 | .prose :where(h2 code):not(:where([class~="not-prose"] *)) { 716 | color: inherit; 717 | font-size: 0.875em; 718 | } 719 | 720 | .prose :where(h3 code):not(:where([class~="not-prose"] *)) { 721 | color: inherit; 722 | font-size: 0.9em; 723 | } 724 | 725 | .prose :where(h4 code):not(:where([class~="not-prose"] *)) { 726 | color: inherit; 727 | } 728 | 729 | .prose :where(blockquote code):not(:where([class~="not-prose"] *)) { 730 | color: inherit; 731 | } 732 | 733 | .prose :where(thead th code):not(:where([class~="not-prose"] *)) { 734 | color: inherit; 735 | } 736 | 737 | .prose :where(pre):not(:where([class~="not-prose"] *)) { 738 | color: var(--tw-prose-pre-code); 739 | background-color: var(--tw-prose-pre-bg); 740 | overflow-x: auto; 741 | font-weight: 400; 742 | font-size: 0.875em; 743 | line-height: 1.7142857; 744 | margin-top: 1.7142857em; 745 | margin-bottom: 1.7142857em; 746 | border-radius: 0.375rem; 747 | padding-top: 0.8571429em; 748 | padding-right: 1.1428571em; 749 | padding-bottom: 0.8571429em; 750 | padding-left: 1.1428571em; 751 | } 752 | 753 | .prose :where(pre code):not(:where([class~="not-prose"] *)) { 754 | background-color: transparent; 755 | border-width: 0; 756 | border-radius: 0; 757 | padding: 0; 758 | font-weight: inherit; 759 | color: inherit; 760 | font-size: inherit; 761 | font-family: inherit; 762 | line-height: inherit; 763 | } 764 | 765 | .prose :where(pre code):not(:where([class~="not-prose"] *))::before { 766 | content: none; 767 | } 768 | 769 | .prose :where(pre code):not(:where([class~="not-prose"] *))::after { 770 | content: none; 771 | } 772 | 773 | .prose :where(table):not(:where([class~="not-prose"] *)) { 774 | width: 100%; 775 | table-layout: auto; 776 | text-align: left; 777 | margin-top: 2em; 778 | margin-bottom: 2em; 779 | font-size: 0.875em; 780 | line-height: 1.7142857; 781 | } 782 | 783 | .prose :where(thead):not(:where([class~="not-prose"] *)) { 784 | border-bottom-width: 1px; 785 | border-bottom-color: var(--tw-prose-th-borders); 786 | } 787 | 788 | .prose :where(thead th):not(:where([class~="not-prose"] *)) { 789 | color: var(--tw-prose-headings); 790 | font-weight: 600; 791 | vertical-align: bottom; 792 | padding-right: 0.5714286em; 793 | padding-bottom: 0.5714286em; 794 | padding-left: 0.5714286em; 795 | } 796 | 797 | .prose :where(tbody tr):not(:where([class~="not-prose"] *)) { 798 | border-bottom-width: 1px; 799 | border-bottom-color: var(--tw-prose-td-borders); 800 | } 801 | 802 | .prose :where(tbody tr:last-child):not(:where([class~="not-prose"] *)) { 803 | border-bottom-width: 0; 804 | } 805 | 806 | .prose :where(tbody td):not(:where([class~="not-prose"] *)) { 807 | vertical-align: baseline; 808 | } 809 | 810 | .prose :where(tfoot):not(:where([class~="not-prose"] *)) { 811 | border-top-width: 1px; 812 | border-top-color: var(--tw-prose-th-borders); 813 | } 814 | 815 | .prose :where(tfoot td):not(:where([class~="not-prose"] *)) { 816 | vertical-align: top; 817 | } 818 | 819 | .prose { 820 | --tw-prose-body: #374151; 821 | --tw-prose-headings: #111827; 822 | --tw-prose-lead: #4b5563; 823 | --tw-prose-links: #111827; 824 | --tw-prose-bold: #111827; 825 | --tw-prose-counters: #6b7280; 826 | --tw-prose-bullets: #d1d5db; 827 | --tw-prose-hr: #e5e7eb; 828 | --tw-prose-quotes: #111827; 829 | --tw-prose-quote-borders: #e5e7eb; 830 | --tw-prose-captions: #6b7280; 831 | --tw-prose-code: #111827; 832 | --tw-prose-pre-code: #e5e7eb; 833 | --tw-prose-pre-bg: #1f2937; 834 | --tw-prose-th-borders: #d1d5db; 835 | --tw-prose-td-borders: #e5e7eb; 836 | --tw-prose-invert-body: #d1d5db; 837 | --tw-prose-invert-headings: #fff; 838 | --tw-prose-invert-lead: #9ca3af; 839 | --tw-prose-invert-links: #fff; 840 | --tw-prose-invert-bold: #fff; 841 | --tw-prose-invert-counters: #9ca3af; 842 | --tw-prose-invert-bullets: #4b5563; 843 | --tw-prose-invert-hr: #374151; 844 | --tw-prose-invert-quotes: #f3f4f6; 845 | --tw-prose-invert-quote-borders: #374151; 846 | --tw-prose-invert-captions: #9ca3af; 847 | --tw-prose-invert-code: #fff; 848 | --tw-prose-invert-pre-code: #d1d5db; 849 | --tw-prose-invert-pre-bg: rgb(0 0 0 / 50%); 850 | --tw-prose-invert-th-borders: #4b5563; 851 | --tw-prose-invert-td-borders: #374151; 852 | font-size: 1rem; 853 | line-height: 1.75; 854 | } 855 | 856 | .prose :where(p):not(:where([class~="not-prose"] *)) { 857 | margin-top: 1.25em; 858 | margin-bottom: 1.25em; 859 | } 860 | 861 | .prose :where(video):not(:where([class~="not-prose"] *)) { 862 | margin-top: 2em; 863 | margin-bottom: 2em; 864 | } 865 | 866 | .prose :where(figure):not(:where([class~="not-prose"] *)) { 867 | margin-top: 2em; 868 | margin-bottom: 2em; 869 | } 870 | 871 | .prose :where(li):not(:where([class~="not-prose"] *)) { 872 | margin-top: 0.5em; 873 | margin-bottom: 0.5em; 874 | } 875 | 876 | .prose :where(ol > li):not(:where([class~="not-prose"] *)) { 877 | padding-left: 0.375em; 878 | } 879 | 880 | .prose :where(ul > li):not(:where([class~="not-prose"] *)) { 881 | padding-left: 0.375em; 882 | } 883 | 884 | .prose :where(.prose > ul > li p):not(:where([class~="not-prose"] *)) { 885 | margin-top: 0.75em; 886 | margin-bottom: 0.75em; 887 | } 888 | 889 | .prose :where(.prose > ul > li > *:first-child):not(:where([class~="not-prose"] *)) { 890 | margin-top: 1.25em; 891 | } 892 | 893 | .prose :where(.prose > ul > li > *:last-child):not(:where([class~="not-prose"] *)) { 894 | margin-bottom: 1.25em; 895 | } 896 | 897 | .prose :where(.prose > ol > li > *:first-child):not(:where([class~="not-prose"] *)) { 898 | margin-top: 1.25em; 899 | } 900 | 901 | .prose :where(.prose > ol > li > *:last-child):not(:where([class~="not-prose"] *)) { 902 | margin-bottom: 1.25em; 903 | } 904 | 905 | .prose :where(ul ul, ul ol, ol ul, ol ol):not(:where([class~="not-prose"] *)) { 906 | margin-top: 0.75em; 907 | margin-bottom: 0.75em; 908 | } 909 | 910 | .prose :where(hr + *):not(:where([class~="not-prose"] *)) { 911 | margin-top: 0; 912 | } 913 | 914 | .prose :where(h2 + *):not(:where([class~="not-prose"] *)) { 915 | margin-top: 0; 916 | } 917 | 918 | .prose :where(h3 + *):not(:where([class~="not-prose"] *)) { 919 | margin-top: 0; 920 | } 921 | 922 | .prose :where(h4 + *):not(:where([class~="not-prose"] *)) { 923 | margin-top: 0; 924 | } 925 | 926 | .prose :where(thead th:first-child):not(:where([class~="not-prose"] *)) { 927 | padding-left: 0; 928 | } 929 | 930 | .prose :where(thead th:last-child):not(:where([class~="not-prose"] *)) { 931 | padding-right: 0; 932 | } 933 | 934 | .prose :where(tbody td, tfoot td):not(:where([class~="not-prose"] *)) { 935 | padding-top: 0.5714286em; 936 | padding-right: 0.5714286em; 937 | padding-bottom: 0.5714286em; 938 | padding-left: 0.5714286em; 939 | } 940 | 941 | .prose :where(tbody td:first-child, tfoot td:first-child):not(:where([class~="not-prose"] *)) { 942 | padding-left: 0; 943 | } 944 | 945 | .prose :where(tbody td:last-child, tfoot td:last-child):not(:where([class~="not-prose"] *)) { 946 | padding-right: 0; 947 | } 948 | 949 | .prose :where(.prose > :first-child):not(:where([class~="not-prose"] *)) { 950 | margin-top: 0; 951 | } 952 | 953 | .prose :where(.prose > :last-child):not(:where([class~="not-prose"] *)) { 954 | margin-bottom: 0; 955 | } 956 | 957 | .prose-sm { 958 | font-size: 0.875rem; 959 | line-height: 1.7142857; 960 | } 961 | 962 | .prose-sm :where(p):not(:where([class~="not-prose"] *)) { 963 | margin-top: 1.1428571em; 964 | margin-bottom: 1.1428571em; 965 | } 966 | 967 | .prose-sm :where([class~="lead"]):not(:where([class~="not-prose"] *)) { 968 | font-size: 1.2857143em; 969 | line-height: 1.5555556; 970 | margin-top: 0.8888889em; 971 | margin-bottom: 0.8888889em; 972 | } 973 | 974 | .prose-sm :where(blockquote):not(:where([class~="not-prose"] *)) { 975 | margin-top: 1.3333333em; 976 | margin-bottom: 1.3333333em; 977 | padding-left: 1.1111111em; 978 | } 979 | 980 | .prose-sm :where(h1):not(:where([class~="not-prose"] *)) { 981 | font-size: 2.1428571em; 982 | margin-top: 0; 983 | margin-bottom: 0.8em; 984 | line-height: 1.2; 985 | } 986 | 987 | .prose-sm :where(h2):not(:where([class~="not-prose"] *)) { 988 | font-size: 1.4285714em; 989 | margin-top: 1.6em; 990 | margin-bottom: 0.8em; 991 | line-height: 1.4; 992 | } 993 | 994 | .prose-sm :where(h3):not(:where([class~="not-prose"] *)) { 995 | font-size: 1.2857143em; 996 | margin-top: 1.5555556em; 997 | margin-bottom: 0.4444444em; 998 | line-height: 1.5555556; 999 | } 1000 | 1001 | .prose-sm :where(h4):not(:where([class~="not-prose"] *)) { 1002 | margin-top: 1.4285714em; 1003 | margin-bottom: 0.5714286em; 1004 | line-height: 1.4285714; 1005 | } 1006 | 1007 | .prose-sm :where(img):not(:where([class~="not-prose"] *)) { 1008 | margin-top: 1.7142857em; 1009 | margin-bottom: 1.7142857em; 1010 | } 1011 | 1012 | .prose-sm :where(video):not(:where([class~="not-prose"] *)) { 1013 | margin-top: 1.7142857em; 1014 | margin-bottom: 1.7142857em; 1015 | } 1016 | 1017 | .prose-sm :where(figure):not(:where([class~="not-prose"] *)) { 1018 | margin-top: 1.7142857em; 1019 | margin-bottom: 1.7142857em; 1020 | } 1021 | 1022 | .prose-sm :where(figure > *):not(:where([class~="not-prose"] *)) { 1023 | margin-top: 0; 1024 | margin-bottom: 0; 1025 | } 1026 | 1027 | .prose-sm :where(figcaption):not(:where([class~="not-prose"] *)) { 1028 | font-size: 0.8571429em; 1029 | line-height: 1.3333333; 1030 | margin-top: 0.6666667em; 1031 | } 1032 | 1033 | .prose-sm :where(code):not(:where([class~="not-prose"] *)) { 1034 | font-size: 0.8571429em; 1035 | } 1036 | 1037 | .prose-sm :where(h2 code):not(:where([class~="not-prose"] *)) { 1038 | font-size: 0.9em; 1039 | } 1040 | 1041 | .prose-sm :where(h3 code):not(:where([class~="not-prose"] *)) { 1042 | font-size: 0.8888889em; 1043 | } 1044 | 1045 | .prose-sm :where(pre):not(:where([class~="not-prose"] *)) { 1046 | font-size: 0.8571429em; 1047 | line-height: 1.6666667; 1048 | margin-top: 1.6666667em; 1049 | margin-bottom: 1.6666667em; 1050 | border-radius: 0.25rem; 1051 | padding-top: 0.6666667em; 1052 | padding-right: 1em; 1053 | padding-bottom: 0.6666667em; 1054 | padding-left: 1em; 1055 | } 1056 | 1057 | .prose-sm :where(ol):not(:where([class~="not-prose"] *)) { 1058 | margin-top: 1.1428571em; 1059 | margin-bottom: 1.1428571em; 1060 | padding-left: 1.5714286em; 1061 | } 1062 | 1063 | .prose-sm :where(ul):not(:where([class~="not-prose"] *)) { 1064 | margin-top: 1.1428571em; 1065 | margin-bottom: 1.1428571em; 1066 | padding-left: 1.5714286em; 1067 | } 1068 | 1069 | .prose-sm :where(li):not(:where([class~="not-prose"] *)) { 1070 | margin-top: 0.2857143em; 1071 | margin-bottom: 0.2857143em; 1072 | } 1073 | 1074 | .prose-sm :where(ol > li):not(:where([class~="not-prose"] *)) { 1075 | padding-left: 0.4285714em; 1076 | } 1077 | 1078 | .prose-sm :where(ul > li):not(:where([class~="not-prose"] *)) { 1079 | padding-left: 0.4285714em; 1080 | } 1081 | 1082 | .prose-sm :where(.prose > ul > li p):not(:where([class~="not-prose"] *)) { 1083 | margin-top: 0.5714286em; 1084 | margin-bottom: 0.5714286em; 1085 | } 1086 | 1087 | .prose-sm :where(.prose > ul > li > *:first-child):not(:where([class~="not-prose"] *)) { 1088 | margin-top: 1.1428571em; 1089 | } 1090 | 1091 | .prose-sm :where(.prose > ul > li > *:last-child):not(:where([class~="not-prose"] *)) { 1092 | margin-bottom: 1.1428571em; 1093 | } 1094 | 1095 | .prose-sm :where(.prose > ol > li > *:first-child):not(:where([class~="not-prose"] *)) { 1096 | margin-top: 1.1428571em; 1097 | } 1098 | 1099 | .prose-sm :where(.prose > ol > li > *:last-child):not(:where([class~="not-prose"] *)) { 1100 | margin-bottom: 1.1428571em; 1101 | } 1102 | 1103 | .prose-sm :where(ul ul, ul ol, ol ul, ol ol):not(:where([class~="not-prose"] *)) { 1104 | margin-top: 0.5714286em; 1105 | margin-bottom: 0.5714286em; 1106 | } 1107 | 1108 | .prose-sm :where(hr):not(:where([class~="not-prose"] *)) { 1109 | margin-top: 2.8571429em; 1110 | margin-bottom: 2.8571429em; 1111 | } 1112 | 1113 | .prose-sm :where(hr + *):not(:where([class~="not-prose"] *)) { 1114 | margin-top: 0; 1115 | } 1116 | 1117 | .prose-sm :where(h2 + *):not(:where([class~="not-prose"] *)) { 1118 | margin-top: 0; 1119 | } 1120 | 1121 | .prose-sm :where(h3 + *):not(:where([class~="not-prose"] *)) { 1122 | margin-top: 0; 1123 | } 1124 | 1125 | .prose-sm :where(h4 + *):not(:where([class~="not-prose"] *)) { 1126 | margin-top: 0; 1127 | } 1128 | 1129 | .prose-sm :where(table):not(:where([class~="not-prose"] *)) { 1130 | font-size: 0.8571429em; 1131 | line-height: 1.5; 1132 | } 1133 | 1134 | .prose-sm :where(thead th):not(:where([class~="not-prose"] *)) { 1135 | padding-right: 1em; 1136 | padding-bottom: 0.6666667em; 1137 | padding-left: 1em; 1138 | } 1139 | 1140 | .prose-sm :where(thead th:first-child):not(:where([class~="not-prose"] *)) { 1141 | padding-left: 0; 1142 | } 1143 | 1144 | .prose-sm :where(thead th:last-child):not(:where([class~="not-prose"] *)) { 1145 | padding-right: 0; 1146 | } 1147 | 1148 | .prose-sm :where(tbody td, tfoot td):not(:where([class~="not-prose"] *)) { 1149 | padding-top: 0.6666667em; 1150 | padding-right: 1em; 1151 | padding-bottom: 0.6666667em; 1152 | padding-left: 1em; 1153 | } 1154 | 1155 | .prose-sm :where(tbody td:first-child, tfoot td:first-child):not(:where([class~="not-prose"] *)) { 1156 | padding-left: 0; 1157 | } 1158 | 1159 | .prose-sm :where(tbody td:last-child, tfoot td:last-child):not(:where([class~="not-prose"] *)) { 1160 | padding-right: 0; 1161 | } 1162 | 1163 | .prose-sm :where(.prose > :first-child):not(:where([class~="not-prose"] *)) { 1164 | margin-top: 0; 1165 | } 1166 | 1167 | .prose-sm :where(.prose > :last-child):not(:where([class~="not-prose"] *)) { 1168 | margin-bottom: 0; 1169 | } 1170 | 1171 | .prose-base { 1172 | font-size: 1rem; 1173 | line-height: 1.75; 1174 | } 1175 | 1176 | .prose-base :where(p):not(:where([class~="not-prose"] *)) { 1177 | margin-top: 1.25em; 1178 | margin-bottom: 1.25em; 1179 | } 1180 | 1181 | .prose-base :where([class~="lead"]):not(:where([class~="not-prose"] *)) { 1182 | font-size: 1.25em; 1183 | line-height: 1.6; 1184 | margin-top: 1.2em; 1185 | margin-bottom: 1.2em; 1186 | } 1187 | 1188 | .prose-base :where(blockquote):not(:where([class~="not-prose"] *)) { 1189 | margin-top: 1.6em; 1190 | margin-bottom: 1.6em; 1191 | padding-left: 1em; 1192 | } 1193 | 1194 | .prose-base :where(h1):not(:where([class~="not-prose"] *)) { 1195 | font-size: 2.25em; 1196 | margin-top: 0; 1197 | margin-bottom: 0.8888889em; 1198 | line-height: 1.1111111; 1199 | } 1200 | 1201 | .prose-base :where(h2):not(:where([class~="not-prose"] *)) { 1202 | font-size: 1.5em; 1203 | margin-top: 2em; 1204 | margin-bottom: 1em; 1205 | line-height: 1.3333333; 1206 | } 1207 | 1208 | .prose-base :where(h3):not(:where([class~="not-prose"] *)) { 1209 | font-size: 1.25em; 1210 | margin-top: 1.6em; 1211 | margin-bottom: 0.6em; 1212 | line-height: 1.6; 1213 | } 1214 | 1215 | .prose-base :where(h4):not(:where([class~="not-prose"] *)) { 1216 | margin-top: 1.5em; 1217 | margin-bottom: 0.5em; 1218 | line-height: 1.5; 1219 | } 1220 | 1221 | .prose-base :where(img):not(:where([class~="not-prose"] *)) { 1222 | margin-top: 2em; 1223 | margin-bottom: 2em; 1224 | } 1225 | 1226 | .prose-base :where(video):not(:where([class~="not-prose"] *)) { 1227 | margin-top: 2em; 1228 | margin-bottom: 2em; 1229 | } 1230 | 1231 | .prose-base :where(figure):not(:where([class~="not-prose"] *)) { 1232 | margin-top: 2em; 1233 | margin-bottom: 2em; 1234 | } 1235 | 1236 | .prose-base :where(figure > *):not(:where([class~="not-prose"] *)) { 1237 | margin-top: 0; 1238 | margin-bottom: 0; 1239 | } 1240 | 1241 | .prose-base :where(figcaption):not(:where([class~="not-prose"] *)) { 1242 | font-size: 0.875em; 1243 | line-height: 1.4285714; 1244 | margin-top: 0.8571429em; 1245 | } 1246 | 1247 | .prose-base :where(code):not(:where([class~="not-prose"] *)) { 1248 | font-size: 0.875em; 1249 | } 1250 | 1251 | .prose-base :where(h2 code):not(:where([class~="not-prose"] *)) { 1252 | font-size: 0.875em; 1253 | } 1254 | 1255 | .prose-base :where(h3 code):not(:where([class~="not-prose"] *)) { 1256 | font-size: 0.9em; 1257 | } 1258 | 1259 | .prose-base :where(pre):not(:where([class~="not-prose"] *)) { 1260 | font-size: 0.875em; 1261 | line-height: 1.7142857; 1262 | margin-top: 1.7142857em; 1263 | margin-bottom: 1.7142857em; 1264 | border-radius: 0.375rem; 1265 | padding-top: 0.8571429em; 1266 | padding-right: 1.1428571em; 1267 | padding-bottom: 0.8571429em; 1268 | padding-left: 1.1428571em; 1269 | } 1270 | 1271 | .prose-base :where(ol):not(:where([class~="not-prose"] *)) { 1272 | margin-top: 1.25em; 1273 | margin-bottom: 1.25em; 1274 | padding-left: 1.625em; 1275 | } 1276 | 1277 | .prose-base :where(ul):not(:where([class~="not-prose"] *)) { 1278 | margin-top: 1.25em; 1279 | margin-bottom: 1.25em; 1280 | padding-left: 1.625em; 1281 | } 1282 | 1283 | .prose-base :where(li):not(:where([class~="not-prose"] *)) { 1284 | margin-top: 0.5em; 1285 | margin-bottom: 0.5em; 1286 | } 1287 | 1288 | .prose-base :where(ol > li):not(:where([class~="not-prose"] *)) { 1289 | padding-left: 0.375em; 1290 | } 1291 | 1292 | .prose-base :where(ul > li):not(:where([class~="not-prose"] *)) { 1293 | padding-left: 0.375em; 1294 | } 1295 | 1296 | .prose-base :where(.prose > ul > li p):not(:where([class~="not-prose"] *)) { 1297 | margin-top: 0.75em; 1298 | margin-bottom: 0.75em; 1299 | } 1300 | 1301 | .prose-base :where(.prose > ul > li > *:first-child):not(:where([class~="not-prose"] *)) { 1302 | margin-top: 1.25em; 1303 | } 1304 | 1305 | .prose-base :where(.prose > ul > li > *:last-child):not(:where([class~="not-prose"] *)) { 1306 | margin-bottom: 1.25em; 1307 | } 1308 | 1309 | .prose-base :where(.prose > ol > li > *:first-child):not(:where([class~="not-prose"] *)) { 1310 | margin-top: 1.25em; 1311 | } 1312 | 1313 | .prose-base :where(.prose > ol > li > *:last-child):not(:where([class~="not-prose"] *)) { 1314 | margin-bottom: 1.25em; 1315 | } 1316 | 1317 | .prose-base :where(ul ul, ul ol, ol ul, ol ol):not(:where([class~="not-prose"] *)) { 1318 | margin-top: 0.75em; 1319 | margin-bottom: 0.75em; 1320 | } 1321 | 1322 | .prose-base :where(hr):not(:where([class~="not-prose"] *)) { 1323 | margin-top: 3em; 1324 | margin-bottom: 3em; 1325 | } 1326 | 1327 | .prose-base :where(hr + *):not(:where([class~="not-prose"] *)) { 1328 | margin-top: 0; 1329 | } 1330 | 1331 | .prose-base :where(h2 + *):not(:where([class~="not-prose"] *)) { 1332 | margin-top: 0; 1333 | } 1334 | 1335 | .prose-base :where(h3 + *):not(:where([class~="not-prose"] *)) { 1336 | margin-top: 0; 1337 | } 1338 | 1339 | .prose-base :where(h4 + *):not(:where([class~="not-prose"] *)) { 1340 | margin-top: 0; 1341 | } 1342 | 1343 | .prose-base :where(table):not(:where([class~="not-prose"] *)) { 1344 | font-size: 0.875em; 1345 | line-height: 1.7142857; 1346 | } 1347 | 1348 | .prose-base :where(thead th):not(:where([class~="not-prose"] *)) { 1349 | padding-right: 0.5714286em; 1350 | padding-bottom: 0.5714286em; 1351 | padding-left: 0.5714286em; 1352 | } 1353 | 1354 | .prose-base :where(thead th:first-child):not(:where([class~="not-prose"] *)) { 1355 | padding-left: 0; 1356 | } 1357 | 1358 | .prose-base :where(thead th:last-child):not(:where([class~="not-prose"] *)) { 1359 | padding-right: 0; 1360 | } 1361 | 1362 | .prose-base :where(tbody td, tfoot td):not(:where([class~="not-prose"] *)) { 1363 | padding-top: 0.5714286em; 1364 | padding-right: 0.5714286em; 1365 | padding-bottom: 0.5714286em; 1366 | padding-left: 0.5714286em; 1367 | } 1368 | 1369 | .prose-base :where(tbody td:first-child, tfoot td:first-child):not(:where([class~="not-prose"] *)) { 1370 | padding-left: 0; 1371 | } 1372 | 1373 | .prose-base :where(tbody td:last-child, tfoot td:last-child):not(:where([class~="not-prose"] *)) { 1374 | padding-right: 0; 1375 | } 1376 | 1377 | .prose-base :where(.prose > :first-child):not(:where([class~="not-prose"] *)) { 1378 | margin-top: 0; 1379 | } 1380 | 1381 | .prose-base :where(.prose > :last-child):not(:where([class~="not-prose"] *)) { 1382 | margin-bottom: 0; 1383 | } 1384 | 1385 | .prose-lg { 1386 | font-size: 1.125rem; 1387 | line-height: 1.7777778; 1388 | } 1389 | 1390 | .prose-lg :where(p):not(:where([class~="not-prose"] *)) { 1391 | margin-top: 1.3333333em; 1392 | margin-bottom: 1.3333333em; 1393 | } 1394 | 1395 | .prose-lg :where([class~="lead"]):not(:where([class~="not-prose"] *)) { 1396 | font-size: 1.2222222em; 1397 | line-height: 1.4545455; 1398 | margin-top: 1.0909091em; 1399 | margin-bottom: 1.0909091em; 1400 | } 1401 | 1402 | .prose-lg :where(blockquote):not(:where([class~="not-prose"] *)) { 1403 | margin-top: 1.6666667em; 1404 | margin-bottom: 1.6666667em; 1405 | padding-left: 1em; 1406 | } 1407 | 1408 | .prose-lg :where(h1):not(:where([class~="not-prose"] *)) { 1409 | font-size: 2.6666667em; 1410 | margin-top: 0; 1411 | margin-bottom: 0.8333333em; 1412 | line-height: 1; 1413 | } 1414 | 1415 | .prose-lg :where(h2):not(:where([class~="not-prose"] *)) { 1416 | font-size: 1.6666667em; 1417 | margin-top: 1.8666667em; 1418 | margin-bottom: 1.0666667em; 1419 | line-height: 1.3333333; 1420 | } 1421 | 1422 | .prose-lg :where(h3):not(:where([class~="not-prose"] *)) { 1423 | font-size: 1.3333333em; 1424 | margin-top: 1.6666667em; 1425 | margin-bottom: 0.6666667em; 1426 | line-height: 1.5; 1427 | } 1428 | 1429 | .prose-lg :where(h4):not(:where([class~="not-prose"] *)) { 1430 | margin-top: 1.7777778em; 1431 | margin-bottom: 0.4444444em; 1432 | line-height: 1.5555556; 1433 | } 1434 | 1435 | .prose-lg :where(img):not(:where([class~="not-prose"] *)) { 1436 | margin-top: 1.7777778em; 1437 | margin-bottom: 1.7777778em; 1438 | } 1439 | 1440 | .prose-lg :where(video):not(:where([class~="not-prose"] *)) { 1441 | margin-top: 1.7777778em; 1442 | margin-bottom: 1.7777778em; 1443 | } 1444 | 1445 | .prose-lg :where(figure):not(:where([class~="not-prose"] *)) { 1446 | margin-top: 1.7777778em; 1447 | margin-bottom: 1.7777778em; 1448 | } 1449 | 1450 | .prose-lg :where(figure > *):not(:where([class~="not-prose"] *)) { 1451 | margin-top: 0; 1452 | margin-bottom: 0; 1453 | } 1454 | 1455 | .prose-lg :where(figcaption):not(:where([class~="not-prose"] *)) { 1456 | font-size: 0.8888889em; 1457 | line-height: 1.5; 1458 | margin-top: 1em; 1459 | } 1460 | 1461 | .prose-lg :where(code):not(:where([class~="not-prose"] *)) { 1462 | font-size: 0.8888889em; 1463 | } 1464 | 1465 | .prose-lg :where(h2 code):not(:where([class~="not-prose"] *)) { 1466 | font-size: 0.8666667em; 1467 | } 1468 | 1469 | .prose-lg :where(h3 code):not(:where([class~="not-prose"] *)) { 1470 | font-size: 0.875em; 1471 | } 1472 | 1473 | .prose-lg :where(pre):not(:where([class~="not-prose"] *)) { 1474 | font-size: 0.8888889em; 1475 | line-height: 1.75; 1476 | margin-top: 2em; 1477 | margin-bottom: 2em; 1478 | border-radius: 0.375rem; 1479 | padding-top: 1em; 1480 | padding-right: 1.5em; 1481 | padding-bottom: 1em; 1482 | padding-left: 1.5em; 1483 | } 1484 | 1485 | .prose-lg :where(ol):not(:where([class~="not-prose"] *)) { 1486 | margin-top: 1.3333333em; 1487 | margin-bottom: 1.3333333em; 1488 | padding-left: 1.5555556em; 1489 | } 1490 | 1491 | .prose-lg :where(ul):not(:where([class~="not-prose"] *)) { 1492 | margin-top: 1.3333333em; 1493 | margin-bottom: 1.3333333em; 1494 | padding-left: 1.5555556em; 1495 | } 1496 | 1497 | .prose-lg :where(li):not(:where([class~="not-prose"] *)) { 1498 | margin-top: 0.6666667em; 1499 | margin-bottom: 0.6666667em; 1500 | } 1501 | 1502 | .prose-lg :where(ol > li):not(:where([class~="not-prose"] *)) { 1503 | padding-left: 0.4444444em; 1504 | } 1505 | 1506 | .prose-lg :where(ul > li):not(:where([class~="not-prose"] *)) { 1507 | padding-left: 0.4444444em; 1508 | } 1509 | 1510 | .prose-lg :where(.prose > ul > li p):not(:where([class~="not-prose"] *)) { 1511 | margin-top: 0.8888889em; 1512 | margin-bottom: 0.8888889em; 1513 | } 1514 | 1515 | .prose-lg :where(.prose > ul > li > *:first-child):not(:where([class~="not-prose"] *)) { 1516 | margin-top: 1.3333333em; 1517 | } 1518 | 1519 | .prose-lg :where(.prose > ul > li > *:last-child):not(:where([class~="not-prose"] *)) { 1520 | margin-bottom: 1.3333333em; 1521 | } 1522 | 1523 | .prose-lg :where(.prose > ol > li > *:first-child):not(:where([class~="not-prose"] *)) { 1524 | margin-top: 1.3333333em; 1525 | } 1526 | 1527 | .prose-lg :where(.prose > ol > li > *:last-child):not(:where([class~="not-prose"] *)) { 1528 | margin-bottom: 1.3333333em; 1529 | } 1530 | 1531 | .prose-lg :where(ul ul, ul ol, ol ul, ol ol):not(:where([class~="not-prose"] *)) { 1532 | margin-top: 0.8888889em; 1533 | margin-bottom: 0.8888889em; 1534 | } 1535 | 1536 | .prose-lg :where(hr):not(:where([class~="not-prose"] *)) { 1537 | margin-top: 3.1111111em; 1538 | margin-bottom: 3.1111111em; 1539 | } 1540 | 1541 | .prose-lg :where(hr + *):not(:where([class~="not-prose"] *)) { 1542 | margin-top: 0; 1543 | } 1544 | 1545 | .prose-lg :where(h2 + *):not(:where([class~="not-prose"] *)) { 1546 | margin-top: 0; 1547 | } 1548 | 1549 | .prose-lg :where(h3 + *):not(:where([class~="not-prose"] *)) { 1550 | margin-top: 0; 1551 | } 1552 | 1553 | .prose-lg :where(h4 + *):not(:where([class~="not-prose"] *)) { 1554 | margin-top: 0; 1555 | } 1556 | 1557 | .prose-lg :where(table):not(:where([class~="not-prose"] *)) { 1558 | font-size: 0.8888889em; 1559 | line-height: 1.5; 1560 | } 1561 | 1562 | .prose-lg :where(thead th):not(:where([class~="not-prose"] *)) { 1563 | padding-right: 0.75em; 1564 | padding-bottom: 0.75em; 1565 | padding-left: 0.75em; 1566 | } 1567 | 1568 | .prose-lg :where(thead th:first-child):not(:where([class~="not-prose"] *)) { 1569 | padding-left: 0; 1570 | } 1571 | 1572 | .prose-lg :where(thead th:last-child):not(:where([class~="not-prose"] *)) { 1573 | padding-right: 0; 1574 | } 1575 | 1576 | .prose-lg :where(tbody td, tfoot td):not(:where([class~="not-prose"] *)) { 1577 | padding-top: 0.75em; 1578 | padding-right: 0.75em; 1579 | padding-bottom: 0.75em; 1580 | padding-left: 0.75em; 1581 | } 1582 | 1583 | .prose-lg :where(tbody td:first-child, tfoot td:first-child):not(:where([class~="not-prose"] *)) { 1584 | padding-left: 0; 1585 | } 1586 | 1587 | .prose-lg :where(tbody td:last-child, tfoot td:last-child):not(:where([class~="not-prose"] *)) { 1588 | padding-right: 0; 1589 | } 1590 | 1591 | .prose-lg :where(.prose > :first-child):not(:where([class~="not-prose"] *)) { 1592 | margin-top: 0; 1593 | } 1594 | 1595 | .prose-lg :where(.prose > :last-child):not(:where([class~="not-prose"] *)) { 1596 | margin-bottom: 0; 1597 | } 1598 | 1599 | .prose-xl { 1600 | font-size: 1.25rem; 1601 | line-height: 1.8; 1602 | } 1603 | 1604 | .prose-xl :where(p):not(:where([class~="not-prose"] *)) { 1605 | margin-top: 1.2em; 1606 | margin-bottom: 1.2em; 1607 | } 1608 | 1609 | .prose-xl :where([class~="lead"]):not(:where([class~="not-prose"] *)) { 1610 | font-size: 1.2em; 1611 | line-height: 1.5; 1612 | margin-top: 1em; 1613 | margin-bottom: 1em; 1614 | } 1615 | 1616 | .prose-xl :where(blockquote):not(:where([class~="not-prose"] *)) { 1617 | margin-top: 1.6em; 1618 | margin-bottom: 1.6em; 1619 | padding-left: 1.0666667em; 1620 | } 1621 | 1622 | .prose-xl :where(h1):not(:where([class~="not-prose"] *)) { 1623 | font-size: 2.8em; 1624 | margin-top: 0; 1625 | margin-bottom: 0.8571429em; 1626 | line-height: 1; 1627 | } 1628 | 1629 | .prose-xl :where(h2):not(:where([class~="not-prose"] *)) { 1630 | font-size: 1.8em; 1631 | margin-top: 1.5555556em; 1632 | margin-bottom: 0.8888889em; 1633 | line-height: 1.1111111; 1634 | } 1635 | 1636 | .prose-xl :where(h3):not(:where([class~="not-prose"] *)) { 1637 | font-size: 1.5em; 1638 | margin-top: 1.6em; 1639 | margin-bottom: 0.6666667em; 1640 | line-height: 1.3333333; 1641 | } 1642 | 1643 | .prose-xl :where(h4):not(:where([class~="not-prose"] *)) { 1644 | margin-top: 1.8em; 1645 | margin-bottom: 0.6em; 1646 | line-height: 1.6; 1647 | } 1648 | 1649 | .prose-xl :where(img):not(:where([class~="not-prose"] *)) { 1650 | margin-top: 2em; 1651 | margin-bottom: 2em; 1652 | } 1653 | 1654 | .prose-xl :where(video):not(:where([class~="not-prose"] *)) { 1655 | margin-top: 2em; 1656 | margin-bottom: 2em; 1657 | } 1658 | 1659 | .prose-xl :where(figure):not(:where([class~="not-prose"] *)) { 1660 | margin-top: 2em; 1661 | margin-bottom: 2em; 1662 | } 1663 | 1664 | .prose-xl :where(figure > *):not(:where([class~="not-prose"] *)) { 1665 | margin-top: 0; 1666 | margin-bottom: 0; 1667 | } 1668 | 1669 | .prose-xl :where(figcaption):not(:where([class~="not-prose"] *)) { 1670 | font-size: 0.9em; 1671 | line-height: 1.5555556; 1672 | margin-top: 1em; 1673 | } 1674 | 1675 | .prose-xl :where(code):not(:where([class~="not-prose"] *)) { 1676 | font-size: 0.9em; 1677 | } 1678 | 1679 | .prose-xl :where(h2 code):not(:where([class~="not-prose"] *)) { 1680 | font-size: 0.8611111em; 1681 | } 1682 | 1683 | .prose-xl :where(h3 code):not(:where([class~="not-prose"] *)) { 1684 | font-size: 0.9em; 1685 | } 1686 | 1687 | .prose-xl :where(pre):not(:where([class~="not-prose"] *)) { 1688 | font-size: 0.9em; 1689 | line-height: 1.7777778; 1690 | margin-top: 2em; 1691 | margin-bottom: 2em; 1692 | border-radius: 0.5rem; 1693 | padding-top: 1.1111111em; 1694 | padding-right: 1.3333333em; 1695 | padding-bottom: 1.1111111em; 1696 | padding-left: 1.3333333em; 1697 | } 1698 | 1699 | .prose-xl :where(ol):not(:where([class~="not-prose"] *)) { 1700 | margin-top: 1.2em; 1701 | margin-bottom: 1.2em; 1702 | padding-left: 1.6em; 1703 | } 1704 | 1705 | .prose-xl :where(ul):not(:where([class~="not-prose"] *)) { 1706 | margin-top: 1.2em; 1707 | margin-bottom: 1.2em; 1708 | padding-left: 1.6em; 1709 | } 1710 | 1711 | .prose-xl :where(li):not(:where([class~="not-prose"] *)) { 1712 | margin-top: 0.6em; 1713 | margin-bottom: 0.6em; 1714 | } 1715 | 1716 | .prose-xl :where(ol > li):not(:where([class~="not-prose"] *)) { 1717 | padding-left: 0.4em; 1718 | } 1719 | 1720 | .prose-xl :where(ul > li):not(:where([class~="not-prose"] *)) { 1721 | padding-left: 0.4em; 1722 | } 1723 | 1724 | .prose-xl :where(.prose > ul > li p):not(:where([class~="not-prose"] *)) { 1725 | margin-top: 0.8em; 1726 | margin-bottom: 0.8em; 1727 | } 1728 | 1729 | .prose-xl :where(.prose > ul > li > *:first-child):not(:where([class~="not-prose"] *)) { 1730 | margin-top: 1.2em; 1731 | } 1732 | 1733 | .prose-xl :where(.prose > ul > li > *:last-child):not(:where([class~="not-prose"] *)) { 1734 | margin-bottom: 1.2em; 1735 | } 1736 | 1737 | .prose-xl :where(.prose > ol > li > *:first-child):not(:where([class~="not-prose"] *)) { 1738 | margin-top: 1.2em; 1739 | } 1740 | 1741 | .prose-xl :where(.prose > ol > li > *:last-child):not(:where([class~="not-prose"] *)) { 1742 | margin-bottom: 1.2em; 1743 | } 1744 | 1745 | .prose-xl :where(ul ul, ul ol, ol ul, ol ol):not(:where([class~="not-prose"] *)) { 1746 | margin-top: 0.8em; 1747 | margin-bottom: 0.8em; 1748 | } 1749 | 1750 | .prose-xl :where(hr):not(:where([class~="not-prose"] *)) { 1751 | margin-top: 2.8em; 1752 | margin-bottom: 2.8em; 1753 | } 1754 | 1755 | .prose-xl :where(hr + *):not(:where([class~="not-prose"] *)) { 1756 | margin-top: 0; 1757 | } 1758 | 1759 | .prose-xl :where(h2 + *):not(:where([class~="not-prose"] *)) { 1760 | margin-top: 0; 1761 | } 1762 | 1763 | .prose-xl :where(h3 + *):not(:where([class~="not-prose"] *)) { 1764 | margin-top: 0; 1765 | } 1766 | 1767 | .prose-xl :where(h4 + *):not(:where([class~="not-prose"] *)) { 1768 | margin-top: 0; 1769 | } 1770 | 1771 | .prose-xl :where(table):not(:where([class~="not-prose"] *)) { 1772 | font-size: 0.9em; 1773 | line-height: 1.5555556; 1774 | } 1775 | 1776 | .prose-xl :where(thead th):not(:where([class~="not-prose"] *)) { 1777 | padding-right: 0.6666667em; 1778 | padding-bottom: 0.8888889em; 1779 | padding-left: 0.6666667em; 1780 | } 1781 | 1782 | .prose-xl :where(thead th:first-child):not(:where([class~="not-prose"] *)) { 1783 | padding-left: 0; 1784 | } 1785 | 1786 | .prose-xl :where(thead th:last-child):not(:where([class~="not-prose"] *)) { 1787 | padding-right: 0; 1788 | } 1789 | 1790 | .prose-xl :where(tbody td, tfoot td):not(:where([class~="not-prose"] *)) { 1791 | padding-top: 0.8888889em; 1792 | padding-right: 0.6666667em; 1793 | padding-bottom: 0.8888889em; 1794 | padding-left: 0.6666667em; 1795 | } 1796 | 1797 | .prose-xl :where(tbody td:first-child, tfoot td:first-child):not(:where([class~="not-prose"] *)) { 1798 | padding-left: 0; 1799 | } 1800 | 1801 | .prose-xl :where(tbody td:last-child, tfoot td:last-child):not(:where([class~="not-prose"] *)) { 1802 | padding-right: 0; 1803 | } 1804 | 1805 | .prose-xl :where(.prose > :first-child):not(:where([class~="not-prose"] *)) { 1806 | margin-top: 0; 1807 | } 1808 | 1809 | .prose-xl :where(.prose > :last-child):not(:where([class~="not-prose"] *)) { 1810 | margin-bottom: 0; 1811 | } 1812 | 1813 | .prose-2xl { 1814 | font-size: 1.5rem; 1815 | line-height: 1.6666667; 1816 | } 1817 | 1818 | .prose-2xl :where(p):not(:where([class~="not-prose"] *)) { 1819 | margin-top: 1.3333333em; 1820 | margin-bottom: 1.3333333em; 1821 | } 1822 | 1823 | .prose-2xl :where([class~="lead"]):not(:where([class~="not-prose"] *)) { 1824 | font-size: 1.25em; 1825 | line-height: 1.4666667; 1826 | margin-top: 1.0666667em; 1827 | margin-bottom: 1.0666667em; 1828 | } 1829 | 1830 | .prose-2xl :where(blockquote):not(:where([class~="not-prose"] *)) { 1831 | margin-top: 1.7777778em; 1832 | margin-bottom: 1.7777778em; 1833 | padding-left: 1.1111111em; 1834 | } 1835 | 1836 | .prose-2xl :where(h1):not(:where([class~="not-prose"] *)) { 1837 | font-size: 2.6666667em; 1838 | margin-top: 0; 1839 | margin-bottom: 0.875em; 1840 | line-height: 1; 1841 | } 1842 | 1843 | .prose-2xl :where(h2):not(:where([class~="not-prose"] *)) { 1844 | font-size: 2em; 1845 | margin-top: 1.5em; 1846 | margin-bottom: 0.8333333em; 1847 | line-height: 1.0833333; 1848 | } 1849 | 1850 | .prose-2xl :where(h3):not(:where([class~="not-prose"] *)) { 1851 | font-size: 1.5em; 1852 | margin-top: 1.5555556em; 1853 | margin-bottom: 0.6666667em; 1854 | line-height: 1.2222222; 1855 | } 1856 | 1857 | .prose-2xl :where(h4):not(:where([class~="not-prose"] *)) { 1858 | margin-top: 1.6666667em; 1859 | margin-bottom: 0.6666667em; 1860 | line-height: 1.5; 1861 | } 1862 | 1863 | .prose-2xl :where(img):not(:where([class~="not-prose"] *)) { 1864 | margin-top: 2em; 1865 | margin-bottom: 2em; 1866 | } 1867 | 1868 | .prose-2xl :where(video):not(:where([class~="not-prose"] *)) { 1869 | margin-top: 2em; 1870 | margin-bottom: 2em; 1871 | } 1872 | 1873 | .prose-2xl :where(figure):not(:where([class~="not-prose"] *)) { 1874 | margin-top: 2em; 1875 | margin-bottom: 2em; 1876 | } 1877 | 1878 | .prose-2xl :where(figure > *):not(:where([class~="not-prose"] *)) { 1879 | margin-top: 0; 1880 | margin-bottom: 0; 1881 | } 1882 | 1883 | .prose-2xl :where(figcaption):not(:where([class~="not-prose"] *)) { 1884 | font-size: 0.8333333em; 1885 | line-height: 1.6; 1886 | margin-top: 1em; 1887 | } 1888 | 1889 | .prose-2xl :where(code):not(:where([class~="not-prose"] *)) { 1890 | font-size: 0.8333333em; 1891 | } 1892 | 1893 | .prose-2xl :where(h2 code):not(:where([class~="not-prose"] *)) { 1894 | font-size: 0.875em; 1895 | } 1896 | 1897 | .prose-2xl :where(h3 code):not(:where([class~="not-prose"] *)) { 1898 | font-size: 0.8888889em; 1899 | } 1900 | 1901 | .prose-2xl :where(pre):not(:where([class~="not-prose"] *)) { 1902 | font-size: 0.8333333em; 1903 | line-height: 1.8; 1904 | margin-top: 2em; 1905 | margin-bottom: 2em; 1906 | border-radius: 0.5rem; 1907 | padding-top: 1.2em; 1908 | padding-right: 1.6em; 1909 | padding-bottom: 1.2em; 1910 | padding-left: 1.6em; 1911 | } 1912 | 1913 | .prose-2xl :where(ol):not(:where([class~="not-prose"] *)) { 1914 | margin-top: 1.3333333em; 1915 | margin-bottom: 1.3333333em; 1916 | padding-left: 1.5833333em; 1917 | } 1918 | 1919 | .prose-2xl :where(ul):not(:where([class~="not-prose"] *)) { 1920 | margin-top: 1.3333333em; 1921 | margin-bottom: 1.3333333em; 1922 | padding-left: 1.5833333em; 1923 | } 1924 | 1925 | .prose-2xl :where(li):not(:where([class~="not-prose"] *)) { 1926 | margin-top: 0.5em; 1927 | margin-bottom: 0.5em; 1928 | } 1929 | 1930 | .prose-2xl :where(ol > li):not(:where([class~="not-prose"] *)) { 1931 | padding-left: 0.4166667em; 1932 | } 1933 | 1934 | .prose-2xl :where(ul > li):not(:where([class~="not-prose"] *)) { 1935 | padding-left: 0.4166667em; 1936 | } 1937 | 1938 | .prose-2xl :where(.prose > ul > li p):not(:where([class~="not-prose"] *)) { 1939 | margin-top: 0.8333333em; 1940 | margin-bottom: 0.8333333em; 1941 | } 1942 | 1943 | .prose-2xl :where(.prose > ul > li > *:first-child):not(:where([class~="not-prose"] *)) { 1944 | margin-top: 1.3333333em; 1945 | } 1946 | 1947 | .prose-2xl :where(.prose > ul > li > *:last-child):not(:where([class~="not-prose"] *)) { 1948 | margin-bottom: 1.3333333em; 1949 | } 1950 | 1951 | .prose-2xl :where(.prose > ol > li > *:first-child):not(:where([class~="not-prose"] *)) { 1952 | margin-top: 1.3333333em; 1953 | } 1954 | 1955 | .prose-2xl :where(.prose > ol > li > *:last-child):not(:where([class~="not-prose"] *)) { 1956 | margin-bottom: 1.3333333em; 1957 | } 1958 | 1959 | .prose-2xl :where(ul ul, ul ol, ol ul, ol ol):not(:where([class~="not-prose"] *)) { 1960 | margin-top: 0.6666667em; 1961 | margin-bottom: 0.6666667em; 1962 | } 1963 | 1964 | .prose-2xl :where(hr):not(:where([class~="not-prose"] *)) { 1965 | margin-top: 3em; 1966 | margin-bottom: 3em; 1967 | } 1968 | 1969 | .prose-2xl :where(hr + *):not(:where([class~="not-prose"] *)) { 1970 | margin-top: 0; 1971 | } 1972 | 1973 | .prose-2xl :where(h2 + *):not(:where([class~="not-prose"] *)) { 1974 | margin-top: 0; 1975 | } 1976 | 1977 | .prose-2xl :where(h3 + *):not(:where([class~="not-prose"] *)) { 1978 | margin-top: 0; 1979 | } 1980 | 1981 | .prose-2xl :where(h4 + *):not(:where([class~="not-prose"] *)) { 1982 | margin-top: 0; 1983 | } 1984 | 1985 | .prose-2xl :where(table):not(:where([class~="not-prose"] *)) { 1986 | font-size: 0.8333333em; 1987 | line-height: 1.4; 1988 | } 1989 | 1990 | .prose-2xl :where(thead th):not(:where([class~="not-prose"] *)) { 1991 | padding-right: 0.6em; 1992 | padding-bottom: 0.8em; 1993 | padding-left: 0.6em; 1994 | } 1995 | 1996 | .prose-2xl :where(thead th:first-child):not(:where([class~="not-prose"] *)) { 1997 | padding-left: 0; 1998 | } 1999 | 2000 | .prose-2xl :where(thead th:last-child):not(:where([class~="not-prose"] *)) { 2001 | padding-right: 0; 2002 | } 2003 | 2004 | .prose-2xl :where(tbody td, tfoot td):not(:where([class~="not-prose"] *)) { 2005 | padding-top: 0.8em; 2006 | padding-right: 0.6em; 2007 | padding-bottom: 0.8em; 2008 | padding-left: 0.6em; 2009 | } 2010 | 2011 | .prose-2xl :where(tbody td:first-child, tfoot td:first-child):not(:where([class~="not-prose"] *)) { 2012 | padding-left: 0; 2013 | } 2014 | 2015 | .prose-2xl :where(tbody td:last-child, tfoot td:last-child):not(:where([class~="not-prose"] *)) { 2016 | padding-right: 0; 2017 | } 2018 | 2019 | .prose-2xl :where(.prose > :first-child):not(:where([class~="not-prose"] *)) { 2020 | margin-top: 0; 2021 | } 2022 | 2023 | .prose-2xl :where(.prose > :last-child):not(:where([class~="not-prose"] *)) { 2024 | margin-bottom: 0; 2025 | } 2026 | 2027 | .prose-slate { 2028 | --tw-prose-body: #334155; 2029 | --tw-prose-headings: #0f172a; 2030 | --tw-prose-lead: #475569; 2031 | --tw-prose-links: #0f172a; 2032 | --tw-prose-bold: #0f172a; 2033 | --tw-prose-counters: #64748b; 2034 | --tw-prose-bullets: #cbd5e1; 2035 | --tw-prose-hr: #e2e8f0; 2036 | --tw-prose-quotes: #0f172a; 2037 | --tw-prose-quote-borders: #e2e8f0; 2038 | --tw-prose-captions: #64748b; 2039 | --tw-prose-code: #0f172a; 2040 | --tw-prose-pre-code: #e2e8f0; 2041 | --tw-prose-pre-bg: #1e293b; 2042 | --tw-prose-th-borders: #cbd5e1; 2043 | --tw-prose-td-borders: #e2e8f0; 2044 | --tw-prose-invert-body: #cbd5e1; 2045 | --tw-prose-invert-headings: #fff; 2046 | --tw-prose-invert-lead: #94a3b8; 2047 | --tw-prose-invert-links: #fff; 2048 | --tw-prose-invert-bold: #fff; 2049 | --tw-prose-invert-counters: #94a3b8; 2050 | --tw-prose-invert-bullets: #475569; 2051 | --tw-prose-invert-hr: #334155; 2052 | --tw-prose-invert-quotes: #f1f5f9; 2053 | --tw-prose-invert-quote-borders: #334155; 2054 | --tw-prose-invert-captions: #94a3b8; 2055 | --tw-prose-invert-code: #fff; 2056 | --tw-prose-invert-pre-code: #cbd5e1; 2057 | --tw-prose-invert-pre-bg: rgb(0 0 0 / 50%); 2058 | --tw-prose-invert-th-borders: #475569; 2059 | --tw-prose-invert-td-borders: #334155; 2060 | } 2061 | 2062 | .prose-neutral { 2063 | --tw-prose-body: #404040; 2064 | --tw-prose-headings: #171717; 2065 | --tw-prose-lead: #525252; 2066 | --tw-prose-links: #171717; 2067 | --tw-prose-bold: #171717; 2068 | --tw-prose-counters: #737373; 2069 | --tw-prose-bullets: #d4d4d4; 2070 | --tw-prose-hr: #e5e5e5; 2071 | --tw-prose-quotes: #171717; 2072 | --tw-prose-quote-borders: #e5e5e5; 2073 | --tw-prose-captions: #737373; 2074 | --tw-prose-code: #171717; 2075 | --tw-prose-pre-code: #e5e5e5; 2076 | --tw-prose-pre-bg: #262626; 2077 | --tw-prose-th-borders: #d4d4d4; 2078 | --tw-prose-td-borders: #e5e5e5; 2079 | --tw-prose-invert-body: #d4d4d4; 2080 | --tw-prose-invert-headings: #fff; 2081 | --tw-prose-invert-lead: #a3a3a3; 2082 | --tw-prose-invert-links: #fff; 2083 | --tw-prose-invert-bold: #fff; 2084 | --tw-prose-invert-counters: #a3a3a3; 2085 | --tw-prose-invert-bullets: #525252; 2086 | --tw-prose-invert-hr: #404040; 2087 | --tw-prose-invert-quotes: #f5f5f5; 2088 | --tw-prose-invert-quote-borders: #404040; 2089 | --tw-prose-invert-captions: #a3a3a3; 2090 | --tw-prose-invert-code: #fff; 2091 | --tw-prose-invert-pre-code: #d4d4d4; 2092 | --tw-prose-invert-pre-bg: rgb(0 0 0 / 50%); 2093 | --tw-prose-invert-th-borders: #525252; 2094 | --tw-prose-invert-td-borders: #404040; 2095 | } 2096 | 2097 | .visible { 2098 | visibility: visible; 2099 | } 2100 | 2101 | .static { 2102 | position: static; 2103 | } 2104 | 2105 | .relative { 2106 | position: relative; 2107 | } 2108 | 2109 | .m-auto { 2110 | margin: auto; 2111 | } 2112 | 2113 | .m-3 { 2114 | margin: 0.75rem; 2115 | } 2116 | 2117 | .m-4 { 2118 | margin: 1rem; 2119 | } 2120 | 2121 | .m-8 { 2122 | margin: 2rem; 2123 | } 2124 | 2125 | .m-16 { 2126 | margin: 4rem; 2127 | } 2128 | 2129 | .mx-2 { 2130 | margin-left: 0.5rem; 2131 | margin-right: 0.5rem; 2132 | } 2133 | 2134 | .mx-6 { 2135 | margin-left: 1.5rem; 2136 | margin-right: 1.5rem; 2137 | } 2138 | 2139 | .my-5 { 2140 | margin-top: 1.25rem; 2141 | margin-bottom: 1.25rem; 2142 | } 2143 | 2144 | .my-16 { 2145 | margin-top: 4rem; 2146 | margin-bottom: 4rem; 2147 | } 2148 | 2149 | .my-8 { 2150 | margin-top: 2rem; 2151 | margin-bottom: 2rem; 2152 | } 2153 | 2154 | .my-4 { 2155 | margin-top: 1rem; 2156 | margin-bottom: 1rem; 2157 | } 2158 | 2159 | .mx-3 { 2160 | margin-left: 0.75rem; 2161 | margin-right: 0.75rem; 2162 | } 2163 | 2164 | .my-3 { 2165 | margin-top: 0.75rem; 2166 | margin-bottom: 0.75rem; 2167 | } 2168 | 2169 | .mx-auto { 2170 | margin-left: auto; 2171 | margin-right: auto; 2172 | } 2173 | 2174 | .ml-6 { 2175 | margin-left: 1.5rem; 2176 | } 2177 | 2178 | .mt-10 { 2179 | margin-top: 2.5rem; 2180 | } 2181 | 2182 | .mt-5 { 2183 | margin-top: 1.25rem; 2184 | } 2185 | 2186 | .mr-2 { 2187 | margin-right: 0.5rem; 2188 | } 2189 | 2190 | .ml-3 { 2191 | margin-left: 0.75rem; 2192 | } 2193 | 2194 | .ml-4 { 2195 | margin-left: 1rem; 2196 | } 2197 | 2198 | .mt-1 { 2199 | margin-top: 0.25rem; 2200 | } 2201 | 2202 | .mt-2 { 2203 | margin-top: 0.5rem; 2204 | } 2205 | 2206 | .mt-3 { 2207 | margin-top: 0.75rem; 2208 | } 2209 | 2210 | .mr-4 { 2211 | margin-right: 1rem; 2212 | } 2213 | 2214 | .mt-20 { 2215 | margin-top: 5rem; 2216 | } 2217 | 2218 | .mb-2 { 2219 | margin-bottom: 0.5rem; 2220 | } 2221 | 2222 | .mb-10 { 2223 | margin-bottom: 2.5rem; 2224 | } 2225 | 2226 | .mt-16 { 2227 | margin-top: 4rem; 2228 | } 2229 | 2230 | .mb-4 { 2231 | margin-bottom: 1rem; 2232 | } 2233 | 2234 | .mt-0 { 2235 | margin-top: 0px; 2236 | } 2237 | 2238 | .mt-4 { 2239 | margin-top: 1rem; 2240 | } 2241 | 2242 | .mt-8 { 2243 | margin-top: 2rem; 2244 | } 2245 | 2246 | .mt-6 { 2247 | margin-top: 1.5rem; 2248 | } 2249 | 2250 | .mb-6 { 2251 | margin-bottom: 1.5rem; 2252 | } 2253 | 2254 | .ml-2 { 2255 | margin-left: 0.5rem; 2256 | } 2257 | 2258 | .block { 2259 | display: block; 2260 | } 2261 | 2262 | .inline-block { 2263 | display: inline-block; 2264 | } 2265 | 2266 | .flex { 2267 | display: flex; 2268 | } 2269 | 2270 | .inline-flex { 2271 | display: inline-flex; 2272 | } 2273 | 2274 | .table { 2275 | display: table; 2276 | } 2277 | 2278 | .table-cell { 2279 | display: table-cell; 2280 | } 2281 | 2282 | .contents { 2283 | display: contents; 2284 | } 2285 | 2286 | .hidden { 2287 | display: none; 2288 | } 2289 | 2290 | .h-16 { 2291 | height: 4rem; 2292 | } 2293 | 2294 | .h-6 { 2295 | height: 1.5rem; 2296 | } 2297 | 2298 | .h-4 { 2299 | height: 1rem; 2300 | } 2301 | 2302 | .h-10 { 2303 | height: 2.5rem; 2304 | } 2305 | 2306 | .h-32 { 2307 | height: 8rem; 2308 | } 2309 | 2310 | .h-20 { 2311 | height: 5rem; 2312 | } 2313 | 2314 | .h-60 { 2315 | height: 15rem; 2316 | } 2317 | 2318 | .h-auto { 2319 | height: auto; 2320 | } 2321 | 2322 | .h-24 { 2323 | height: 6rem; 2324 | } 2325 | 2326 | .h-px { 2327 | height: 1px; 2328 | } 2329 | 2330 | .h-\[calc\(100vh-16rem\)\] { 2331 | height: calc(100vh - 16rem); 2332 | } 2333 | 2334 | .w-full { 2335 | width: 100%; 2336 | } 2337 | 2338 | .w-auto { 2339 | width: auto; 2340 | } 2341 | 2342 | .w-20 { 2343 | width: 5rem; 2344 | } 2345 | 2346 | .w-6 { 2347 | width: 1.5rem; 2348 | } 2349 | 2350 | .w-4 { 2351 | width: 1rem; 2352 | } 2353 | 2354 | .w-16 { 2355 | width: 4rem; 2356 | } 2357 | 2358 | .w-32 { 2359 | width: 8rem; 2360 | } 2361 | 2362 | .w-60 { 2363 | width: 15rem; 2364 | } 2365 | 2366 | .w-24 { 2367 | width: 6rem; 2368 | } 2369 | 2370 | .w-96 { 2371 | width: 24rem; 2372 | } 2373 | 2374 | .w-\[calc\(100\%-6rem\)\] { 2375 | width: calc(100% - 6rem); 2376 | } 2377 | 2378 | .w-\[36rem\] { 2379 | width: 36rem; 2380 | } 2381 | 2382 | .w-\[calc\(100\%-10rem\)\] { 2383 | width: calc(100% - 10rem); 2384 | } 2385 | 2386 | .max-w-min { 2387 | max-width: -webkit-min-content; 2388 | max-width: -moz-min-content; 2389 | max-width: min-content; 2390 | } 2391 | 2392 | .max-w-full { 2393 | max-width: 100%; 2394 | } 2395 | 2396 | .flex-shrink { 2397 | flex-shrink: 1; 2398 | } 2399 | 2400 | .flex-shrink-0 { 2401 | flex-shrink: 0; 2402 | } 2403 | 2404 | .shrink { 2405 | flex-shrink: 1; 2406 | } 2407 | 2408 | .flex-grow { 2409 | flex-grow: 1; 2410 | } 2411 | 2412 | .flex-grow-0 { 2413 | flex-grow: 0; 2414 | } 2415 | 2416 | .grow { 2417 | flex-grow: 1; 2418 | } 2419 | 2420 | .table-auto { 2421 | table-layout: auto; 2422 | } 2423 | 2424 | .table-fixed { 2425 | table-layout: fixed; 2426 | } 2427 | 2428 | .border-collapse { 2429 | border-collapse: collapse; 2430 | } 2431 | 2432 | .transform { 2433 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 2434 | } 2435 | 2436 | .cursor { 2437 | cursor: default; 2438 | } 2439 | 2440 | .resize { 2441 | resize: both; 2442 | } 2443 | 2444 | .flex-col { 2445 | flex-direction: column; 2446 | } 2447 | 2448 | .flex-wrap { 2449 | flex-wrap: wrap; 2450 | } 2451 | 2452 | .items-end { 2453 | align-items: flex-end; 2454 | } 2455 | 2456 | .items-center { 2457 | align-items: center; 2458 | } 2459 | 2460 | .items-baseline { 2461 | align-items: baseline; 2462 | } 2463 | 2464 | .justify-start { 2465 | justify-content: flex-start; 2466 | } 2467 | 2468 | .justify-end { 2469 | justify-content: flex-end; 2470 | } 2471 | 2472 | .justify-center { 2473 | justify-content: center; 2474 | } 2475 | 2476 | .justify-between { 2477 | justify-content: space-between; 2478 | } 2479 | 2480 | .justify-evenly { 2481 | justify-content: space-evenly; 2482 | } 2483 | 2484 | .overflow-hidden { 2485 | overflow: hidden; 2486 | } 2487 | 2488 | .overflow-y-scroll { 2489 | overflow-y: scroll; 2490 | } 2491 | 2492 | .truncate { 2493 | overflow: hidden; 2494 | text-overflow: ellipsis; 2495 | white-space: nowrap; 2496 | } 2497 | 2498 | .text-ellipsis { 2499 | text-overflow: ellipsis; 2500 | } 2501 | 2502 | .whitespace-nowrap { 2503 | white-space: nowrap; 2504 | } 2505 | 2506 | .rounded-md { 2507 | border-radius: 0.375rem; 2508 | } 2509 | 2510 | .border { 2511 | border-width: 1px; 2512 | } 2513 | 2514 | .border-0 { 2515 | border-width: 0; 2516 | } 2517 | 2518 | .border-b { 2519 | border-bottom-width: 1px; 2520 | } 2521 | 2522 | .border-t { 2523 | border-top-width: 1px; 2524 | } 2525 | 2526 | .bg-body { 2527 | --tw-bg-opacity: 1; 2528 | background-color: rgb(255 255 255 / var(--tw-bg-opacity)); 2529 | } 2530 | 2531 | .bg-blue-500 { 2532 | --tw-bg-opacity: 1; 2533 | background-color: rgb(59 130 246 / var(--tw-bg-opacity)); 2534 | } 2535 | 2536 | .bg-coolGray-50 { 2537 | --tw-bg-opacity: 1; 2538 | background-color: rgb(247 248 249 / var(--tw-bg-opacity)); 2539 | } 2540 | 2541 | .bg-blue-200 { 2542 | --tw-bg-opacity: 1; 2543 | background-color: rgb(206 224 253 / var(--tw-bg-opacity)); 2544 | } 2545 | 2546 | .bg-gray-200 { 2547 | --tw-bg-opacity: 1; 2548 | background-color: rgb(229 231 235 / var(--tw-bg-opacity)); 2549 | } 2550 | 2551 | .bg-gray-100 { 2552 | --tw-bg-opacity: 1; 2553 | background-color: rgb(243 244 246 / var(--tw-bg-opacity)); 2554 | } 2555 | 2556 | .bg-gray-500 { 2557 | --tw-bg-opacity: 1; 2558 | background-color: rgb(107 114 128 / var(--tw-bg-opacity)); 2559 | } 2560 | 2561 | .bg-blue-100 { 2562 | --tw-bg-opacity: 1; 2563 | background-color: rgb(235 243 254 / var(--tw-bg-opacity)); 2564 | } 2565 | 2566 | .bg-blue-50 { 2567 | --tw-bg-opacity: 1; 2568 | background-color: rgb(245 249 255 / var(--tw-bg-opacity)); 2569 | } 2570 | 2571 | .bg-gray-400 { 2572 | --tw-bg-opacity: 1; 2573 | background-color: rgb(156 163 175 / var(--tw-bg-opacity)); 2574 | } 2575 | 2576 | .p-6 { 2577 | padding: 1.5rem; 2578 | } 2579 | 2580 | .p-2 { 2581 | padding: 0.5rem; 2582 | } 2583 | 2584 | .p-5 { 2585 | padding: 1.25rem; 2586 | } 2587 | 2588 | .p-4 { 2589 | padding: 1rem; 2590 | } 2591 | 2592 | .p-1 { 2593 | padding: 0.25rem; 2594 | } 2595 | 2596 | .p-3 { 2597 | padding: 0.75rem; 2598 | } 2599 | 2600 | .px-7 { 2601 | padding-left: 1.75rem; 2602 | padding-right: 1.75rem; 2603 | } 2604 | 2605 | .py-5 { 2606 | padding-top: 1.25rem; 2607 | padding-bottom: 1.25rem; 2608 | } 2609 | 2610 | .px-4 { 2611 | padding-left: 1rem; 2612 | padding-right: 1rem; 2613 | } 2614 | 2615 | .px-0 { 2616 | padding-left: 0px; 2617 | padding-right: 0px; 2618 | } 2619 | 2620 | .py-2 { 2621 | padding-top: 0.5rem; 2622 | padding-bottom: 0.5rem; 2623 | } 2624 | 2625 | .py-6 { 2626 | padding-top: 1.5rem; 2627 | padding-bottom: 1.5rem; 2628 | } 2629 | 2630 | .py-1 { 2631 | padding-top: 0.25rem; 2632 | padding-bottom: 0.25rem; 2633 | } 2634 | 2635 | .py-4 { 2636 | padding-top: 1rem; 2637 | padding-bottom: 1rem; 2638 | } 2639 | 2640 | .px-3 { 2641 | padding-left: 0.75rem; 2642 | padding-right: 0.75rem; 2643 | } 2644 | 2645 | .py-3 { 2646 | padding-top: 0.75rem; 2647 | padding-bottom: 0.75rem; 2648 | } 2649 | 2650 | .px-2 { 2651 | padding-left: 0.5rem; 2652 | padding-right: 0.5rem; 2653 | } 2654 | 2655 | .px-10 { 2656 | padding-left: 2.5rem; 2657 | padding-right: 2.5rem; 2658 | } 2659 | 2660 | .pr-4 { 2661 | padding-right: 1rem; 2662 | } 2663 | 2664 | .pr-2 { 2665 | padding-right: 0.5rem; 2666 | } 2667 | 2668 | .pr-0 { 2669 | padding-right: 0px; 2670 | } 2671 | 2672 | .text-center { 2673 | text-align: center; 2674 | } 2675 | 2676 | .font-body { 2677 | font-family: "Poppins", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 2678 | } 2679 | 2680 | .text-lg { 2681 | font-size: 1.125rem; 2682 | } 2683 | 2684 | .text-sm { 2685 | font-size: 0.875rem; 2686 | } 2687 | 2688 | .text-3xl { 2689 | font-size: 1.875rem; 2690 | } 2691 | 2692 | .text-xl { 2693 | font-size: 1.25rem; 2694 | } 2695 | 2696 | .text-4xl { 2697 | font-size: 2.25rem; 2698 | } 2699 | 2700 | .font-medium { 2701 | font-weight: 500; 2702 | } 2703 | 2704 | .font-bold { 2705 | font-weight: 700; 2706 | } 2707 | 2708 | .text-body { 2709 | --tw-text-opacity: 1; 2710 | color: rgb(42 51 66 / var(--tw-text-opacity)); 2711 | } 2712 | 2713 | .text-blue-50 { 2714 | --tw-text-opacity: 1; 2715 | color: rgb(245 249 255 / var(--tw-text-opacity)); 2716 | } 2717 | 2718 | .text-gray-300 { 2719 | --tw-text-opacity: 1; 2720 | color: rgb(209 213 219 / var(--tw-text-opacity)); 2721 | } 2722 | 2723 | .text-gray-200 { 2724 | --tw-text-opacity: 1; 2725 | color: rgb(229 231 235 / var(--tw-text-opacity)); 2726 | } 2727 | 2728 | .text-gray-100 { 2729 | --tw-text-opacity: 1; 2730 | color: rgb(243 244 246 / var(--tw-text-opacity)); 2731 | } 2732 | 2733 | .text-gray-500 { 2734 | --tw-text-opacity: 1; 2735 | color: rgb(107 114 128 / var(--tw-text-opacity)); 2736 | } 2737 | 2738 | .text-gray-400 { 2739 | --tw-text-opacity: 1; 2740 | color: rgb(156 163 175 / var(--tw-text-opacity)); 2741 | } 2742 | 2743 | .text-green-300 { 2744 | --tw-text-opacity: 1; 2745 | color: rgb(170 237 195 / var(--tw-text-opacity)); 2746 | } 2747 | 2748 | .text-green-500 { 2749 | --tw-text-opacity: 1; 2750 | color: rgb(42 209 103 / var(--tw-text-opacity)); 2751 | } 2752 | 2753 | .text-blue-500 { 2754 | --tw-text-opacity: 1; 2755 | color: rgb(59 130 246 / var(--tw-text-opacity)); 2756 | } 2757 | 2758 | .underline { 2759 | -webkit-text-decoration-line: underline; 2760 | text-decoration-line: underline; 2761 | } 2762 | 2763 | .shadow { 2764 | --tw-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); 2765 | --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px 0 var(--tw-shadow-color); 2766 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 2767 | } 2768 | 2769 | .outline { 2770 | outline-style: solid; 2771 | } 2772 | 2773 | .transition { 2774 | transition-property: color, background-color, border-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-text-decoration-color, -webkit-backdrop-filter; 2775 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; 2776 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-text-decoration-color, -webkit-backdrop-filter; 2777 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 2778 | transition-duration: 150ms; 2779 | } 2780 | 2781 | .hover\:text-blue-700:hover { 2782 | --tw-text-opacity: 1; 2783 | color: rgb(44 98 185 / var(--tw-text-opacity)); 2784 | } 2785 | 2786 | .disabled\:bg-gray-500:disabled { 2787 | --tw-bg-opacity: 1; 2788 | background-color: rgb(107 114 128 / var(--tw-bg-opacity)); 2789 | } 2790 | 2791 | @media (min-width: 1024px) { 2792 | .lg\:mt-0 { 2793 | margin-top: 0px; 2794 | } 2795 | 2796 | .lg\:w-2\/3 { 2797 | width: 66.666667%; 2798 | } 2799 | 2800 | .lg\:w-1\/3 { 2801 | width: 33.333333%; 2802 | } 2803 | 2804 | .lg\:px-4 { 2805 | padding-left: 1rem; 2806 | padding-right: 1rem; 2807 | } 2808 | } --------------------------------------------------------------------------------