Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions crates/prime-domain/src/delete_entry.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
use db::DeleteModelError;
use models::{Entry, dvf::RecordId};

use crate::PrimeDomainService;

impl PrimeDomainService {
/// Deletes an [`Entry`].
pub async fn delete_entry(
&self,
id: RecordId<Entry>,
) -> Result<Option<RecordId<Entry>>, DeleteModelError> {
self
.entry_repo
.delete_model(id)
.await
.map(|b| b.then_some(id))
}
}
1 change: 1 addition & 0 deletions crates/prime-domain/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
mod counts;
mod create;
mod delete_entry;
pub mod download;
mod fetch_by_id;
mod fetch_by_name;
Expand Down
4 changes: 2 additions & 2 deletions crates/site-app/src/components/copy_button.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ use crate::components::DocumentDuplicateHeroIcon;

#[island]
pub fn CopyButton(copy_content: String) -> impl IntoView {
let copy_content = Signal::stored(copy_content);
let _copy_content = Signal::stored(copy_content);

let on_click = move |_| {
#[cfg(feature = "hydrate")]
(leptos_use::use_clipboard().copy)(&copy_content())
(leptos_use::use_clipboard().copy)(&_copy_content())
};

const CLASS: &str = "cursor-pointer stroke-[2.0] stroke-base-11/50 \
Expand Down
5 changes: 3 additions & 2 deletions crates/site-app/src/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
mod cache_hook;
mod create_cache_hook;
mod create_org_hook;
mod delete_entry_hook;
mod entry_hook;
mod login_hook;
mod org_hook;
mod signup_hook;

// pub use self::cache_hook::*;
pub use self::{
create_cache_hook::*, create_org_hook::*, entry_hook::*, login_hook::*,
org_hook::*, signup_hook::*,
create_cache_hook::*, create_org_hook::*, delete_entry_hook::*,
entry_hook::*, login_hook::*, org_hook::*, signup_hook::*,
};
83 changes: 83 additions & 0 deletions crates/site-app/src/hooks/delete_entry_hook.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
use leptos::{prelude::*, server::ServerAction};
use models::{dvf::RecordId, Entry};

use crate::{hooks::OrgHook, navigation::navigate_to};

pub struct DeleteEntryHook {
key: Callback<(), RecordId<Entry>>,
action: ServerAction<DeleteEntry>,
}

impl DeleteEntryHook {
pub fn new(
key: impl Fn() -> RecordId<Entry> + Copy + Send + Sync + 'static,
) -> Self {
Self {
key: Callback::new(move |()| key()),
action: ServerAction::new(),
}
}

pub fn show_spinner(&self) -> Signal<bool> {
let (pending, value) = (self.action.pending(), self.action.value());
// show if the action is loading or completed successfully
Signal::derive(move || pending() || matches!(value.get(), Some(Ok(_))))
}

pub fn button_text(&self) -> Signal<&'static str> {
let (pending, value) = (self.action.pending(), self.action.value());
Signal::derive(move || match (value.get(), pending()) {
// if the action is loading at all
(_, true) => "Deleting...",
// if it's completed successfully
(Some(Ok(_)), _) => "Redirecting...",
// any other state
_ => "Delete Entry",
})
}

pub fn action_trigger(&self) -> Callback<()> {
let (key, action) = (self.key, self.action);
Callback::new(move |()| {
action.dispatch(DeleteEntry { id: key.run(()) });
})
}

pub fn create_redirect_effect(&self) -> Effect<LocalStorage> {
let action = self.action;
Effect::new(move || {
if matches!(action.value().get(), Some(Ok(_))) {
let org_hook = OrgHook::new_requested();
navigate_to(&org_hook.dashboard_url()());
}
})
}
}

#[server(prefix = "/api/sfn")]
async fn delete_entry(
id: RecordId<Entry>,
) -> Result<Option<RecordId<Entry>>, ServerFnError> {
use prime_domain::PrimeDomainService;

let prime_domain_service = expect_context::<PrimeDomainService>();

let entry =
prime_domain_service
.fetch_entry_by_id(id)
.await
.map_err(|e| {
tracing::error!("failed to fetch entry to delete: {e}");
ServerFnError::new("internal error")
})?;
let Some(entry) = entry else {
return Ok(None);
};

crate::resources::authorize_for_org(entry.org)?;

prime_domain_service.delete_entry(id).await.map_err(|e| {
tracing::error!("failed to delete entry: {e}");
ServerFnError::new("internal error")
})
}
163 changes: 14 additions & 149 deletions crates/site-app/src/pages/entry.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
mod action_tile;
mod caches_tile;
mod store_path_tile;
mod title_tile;

use leptos::prelude::*;
use leptos_fetch::QueryClient;
use leptos_router::hooks::use_params_map;
use models::{
dvf::{RecordId, Visibility},
Cache, Entry, PvCache, StorePath,
};
use models::{dvf::RecordId, Entry};

use crate::{
components::{CacheItemLink, CopyButton, LockClosedHeroIcon},
hooks::EntryHook,
pages::UnauthorizedPage,
use self::{
action_tile::ActionTile, caches_tile::CachesTile,
store_path_tile::StorePathTile, title_tile::TitleTile,
};
use crate::{hooks::EntryHook, pages::UnauthorizedPage};

#[component]
pub fn EntryPage() -> impl IntoView {
Expand Down Expand Up @@ -46,154 +47,18 @@ pub fn EntryPage() -> impl IntoView {
#[component]
fn EntryInner(entry: Entry) -> impl IntoView {
view! {
<div class="flex flex-col gap-4">
<div class="flex flex-col md:grid md:grid-cols-[max-content_auto] gap-4">
<div class="hidden md:block"/>
<TitleTile store_path={entry.store_path.clone()} />
<div class="grid gap-4 grid-flow-col">
<ActionTile entry_id=entry.id />
<div class="flex flex-row gap-4 flex-wrap">
<StorePathTile store_path={entry.store_path.clone()} />
<CachesTile entry={entry.clone()} />
</div>
</div>
}
}

#[component]
fn TitleTile(store_path: StorePath<String>) -> impl IntoView {
let path = store_path.to_absolute_path();
view! {
<div class="p-6 elevation-flat flex flex-row gap-2 items-center">
<p class="text-base-12 text-xl">
{ path.clone() }
</p>
<CopyButton copy_content={path} {..} class="size-6" />
</div>
}
}

#[component]
fn StorePathTile(store_path: StorePath<String>) -> impl IntoView {
let string = store_path.to_string();
let separator_index =
string.find('-').expect("no separator found in store path");
let (digest, _) = string.split_at(separator_index);
let name = store_path.name().clone();

const KEY_CLASS: &str = "place-self-end";
const VALUE_CLASS: &str = "text-base-12 font-medium";

let value_element = move |s: &str| {
view! {
<div class="flex flex-row gap-2 items-center">
<p class=VALUE_CLASS>{ s }</p>
<CopyButton
copy_content={s.to_string()}
{..} class="size-4"
/>
</div>
}
};

view! {
<div class="p-6 elevation-flat flex flex-col gap-4">
<p class="subtitle">
"Store Path Breakdown"
</p>
<div class="grid gap-x-4 gap-y-1 grid-cols-[repeat(2,auto)]">
<p class=KEY_CLASS>"Prefix"</p>
{ value_element("/nix/store/") }
<p class=KEY_CLASS>"Digest"</p>
{ value_element(digest) }
<p class=KEY_CLASS>"Name"</p>
{ value_element(&name) }
</div>
</div>
}
}

#[component]
fn CachesTile(entry: Entry) -> impl IntoView {
let caches = Signal::stored(entry.caches.clone());
let store_path = Signal::stored(entry.store_path);
view! {
<div class="p-6 elevation-flat flex flex-col gap-4">
<p class="subtitle">
"Resident Caches"
</p>

<table class="table">
<thead>
<th>"Name"</th>
<th>"Download Url"</th>
<th>"Visibility"</th>
</thead>
<tbody class="animate-fade-in min-h-10">
<For each=caches key=|r| *r children=move |r| view! {
<CachesTileRow store_path=store_path cache_id=r />
} />
</tbody>
</table>
</div>
}
}

#[component]
fn CachesTileRow(
store_path: Signal<StorePath<String>>,
cache_id: RecordId<Cache>,
) -> impl IntoView {
let query_client = expect_context::<QueryClient>();

let query_scope = crate::resources::cache::cache_query_scope();
let resource = query_client.resource(query_scope, move || cache_id);

let suspend = move || {
Suspend::new(async move {
match resource.await {
Ok(Some(c)) => {
view! { <CachesTileDataRow cache=c store_path=store_path /> }
.into_any()
}
Ok(None) => None::<()>.into_any(),
Err(e) => format!("Error: {e}").into_any(),
}
})
};

view! {
<Transition fallback=|| ()>{ suspend }</Transition>
}
}

#[component]
fn CachesTileDataRow(
store_path: Signal<StorePath<String>>,
cache: PvCache,
) -> impl IntoView {
let download_url = format!(
"/api/v1/c/{cache_name}/download/{store_path}",
cache_name = cache.name,
store_path = store_path(),
);
let vis_icon =
matches!(cache.visibility, Visibility::Private).then_some(view! {
<LockClosedHeroIcon {..} class="size-4 stroke-base-11/75 stroke-[2.0]" />
});

view! {
<tr>
<th scope="row">
<CacheItemLink id=cache.id extra_class="text-link-primary"/>
</th>
<td>
<a href={download_url} class="text-link text-link-primary">"Download"</a>
</td>
<td class="flex flex-row items-center gap-1">
{ cache.visibility.to_string() }
{ vis_icon }
</td>
</tr>
}
}

#[component]
fn MissingEntryPage() -> impl IntoView {
view! {
Expand Down
32 changes: 32 additions & 0 deletions crates/site-app/src/pages/entry/action_tile.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
use leptos::prelude::*;
use models::{dvf::RecordId, Entry};

use crate::{components::LoadingCircle, hooks::DeleteEntryHook};

#[island]
pub(crate) fn ActionTile(entry_id: RecordId<Entry>) -> impl IntoView {
let delete_hook = DeleteEntryHook::new(move || entry_id);
let show_delete_spinner = delete_hook.show_spinner();
let delete_button_text = delete_hook.button_text();
let delete_dispatcher = delete_hook.action_trigger();
let _ = delete_hook.create_redirect_effect();

view! {
<div class="md:w-64 p-6 elevation-flat flex flex-col gap-4 align-self-start">
<p class="subtitle">"Actions"</p>
<div class="flex flex-col gap-2">
<button
class="btn btn-critical justify-between"
on:click={move |_| delete_dispatcher.run(())}
>
<div class="size-4" />
{ delete_button_text }
<LoadingCircle {..}
class="size-4 transition-opacity"
class=("opacity-0", move || { !show_delete_spinner() })
/>
</button>
</div>
</div>
}
}
Loading