feat: Add working workspace #25
@@ -1,28 +1,26 @@
|
||||
# SynCode Front
|
||||
|
||||
The Leptos client for [`syncode-identity`](https://syncode.sh/syncode/identity):
|
||||
login, account, and linked-provider management. CSR/WASM, built with
|
||||
[Trunk](https://trunkrs.dev), styled with Tailwind and per-page SCSS. It talks
|
||||
to `syncode-identity`'s GraphQL API and issues the same session cookie the
|
||||
forge's Go bridge already reads, so one login works in both the new front and
|
||||
the old Go UI.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
cargo fmt --check
|
||||
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
trunk build
|
||||
trunk serve
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Static output (`dist/`), deployed to Cloudflare Pages — `new.dev.syncode.sh`
|
||||
tracks `develop`, `new.syncode.sh` tracks `main`. `dev.syncode.sh` as a
|
||||
literal DNS suffix (not `dev.new.syncode.sh`) matters: the session cookie's
|
||||
`Domain` is `dev.syncode.sh` on dev, so it stays isolated from prod.
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
# SynCode Front
|
||||
|
||||
The Leptos client for SynCode Identity, Repository and Collaboration. It
|
||||
provides login and account management, repository browsing, and native
|
||||
Workspace, Project, Docs, Version and Issue flows. CSR/WASM is built with
|
||||
[Trunk](https://trunkrs.dev) and styled with Tailwind and per-page SCSS.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
cargo fmt --check
|
||||
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
trunk build
|
||||
trunk serve
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Static output (`dist/`), deployed to Cloudflare Pages — `new.dev.syncode.sh`
|
||||
tracks `develop`, `new.syncode.sh` tracks `main`. `dev.syncode.sh` as a
|
||||
literal DNS suffix (not `dev.new.syncode.sh`) matters: the session cookie's
|
||||
`Domain` is `dev.syncode.sh` on dev, so it stays isolated from prod.
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
@@ -1,65 +1,69 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos_meta::{provide_meta_context, Title};
|
||||
use leptos_router::components::{Route, Router, Routes};
|
||||
use leptos_router::{ParamSegment, StaticSegment};
|
||||
|
||||
use crate::pages::account::Account;
|
||||
use crate::pages::account_emails::AccountEmails;
|
||||
use crate::pages::account_security::AccountSecurity;
|
||||
use crate::pages::home::Home;
|
||||
use crate::pages::login::Login;
|
||||
use crate::pages::org_agents::OrgAgents;
|
||||
use crate::pages::org_teams::OrgTeams;
|
||||
use crate::pages::profile::Profile;
|
||||
use crate::pages::repository::{NewRepository, RepositoryPage};
|
||||
use crate::pages::repository_code::{
|
||||
RepositoryBlame, RepositoryBlob, RepositoryCode, RepositoryCodeDefault,
|
||||
RepositoryCommitsDefault,
|
||||
};
|
||||
use crate::pages::repository_commits::{RepositoryCommit, RepositoryCommits};
|
||||
use crate::pages::repository_compare::RepositoryCompare;
|
||||
use crate::pages::repository_fork::RepositoryFork;
|
||||
use crate::pages::repository_protection::RepositoryProtection;
|
||||
use crate::pages::repository_refs::{RepositoryBranches, RepositoryTags};
|
||||
use crate::pages::repository_settings::RepositorySettings;
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
provide_meta_context();
|
||||
|
||||
view! {
|
||||
<Title text="SynCode"/>
|
||||
<Router>
|
||||
<main>
|
||||
<Routes fallback=|| "404 not found">
|
||||
<Route path=StaticSegment("") view=Home/>
|
||||
<Route path=StaticSegment("login") view=Login/>
|
||||
<Route path=StaticSegment("account") view=Account/>
|
||||
<Route path=(StaticSegment("account"), StaticSegment("emails")) view=AccountEmails/>
|
||||
<Route path=(StaticSegment("account"), StaticSegment("security")) view=AccountSecurity/>
|
||||
<Route path=(StaticSegment("org"), ParamSegment("slug"), StaticSegment("teams")) view=OrgTeams/>
|
||||
<Route path=(StaticSegment("org"), ParamSegment("slug"), StaticSegment("agents")) view=OrgAgents/>
|
||||
<Route path=(StaticSegment("repo"), StaticSegment("new")) view=NewRepository/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository")) view=RepositoryPage/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("code")) view=RepositoryCodeDefault/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("code"), ParamSegment("revision")) view=RepositoryCode/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("blob"), ParamSegment("revision")) view=RepositoryBlob/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("blame"), ParamSegment("revision")) view=RepositoryBlame/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("commits")) view=RepositoryCommitsDefault/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("commits"), ParamSegment("revision")) view=RepositoryCommits/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("changeset"), ParamSegment("object_id")) view=RepositoryCommit/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("branches")) view=RepositoryBranches/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("tags")) view=RepositoryTags/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("compare")) view=RepositoryCompare/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("settings")) view=RepositorySettings/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("settings"), StaticSegment("protection")) view=RepositoryProtection/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("fork")) view=RepositoryFork/>
|
||||
// Catch-all profile route — must stay last, or it would
|
||||
// shadow every static route above it (e.g. /login would
|
||||
// match as owner="login").
|
||||
<Route path=ParamSegment("owner") view=Profile/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
use leptos::prelude::*;
|
||||
use leptos_meta::{provide_meta_context, Title};
|
||||
use leptos_router::components::{Route, Router, Routes};
|
||||
use leptos_router::{ParamSegment, StaticSegment};
|
||||
|
||||
use crate::pages::account::Account;
|
||||
use crate::pages::account_emails::AccountEmails;
|
||||
use crate::pages::account_security::AccountSecurity;
|
||||
use crate::pages::home::Home;
|
||||
use crate::pages::login::Login;
|
||||
use crate::pages::org_agents::OrgAgents;
|
||||
use crate::pages::org_teams::OrgTeams;
|
||||
use crate::pages::profile::Profile;
|
||||
use crate::pages::repository::{NewRepository, RepositoryPage};
|
||||
use crate::pages::repository_code::{
|
||||
RepositoryBlame, RepositoryBlob, RepositoryCode, RepositoryCodeDefault,
|
||||
RepositoryCommitsDefault,
|
||||
};
|
||||
use crate::pages::repository_commits::{RepositoryCommit, RepositoryCommits};
|
||||
use crate::pages::repository_compare::RepositoryCompare;
|
||||
use crate::pages::repository_fork::RepositoryFork;
|
||||
use crate::pages::repository_protection::RepositoryProtection;
|
||||
use crate::pages::repository_refs::{RepositoryBranches, RepositoryTags};
|
||||
use crate::pages::repository_settings::RepositorySettings;
|
||||
use crate::pages::workspace::WorkspacePage;
|
||||
use crate::pages::workspaces::Workspaces;
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
provide_meta_context();
|
||||
|
||||
view! {
|
||||
<Title text="SynCode"/>
|
||||
<Router>
|
||||
<main>
|
||||
<Routes fallback=|| "404 not found">
|
||||
<Route path=StaticSegment("") view=Home/>
|
||||
<Route path=StaticSegment("login") view=Login/>
|
||||
<Route path=StaticSegment("account") view=Account/>
|
||||
<Route path=(StaticSegment("account"), StaticSegment("emails")) view=AccountEmails/>
|
||||
<Route path=(StaticSegment("account"), StaticSegment("security")) view=AccountSecurity/>
|
||||
<Route path=(StaticSegment("org"), ParamSegment("slug"), StaticSegment("teams")) view=OrgTeams/>
|
||||
<Route path=(StaticSegment("org"), ParamSegment("slug"), StaticSegment("agents")) view=OrgAgents/>
|
||||
<Route path=(StaticSegment("repo"), StaticSegment("new")) view=NewRepository/>
|
||||
<Route path=StaticSegment("workspaces") view=Workspaces/>
|
||||
<Route path=(StaticSegment("workspaces"), ParamSegment("id")) view=WorkspacePage/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository")) view=RepositoryPage/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("code")) view=RepositoryCodeDefault/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("code"), ParamSegment("revision")) view=RepositoryCode/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("blob"), ParamSegment("revision")) view=RepositoryBlob/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("blame"), ParamSegment("revision")) view=RepositoryBlame/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("commits")) view=RepositoryCommitsDefault/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("commits"), ParamSegment("revision")) view=RepositoryCommits/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("changeset"), ParamSegment("object_id")) view=RepositoryCommit/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("branches")) view=RepositoryBranches/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("tags")) view=RepositoryTags/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("compare")) view=RepositoryCompare/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("settings")) view=RepositorySettings/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("settings"), StaticSegment("protection")) view=RepositoryProtection/>
|
||||
<Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("fork")) view=RepositoryFork/>
|
||||
// Catch-all profile route — must stay last, or it would
|
||||
// shadow every static route above it (e.g. /login would
|
||||
// match as owner="login").
|
||||
<Route path=ParamSegment("owner") view=Profile/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
@@ -1,108 +1,150 @@
|
||||
use gloo_net::http::Request;
|
||||
use web_sys::RequestCredentials;
|
||||
|
||||
pub mod schema {
|
||||
cynic::use_schema!("schema.graphql");
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, cynic::Scalar)]
|
||||
#[cynic(schema_module = "schema")]
|
||||
pub struct DateTime(pub String);
|
||||
|
||||
impl std::fmt::Display for DateTime {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(formatter)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, cynic::Scalar)]
|
||||
#[cynic(schema_module = "schema")]
|
||||
pub struct NaiveDate(pub String);
|
||||
|
||||
impl std::ops::Deref for NaiveDate {
|
||||
type Target = str;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
pub mod repo_schema {
|
||||
cynic::use_schema!("repo-schema.graphql");
|
||||
}
|
||||
|
||||
pub fn identity_origin() -> String {
|
||||
let window = web_sys::window().expect("SynCode front requires a browser window");
|
||||
let hostname = window
|
||||
.location()
|
||||
.hostname()
|
||||
.expect("SynCode front requires a readable browser hostname");
|
||||
if hostname == "localhost" || hostname == "127.0.0.1" {
|
||||
return "https://identity.dev.syncode.sh".to_owned();
|
||||
}
|
||||
match hostname.strip_prefix("new.") {
|
||||
Some(rest) => format!("https://identity.{rest}"),
|
||||
None => format!("https://identity.{hostname}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn typed_graphql<T, V>(operation: cynic::Operation<T, V>) -> Result<T, String>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
V: serde::Serialize,
|
||||
{
|
||||
let response = Request::post(&format!("{}/graphql", identity_origin()))
|
||||
.credentials(RequestCredentials::Include)
|
||||
.header("content-type", "application/json")
|
||||
.json(&operation)
|
||||
.map_err(|error| error.to_string())?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let response: cynic::GraphQlResponse<T> =
|
||||
response.json().await.map_err(|error| error.to_string())?;
|
||||
if let Some(message) = response.errors.into_iter().flatten().next() {
|
||||
return Err(message.message);
|
||||
}
|
||||
response
|
||||
.data
|
||||
.ok_or_else(|| "the response had no data".to_owned())
|
||||
}
|
||||
|
||||
pub fn repo_origin() -> String {
|
||||
let window = web_sys::window().expect("SynCode front requires a browser window");
|
||||
let hostname = window
|
||||
.location()
|
||||
.hostname()
|
||||
.expect("SynCode front requires a readable browser hostname");
|
||||
if hostname == "localhost" || hostname == "127.0.0.1" {
|
||||
return "http://127.0.0.1:8201".to_owned();
|
||||
}
|
||||
match hostname.strip_prefix("new.") {
|
||||
Some(rest) => format!("https://repo.{rest}"),
|
||||
None => format!("https://repo.{hostname}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn typed_repo_graphql<T, V>(operation: cynic::Operation<T, V>) -> Result<T, String>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
V: serde::Serialize,
|
||||
{
|
||||
let response = Request::post(&format!("{}/graphql", repo_origin()))
|
||||
.credentials(RequestCredentials::Include)
|
||||
.header("content-type", "application/json")
|
||||
.json(&operation)
|
||||
.map_err(|error| error.to_string())?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let response: cynic::GraphQlResponse<T> =
|
||||
response.json().await.map_err(|error| error.to_string())?;
|
||||
if let Some(message) = response.errors.into_iter().flatten().next() {
|
||||
return Err(message.message);
|
||||
}
|
||||
response
|
||||
.data
|
||||
.ok_or_else(|| "the response had no data".to_owned())
|
||||
}
|
||||
use gloo_net::http::Request;
|
||||
use web_sys::RequestCredentials;
|
||||
|
||||
pub mod schema {
|
||||
cynic::use_schema!("schema.graphql");
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, cynic::Scalar)]
|
||||
#[cynic(schema_module = "schema")]
|
||||
pub struct DateTime(pub String);
|
||||
|
||||
impl std::fmt::Display for DateTime {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(formatter)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, cynic::Scalar)]
|
||||
#[cynic(schema_module = "schema")]
|
||||
pub struct NaiveDate(pub String);
|
||||
|
||||
impl std::ops::Deref for NaiveDate {
|
||||
type Target = str;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
pub mod repo_schema {
|
||||
cynic::use_schema!("repo-schema.graphql");
|
||||
}
|
||||
|
||||
pub mod collab_schema {
|
||||
cynic::use_schema!("collab-schema.graphql");
|
||||
}
|
||||
|
||||
pub fn collab_origin() -> String {
|
||||
let window = web_sys::window().expect("SynCode front requires a browser window");
|
||||
let hostname = window
|
||||
.location()
|
||||
.hostname()
|
||||
.expect("SynCode front requires a readable browser hostname");
|
||||
if hostname == "localhost" || hostname == "127.0.0.1" {
|
||||
return "https://dev.syncode.sh/collaboration/graphql".to_owned();
|
||||
}
|
||||
match hostname.strip_prefix("new.") {
|
||||
Some(rest) => format!("https://{rest}/collaboration/graphql"),
|
||||
None => format!("https://{hostname}/collaboration/graphql"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn typed_collab_graphql<T, V>(operation: cynic::Operation<T, V>) -> Result<T, String>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
V: serde::Serialize,
|
||||
{
|
||||
let response = Request::post(&collab_origin())
|
||||
.credentials(RequestCredentials::Include)
|
||||
.header("content-type", "application/json")
|
||||
.json(&operation)
|
||||
.map_err(|error| error.to_string())?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let response: cynic::GraphQlResponse<T> =
|
||||
response.json().await.map_err(|error| error.to_string())?;
|
||||
if let Some(message) = response.errors.into_iter().flatten().next() {
|
||||
return Err(message.message);
|
||||
}
|
||||
response
|
||||
.data
|
||||
.ok_or_else(|| "the response had no data".to_owned())
|
||||
}
|
||||
|
||||
pub fn identity_origin() -> String {
|
||||
let window = web_sys::window().expect("SynCode front requires a browser window");
|
||||
let hostname = window
|
||||
.location()
|
||||
.hostname()
|
||||
.expect("SynCode front requires a readable browser hostname");
|
||||
if hostname == "localhost" || hostname == "127.0.0.1" {
|
||||
return "https://identity.dev.syncode.sh".to_owned();
|
||||
}
|
||||
match hostname.strip_prefix("new.") {
|
||||
Some(rest) => format!("https://identity.{rest}"),
|
||||
None => format!("https://identity.{hostname}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn typed_graphql<T, V>(operation: cynic::Operation<T, V>) -> Result<T, String>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
V: serde::Serialize,
|
||||
{
|
||||
let response = Request::post(&format!("{}/graphql", identity_origin()))
|
||||
.credentials(RequestCredentials::Include)
|
||||
.header("content-type", "application/json")
|
||||
.json(&operation)
|
||||
.map_err(|error| error.to_string())?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let response: cynic::GraphQlResponse<T> =
|
||||
response.json().await.map_err(|error| error.to_string())?;
|
||||
if let Some(message) = response.errors.into_iter().flatten().next() {
|
||||
return Err(message.message);
|
||||
}
|
||||
response
|
||||
.data
|
||||
.ok_or_else(|| "the response had no data".to_owned())
|
||||
}
|
||||
|
||||
pub fn repo_origin() -> String {
|
||||
let window = web_sys::window().expect("SynCode front requires a browser window");
|
||||
let hostname = window
|
||||
.location()
|
||||
.hostname()
|
||||
.expect("SynCode front requires a readable browser hostname");
|
||||
if hostname == "localhost" || hostname == "127.0.0.1" {
|
||||
return "http://127.0.0.1:8201".to_owned();
|
||||
}
|
||||
match hostname.strip_prefix("new.") {
|
||||
Some(rest) => format!("https://repo.{rest}"),
|
||||
None => format!("https://repo.{hostname}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn typed_repo_graphql<T, V>(operation: cynic::Operation<T, V>) -> Result<T, String>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
V: serde::Serialize,
|
||||
{
|
||||
let response = Request::post(&format!("{}/graphql", repo_origin()))
|
||||
.credentials(RequestCredentials::Include)
|
||||
.header("content-type", "application/json")
|
||||
.json(&operation)
|
||||
.map_err(|error| error.to_string())?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let response: cynic::GraphQlResponse<T> =
|
||||
response.json().await.map_err(|error| error.to_string())?;
|
||||
if let Some(message) = response.errors.into_iter().flatten().next() {
|
||||
return Err(message.message);
|
||||
}
|
||||
response
|
||||
.data
|
||||
.ok_or_else(|| "the response had no data".to_owned())
|
||||
}
|
||||
@@ -1,23 +1,25 @@
|
||||
mod app;
|
||||
mod branch_protection;
|
||||
mod components;
|
||||
mod graphql;
|
||||
mod heatmap;
|
||||
mod identity;
|
||||
mod org;
|
||||
mod pages;
|
||||
mod platform_agents;
|
||||
mod repo;
|
||||
mod repo_browser;
|
||||
mod repo_insights;
|
||||
mod repo_mutations;
|
||||
mod repo_objects;
|
||||
mod repo_read;
|
||||
|
||||
use app::App;
|
||||
|
||||
fn main() {
|
||||
console_error_panic_hook::set_once();
|
||||
_ = console_log::init_with_level(log::Level::Warn);
|
||||
leptos::mount::mount_to_body(App);
|
||||
}
|
||||
mod app;
|
||||
mod branch_protection;
|
||||
mod collab;
|
||||
mod collab_write;
|
||||
mod components;
|
||||
mod graphql;
|
||||
mod heatmap;
|
||||
mod identity;
|
||||
mod org;
|
||||
mod pages;
|
||||
mod platform_agents;
|
||||
mod repo;
|
||||
mod repo_browser;
|
||||
mod repo_insights;
|
||||
mod repo_mutations;
|
||||
mod repo_objects;
|
||||
mod repo_read;
|
||||
|
||||
use app::App;
|
||||
|
||||
fn main() {
|
||||
console_error_panic_hook::set_once();
|
||||
_ = console_log::init_with_level(log::Level::Warn);
|
||||
leptos::mount::mount_to_body(App);
|
||||
}
|
||||
+15
-10
@@ -1,133 +1,138 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::components::A;
|
||||
|
||||
use crate::components::logo::Ring;
|
||||
use crate::components::nav::Nav;
|
||||
use crate::identity::fetch_me;
|
||||
|
||||
fn forge_url() -> String {
|
||||
let host = web_sys::window()
|
||||
.expect("SynCode home page requires a browser window")
|
||||
.location()
|
||||
.host()
|
||||
.expect("SynCode home page requires a readable host");
|
||||
forge_url_for_host(&host)
|
||||
.unwrap_or_else(|| panic!("unsupported SynCode front host: {host}"))
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn forge_url_for_host(host: &str) -> Option<&'static str> {
|
||||
match host {
|
||||
"new.syncode.sh" => Some("https://syncode.sh"),
|
||||
"new.dev.syncode.sh" => Some("https://dev.syncode.sh"),
|
||||
"localhost" | "127.0.0.1" => Some("https://dev.syncode.sh"),
|
||||
host if host.starts_with("localhost:") || host.starts_with("127.0.0.1:") => {
|
||||
Some("https://dev.syncode.sh")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Home() -> impl IntoView {
|
||||
let me = LocalResource::new(fetch_me);
|
||||
let signed_in = move || {
|
||||
matches!(
|
||||
me.get().map(|wrapped| (*wrapped).clone()),
|
||||
Some(Ok(Some(_)))
|
||||
)
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="relative flex min-h-screen flex-col">
|
||||
<div class="relative z-10 bg-white">
|
||||
<Nav>
|
||||
{move || {
|
||||
if signed_in() {
|
||||
view! {
|
||||
<A href="/account" attr:class="text-sm font-medium hover:text-primary">
|
||||
"Account"
|
||||
</A>
|
||||
}
|
||||
} else {
|
||||
view! {
|
||||
<A href="/login" attr:class="text-sm font-medium hover:text-primary">
|
||||
"Sign in"
|
||||
</A>
|
||||
}
|
||||
}
|
||||
}}
|
||||
</Nav>
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-1">
|
||||
<div class="absolute inset-y-0 left-1/2 hidden w-px bg-ink/90 md:block"></div>
|
||||
|
||||
<div class="relative z-10 flex w-full flex-col justify-center bg-white px-10 py-10 md:w-1/2 md:px-20">
|
||||
<div class="max-w-lg">
|
||||
<h1 class="text-6xl font-bold tracking-tight text-ink md:text-7xl">"SynCode"</h1>
|
||||
<p class="mt-6 max-w-sm text-lg text-ink/60">
|
||||
"The open-source federated code forge."
|
||||
</p>
|
||||
<div class="mt-10 flex items-center gap-6">
|
||||
{move || {
|
||||
let (href, label) = if signed_in() {
|
||||
("/account", "Go to account")
|
||||
} else {
|
||||
("/login", "Sign in")
|
||||
};
|
||||
view! {
|
||||
<A
|
||||
href=href
|
||||
attr:class="rounded-lg bg-primary px-6 py-3 text-sm font-medium text-white no-underline hover:opacity-90"
|
||||
>
|
||||
{label}
|
||||
</A>
|
||||
}
|
||||
}}
|
||||
<a
|
||||
href=forge_url()
|
||||
class="text-sm font-medium text-ink/70 no-underline hover:text-ink"
|
||||
>
|
||||
"Explore the forge →"
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute top-1/2 right-0 hidden -translate-y-1/2 translate-x-1/2 flex-col items-center gap-3 md:flex">
|
||||
<div class="size-7 rotate-45 bg-tertiary"></div>
|
||||
<div class="h-6 w-12 bg-tertiary"></div>
|
||||
<div class="size-7 rotate-45 bg-tertiary"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative hidden md:block md:w-1/2">
|
||||
<div class="hero-art"></div>
|
||||
<Ring class="pointer-events-none absolute top-1/2 left-0 z-20 h-[85%] w-auto -translate-x-1/3 -translate-y-1/2 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::forge_url_for_host;
|
||||
|
||||
#[test]
|
||||
fn forge_link_matches_the_front_environment() {
|
||||
assert_eq!(
|
||||
forge_url_for_host("new.syncode.sh"),
|
||||
Some("https://syncode.sh")
|
||||
);
|
||||
assert_eq!(
|
||||
forge_url_for_host("new.dev.syncode.sh"),
|
||||
Some("https://dev.syncode.sh")
|
||||
);
|
||||
assert_eq!(
|
||||
forge_url_for_host("localhost:8180"),
|
||||
Some("https://dev.syncode.sh")
|
||||
);
|
||||
assert_eq!(forge_url_for_host("unexpected.example"), None);
|
||||
}
|
||||
}
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::components::A;
|
||||
|
||||
use crate::components::logo::Ring;
|
||||
use crate::components::nav::Nav;
|
||||
use crate::identity::fetch_me;
|
||||
|
||||
fn forge_url() -> String {
|
||||
let host = web_sys::window()
|
||||
.expect("SynCode home page requires a browser window")
|
||||
.location()
|
||||
.host()
|
||||
.expect("SynCode home page requires a readable host");
|
||||
forge_url_for_host(&host)
|
||||
.unwrap_or_else(|| panic!("unsupported SynCode front host: {host}"))
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn forge_url_for_host(host: &str) -> Option<&'static str> {
|
||||
match host {
|
||||
"new.syncode.sh" => Some("https://syncode.sh"),
|
||||
"new.dev.syncode.sh" => Some("https://dev.syncode.sh"),
|
||||
"localhost" | "127.0.0.1" => Some("https://dev.syncode.sh"),
|
||||
host if host.starts_with("localhost:") || host.starts_with("127.0.0.1:") => {
|
||||
Some("https://dev.syncode.sh")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Home() -> impl IntoView {
|
||||
let me = LocalResource::new(fetch_me);
|
||||
let signed_in = move || {
|
||||
matches!(
|
||||
me.get().map(|wrapped| (*wrapped).clone()),
|
||||
Some(Ok(Some(_)))
|
||||
)
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="relative flex min-h-screen flex-col">
|
||||
<div class="relative z-10 bg-white">
|
||||
<Nav>
|
||||
{move || {
|
||||
if signed_in() {
|
||||
view! {
|
||||
<div class="flex items-center gap-5">
|
||||
<A href="/workspaces" attr:class="text-sm font-medium hover:text-primary">
|
||||
"Workspaces"
|
||||
</A>
|
||||
<A href="/account" attr:class="text-sm font-medium hover:text-primary">
|
||||
"Account"
|
||||
</A>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<A href="/login" attr:class="text-sm font-medium hover:text-primary">
|
||||
"Sign in"
|
||||
</A>
|
||||
}.into_any()
|
||||
}
|
||||
}}
|
||||
</Nav>
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-1">
|
||||
<div class="absolute inset-y-0 left-1/2 hidden w-px bg-ink/90 md:block"></div>
|
||||
|
||||
<div class="relative z-10 flex w-full flex-col justify-center bg-white px-10 py-10 md:w-1/2 md:px-20">
|
||||
<div class="max-w-lg">
|
||||
<h1 class="text-6xl font-bold tracking-tight text-ink md:text-7xl">"SynCode"</h1>
|
||||
<p class="mt-6 max-w-sm text-lg text-ink/60">
|
||||
"The open-source federated code forge."
|
||||
</p>
|
||||
<div class="mt-10 flex items-center gap-6">
|
||||
{move || {
|
||||
let (href, label) = if signed_in() {
|
||||
("/workspaces", "Open workspaces")
|
||||
} else {
|
||||
("/login", "Sign in")
|
||||
};
|
||||
view! {
|
||||
<A
|
||||
href=href
|
||||
attr:class="rounded-lg bg-primary px-6 py-3 text-sm font-medium text-white no-underline hover:opacity-90"
|
||||
>
|
||||
{label}
|
||||
</A>
|
||||
}
|
||||
}}
|
||||
<a
|
||||
href=forge_url()
|
||||
class="text-sm font-medium text-ink/70 no-underline hover:text-ink"
|
||||
>
|
||||
"Explore the forge →"
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute top-1/2 right-0 hidden -translate-y-1/2 translate-x-1/2 flex-col items-center gap-3 md:flex">
|
||||
<div class="size-7 rotate-45 bg-tertiary"></div>
|
||||
<div class="h-6 w-12 bg-tertiary"></div>
|
||||
<div class="size-7 rotate-45 bg-tertiary"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative hidden md:block md:w-1/2">
|
||||
<div class="hero-art"></div>
|
||||
<Ring class="pointer-events-none absolute top-1/2 left-0 z-20 h-[85%] w-auto -translate-x-1/3 -translate-y-1/2 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::forge_url_for_host;
|
||||
|
||||
#[test]
|
||||
fn forge_link_matches_the_front_environment() {
|
||||
assert_eq!(
|
||||
forge_url_for_host("new.syncode.sh"),
|
||||
Some("https://syncode.sh")
|
||||
);
|
||||
assert_eq!(
|
||||
forge_url_for_host("new.dev.syncode.sh"),
|
||||
Some("https://dev.syncode.sh")
|
||||
);
|
||||
assert_eq!(
|
||||
forge_url_for_host("localhost:8180"),
|
||||
Some("https://dev.syncode.sh")
|
||||
);
|
||||
assert_eq!(forge_url_for_host("unexpected.example"), None);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
pub mod account;
|
||||
pub mod account_emails;
|
||||
pub mod account_security;
|
||||
pub mod home;
|
||||
pub mod login;
|
||||
mod org_agent_forms;
|
||||
pub mod org_agents;
|
||||
pub mod org_teams;
|
||||
pub mod profile;
|
||||
pub mod repository;
|
||||
pub mod repository_code;
|
||||
pub mod repository_commits;
|
||||
pub mod repository_compare;
|
||||
pub mod repository_fork;
|
||||
pub mod repository_protection;
|
||||
pub mod repository_refs;
|
||||
pub mod repository_settings;
|
||||
mod repository_shell;
|
||||
pub mod account;
|
||||
pub mod account_emails;
|
||||
pub mod account_security;
|
||||
pub mod home;
|
||||
pub mod login;
|
||||
mod org_agent_forms;
|
||||
pub mod org_agents;
|
||||
pub mod org_teams;
|
||||
pub mod profile;
|
||||
pub mod repository;
|
||||
pub mod repository_code;
|
||||
pub mod repository_commits;
|
||||
pub mod repository_compare;
|
||||
pub mod repository_fork;
|
||||
pub mod repository_protection;
|
||||
pub mod repository_refs;
|
||||
pub mod repository_settings;
|
||||
mod repository_shell;
|
||||
pub mod workspace;
|
||||
mod workspace_forms;
|
||||
pub mod workspaces;
|
||||
@@ -1,0 +1,158 @@
|
||||
input AttachRepositoryInput {
|
||||
workspaceId: ID!
|
||||
owner: String!
|
||||
name: String!
|
||||
role: RepositoryRole!
|
||||
branch: String!
|
||||
}
|
||||
|
||||
type Checkout {
|
||||
workspace: Workspace!
|
||||
environment: String!
|
||||
repositories: [CheckoutRepository!]!
|
||||
omittedRepositories: Int!
|
||||
}
|
||||
|
||||
type CheckoutRepository {
|
||||
repositoryId: ID!
|
||||
owner: String!
|
||||
name: String!
|
||||
role: RepositoryRole!
|
||||
branch: String!
|
||||
}
|
||||
|
||||
input ConfigureEnvironmentInput {
|
||||
workspaceId: ID!
|
||||
name: String!
|
||||
defaultSourceBranch: String!
|
||||
infraBranch: String
|
||||
}
|
||||
|
||||
input CreateIssueInput {
|
||||
projectId: ID!
|
||||
title: String!
|
||||
description: String! = ""
|
||||
priority: IssuePriority!
|
||||
}
|
||||
|
||||
input CreateProjectInput {
|
||||
workspaceId: ID!
|
||||
key: String!
|
||||
name: String!
|
||||
slug: String!
|
||||
description: String! = ""
|
||||
kind: ProjectKind!
|
||||
}
|
||||
|
||||
input CreateVersionInput {
|
||||
projectId: ID!
|
||||
name: String!
|
||||
slug: String!
|
||||
}
|
||||
|
||||
input CreateWorkspaceInput {
|
||||
ownerKind: OwnerKind!
|
||||
ownerId: ID!
|
||||
name: String!
|
||||
slug: String!
|
||||
defaultEnvironment: String!
|
||||
defaultSourceBranch: String!
|
||||
}
|
||||
|
||||
type Document {
|
||||
id: ID!
|
||||
workspaceId: ID!
|
||||
projectId: ID
|
||||
path: String!
|
||||
revisionId: ID!
|
||||
content: String!
|
||||
}
|
||||
|
||||
type Environment {
|
||||
name: String!
|
||||
defaultSourceBranch: String!
|
||||
infraBranch: String
|
||||
}
|
||||
|
||||
type Issue {
|
||||
id: ID!
|
||||
projectId: ID!
|
||||
number: Int!
|
||||
title: String!
|
||||
description: String!
|
||||
statusId: ID!
|
||||
typeId: ID!
|
||||
priority: IssuePriority!
|
||||
version: Int!
|
||||
}
|
||||
|
||||
enum IssuePriority { NONE LOW NORMAL HIGH URGENT }
|
||||
|
||||
type Mutation {
|
||||
createWorkspace(input: CreateWorkspaceInput!): Workspace!
|
||||
attachRepository(input: AttachRepositoryInput!): Boolean!
|
||||
configureEnvironment(input: ConfigureEnvironmentInput!): Environment!
|
||||
createProject(input: CreateProjectInput!): Project!
|
||||
putDocument(input: PutDocumentInput!): Document!
|
||||
createIssue(input: CreateIssueInput!): Issue!
|
||||
createVersion(input: CreateVersionInput!): Version!
|
||||
}
|
||||
|
||||
enum OwnerKind { USER ORGANIZATION }
|
||||
|
||||
type Project {
|
||||
id: ID!
|
||||
workspaceId: ID!
|
||||
key: String!
|
||||
name: String!
|
||||
slug: String!
|
||||
description: String!
|
||||
kind: ProjectKind!
|
||||
lifecycle: String!
|
||||
version: Int!
|
||||
}
|
||||
|
||||
enum ProjectKind { CONTINUOUS FINITE }
|
||||
|
||||
input PutDocumentInput {
|
||||
workspaceId: ID!
|
||||
projectId: ID
|
||||
path: String!
|
||||
content: String!
|
||||
baseRevisionId: ID
|
||||
}
|
||||
|
||||
type Query {
|
||||
version: String!
|
||||
envelopeProtocolVersion: Int!
|
||||
workspaces: [Workspace!]!
|
||||
workspace(id: ID!): Workspace!
|
||||
checkout(workspaceId: ID!, environment: String, partial: Boolean): Checkout!
|
||||
projects(workspaceId: ID!): [Project!]!
|
||||
environments(workspaceId: ID!): [Environment!]!
|
||||
documents(workspaceId: ID!, projectId: ID): [Document!]!
|
||||
issues(projectId: ID!): [Issue!]!
|
||||
versions(projectId: ID!): [Version!]!
|
||||
}
|
||||
|
||||
enum RepositoryRole { SOURCE INFRA }
|
||||
|
||||
type Version {
|
||||
id: ID!
|
||||
projectId: ID!
|
||||
name: String!
|
||||
slug: String!
|
||||
state: String!
|
||||
}
|
||||
|
||||
type Workspace {
|
||||
id: ID!
|
||||
ownerKind: OwnerKind!
|
||||
ownerId: ID!
|
||||
name: String!
|
||||
slug: String!
|
||||
defaultEnvironment: String!
|
||||
version: Int!
|
||||
}
|
||||
|
||||
schema { query: Query mutation: Mutation }
|
||||
+299
@@ -1,0 +1,299 @@
|
||||
use cynic::{MutationBuilder, QueryBuilder};
|
||||
|
||||
use crate::graphql::collab_schema as schema;
|
||||
use crate::graphql::typed_collab_graphql;
|
||||
|
||||
#[derive(Clone, cynic::Enum, PartialEq, Eq)]
|
||||
#[cynic(schema_path = "collab-schema.graphql")]
|
||||
pub enum OwnerKind {
|
||||
User,
|
||||
Organization,
|
||||
}
|
||||
|
||||
#[derive(Clone, cynic::Enum, PartialEq, Eq)]
|
||||
#[cynic(schema_path = "collab-schema.graphql")]
|
||||
pub enum RepositoryRole {
|
||||
Source,
|
||||
Infra,
|
||||
}
|
||||
|
||||
#[derive(Clone, cynic::Enum, PartialEq, Eq)]
|
||||
#[cynic(schema_path = "collab-schema.graphql")]
|
||||
pub enum ProjectKind {
|
||||
Continuous,
|
||||
Finite,
|
||||
}
|
||||
|
||||
#[derive(Clone, cynic::Enum, PartialEq, Eq)]
|
||||
#[cynic(schema_path = "collab-schema.graphql")]
|
||||
pub enum IssuePriority {
|
||||
None,
|
||||
Low,
|
||||
Normal,
|
||||
High,
|
||||
Urgent,
|
||||
}
|
||||
|
||||
#[derive(Clone, cynic::QueryFragment)]
|
||||
#[cynic(schema_path = "collab-schema.graphql")]
|
||||
pub struct Workspace {
|
||||
pub id: cynic::Id,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub default_environment: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, cynic::QueryFragment)]
|
||||
#[cynic(schema_path = "collab-schema.graphql")]
|
||||
pub struct CheckoutRepository {
|
||||
pub owner: String,
|
||||
pub name: String,
|
||||
pub role: RepositoryRole,
|
||||
pub branch: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, cynic::QueryFragment)]
|
||||
#[cynic(schema_path = "collab-schema.graphql")]
|
||||
pub struct Checkout {
|
||||
pub repositories: Vec<CheckoutRepository>,
|
||||
}
|
||||
|
||||
#[derive(Clone, cynic::QueryFragment)]
|
||||
#[cynic(schema_path = "collab-schema.graphql")]
|
||||
pub struct Environment {
|
||||
pub name: String,
|
||||
pub default_source_branch: String,
|
||||
pub infra_branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, cynic::QueryFragment)]
|
||||
#[cynic(schema_path = "collab-schema.graphql")]
|
||||
pub struct Project {
|
||||
pub id: cynic::Id,
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub description: String,
|
||||
pub lifecycle: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, cynic::QueryFragment)]
|
||||
#[cynic(schema_path = "collab-schema.graphql")]
|
||||
pub struct Document {
|
||||
pub path: String,
|
||||
pub revision_id: cynic::Id,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, cynic::QueryFragment)]
|
||||
#[cynic(schema_path = "collab-schema.graphql")]
|
||||
pub struct Issue {
|
||||
pub number: i32,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, cynic::QueryFragment)]
|
||||
#[cynic(schema_path = "collab-schema.graphql")]
|
||||
pub struct Version {
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryFragment)]
|
||||
#[cynic(schema_path = "collab-schema.graphql", graphql_type = "Query")]
|
||||
struct WorkspacesResponse {
|
||||
workspaces: Vec<Workspace>,
|
||||
}
|
||||
|
||||
pub async fn workspaces() -> Result<Vec<Workspace>, String> {
|
||||
let response: WorkspacesResponse = typed_collab_graphql(QueryBuilder::build(())).await?;
|
||||
Ok(response.workspaces)
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryVariables)]
|
||||
struct WorkspaceVariables {
|
||||
id: cynic::Id,
|
||||
workspace_id: cynic::Id,
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryFragment)]
|
||||
#[cynic(
|
||||
schema_path = "collab-schema.graphql",
|
||||
graphql_type = "Query",
|
||||
variables = "WorkspaceVariables"
|
||||
)]
|
||||
struct WorkspaceResponse {
|
||||
#[arguments(id: $id)]
|
||||
workspace: Workspace,
|
||||
#[arguments(workspaceId: $workspace_id)]
|
||||
checkout: Checkout,
|
||||
#[arguments(workspaceId: $workspace_id)]
|
||||
environments: Vec<Environment>,
|
||||
#[arguments(workspaceId: $workspace_id)]
|
||||
projects: Vec<Project>,
|
||||
#[arguments(workspaceId: $workspace_id)]
|
||||
documents: Vec<Document>,
|
||||
}
|
||||
|
||||
pub async fn workspace(
|
||||
id: cynic::Id,
|
||||
) -> Result<
|
||||
(
|
||||
Workspace,
|
||||
Vec<CheckoutRepository>,
|
||||
Vec<Environment>,
|
||||
Vec<Project>,
|
||||
Vec<Document>,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
let response: WorkspaceResponse =
|
||||
typed_collab_graphql(QueryBuilder::build(WorkspaceVariables {
|
||||
id: id.clone(),
|
||||
workspace_id: id,
|
||||
}))
|
||||
.await?;
|
||||
Ok((
|
||||
response.workspace,
|
||||
response.checkout.repositories,
|
||||
response.environments,
|
||||
response.projects,
|
||||
response.documents,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryVariables)]
|
||||
struct IssuesVariables {
|
||||
project_id: cynic::Id,
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryFragment)]
|
||||
#[cynic(
|
||||
schema_path = "collab-schema.graphql",
|
||||
graphql_type = "Query",
|
||||
variables = "IssuesVariables"
|
||||
)]
|
||||
struct IssuesResponse {
|
||||
#[arguments(projectId: $project_id)]
|
||||
issues: Vec<Issue>,
|
||||
}
|
||||
|
||||
pub async fn issues(project_id: cynic::Id) -> Result<Vec<Issue>, String> {
|
||||
let response: IssuesResponse =
|
||||
typed_collab_graphql(QueryBuilder::build(IssuesVariables { project_id })).await?;
|
||||
Ok(response.issues)
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryVariables)]
|
||||
struct VersionsVariables {
|
||||
project_id: cynic::Id,
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryFragment)]
|
||||
#[cynic(
|
||||
schema_path = "collab-schema.graphql",
|
||||
graphql_type = "Query",
|
||||
variables = "VersionsVariables"
|
||||
)]
|
||||
struct VersionsResponse {
|
||||
#[arguments(projectId: $project_id)]
|
||||
versions: Vec<Version>,
|
||||
}
|
||||
|
||||
pub async fn versions(project_id: cynic::Id) -> Result<Vec<Version>, String> {
|
||||
let response: VersionsResponse =
|
||||
typed_collab_graphql(QueryBuilder::build(VersionsVariables { project_id })).await?;
|
||||
Ok(response.versions)
|
||||
}
|
||||
|
||||
#[derive(cynic::InputObject)]
|
||||
#[cynic(schema_path = "collab-schema.graphql")]
|
||||
struct CreateWorkspaceInput {
|
||||
owner_kind: OwnerKind,
|
||||
owner_id: cynic::Id,
|
||||
name: String,
|
||||
slug: String,
|
||||
default_environment: String,
|
||||
default_source_branch: String,
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryVariables)]
|
||||
struct CreateWorkspaceVariables {
|
||||
input: CreateWorkspaceInput,
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryFragment)]
|
||||
#[cynic(
|
||||
schema_path = "collab-schema.graphql",
|
||||
graphql_type = "Mutation",
|
||||
variables = "CreateWorkspaceVariables"
|
||||
)]
|
||||
struct CreateWorkspaceResponse {
|
||||
#[arguments(input: $input)]
|
||||
create_workspace: Workspace,
|
||||
}
|
||||
|
||||
pub async fn create_workspace(owner_id: &str, name: &str, slug: &str) -> Result<Workspace, String> {
|
||||
let response: CreateWorkspaceResponse =
|
||||
typed_collab_graphql(MutationBuilder::build(CreateWorkspaceVariables {
|
||||
input: CreateWorkspaceInput {
|
||||
owner_kind: OwnerKind::User,
|
||||
owner_id: cynic::Id::new(owner_id),
|
||||
name: name.to_owned(),
|
||||
slug: slug.to_owned(),
|
||||
default_environment: "development".to_owned(),
|
||||
default_source_branch: "main".to_owned(),
|
||||
},
|
||||
}))
|
||||
.await?;
|
||||
Ok(response.create_workspace)
|
||||
}
|
||||
|
||||
#[derive(cynic::InputObject)]
|
||||
#[cynic(schema_path = "collab-schema.graphql")]
|
||||
struct CreateProjectInput {
|
||||
workspace_id: cynic::Id,
|
||||
key: String,
|
||||
name: String,
|
||||
slug: String,
|
||||
description: String,
|
||||
kind: ProjectKind,
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryVariables)]
|
||||
struct CreateProjectVariables {
|
||||
input: CreateProjectInput,
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryFragment)]
|
||||
#[cynic(
|
||||
schema_path = "collab-schema.graphql",
|
||||
graphql_type = "Mutation",
|
||||
variables = "CreateProjectVariables"
|
||||
)]
|
||||
struct CreateProjectResponse {
|
||||
#[arguments(input: $input)]
|
||||
create_project: Project,
|
||||
}
|
||||
|
||||
pub async fn create_project(
|
||||
workspace_id: &str,
|
||||
key: &str,
|
||||
name: &str,
|
||||
slug: &str,
|
||||
) -> Result<Project, String> {
|
||||
let response: CreateProjectResponse =
|
||||
typed_collab_graphql(MutationBuilder::build(CreateProjectVariables {
|
||||
input: CreateProjectInput {
|
||||
workspace_id: cynic::Id::new(workspace_id),
|
||||
key: key.to_owned(),
|
||||
name: name.to_owned(),
|
||||
slug: slug.to_owned(),
|
||||
description: String::new(),
|
||||
kind: ProjectKind::Continuous,
|
||||
},
|
||||
}))
|
||||
.await?;
|
||||
Ok(response.create_project)
|
||||
}
|
||||
@@ -1,0 +1,222 @@
|
||||
use cynic::MutationBuilder;
|
||||
|
||||
use crate::collab::{Document, Environment, Issue, IssuePriority, RepositoryRole, Version};
|
||||
use crate::graphql::collab_schema as schema;
|
||||
use crate::graphql::typed_collab_graphql;
|
||||
|
||||
#[derive(cynic::InputObject)]
|
||||
#[cynic(schema_path = "collab-schema.graphql")]
|
||||
struct AttachRepositoryInput {
|
||||
workspace_id: cynic::Id,
|
||||
owner: String,
|
||||
name: String,
|
||||
role: RepositoryRole,
|
||||
branch: String,
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryVariables)]
|
||||
struct AttachRepositoryVariables {
|
||||
input: AttachRepositoryInput,
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryFragment)]
|
||||
#[cynic(
|
||||
schema_path = "collab-schema.graphql",
|
||||
graphql_type = "Mutation",
|
||||
variables = "AttachRepositoryVariables"
|
||||
)]
|
||||
struct AttachRepositoryResponse {
|
||||
#[arguments(input: $input)]
|
||||
attach_repository: bool,
|
||||
}
|
||||
|
||||
pub async fn attach_repository(
|
||||
workspace_id: &str,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
role: RepositoryRole,
|
||||
branch: &str,
|
||||
) -> Result<(), String> {
|
||||
let response: AttachRepositoryResponse =
|
||||
typed_collab_graphql(MutationBuilder::build(AttachRepositoryVariables {
|
||||
input: AttachRepositoryInput {
|
||||
workspace_id: cynic::Id::new(workspace_id),
|
||||
owner: owner.to_owned(),
|
||||
name: name.to_owned(),
|
||||
role,
|
||||
branch: branch.to_owned(),
|
||||
},
|
||||
}))
|
||||
.await?;
|
||||
response
|
||||
.attach_repository
|
||||
.then_some(())
|
||||
.ok_or_else(|| "repository was not attached".to_owned())
|
||||
}
|
||||
|
||||
#[derive(cynic::InputObject)]
|
||||
#[cynic(schema_path = "collab-schema.graphql")]
|
||||
struct ConfigureEnvironmentInput {
|
||||
workspace_id: cynic::Id,
|
||||
name: String,
|
||||
default_source_branch: String,
|
||||
infra_branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryVariables)]
|
||||
struct ConfigureEnvironmentVariables {
|
||||
input: ConfigureEnvironmentInput,
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryFragment)]
|
||||
#[cynic(
|
||||
schema_path = "collab-schema.graphql",
|
||||
graphql_type = "Mutation",
|
||||
variables = "ConfigureEnvironmentVariables"
|
||||
)]
|
||||
struct ConfigureEnvironmentResponse {
|
||||
#[arguments(input: $input)]
|
||||
configure_environment: Environment,
|
||||
}
|
||||
|
||||
pub async fn configure_environment(
|
||||
workspace_id: &str,
|
||||
name: &str,
|
||||
source_branch: &str,
|
||||
infra_branch: Option<&str>,
|
||||
) -> Result<Environment, String> {
|
||||
let response: ConfigureEnvironmentResponse =
|
||||
typed_collab_graphql(MutationBuilder::build(ConfigureEnvironmentVariables {
|
||||
input: ConfigureEnvironmentInput {
|
||||
workspace_id: cynic::Id::new(workspace_id),
|
||||
name: name.to_owned(),
|
||||
default_source_branch: source_branch.to_owned(),
|
||||
infra_branch: infra_branch.map(str::to_owned),
|
||||
},
|
||||
}))
|
||||
.await?;
|
||||
Ok(response.configure_environment)
|
||||
}
|
||||
|
||||
#[derive(cynic::InputObject)]
|
||||
#[cynic(schema_path = "collab-schema.graphql")]
|
||||
struct PutDocumentInput {
|
||||
workspace_id: cynic::Id,
|
||||
project_id: Option<cynic::Id>,
|
||||
path: String,
|
||||
content: String,
|
||||
base_revision_id: Option<cynic::Id>,
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryVariables)]
|
||||
struct PutDocumentVariables {
|
||||
input: PutDocumentInput,
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryFragment)]
|
||||
#[cynic(
|
||||
schema_path = "collab-schema.graphql",
|
||||
graphql_type = "Mutation",
|
||||
variables = "PutDocumentVariables"
|
||||
)]
|
||||
struct PutDocumentResponse {
|
||||
#[arguments(input: $input)]
|
||||
put_document: Document,
|
||||
}
|
||||
|
||||
pub async fn put_workspace_document(
|
||||
workspace_id: &str,
|
||||
project_id: Option<&str>,
|
||||
path: &str,
|
||||
content: &str,
|
||||
base_revision_id: Option<&str>,
|
||||
) -> Result<Document, String> {
|
||||
let response: PutDocumentResponse =
|
||||
typed_collab_graphql(MutationBuilder::build(PutDocumentVariables {
|
||||
input: PutDocumentInput {
|
||||
workspace_id: cynic::Id::new(workspace_id),
|
||||
project_id: project_id.map(cynic::Id::new),
|
||||
path: path.to_owned(),
|
||||
content: content.to_owned(),
|
||||
base_revision_id: base_revision_id.map(cynic::Id::new),
|
||||
},
|
||||
}))
|
||||
.await?;
|
||||
Ok(response.put_document)
|
||||
}
|
||||
|
||||
#[derive(cynic::InputObject)]
|
||||
#[cynic(schema_path = "collab-schema.graphql")]
|
||||
struct CreateIssueInput {
|
||||
project_id: cynic::Id,
|
||||
title: String,
|
||||
description: String,
|
||||
priority: IssuePriority,
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryVariables)]
|
||||
struct CreateIssueVariables {
|
||||
input: CreateIssueInput,
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryFragment)]
|
||||
#[cynic(
|
||||
schema_path = "collab-schema.graphql",
|
||||
graphql_type = "Mutation",
|
||||
variables = "CreateIssueVariables"
|
||||
)]
|
||||
struct CreateIssueResponse {
|
||||
#[arguments(input: $input)]
|
||||
create_issue: Issue,
|
||||
}
|
||||
|
||||
pub async fn create_issue(project_id: &str, title: &str) -> Result<Issue, String> {
|
||||
let response: CreateIssueResponse =
|
||||
typed_collab_graphql(MutationBuilder::build(CreateIssueVariables {
|
||||
input: CreateIssueInput {
|
||||
project_id: cynic::Id::new(project_id),
|
||||
title: title.to_owned(),
|
||||
description: String::new(),
|
||||
priority: IssuePriority::Normal,
|
||||
},
|
||||
}))
|
||||
.await?;
|
||||
Ok(response.create_issue)
|
||||
}
|
||||
|
||||
#[derive(cynic::InputObject)]
|
||||
#[cynic(schema_path = "collab-schema.graphql")]
|
||||
struct CreateVersionInput {
|
||||
project_id: cynic::Id,
|
||||
name: String,
|
||||
slug: String,
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryVariables)]
|
||||
struct CreateVersionVariables {
|
||||
input: CreateVersionInput,
|
||||
}
|
||||
|
||||
#[derive(cynic::QueryFragment)]
|
||||
#[cynic(
|
||||
schema_path = "collab-schema.graphql",
|
||||
graphql_type = "Mutation",
|
||||
variables = "CreateVersionVariables"
|
||||
)]
|
||||
struct CreateVersionResponse {
|
||||
#[arguments(input: $input)]
|
||||
create_version: Version,
|
||||
}
|
||||
|
||||
pub async fn create_version(project_id: &str, name: &str, slug: &str) -> Result<Version, String> {
|
||||
let response: CreateVersionResponse =
|
||||
typed_collab_graphql(MutationBuilder::build(CreateVersionVariables {
|
||||
input: CreateVersionInput {
|
||||
project_id: cynic::Id::new(project_id),
|
||||
name: name.to_owned(),
|
||||
slug: slug.to_owned(),
|
||||
},
|
||||
}))
|
||||
.await?;
|
||||
Ok(response.create_version)
|
||||
}
|
||||
@@ -1,0 +1,105 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::components::A;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
|
||||
use crate::collab::{issues, versions, workspace, Issue, Project, Version};
|
||||
use crate::components::nav::Nav;
|
||||
|
||||
use super::workspace_forms::{
|
||||
AttachRepositoryForm, DocumentForm, EnvironmentForm, IssueForm, ProjectForm, VersionForm,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct WorkspaceData {
|
||||
workspace: crate::collab::Workspace,
|
||||
repositories: Vec<crate::collab::CheckoutRepository>,
|
||||
environments: Vec<crate::collab::Environment>,
|
||||
projects: Vec<(Project, Vec<Issue>, Vec<Version>)>,
|
||||
documents: Vec<crate::collab::Document>,
|
||||
}
|
||||
|
||||
async fn load_workspace(id: &str) -> Result<WorkspaceData, String> {
|
||||
let (workspace, repositories, environments, projects, documents) =
|
||||
workspace(cynic::Id::new(id)).await?;
|
||||
let mut projects_with_issues = Vec::with_capacity(projects.len());
|
||||
for project in projects {
|
||||
let project_issues = issues(project.id.clone()).await?;
|
||||
let project_versions = versions(project.id.clone()).await?;
|
||||
projects_with_issues.push((project, project_issues, project_versions));
|
||||
}
|
||||
Ok(WorkspaceData {
|
||||
workspace,
|
||||
repositories,
|
||||
environments,
|
||||
projects: projects_with_issues,
|
||||
documents,
|
||||
})
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn WorkspacePage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let refresh = RwSignal::new(0_u64);
|
||||
let data = LocalResource::new(move || {
|
||||
refresh.get();
|
||||
let id = params.read().get("id").unwrap_or_default();
|
||||
async move { load_workspace(&id).await }
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="flex min-h-screen flex-col bg-paper text-ink">
|
||||
<Nav>
|
||||
<A href="/workspaces" attr:class="text-sm font-medium hover:text-primary">"Workspaces"</A>
|
||||
<A href="/account" attr:class="text-sm font-medium hover:text-primary">"Account"</A>
|
||||
</Nav>
|
||||
<main class="mx-auto w-full max-w-6xl flex-1 px-6 py-12">
|
||||
<Suspense fallback=|| view! { <p class="text-ink/60">"Loading workspace…"</p> }>
|
||||
{move || data.get().map(|result| match &*result {
|
||||
Ok(data) => {
|
||||
let workspace = data.workspace.clone();
|
||||
let workspace_id = workspace.id.inner().to_owned();
|
||||
let projects = data.projects.clone();
|
||||
let documents = data.documents.clone();
|
||||
let repositories = data.repositories.clone();
|
||||
let environments = data.environments.clone();
|
||||
view! {
|
||||
<header class="flex flex-wrap items-end justify-between gap-4 border-b border-ink/10 pb-8">
|
||||
<div><p class="font-mono text-sm text-ink/50">{workspace.slug}</p><h1 class="mt-1 text-3xl font-semibold">{workspace.name}</h1></div>
|
||||
<span class="rounded-full bg-tertiary/20 px-3 py-1 text-xs font-medium">{workspace.default_environment}</span>
|
||||
</header>
|
||||
<div class="mt-8 grid gap-8 lg:grid-cols-[1fr_22rem]">
|
||||
<div class="space-y-10">
|
||||
<section>
|
||||
<div class="flex items-center justify-between"><h2 class="text-xl font-semibold">"Projects"</h2><span class="text-sm text-ink/50">{projects.len()}</span></div>
|
||||
<ul class="mt-4 space-y-4">{projects.clone().into_iter().map(|(project, issues, versions)| view! {
|
||||
<li class="rounded-xl border border-ink/10 bg-white p-5">
|
||||
<div class="flex items-center justify-between gap-4"><div><h3 class="font-semibold">{format!("{} · {}", project.key, project.name)}</h3><p class="mt-1 text-sm text-ink/55">{project.description}</p><p class="mt-1 font-mono text-xs text-ink/40">{project.slug}</p></div><span class="text-xs uppercase text-ink/45">{project.lifecycle}</span></div>
|
||||
<ul class="mt-4 divide-y divide-ink/10 border-t border-ink/10">{issues.into_iter().map(|issue| view! { <li class="flex items-center gap-3 py-3 text-sm"><span class="font-mono text-ink/45">{format!("#{}", issue.number)}</span><span>{issue.title}</span></li> }).collect_view()}</ul>
|
||||
<IssueForm project_id=project.id.inner().to_owned() refresh=refresh/>
|
||||
<div class="mt-5 border-t border-ink/10 pt-4"><h4 class="text-xs font-semibold uppercase tracking-wide text-ink/45">"Versions"</h4><ul class="mt-2 flex flex-wrap gap-2">{versions.into_iter().map(|version| view! { <li class="rounded-full bg-ink/5 px-3 py-1 text-xs">{format!("{} ({}) · {}", version.name, version.slug, version.state)}</li> }).collect_view()}</ul><VersionForm project_id=project.id.inner().to_owned() refresh=refresh/></div>
|
||||
</li>
|
||||
}).collect_view()}</ul>
|
||||
</section>
|
||||
<section>
|
||||
<div class="flex items-center justify-between"><h2 class="text-xl font-semibold">"Docs"</h2><span class="text-sm text-ink/50">{documents.len()}</span></div>
|
||||
<ul class="mt-4 space-y-3">{documents.into_iter().map(|document| view! { <li class="rounded-xl border border-ink/10 bg-white p-5"><div class="flex items-center justify-between gap-4"><p class="font-mono text-sm font-semibold">{document.path}</p><span class="font-mono text-xs text-ink/35">{document.revision_id.inner().to_owned()}</span></div><pre class="mt-3 whitespace-pre-wrap text-sm text-ink/70">{document.content}</pre></li> }).collect_view()}</ul>
|
||||
</section>
|
||||
</div>
|
||||
<aside class="space-y-5">
|
||||
<section class="rounded-xl border border-ink/10 bg-white p-5"><div class="flex items-center justify-between"><h2 class="font-semibold">"Environments"</h2><span class="text-sm text-ink/50">{environments.len()}</span></div><ul class="mt-3 space-y-2">{environments.into_iter().map(|environment| view! { <li class="rounded border border-ink/10 px-3 py-2 text-sm"><p class="font-medium">{environment.name}</p><p class="mt-1 font-mono text-xs text-ink/45">{format!("source: {} · infra: {}", environment.default_source_branch, environment.infra_branch.unwrap_or_else(|| "not configured".to_owned()))}</p></li> }).collect_view()}</ul></section>
|
||||
<EnvironmentForm workspace_id=workspace_id.clone() refresh=refresh/>
|
||||
<section class="rounded-xl border border-ink/10 bg-white p-5"><div class="flex items-center justify-between"><h2 class="font-semibold">"Repositories"</h2><span class="text-sm text-ink/50">{repositories.len()}</span></div><ul class="mt-3 space-y-2">{repositories.into_iter().map(|repository| { let role = match repository.role { crate::collab::RepositoryRole::Source => "source", crate::collab::RepositoryRole::Infra => "infra" }; view! { <li class="rounded border border-ink/10 px-3 py-2 text-sm"><p class="font-medium">{format!("{}/{}", repository.owner, repository.name)}</p><p class="mt-1 font-mono text-xs text-ink/45">{format!("{} · {role}", repository.branch)}</p></li> } }).collect_view()}</ul></section>
|
||||
<ProjectForm workspace_id=workspace_id.clone() refresh=refresh/>
|
||||
<DocumentForm workspace_id=workspace_id.clone() refresh=refresh/>
|
||||
<AttachRepositoryForm workspace_id=workspace_id refresh=refresh/>
|
||||
</aside>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
Err(message) => view! { <p class="text-red-600">{message.clone()}</p> }.into_any(),
|
||||
})}
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,0 +1,145 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
|
||||
use crate::collab::create_project;
|
||||
use crate::collab::RepositoryRole;
|
||||
use crate::collab_write::{
|
||||
attach_repository, configure_environment, create_issue, create_version, put_workspace_document,
|
||||
};
|
||||
|
||||
fn submit_result(
|
||||
result: Result<(), String>,
|
||||
error: RwSignal<Option<String>>,
|
||||
pending: RwSignal<bool>,
|
||||
refresh: RwSignal<u64>,
|
||||
) {
|
||||
match result {
|
||||
Ok(()) => refresh.update(|value| *value += 1),
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
pending.set(false);
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ProjectForm(workspace_id: String, refresh: RwSignal<u64>) -> impl IntoView {
|
||||
let key = RwSignal::new(String::new());
|
||||
let name = RwSignal::new(String::new());
|
||||
let slug = RwSignal::new(String::new());
|
||||
let error = RwSignal::new(None::<String>);
|
||||
let pending = RwSignal::new(false);
|
||||
view! {
|
||||
<FormCard title="New project" on_submit=move || {
|
||||
let workspace_id = workspace_id.clone();
|
||||
let key = key.get(); let name = name.get(); let slug = slug.get();
|
||||
pending.set(true); error.set(None);
|
||||
spawn_local(async move { submit_result(create_project(&workspace_id, &key, &name, &slug).await.map(|_| ()), error, pending, refresh); });
|
||||
} pending=pending error=error>
|
||||
<Input label="Key" value=key/><Input label="Name" value=name/><Input label="Slug" value=slug/>
|
||||
</FormCard>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DocumentForm(workspace_id: String, refresh: RwSignal<u64>) -> impl IntoView {
|
||||
let path = RwSignal::new(String::new());
|
||||
let content = RwSignal::new(String::new());
|
||||
let project_id = RwSignal::new(String::new());
|
||||
let base_revision = RwSignal::new(String::new());
|
||||
let error = RwSignal::new(None::<String>);
|
||||
let pending = RwSignal::new(false);
|
||||
view! {
|
||||
<FormCard title="Add document" on_submit=move || {
|
||||
let workspace_id = workspace_id.clone(); let path = path.get(); let content = content.get(); let project_id = project_id.get(); let base_revision = base_revision.get();
|
||||
pending.set(true); error.set(None);
|
||||
spawn_local(async move { submit_result(put_workspace_document(&workspace_id, (!project_id.is_empty()).then_some(project_id.as_str()), &path, &content, (!base_revision.is_empty()).then_some(base_revision.as_str())).await.map(|_| ()), error, pending, refresh); });
|
||||
} pending=pending error=error>
|
||||
<Input label="Path" value=path/>
|
||||
<Input label="Project ID (optional)" value=project_id/>
|
||||
<Input label="Base revision (when editing)" value=base_revision/>
|
||||
<label class="text-sm">"Content"<textarea class="mt-1 min-h-28 w-full rounded border border-ink/20 px-3 py-2" prop:value=move || content.get() on:input=move |event| content.set(event_target_value(&event))></textarea></label>
|
||||
</FormCard>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AttachRepositoryForm(workspace_id: String, refresh: RwSignal<u64>) -> impl IntoView {
|
||||
let owner = RwSignal::new(String::new());
|
||||
let name = RwSignal::new(String::new());
|
||||
let branch = RwSignal::new("main".to_owned());
|
||||
let role = RwSignal::new("source".to_owned());
|
||||
let error = RwSignal::new(None::<String>);
|
||||
let pending = RwSignal::new(false);
|
||||
view! {
|
||||
<FormCard title="Attach source repository" on_submit=move || {
|
||||
let workspace_id = workspace_id.clone(); let owner = owner.get(); let name = name.get(); let branch = branch.get(); let role = if role.get() == "infra" { RepositoryRole::Infra } else { RepositoryRole::Source };
|
||||
pending.set(true); error.set(None);
|
||||
spawn_local(async move { submit_result(attach_repository(&workspace_id, &owner, &name, role, &branch).await, error, pending, refresh); });
|
||||
} pending=pending error=error>
|
||||
<Input label="Owner" value=owner/><Input label="Repository" value=name/><label class="text-sm">"Role"<select class="mt-1 w-full rounded border border-ink/20 px-3 py-2" on:change=move |event| role.set(event_target_value(&event))><option value="source">"source"</option><option value="infra">"infra"</option></select></label><Input label="Branch" value=branch/>
|
||||
</FormCard>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn EnvironmentForm(workspace_id: String, refresh: RwSignal<u64>) -> impl IntoView {
|
||||
let name = RwSignal::new(String::new());
|
||||
let source_branch = RwSignal::new("main".to_owned());
|
||||
let infra_branch = RwSignal::new(String::new());
|
||||
let error = RwSignal::new(None::<String>);
|
||||
let pending = RwSignal::new(false);
|
||||
view! {
|
||||
<FormCard title="Configure environment" on_submit=move || {
|
||||
let workspace_id = workspace_id.clone(); let name = name.get(); let source_branch = source_branch.get(); let infra_branch = infra_branch.get();
|
||||
pending.set(true); error.set(None);
|
||||
spawn_local(async move { submit_result(configure_environment(&workspace_id, &name, &source_branch, (!infra_branch.is_empty()).then_some(infra_branch.as_str())).await.map(|_| ()), error, pending, refresh); });
|
||||
} pending=pending error=error>
|
||||
<Input label="Name" value=name/><Input label="Default source branch" value=source_branch/><Input label="Infra branch (optional)" value=infra_branch/>
|
||||
</FormCard>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn IssueForm(project_id: String, refresh: RwSignal<u64>) -> impl IntoView {
|
||||
let title = RwSignal::new(String::new());
|
||||
let error = RwSignal::new(None::<String>);
|
||||
let pending = RwSignal::new(false);
|
||||
view! {
|
||||
<form class="mt-3 flex gap-2" on:submit=move |event| { event.prevent_default(); let project_id = project_id.clone(); let title = title.get(); pending.set(true); error.set(None); spawn_local(async move { submit_result(create_issue(&project_id, &title).await.map(|_| ()), error, pending, refresh); }); }>
|
||||
<input class="min-w-0 flex-1 rounded border border-ink/20 px-3 py-2 text-sm" placeholder="New issue" prop:value=move || title.get() on:input=move |event| title.set(event_target_value(&event))/>
|
||||
<button class="rounded bg-primary px-3 py-2 text-sm text-white disabled:opacity-50" disabled=move || pending.get()>"Add"</button>
|
||||
</form>
|
||||
{move || error.get().map(|message| view! { <p class="mt-2 text-xs text-red-600">{message}</p> })}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn VersionForm(project_id: String, refresh: RwSignal<u64>) -> impl IntoView {
|
||||
let name = RwSignal::new(String::new());
|
||||
let slug = RwSignal::new(String::new());
|
||||
let error = RwSignal::new(None::<String>);
|
||||
let pending = RwSignal::new(false);
|
||||
view! {
|
||||
<form class="mt-3 grid grid-cols-[1fr_1fr_auto] gap-2" on:submit=move |event| { event.prevent_default(); let project_id = project_id.clone(); let name = name.get(); let slug = slug.get(); pending.set(true); error.set(None); spawn_local(async move { submit_result(create_version(&project_id, &name, &slug).await.map(|_| ()), error, pending, refresh); }); }>
|
||||
<input class="min-w-0 rounded border border-ink/20 px-3 py-2 text-sm" placeholder="Version" prop:value=move || name.get() on:input=move |event| name.set(event_target_value(&event))/>
|
||||
<input class="min-w-0 rounded border border-ink/20 px-3 py-2 text-sm font-mono" placeholder="v0-6-0" prop:value=move || slug.get() on:input=move |event| slug.set(event_target_value(&event))/>
|
||||
<button class="rounded bg-primary px-3 py-2 text-sm text-white disabled:opacity-50" disabled=move || pending.get()>"Add"</button>
|
||||
</form>
|
||||
{move || error.get().map(|message| view! { <p class="mt-2 text-xs text-red-600">{message}</p> })}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Input(label: &'static str, value: RwSignal<String>) -> impl IntoView {
|
||||
view! { <label class="text-sm">{label}<input class="mt-1 w-full rounded border border-ink/20 px-3 py-2" prop:value=move || value.get() on:input=move |event| value.set(event_target_value(&event))/></label> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn FormCard(
|
||||
title: &'static str,
|
||||
on_submit: impl Fn() + 'static,
|
||||
pending: RwSignal<bool>,
|
||||
error: RwSignal<Option<String>>,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
view! { <form class="grid gap-3 rounded-xl border border-ink/10 bg-white p-5" on:submit=move |event| { event.prevent_default(); on_submit(); }><h2 class="font-semibold">{title}</h2>{children()}<button class="rounded bg-primary px-3 py-2 text-sm font-medium text-white disabled:opacity-50" disabled=move || pending.get()>"Save"</button>{move || error.get().map(|message| view! { <p class="text-xs text-red-600">{message}</p> })}</form> }
|
||||
}
|
||||
@@ -1,0 +1,88 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_router::components::A;
|
||||
use leptos_router::hooks::use_navigate;
|
||||
|
||||
use crate::collab::create_workspace;
|
||||
use crate::components::nav::Nav;
|
||||
use crate::identity::fetch_me;
|
||||
|
||||
#[component]
|
||||
pub fn Workspaces() -> impl IntoView {
|
||||
let refresh = RwSignal::new(0_u64);
|
||||
let workspaces = LocalResource::new(move || {
|
||||
refresh.get();
|
||||
crate::collab::workspaces()
|
||||
});
|
||||
let me = LocalResource::new(fetch_me);
|
||||
let navigate = use_navigate();
|
||||
let name = RwSignal::new(String::new());
|
||||
let slug = RwSignal::new(String::new());
|
||||
let error = RwSignal::new(None::<String>);
|
||||
let pending = RwSignal::new(false);
|
||||
|
||||
view! {
|
||||
<div class="flex min-h-screen flex-col bg-paper text-ink">
|
||||
<Nav>
|
||||
<A href="/account" attr:class="text-sm font-medium hover:text-primary">"Account"</A>
|
||||
</Nav>
|
||||
<main class="mx-auto w-full max-w-5xl flex-1 px-6 py-12">
|
||||
<div class="flex flex-wrap items-start justify-between gap-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold">"Workspaces"</h1>
|
||||
<p class="mt-2 text-sm text-ink/60">"Projects, documentation and delivery context in one place."</p>
|
||||
</div>
|
||||
<form
|
||||
class="grid w-full max-w-md gap-3 rounded-xl border border-ink/10 bg-white p-5"
|
||||
on:submit=move |event| {
|
||||
event.prevent_default();
|
||||
let Some(Ok(Some(user))) = me.get().map(|value| (*value).clone()) else {
|
||||
error.set(Some("sign in before creating a workspace".to_owned()));
|
||||
return;
|
||||
};
|
||||
let name_value = name.get();
|
||||
let slug_value = slug.get();
|
||||
let navigate = navigate.clone();
|
||||
pending.set(true);
|
||||
error.set(None);
|
||||
spawn_local(async move {
|
||||
match create_workspace(user.id.inner(), &name_value, &slug_value).await {
|
||||
Ok(workspace) => navigate(
|
||||
&format!("/workspaces/{}", workspace.id.inner()),
|
||||
Default::default(),
|
||||
),
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
pending.set(false);
|
||||
});
|
||||
}
|
||||
>
|
||||
<h2 class="font-semibold">"New workspace"</h2>
|
||||
<label class="text-sm">"Name"<input class="mt-1 w-full rounded border border-ink/20 px-3 py-2" prop:value=move || name.get() on:input=move |event| name.set(event_target_value(&event))/></label>
|
||||
<label class="text-sm">"Slug"<input class="mt-1 w-full rounded border border-ink/20 px-3 py-2 font-mono" prop:value=move || slug.get() on:input=move |event| slug.set(event_target_value(&event))/></label>
|
||||
<button class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50" type="submit" disabled=move || pending.get()>"Create workspace"</button>
|
||||
{move || error.get().map(|message| view! { <p class="text-sm text-red-600">{message}</p> })}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<Suspense fallback=|| view! { <p class="mt-10 text-ink/60">"Loading workspaces…"</p> }>
|
||||
{move || workspaces.get().map(|result| match &*result {
|
||||
Ok(items) if items.is_empty() => view! { <p class="mt-10 rounded-xl border border-dashed border-ink/20 p-8 text-center text-ink/60">"No workspaces yet."</p> }.into_any(),
|
||||
Ok(items) => view! {
|
||||
<ul class="mt-10 grid gap-4 md:grid-cols-2">
|
||||
{items.clone().into_iter().map(|workspace| view! {
|
||||
<li><A href=format!("/workspaces/{}", workspace.id.inner()) attr:class="block rounded-xl border border-ink/10 bg-white p-5 no-underline hover:border-primary/50">
|
||||
<h2 class="text-lg font-semibold">{workspace.name}</h2>
|
||||
<p class="mt-1 font-mono text-sm text-ink/50">{workspace.slug}</p>
|
||||
<p class="mt-4 text-xs uppercase tracking-wide text-ink/45">{workspace.default_environment}</p>
|
||||
</A></li>
|
||||
}).collect_view()}
|
||||
</ul>
|
||||
}.into_any(),
|
||||
Err(message) => view! { <p class="mt-10 text-red-600">{message.clone()}</p> }.into_any(),
|
||||
})}
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user