feat!: add repo searching, only show most relevant repos first

This commit is contained in:
2026-01-16 23:29:15 -08:00
parent 68d9a0a626
commit 831259a6a6
14 changed files with 174 additions and 59 deletions

1
api/Cargo.lock generated
View File

@@ -2476,6 +2476,7 @@ dependencies = [
"rustls-platform-verifier", "rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",

View File

@@ -7,7 +7,7 @@ edition = "2024"
actix-web = "4.12.1" actix-web = "4.12.1"
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"] }
reqwest = { version = "0.13.1", features = ["json"] } 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"
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-native-tls"] } sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-native-tls"] }

View File

@@ -6,14 +6,18 @@ use jsonwebtoken::{
}; };
use serde::Deserialize; use serde::Deserialize;
pub struct UserId(pub String); #[derive(Clone)]
pub struct User {
pub id: String,
pub name: String,
}
pub struct JWT { pub struct JWT {
reqwest_client: reqwest::Client, reqwest_client: reqwest::Client,
} }
pub trait AuthImpl { pub trait AuthImpl {
async fn for_protected(&self, token: &str) -> Result<UserId>; async fn for_protected(&self, token: &str) -> Result<User>;
} }
pub enum Auth { pub enum Auth {
@@ -23,6 +27,7 @@ pub enum Auth {
#[derive(Deserialize)] #[derive(Deserialize)]
struct Claims { struct Claims {
sub: String, sub: String,
name: String,
} }
impl JWT { impl JWT {
@@ -32,7 +37,7 @@ impl JWT {
} }
impl AuthImpl for JWT { impl AuthImpl for JWT {
async fn for_protected(&self, token: &str) -> Result<UserId> { async fn for_protected(&self, token: &str) -> Result<User> {
let frontend_url = let frontend_url =
env::var("FRONTEND_BASE_URL").unwrap_or_else(|_| "http://localhost:5173".to_string()); env::var("FRONTEND_BASE_URL").unwrap_or_else(|_| "http://localhost:5173".to_string());
@@ -57,12 +62,15 @@ impl AuthImpl for JWT {
_ => crate::error::Error::Unauthorized, _ => crate::error::Error::Unauthorized,
})?; })?;
Ok(UserId(token_data.claims.sub)) Ok(User {
id: token_data.claims.sub,
name: token_data.claims.name,
})
} }
} }
impl AuthImpl for Auth { impl AuthImpl for Auth {
async fn for_protected(&self, token: &str) -> Result<UserId> { async fn for_protected(&self, token: &str) -> Result<User> {
match self { match self {
Auth::JWT(jwt) => jwt.for_protected(token).await, Auth::JWT(jwt) => jwt.for_protected(token).await,
} }

View File

@@ -2,38 +2,42 @@ use actix_web::{HttpRequest, HttpResponse, web};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{AppState, account::AccountRepository, error::Result}; use crate::{AppState, account::AccountRepository, auth::User, error::Result};
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
struct Repository { pub struct Repository {
id: u64, pub id: u64,
name: String, pub name: String,
full_name: String, pub full_name: String,
description: Option<String>, pub description: Option<String>,
language: Option<String>, pub language: Option<String>,
#[serde(alias = "stargazers_count")] #[serde(alias = "stargazers_count")]
stars: Option<usize>, pub stars: Option<usize>,
updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
private: bool, pub private: bool,
} }
pub async fn get_repos( pub async fn get_repos(
app_state: web::Data<AppState>, app_state: web::Data<AppState>,
path: web::Path<String>, req: web::ReqData<User>,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
let user_id = path.into_inner(); let user = req.into_inner();
let query = AccountRepository::new(&app_state.pool); let query = AccountRepository::new(&app_state.pool);
let token = query.get_access_token(&user_id).await?; let token = query.get_access_token(&user.id).await?;
let response = app_state let response = app_state
.reqwest_client .reqwest_client
.get("https://api.github.com/user/repos?affiliation=owner") .get("https://api.github.com/user/repos?affiliation=owner&sort=updated&direction=desc&per_page=5")
.bearer_auth(token) .bearer_auth(token)
.send() .send()
.await?; .await?;
response.error_for_status_ref()?; response.error_for_status_ref()?;
let data = response.json::<Vec<Repository>>().await?; let data = response
tracing::debug!(github_response = ?data.iter().filter(|r| r.private == true).collect::<Vec<&Repository>>(), "received repos"); .json::<Vec<Repository>>()
.await?
.into_iter()
.filter(|r| r.private == false)
.collect::<Vec<Repository>>();
Ok(HttpResponse::Ok().json(data)) Ok(HttpResponse::Ok().json(data))
} }

View File

@@ -1 +1,2 @@
pub mod get_repos; pub mod get_repos;
pub mod search_repos;

View File

@@ -0,0 +1,50 @@
use crate::{
account::AccountRepository, auth::User, endpoints::get_repos::Repository, error::Result,
};
use actix_web::{
HttpRequest, HttpResponse,
web::{self, ReqData},
};
use serde::{Deserialize, Serialize};
use crate::AppState;
#[derive(Deserialize)]
pub(crate) struct SearchQuery {
q: String,
}
#[derive(Serialize, Deserialize)]
struct SearchResponse {
items: Vec<Repository>,
}
pub async fn search_repos(
app_state: web::Data<AppState>,
query: web::Query<SearchQuery>,
req: ReqData<User>,
) -> Result<HttpResponse> {
let user = req.into_inner();
let token = AccountRepository::new(&app_state.pool)
.get_access_token(&user.id)
.await?;
let search_query = format!("user:{} {} fork:true", user.name, query.q);
let response = app_state
.reqwest_client
.get("https://api.github.com/search/repositories")
.query(&[("q", &search_query)])
.bearer_auth(&token)
.send()
.await?
.json::<SearchResponse>()
.await?;
Ok(HttpResponse::Ok().json(
response
.items
.into_iter()
.filter(|r| r.private == false)
.collect::<Vec<Repository>>(),
))
}

View File

@@ -6,7 +6,12 @@ mod middleware;
use std::env; use std::env;
use actix_web::{App, HttpServer, middleware::from_fn, rt::System, web}; use actix_web::{
App, HttpServer,
middleware::from_fn,
rt::System,
web::{self, route},
};
use sqlx::PgPool; use sqlx::PgPool;
use tracing::level_filters::LevelFilter; use tracing::level_filters::LevelFilter;
use tracing_subscriber::{ use tracing_subscriber::{
@@ -59,9 +64,11 @@ async fn run() -> std::io::Result<()> {
web::scope("/api").service( web::scope("/api").service(
web::scope("/v0").service( web::scope("/v0").service(
web::scope("/user") web::scope("/user")
.route("/repos", web::get().to(endpoints::get_repos::get_repos))
.wrap(from_fn(middleware::protected))
.route( .route(
"/{user_id}/repos", "/repos/search",
web::get().to(endpoints::get_repos::get_repos), web::get().to(endpoints::search_repos::search_repos),
) )
.wrap(from_fn(middleware::protected)), .wrap(from_fn(middleware::protected)),
), ),

View File

@@ -20,8 +20,8 @@ pub async fn protected(
.map_into_right_body()); .map_into_right_body());
}; };
let user_id = app_state.auth.for_protected(&token).await?; let user = app_state.auth.for_protected(&token).await?;
req.extensions_mut().insert(user_id); req.extensions_mut().insert(user);
next.call(req) next.call(req)
.await .await

View File

@@ -14,6 +14,7 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"jose": "^6.1.3", "jose": "^6.1.3",
"nprogress": "^0.2.0",
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-auto": "^7.0.0",
@@ -21,6 +22,7 @@
"@sveltejs/kit": "^2.49.1", "@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@types/nprogress": "^0.2.3",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
@@ -290,6 +292,8 @@
"@types/node": ["@types/node@22.19.6", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ=="], "@types/node": ["@types/node@22.19.6", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ=="],
"@types/nprogress": ["@types/nprogress@0.2.3", "", {}, "sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA=="],
"@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="], "@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="],
"@vercel/nft": ["@vercel/nft@1.2.0", "", { "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", "@rollup/pluginutils": "^5.1.3", "acorn": "^8.6.0", "acorn-import-attributes": "^1.9.5", "async-sema": "^3.1.1", "bindings": "^1.4.0", "estree-walker": "2.0.2", "glob": "^13.0.0", "graceful-fs": "^4.2.9", "node-gyp-build": "^4.2.2", "picomatch": "^4.0.2", "resolve-from": "^5.0.0" }, "bin": { "nft": "out/cli.js" } }, "sha512-68326CAWJmd6P1cUgUmufor5d4ocPbpLxiy9TKG6U/a4aWEx9aC+NIzaDI6GmBZVpt3+MkO3OwnQ2YcgJg12Qw=="], "@vercel/nft": ["@vercel/nft@1.2.0", "", { "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", "@rollup/pluginutils": "^5.1.3", "acorn": "^8.6.0", "acorn-import-attributes": "^1.9.5", "async-sema": "^3.1.1", "bindings": "^1.4.0", "estree-walker": "2.0.2", "glob": "^13.0.0", "graceful-fs": "^4.2.9", "node-gyp-build": "^4.2.2", "picomatch": "^4.0.2", "resolve-from": "^5.0.0" }, "bin": { "nft": "out/cli.js" } }, "sha512-68326CAWJmd6P1cUgUmufor5d4ocPbpLxiy9TKG6U/a4aWEx9aC+NIzaDI6GmBZVpt3+MkO3OwnQ2YcgJg12Qw=="],
@@ -438,6 +442,8 @@
"nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
"nprogress": ["nprogress@0.2.0", "", {}, "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="], "path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="],

View File

@@ -7,6 +7,7 @@
"@sveltejs/kit": "^2.49.1", "@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@types/nprogress": "^0.2.3",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
@@ -38,6 +39,7 @@
"bits-ui": "^2.15.4", "bits-ui": "^2.15.4",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"jose": "^6.1.3" "jose": "^6.1.3",
"nprogress": "^0.2.0"
} }
} }

View File

@@ -1,32 +1,57 @@
<script lang="ts"> <script lang="ts">
import { Button } from 'bits-ui'; import { Button } from 'bits-ui';
import { GitBranch, Star, Clock, Import, Search, RefreshCw } 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';
import { authClient } from './auth-client'; import { authClient } from './auth-client';
import { apiClient } from './api-client'; import { apiClient } from './api-client';
import NProgress from 'nprogress';
const session = authClient.useSession(); const session = authClient.useSession();
const query = createQuery(() => ({ const query = createQuery(() => ({
queryKey: ['github-repositories'], queryKey: ['recent-repositories'],
queryFn: async () => { queryFn: async () => {
return await apiClient.request<Repository[]>(`/api/v0/user/${$session.data?.user.id}/repos`); return await apiClient.request<Repository[]>(`/api/v0/user/repos`);
}, },
enabled: !!$session.data?.user.id, enabled: !!$session.data?.user.id,
staleTime: 30000 staleTime: 30000
})); }));
let searchQuery = $state(''); let searchQuery = $state('');
let throttledQuery = $state('');
$effect(() => {
const value = searchQuery;
const t = setTimeout(() => {
throttledQuery = value;
}, 300);
return () => clearTimeout(t);
});
$effect(() => {
if (searchResultsQuery.isFetching || query.isFetching) {
NProgress.start();
} else {
NProgress.done();
}
});
const searchResultsQuery = createQuery(() => ({
queryKey: ['search-repositories', throttledQuery],
queryFn: async () => {
return await apiClient.request<Repository[]>(
`/api/v0/user/repos/search?q=${encodeURIComponent(throttledQuery)}`
);
},
enabled: !!$session.data?.user.id && throttledQuery.length > 0
}));
let adding = $state<number | null>(null); let adding = $state<number | null>(null);
const filteredRepositories = $derived( const searching = $derived(throttledQuery.length > 0);
(query.data ?? []).filter( const repos = $derived(searching ? (searchResultsQuery.data ?? []) : (query.data ?? []));
(repo) => const isPending = $derived(searching ? searchResultsQuery.isPending : query.isPending);
repo.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
repo.description?.toLowerCase().includes(searchQuery.toLowerCase())
)
);
const handleImport = async (repoId: number) => { const handleImport = async (repoId: number) => {
adding = repoId; adding = repoId;
@@ -48,9 +73,11 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<GitBranch class="h-6 w-6 text-gray-400" /> <GitBranch class="h-6 w-6 text-gray-400" />
<h2 class="text-xl font-semibold text-white">Import Git Repository</h2> <h2 class="text-xl font-semibold text-white">Import Git Repository</h2>
<span class="text-xs text-gray-500">repo must be public</span>
</div> </div>
<Button.Root <Button.Root
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-400 transition-colors hover:bg-gray-800 hover:text-white" class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
onclick={() => (searching ? searchResultsQuery.refetch() : query.refetch())}
> >
<RefreshCw class="h-4 w-4" /> <RefreshCw class="h-4 w-4" />
<span>Refresh</span> <span>Refresh</span>
@@ -68,18 +95,13 @@
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
{#each filteredRepositories as repo (repo.id)} {#each repos as repo (repo.id)}
<div <div
class="group flex items-center justify-between rounded-lg border border-gray-800 bg-gray-800/50 p-4 transition-colors hover:border-gray-700 hover:bg-gray-800" class="group flex items-center justify-between rounded-lg border border-gray-800 bg-gray-800/50 p-4 transition-colors hover:border-gray-700 hover:bg-gray-800"
> >
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<h3 class="truncate font-medium text-white">{repo.name}</h3> <h3 class="truncate font-medium text-white">{repo.name}</h3>
{#if repo.isPrivate}
<span class="rounded-full border border-gray-600 px-2 py-0.5 text-xs text-gray-400">
Private
</span>
{/if}
</div> </div>
{#if repo.description} {#if repo.description}
<p class="mt-1 truncate text-sm text-gray-400">{repo.description}</p> <p class="mt-1 truncate text-sm text-gray-400">{repo.description}</p>
@@ -106,7 +128,7 @@
</div> </div>
</div> </div>
<Button.Root <Button.Root
class="ml-4 flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50" class="ml-4 flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-500 disabled:cursor-default disabled:opacity-50"
disabled={adding !== null} disabled={adding !== null}
onclick={() => handleImport(repo.id)} onclick={() => handleImport(repo.id)}
> >
@@ -120,9 +142,15 @@
</Button.Root> </Button.Root>
</div> </div>
{:else} {:else}
<div class="py-8 text-center text-gray-500"> {#if isPending}
<p>No repositories found matching "{searchQuery}"</p> <div class="flex justify-center">
</div> <LoaderCircle class="animate-spin" />
</div>
{:else}
<div class="py-8 text-center text-gray-500">
<p>No repositories found matching "{searchQuery}"</p>
</div>
{/if}
{/each} {/each}
</div> </div>

View File

@@ -5,15 +5,16 @@ import * as authSchema from './db/auth-schema';
import { jwt } from 'better-auth/plugins'; import { jwt } from 'better-auth/plugins';
export const auth = betterAuth({ export const auth = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
provider: 'pg', provider: 'pg',
schema: { ...authSchema } schema: { ...authSchema }
}), }),
plugins: [jwt()], plugins: [jwt()],
socialProviders: { socialProviders: {
github: { github: {
clientId: process.env.GH_CLIENT_ID!, clientId: process.env.GH_CLIENT_ID!,
clientSecret: process.env.GH_CLIENT_SECRET! clientSecret: process.env.GH_CLIENT_SECRET!,
} scope: ["repo"]
} }
}
}); });

View File

@@ -2,10 +2,13 @@
import Header from '$lib/Header.svelte'; import Header from '$lib/Header.svelte';
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query'; import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
import './layout.css'; import './layout.css';
import 'nprogress/nprogress.css';
import NProgress from 'nprogress';
import { SvelteQueryDevtools } from '@tanstack/svelte-query-devtools'; import { SvelteQueryDevtools } from '@tanstack/svelte-query-devtools';
let { children } = $props(); let { children } = $props();
const queryClient = new QueryClient(); const queryClient = new QueryClient();
NProgress.configure({ showSpinner: false });
</script> </script>
<Header /> <Header />

View File

@@ -61,6 +61,10 @@ h1 {
@apply hover:bg-gray-800; @apply hover:bg-gray-800;
} }
#nprogress .bar {
top: 3.5rem;
}
button:default { button:default {
border-radius: 8px; border-radius: 8px;
border: 1px solid transparent; border: 1px solid transparent;