Compare commits

..

4 Commits

19 changed files with 412 additions and 151 deletions

58
api/Cargo.lock generated
View File

@@ -29,7 +29,7 @@ dependencies = [
"actix-rt", "actix-rt",
"actix-service", "actix-service",
"actix-utils", "actix-utils",
"base64", "base64 0.22.1",
"bitflags", "bitflags",
"brotli", "brotli",
"bytes", "bytes",
@@ -248,9 +248,11 @@ dependencies = [
"aws-sdk-dynamodb", "aws-sdk-dynamodb",
"chrono", "chrono",
"jsonwebtoken", "jsonwebtoken",
"mime_guess",
"reqwest 0.13.1", "reqwest 0.13.1",
"sentry", "sentry",
"serde", "serde",
"serde_dynamo",
"sqlx", "sqlx",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
@@ -684,6 +686,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@@ -1736,7 +1744,7 @@ version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@@ -1968,7 +1976,7 @@ version = "10.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e" checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"ed25519-dalek", "ed25519-dalek",
"getrandom 0.2.17", "getrandom 0.2.17",
"hmac", "hmac",
@@ -2114,6 +2122,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2547,7 +2565,7 @@ version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"serde_core", "serde_core",
] ]
@@ -2856,7 +2874,7 @@ version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@@ -2894,7 +2912,7 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"encoding_rs", "encoding_rs",
"futures-core", "futures-core",
@@ -3357,6 +3375,18 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "serde_dynamo"
version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873a97c3f7a67dd042bceb47d056d288424b82d4c66b0a25e1a3b34675620951"
dependencies = [
"aws-sdk-dynamodb",
"base64 0.21.7",
"serde",
"serde_core",
]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.149" version = "1.0.149"
@@ -3530,7 +3560,7 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"crc", "crc",
"crossbeam-queue", "crossbeam-queue",
@@ -3604,7 +3634,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64 0.22.1",
"bitflags", "bitflags",
"byteorder", "byteorder",
"bytes", "bytes",
@@ -3646,7 +3676,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64 0.22.1",
"bitflags", "bitflags",
"byteorder", "byteorder",
"crc", "crc",
@@ -4104,6 +4134,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.18" version = "0.3.18"
@@ -4155,7 +4191,7 @@ version = "3.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"der", "der",
"log", "log",
"native-tls", "native-tls",
@@ -4172,7 +4208,7 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"http 1.4.0", "http 1.4.0",
"httparse", "httparse",
"log", "log",

View File

@@ -9,9 +9,11 @@ aws-config = "1.8.12"
aws-sdk-dynamodb = "1.102.0" aws-sdk-dynamodb = "1.102.0"
chrono = { version = "0.4.43", features = ["serde"] } chrono = { version = "0.4.43", features = ["serde"] }
jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] } jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] }
mime_guess = "2.0.5"
reqwest = { version = "0.13.1", features = ["json", "query"] } reqwest = { version = "0.13.1", features = ["json", "query"] }
sentry = { version = "0.46.1", features = ["actix", "tracing"] } sentry = { version = "0.46.1", features = ["actix", "tracing"] }
serde = "1.0.228" serde = "1.0.228"
serde_dynamo = { version = "4.3.0", features = ["aws-sdk-dynamodb+1"] }
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-native-tls"] } sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-native-tls"] }
thiserror = "2.0.17" thiserror = "2.0.17"
tokio = "1.49.0" tokio = "1.49.0"

View File

@@ -1,4 +1,4 @@
use std::{env, sync::Arc}; use std::env;
use crate::error::Result; use crate::error::Result;
use jsonwebtoken::{ use jsonwebtoken::{

View File

@@ -1,4 +1,4 @@
use crate::{auth::User, error::Result, user::RepositorySchema, validate::validate_repo}; use crate::{auth::User, error::Result, user::RepositoryDefinition, validate::validate_repo};
use actix_web::{HttpResponse, web}; use actix_web::{HttpResponse, web};
use crate::AppState; use crate::AppState;
@@ -6,9 +6,11 @@ use crate::AppState;
pub async fn add_repo( pub async fn add_repo(
app_state: web::Data<AppState>, app_state: web::Data<AppState>,
user: web::ReqData<User>, user: web::ReqData<User>,
payload: web::Json<RepositorySchema>, repo: web::Json<RepositoryDefinition>,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
let repo = payload.into_inner(); validate_repo(app_state.clone(), &repo, &user.id).await?;
validate_repo(app_state.clone(), &repo).await?; app_state
app_state.user.add_repository(&user.id, repo).await .user
.add_repository(&user.id, repo.into_inner())
.await
} }

View File

@@ -24,9 +24,8 @@ pub struct Repository {
pub async fn get_repos( pub async fn get_repos(
app_state: web::Data<AppState>, app_state: web::Data<AppState>,
req: web::ReqData<User>, user: web::ReqData<User>,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
let user = req.into_inner();
let token = app_state.user.get_access_token(&user.id).await?; let token = app_state.user.get_access_token(&user.id).await?;
let response = app_state let response = app_state
@@ -38,21 +37,20 @@ pub async fn get_repos(
response.error_for_status_ref()?; response.error_for_status_ref()?;
let added_ids = app_state let added_ids = app_state
.user .user
.get_repositories(&user.id) .get_repositories_user(&user.id)
.await? .await?
.into_iter() .into_iter()
.map(|r| r.id) .map(|r| r.id)
.collect::<HashSet<String>>(); .collect::<HashSet<String>>();
println!("{added_ids:?}");
let data = response let data = response
.json::<Vec<Repository>>() .json::<Vec<Repository>>()
.await? .await?
.into_iter() .into_iter()
.filter_map(|mut r| { .map(|mut r| {
(!r.private).then(|| {
r.added = added_ids.contains(&r.id.to_string()); r.added = added_ids.contains(&r.id.to_string());
r r
}) })
})
.collect::<Vec<Repository>>(); .collect::<Vec<Repository>>();
Ok(HttpResponse::Ok().json(data)) Ok(HttpResponse::Ok().json(data))

View File

@@ -1,4 +1,5 @@
pub mod add_repo; pub mod add_repo;
pub mod get_repos; pub mod get_repos;
pub mod global_repos; pub mod global_repos;
pub mod proxy_file;
pub mod search_repos; pub mod search_repos;

View File

@@ -0,0 +1,47 @@
use actix_web::{HttpResponse, web};
use crate::{
AppState,
error::{Error, Result},
};
#[derive(serde::Deserialize)]
pub struct Params {
repo_id: String,
file: String,
}
pub async fn proxy_file(
app_state: web::Data<AppState>,
path: web::Path<Params>,
) -> Result<HttpResponse> {
let repo = app_state
.user
.get_approved_repository(&path.repo_id)
.await?
.ok_or(Error::NotFound)?;
let token = app_state.user.get_access_token(&repo.owner_id).await?;
let url = format!(
"https://raw.githubusercontent.com/{}/HEAD/dist/{}",
repo.full_name, path.file
);
let response = app_state
.reqwest_client
.get(&url)
.bearer_auth(token)
.send()
.await?;
response.error_for_status_ref()?;
let bytes = response.bytes().await?;
let mime = mime_guess::from_path(&path.file)
.first_or_octet_stream()
.to_string();
Ok(HttpResponse::Ok()
.content_type(mime)
.insert_header(("Cache-Control", "public, max-age=3600"))
.body(bytes))
}

View File

@@ -1,6 +1,6 @@
use crate::{auth::User, endpoints::get_repos::Repository, error::Result}; use crate::{auth::User, endpoints::get_repos::Repository, error::Result};
use actix_web::{ use actix_web::{
HttpRequest, HttpResponse, HttpResponse,
web::{self, ReqData}, web::{self, ReqData},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -20,9 +20,8 @@ struct SearchResponse {
pub async fn search_repos( pub async fn search_repos(
app_state: web::Data<AppState>, app_state: web::Data<AppState>,
query: web::Query<SearchQuery>, query: web::Query<SearchQuery>,
req: ReqData<User>, user: ReqData<User>,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
let user = req.into_inner();
let token = app_state.user.get_access_token(&user.id).await?; let token = app_state.user.get_access_token(&user.id).await?;
let search_query = format!("user:{} {} fork:true", user.name, query.q); let search_query = format!("user:{} {} fork:true", user.name, query.q);

View File

@@ -1,4 +1,4 @@
use actix_web::{HttpResponse, ResponseError, http::StatusCode}; use actix_web::{HttpResponse, ResponseError};
use aws_sdk_dynamodb::error::SdkError; use aws_sdk_dynamodb::error::SdkError;
use serde::Serialize; use serde::Serialize;
use thiserror::Error; use thiserror::Error;
@@ -25,6 +25,8 @@ pub enum Error {
AlreadyExists, AlreadyExists,
#[error("validation failed: {0}")] #[error("validation failed: {0}")]
ValidationFailed(String), ValidationFailed(String),
#[error("not found")]
NotFound,
} }
impl<E: std::fmt::Debug> From<SdkError<E>> for Error { impl<E: std::fmt::Debug> From<SdkError<E>> for Error {
@@ -49,9 +51,10 @@ impl ResponseError for Error {
Error::AlreadyExists => HttpResponse::BadRequest().json(ErrorResponse { Error::AlreadyExists => HttpResponse::BadRequest().json(ErrorResponse {
error: "item already exists".to_string(), error: "item already exists".to_string(),
}), }),
Error::ValidationFailed(msg) => HttpResponse::BadRequest().json(ErrorResponse { Error::ValidationFailed(msg) => {
error: msg.clone(), HttpResponse::BadRequest().json(ErrorResponse { error: msg.clone() })
}), }
Error::NotFound => HttpResponse::NotFound().finish(),
_ => HttpResponse::InternalServerError().finish(), _ => HttpResponse::InternalServerError().finish(),
} }
} }

View File

@@ -89,6 +89,10 @@ async fn run() -> std::io::Result<()> {
.route( .route(
"/repos", "/repos",
web::get().to(endpoints::global_repos::global_repos), web::get().to(endpoints::global_repos::global_repos),
)
.route(
"/repos/{repo_id}/files/{file:.*}",
web::get().to(endpoints::proxy_file::proxy_file),
), ),
), ),
) )

View File

@@ -6,6 +6,7 @@ use actix_web::HttpResponse;
use aws_sdk_dynamodb::types::AttributeValue; use aws_sdk_dynamodb::types::AttributeValue;
use chrono::Utc; use chrono::Utc;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_dynamo::from_item;
use sqlx::{PgPool, query_scalar}; use sqlx::{PgPool, query_scalar};
pub struct UserRepository { pub struct UserRepository {
@@ -14,10 +15,52 @@ pub struct UserRepository {
table_name: String, table_name: String,
} }
#[derive(Deserialize)]
struct RepositoryDB {
sk: String,
full_name: String,
owner_id: String,
description: String,
#[serde(default)]
approved: bool,
}
impl RepositoryDB {
fn into_repository(self) -> Option<RepositoryDefinition> {
Some(RepositoryDefinition {
id: self.sk.strip_prefix("REPO#")?.to_string(),
full_name: self.full_name,
owner_id: self.owner_id,
description: self.description,
})
}
fn into_global_response(self) -> Option<GlobalRepositoriesResponse> {
if !self.approved {
return None;
}
Some(GlobalRepositoriesResponse {
id: self.sk.strip_prefix("REPO#")?.to_string(),
full_name: self.full_name,
description: self.description,
})
}
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct RepositorySchema { pub struct RepositoryDefinition {
pub id: String, pub id: String,
pub full_name: String, pub full_name: String,
#[serde(skip)]
pub owner_id: String,
pub description: String,
}
#[derive(Serialize)]
pub struct GlobalRepositoriesResponse {
pub id: String,
pub full_name: String,
pub description: String,
} }
impl UserRepository { impl UserRepository {
@@ -38,7 +81,7 @@ impl UserRepository {
.map_err(|_| crate::error::Error::AccessToken) .map_err(|_| crate::error::Error::AccessToken)
} }
pub async fn get_repositories(&self, user_id: &str) -> Result<Vec<RepositorySchema>> { pub async fn get_repositories_user(&self, user_id: &str) -> Result<Vec<RepositoryDefinition>> {
let response = self let response = self
.dynamodb_client .dynamodb_client
.query() .query()
@@ -47,50 +90,72 @@ impl UserRepository {
.expression_attribute_values(":pk", AttributeValue::S(format!("USER#{user_id}"))) .expression_attribute_values(":pk", AttributeValue::S(format!("USER#{user_id}")))
.expression_attribute_values(":sk", AttributeValue::S("REPO#".into())) .expression_attribute_values(":sk", AttributeValue::S("REPO#".into()))
.send() .send()
.await? .await?;
let repos = response
.items() .items()
.iter() .iter()
.filter_map(|item| { .filter_map(|item| {
Some(RepositorySchema { let dynamo_repo: RepositoryDB = from_item(item.clone()).ok()?;
id: item dynamo_repo.into_repository()
.get("sk")?
.as_s()
.ok()?
.strip_prefix("REPO#")?
.to_string(),
full_name: item.get("full_name")?.as_s().ok()?.to_string(),
}) })
}) .collect();
.collect::<Vec<RepositorySchema>>();
Ok(response) Ok(repos)
} }
pub async fn global_repositories(&self) -> Result<HttpResponse> { pub async fn global_repositories(&self) -> Result<HttpResponse> {
let response = self let response = self
.dynamodb_client .dynamodb_client
.query() .query()
.key_condition_expression("pk = :pk") .table_name(&self.table_name)
.index_name("gsi1")
.key_condition_expression("gsi1pk = :pk")
.expression_attribute_values(":pk", AttributeValue::S("REPOS".into())) .expression_attribute_values(":pk", AttributeValue::S("REPOS".into()))
.send() .send()
.await? .await?;
let repos: Vec<GlobalRepositoriesResponse> = response
.items() .items()
.iter() .iter()
.filter_map(|item| { .filter_map(|item| {
if (*item.get("approved")?.as_bool().ok()?) == false { let dynamo_repo: RepositoryDB = from_item(item.clone()).ok()?;
return None; dynamo_repo.into_global_response()
};
Some(item.get("full_name")?.as_s().ok()?.to_string())
}) })
.collect::<Vec<String>>(); .collect();
Ok(HttpResponse::Ok().json(response)) Ok(HttpResponse::Ok().json(repos))
}
pub async fn get_approved_repository(
&self,
repo_id: &str,
) -> Result<Option<RepositoryDefinition>> {
let response = self
.dynamodb_client
.query()
.table_name(&self.table_name)
.index_name("gsi1")
.key_condition_expression("gsi1pk = :pk")
.filter_expression("sk = :sk AND approved = :approved")
.expression_attribute_values(":pk", AttributeValue::S("REPOS".into()))
.expression_attribute_values(":sk", AttributeValue::S(format!("REPO#{repo_id}")))
.expression_attribute_values(":approved", AttributeValue::Bool(true))
.send()
.await?;
let repo = response.items().first().and_then(|item| {
let dynamo_repo: RepositoryDB = from_item(item.clone()).ok()?;
dynamo_repo.into_repository()
});
Ok(repo)
} }
pub async fn add_repository( pub async fn add_repository(
&self, &self,
user_id: &str, user_id: &str,
repo: RepositorySchema, repo: RepositoryDefinition,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
let response = self let response = self
@@ -105,6 +170,8 @@ impl UserRepository {
.item("gsi1sk", AttributeValue::S(now.clone())) .item("gsi1sk", AttributeValue::S(now.clone()))
.item("imported_at", AttributeValue::S(now)) .item("imported_at", AttributeValue::S(now))
.item("approved", AttributeValue::Bool(false)) .item("approved", AttributeValue::Bool(false))
.item("owner_id", AttributeValue::S(user_id.into()))
.item("description", AttributeValue::S(repo.description.clone()))
.send() .send()
.await; .await;

View File

@@ -2,15 +2,21 @@ use actix_web::web;
use crate::AppState; use crate::AppState;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::user::RepositorySchema; use crate::user::RepositoryDefinition;
pub async fn validate_repo(app_state: web::Data<AppState>, repo: &RepositorySchema) -> Result<()> { pub async fn validate_repo(
app_state: web::Data<AppState>,
repo: &RepositoryDefinition,
user_id: &str,
) -> Result<()> {
let token = app_state.user.get_access_token(&user_id).await?;
let response = app_state let response = app_state
.reqwest_client .reqwest_client
.get(format!( .get(format!(
"https://raw.githubusercontent.com/{}/HEAD/dist/index.html", "https://raw.githubusercontent.com/{}/HEAD/dist/index.html",
repo.full_name repo.full_name
)) ))
.bearer_auth(token)
.send() .send()
.await?; .await?;

View File

@@ -18,10 +18,10 @@
}; };
</script> </script>
<div class="h-14 w-full bg-[#292e42]"> <div class="h-14 w-full bg-[#292e42] sm:p-2">
<div class="mx-auto flex h-full max-w-[78rem] items-center justify-between"> <div class="mx-auto flex h-full max-w-312 items-center justify-between">
<Button.Root onclick={() => goto('/')}> <Button.Root onclick={() => goto('/')}>
<h1 class="header-title font-light">Godot Host</h1> <h1 class="header-title font-light">Project Host</h1>
</Button.Root> </Button.Root>
{#if !user && !$session.isPending} {#if !user && !$session.isPending}
<Button.Root class="rounded-md p-2 transition-colors hover:bg-gray-800" onclick={signIn}> <Button.Root class="rounded-md p-2 transition-colors hover:bg-gray-800" onclick={signIn}>

View File

@@ -1,61 +1,52 @@
<script lang="ts"> <script lang="ts">
import { Play } from '@lucide/svelte'; import { Play, LoaderCircle } from '@lucide/svelte';
import { Button } from 'bits-ui'; import { Button } from 'bits-ui';
import type { Project } from './types/project'; import type { Project, RepoDefinition } from './types/project';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Image from './Image.svelte'; import Image from './Image.svelte';
import { createQuery } from '@tanstack/svelte-query';
const mockProjects: Project[] = [ const reposQuery = createQuery<RepoDefinition[]>(() => ({
{ queryKey: ['global-repos'],
id: 1, queryFn: async () => {
name: 'Uno', const response = await fetch('/api/v0/repos');
description: 'Recreation of the classic card game', if (!response.ok) {
thumbnail: 'https://picsum.photos/seed/uno/400/225', throw new Error('Failed to fetch repositories');
url: '/game/index.html'
},
{
id: 2,
name: 'Space Invaders',
description: 'Retro arcade shooter',
thumbnail: 'https://picsum.photos/seed/space/400/225'
},
{
id: 3,
name: 'Tetris',
description: 'Block stacking puzzle game',
thumbnail: 'https://picsum.photos/seed/tetris/400/225'
},
{
id: 4,
name: 'Snake',
description: 'Classic snake game with power-ups',
thumbnail: 'https://picsum.photos/seed/snake/400/225'
},
{
id: 5,
name: 'Pong',
description: 'Two-player paddle game',
thumbnail: 'https://picsum.photos/seed/pong/400/225'
},
{
id: 6,
name: 'Breakout',
description: 'Brick-breaking arcade game',
thumbnail: 'https://picsum.photos/seed/breakout/400/225'
} }
]; return response.json();
},
staleTime: 60000
}));
const projects = $derived(reposQuery.data ?? []);
const isLoading = $derived(reposQuery.isPending);
const hasError = $derived(reposQuery.isError);
const isEmpty = $derived(!isLoading && !hasError && projects.length === 0);
</script> </script>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> {#if isLoading}
{#each mockProjects as project (project.id)} <div class="flex items-center justify-center py-12">
<LoaderCircle class="h-8 w-8 animate-spin text-text-muted" />
</div>
{:else if hasError}
<div class="py-12 text-center text-text-muted">
<p>Failed to load projects. Please try again later.</p>
</div>
{:else if isEmpty}
<div class="py-12 text-center text-text-muted">
<p>No projects available yet.</p>
</div>
{:else}
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each projects as project (project.id)}
<div <div
class="group relative overflow-hidden rounded-lg bg-gray-900 shadow-xl ring-1 ring-gray-800 transition-transform hover:scale-[1.02]" class="group relative overflow-hidden rounded-lg bg-gray-900 shadow-xl ring-1 ring-gray-800 transition-transform hover:scale-[1.02]"
> >
<div class="relative aspect-video overflow-hidden"> <div class="relative aspect-video overflow-hidden">
<Image <Image
src={project.thumbnail} src={'https://picsum.photos/seed/yama/400/225'}
alt={project.name} alt={project.full_name}
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" class="min-h-56.5 w-full min-w-100 object-cover transition-transform duration-300 group-hover:scale-105"
/> />
<div <div
@@ -75,9 +66,10 @@
</div> </div>
<div class="p-4"> <div class="p-4">
<h3 class="text-lg font-semibold text-white">{project.name}</h3> <h3 class="text-lg font-semibold text-white">{project.full_name}</h3>
<p class="mt-1 text-sm text-gray-400">{project.description}</p> <p class="mt-1 text-sm text-gray-400">{project.description}</p>
</div> </div>
</div> </div>
{/each} {/each}
</div> </div>
{/if}

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Button } from 'bits-ui'; import { AlertDialog, Button } from 'bits-ui';
import { GitBranch, Star, Clock, Import, Search, RefreshCw, LoaderCircle } from '@lucide/svelte'; import { GitBranch, Star, Clock, Import, Search, RefreshCw, LoaderCircle } from '@lucide/svelte';
import type { Repository } from './types/repo'; import type { Repository } from './types/repo';
import { createQuery } from '@tanstack/svelte-query'; import { createQuery } from '@tanstack/svelte-query';
@@ -59,12 +59,25 @@
const repos = $derived(searching ? (searchResultsQuery.data ?? []) : (query.data ?? [])); const repos = $derived(searching ? (searchResultsQuery.data ?? []) : (query.data ?? []));
const isPending = $derived(searching ? searchResultsQuery.isPending : query.isPending); const isPending = $derived(searching ? searchResultsQuery.isPending : query.isPending);
const handleImport = async (repo: Repository) => { let dialogOpen = $state(false);
let selectedRepo = $state<Repository | null>(null);
let description = $state('');
const openDialog = (repo: Repository) => {
if (added.includes(repo.id)) { if (added.includes(repo.id)) {
toast.warning('Repository already imported'); toast.warning('Repository already imported');
return; return;
} }
adding = repo.id; selectedRepo = repo;
description = repo.description ?? '';
dialogOpen = true;
};
const handleImport = async () => {
if (!selectedRepo) return;
adding = selectedRepo.id;
dialogOpen = false;
let response = await fetch('/api/v0/user/repo/add', { let response = await fetch('/api/v0/user/repo/add', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -72,8 +85,9 @@
Authorization: (await apiClient.getToken()) ?? '' Authorization: (await apiClient.getToken()) ?? ''
}, },
body: JSON.stringify({ body: JSON.stringify({
id: repo.id.toString(), id: selectedRepo.id.toString(),
full_name: repo.full_name full_name: selectedRepo.full_name,
description
}) })
}); });
const data = await response.json(); const data = await response.json();
@@ -84,8 +98,10 @@
} else { } else {
toast.success('Successfully added repository'); toast.success('Successfully added repository');
} }
added.push(repo.id); added.push(selectedRepo.id);
adding = null; adding = null;
selectedRepo = null;
description = '';
}; };
const languageColors: Record<string, string> = { const languageColors: Record<string, string> = {
@@ -161,7 +177,7 @@
? 'border-success-border bg-success' ? 'border-success-border bg-success'
: 'bg-primary'} disabled:cursor-default disabled:opacity-50" : 'bg-primary'} disabled:cursor-default disabled:opacity-50"
disabled={adding !== null} disabled={adding !== null}
onclick={() => handleImport(repo)} onclick={() => openDialog(repo)}
> >
{#if adding === repo.id} {#if adding === repo.id}
<RefreshCw class="h-4 w-4 animate-spin" /> <RefreshCw class="h-4 w-4 animate-spin" />
@@ -189,3 +205,52 @@
You must have a dist/ folder with index.html + index.wasm You must have a dist/ folder with index.html + index.wasm
</p> </p>
</div> </div>
<AlertDialog.Root bind:open={dialogOpen}>
<AlertDialog.Portal>
<AlertDialog.Overlay class="fixed inset-0 z-50 bg-black/80" />
<AlertDialog.Content
class="fixed top-1/2 left-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-surface p-6 shadow-xl"
>
<div class="flex flex-col gap-4">
<AlertDialog.Title class="text-lg font-semibold text-text-primary">
Add Repository
</AlertDialog.Title>
<AlertDialog.Description class="text-sm text-text-secondary">
{#if selectedRepo}
You are about to add <span class="font-semibold text-text-primary"
>{selectedRepo.full_name}</span
>. Please provide a description for your project.
{/if}
</AlertDialog.Description>
<div class="mt-2">
<label for="description" class="mb-2 block text-sm font-medium text-text-primary">
Description
</label>
<textarea
id="description"
bind:value={description}
placeholder="Describe your project..."
rows="3"
class="w-full rounded-lg border border-border-hover bg-surface-hover px-3 py-2 text-text-primary placeholder-text-muted transition-colors outline-none focus:border-border-focus focus:ring-1 focus:ring-border-focus"
></textarea>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<AlertDialog.Cancel
class="rounded-lg border border-border px-4 py-2 text-sm font-medium text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary"
>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-text-primary transition-colors hover:bg-primary/90"
onclick={handleImport}
>
Continue
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>

View File

@@ -0,0 +1,28 @@
import type { Project, RepoDefinition } from "$lib/types/project";
export async function resolveProjectData(repo: RepoDefinition): Promise<Project> {
const { id, full_name: fullName } = repo;
let full_name_p = fullName.split("/")
const name = full_name_p[1] ?? fullName;
let description;
const repoResponse = await fetch(`https://api.github.com/repos/${fullName}`);
if (repoResponse.ok) {
const repoData = await repoResponse.json();
description = repoData.description ?? `by ${full_name_p[0]}`;
}
let thumbnail = `https://raw.githubusercontent.com/${fullName}/HEAD/thumbnail.webp`
const thumbResponse = await fetch(thumbnail, { method: 'HEAD' });
if (!thumbResponse.ok) {
thumbnail = `https://picsum.photos/seed/${encodeURIComponent(fullName.replaceAll("/", "-"))}/400/225`;
}
return {
id,
full_name: fullName,
name,
thumbnail,
description
}
}

View File

@@ -1,7 +1,13 @@
export type Project = { export type Project = {
id: number; id: string;
full_name: string;
name: string; name: string;
thumbnail?: string; thumbnail?: string;
description: string; description?: string;
url?: string; };
export type RepoDefinition = {
id: string;
full_name: string;
description: string
}; };

View File

@@ -151,6 +151,14 @@ button:focus-visible {
transform-origin: top; transform-origin: top;
} }
[data-alert-dialog-content][data-state="open"] {
animation: grow-in 150ms ease-out;
}
[data-alert-dialog-content][data-state="closed"] {
animation: grow-out 150ms ease-out;
}
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { :root {
color: #213547; color: #213547;

View File

@@ -5,7 +5,7 @@
import { Maximize, Minimize, ArrowLeft } from '@lucide/svelte'; import { Maximize, Minimize, ArrowLeft } from '@lucide/svelte';
const id = $derived(page.params.id); const id = $derived(page.params.id);
const src = $derived(`/projects/${id}/index.html`); const src = $derived(`/api/v0/repos/${id}/files/index.html`);
let isFullscreen = $state(false); let isFullscreen = $state(false);
let containerRef = $state<HTMLDivElement | null>(null); let containerRef = $state<HTMLDivElement | null>(null);
@@ -16,11 +16,8 @@
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
await containerRef.requestFullscreen(); await containerRef.requestFullscreen();
isFullscreen = true;
iframeRef?.focus();
} else { } else {
await document.exitFullscreen(); await document.exitFullscreen();
isFullscreen = false;
} }
}; };
@@ -45,7 +42,7 @@
<svelte:document onfullscreenchange={handleFullscreenChange} /> <svelte:document onfullscreenchange={handleFullscreenChange} />
<main class="flex min-h-screen flex-col items-center px-4 py-8"> <main class="flex min-h-screen flex-col items-center px-4 py-8">
<div class="relative mb-6 flex w-full max-w-4xl items-center justify-center"> <div class="relative mb-6 flex w-full max-w-6xl items-center justify-center">
<Button.Root <Button.Root
class="absolute left-0 flex items-center gap-2 rounded-md px-3 py-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white" class="absolute left-0 flex items-center gap-2 rounded-md px-3 py-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
onclick={() => goto('/')} onclick={() => goto('/')}
@@ -54,17 +51,17 @@
<span>Back</span> <span>Back</span>
</Button.Root> </Button.Root>
<h1 class="text-xl font-semibold text-white">Project {id}</h1> <h1 class="text-xl font-semibold text-white"></h1>
</div> </div>
<div <div
bind:this={containerRef} bind:this={containerRef}
class="relative w-full max-w-4xl overflow-hidden rounded-md bg-black shadow-2xl ring-1 ring-gray-700/80" class="relative w-full max-w-6xl overflow-hidden rounded-md bg-black shadow-2xl ring-1 ring-gray-700/80"
class:max-w-none={isFullscreen} class:max-w-none={isFullscreen}
class:h-screen={isFullscreen} class:h-screen={isFullscreen}
class:rounded-none={isFullscreen} class:rounded-none={isFullscreen}
> >
<div class="aspect-video" class:aspect-auto={isFullscreen} class:h-full={isFullscreen}> <div class="aspect-1152/648" class:aspect-auto={isFullscreen} class:h-full={isFullscreen}>
<iframe <iframe
bind:this={iframeRef} bind:this={iframeRef}
{src} {src}