refactor!: setup file proxy for projects
This commit is contained in:
17
api/Cargo.lock
generated
17
api/Cargo.lock
generated
@@ -248,6 +248,7 @@ 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",
|
||||||
@@ -2114,6 +2115,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"
|
||||||
@@ -4104,6 +4115,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"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::{env, sync::Arc};
|
use std::env;
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use jsonwebtoken::{
|
use jsonwebtoken::{
|
||||||
|
|||||||
@@ -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<RepositorySchema>,
|
||||||
) -> 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +37,7 @@ 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)
|
||||||
@@ -47,12 +46,10 @@ pub async fn get_repos(
|
|||||||
.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))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
47
api/src/endpoints/proxy_file.rs
Normal file
47
api/src/endpoints/proxy_file.rs
Normal 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))
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ pub struct UserRepository {
|
|||||||
pub struct RepositorySchema {
|
pub struct RepositorySchema {
|
||||||
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 +48,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<RepositorySchema>> {
|
||||||
let response = self
|
let response = self
|
||||||
.dynamodb_client
|
.dynamodb_client
|
||||||
.query()
|
.query()
|
||||||
@@ -59,6 +69,8 @@ impl UserRepository {
|
|||||||
.strip_prefix("REPO#")?
|
.strip_prefix("REPO#")?
|
||||||
.to_string(),
|
.to_string(),
|
||||||
full_name: item.get("full_name")?.as_s().ok()?.to_string(),
|
full_name: item.get("full_name")?.as_s().ok()?.to_string(),
|
||||||
|
owner_id: item.get("owner_id")?.as_s().ok()?.to_string(),
|
||||||
|
description: item.get("description")?.as_s().ok()?.to_string(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<Vec<RepositorySchema>>();
|
.collect::<Vec<RepositorySchema>>();
|
||||||
@@ -70,7 +82,9 @@ impl UserRepository {
|
|||||||
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?
|
||||||
@@ -80,13 +94,53 @@ impl UserRepository {
|
|||||||
if (*item.get("approved")?.as_bool().ok()?) == false {
|
if (*item.get("approved")?.as_bool().ok()?) == false {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
Some(item.get("full_name")?.as_s().ok()?.to_string())
|
Some(GlobalRepositoriesResponse {
|
||||||
|
id: item
|
||||||
|
.get("sk")?
|
||||||
|
.as_s()
|
||||||
|
.ok()?
|
||||||
|
.strip_prefix("REPO#")?
|
||||||
|
.to_string(),
|
||||||
|
full_name: item.get("full_name")?.as_s().ok()?.to_string(),
|
||||||
|
description: item.get("description")?.as_s().ok()?.to_string(),
|
||||||
})
|
})
|
||||||
.collect::<Vec<String>>();
|
})
|
||||||
|
.collect::<Vec<GlobalRepositoriesResponse>>();
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(response))
|
Ok(HttpResponse::Ok().json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_approved_repository(&self, repo_id: &str) -> Result<Option<RepositorySchema>> {
|
||||||
|
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| {
|
||||||
|
Some(RepositorySchema {
|
||||||
|
id: item
|
||||||
|
.get("sk")?
|
||||||
|
.as_s()
|
||||||
|
.ok()?
|
||||||
|
.strip_prefix("REPO#")?
|
||||||
|
.to_string(),
|
||||||
|
full_name: item.get("full_name")?.as_s().ok()?.to_string(),
|
||||||
|
owner_id: item.get("owner_id")?.as_s().ok()?.to_string(),
|
||||||
|
description: item.get("description")?.as_s().ok()?.to_string(),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(repo)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn add_repository(
|
pub async fn add_repository(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
@@ -105,6 +159,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;
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,19 @@ use crate::AppState;
|
|||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::user::RepositorySchema;
|
use crate::user::RepositorySchema;
|
||||||
|
|
||||||
pub async fn validate_repo(app_state: web::Data<AppState>, repo: &RepositorySchema) -> Result<()> {
|
pub async fn validate_repo(
|
||||||
|
app_state: web::Data<AppState>,
|
||||||
|
repo: &RepositorySchema,
|
||||||
|
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?;
|
||||||
|
|
||||||
|
|||||||
@@ -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-[78rem] 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}>
|
||||||
|
|||||||
@@ -1,61 +1,70 @@
|
|||||||
<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 projectsQuery = createQuery<Project[]>(() => ({
|
||||||
|
queryKey: ['projects', reposQuery.data],
|
||||||
|
queryFn: async () => {
|
||||||
|
const repos = reposQuery.data ?? [];
|
||||||
|
const projects = await Promise.all(
|
||||||
|
repos.map(async (repo: RepoDefinition) => {
|
||||||
|
const project = await resolveProjectData(repo);
|
||||||
|
return project;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return projects;
|
||||||
|
},
|
||||||
|
enabled: !!reposQuery.data && reposQuery.data.length > 0,
|
||||||
|
staleTime: 60000
|
||||||
|
}));
|
||||||
|
|
||||||
|
const projects = $derived(projectsQuery.data ?? []);
|
||||||
|
const isLoading = $derived(
|
||||||
|
reposQuery.isPending || (projectsQuery.isPending && projectsQuery.isEnabled)
|
||||||
|
);
|
||||||
|
const hasError = $derived(reposQuery.isError || projectsQuery.isError);
|
||||||
|
const isEmpty = $derived(!isLoading && !hasError && projects.length === 0);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<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">
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each mockProjects as project (project.id)}
|
{#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.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
|
||||||
@@ -81,3 +90,4 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
28
src/lib/project/resolve.ts
Normal file
28
src/lib/project/resolve.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user