atproto blogging
1//! Panel showing current collaborators on a resource.
2
3use crate::auth::AuthState;
4use crate::components::button::{Button, ButtonVariant};
5use crate::fetch::Fetcher;
6use dioxus::prelude::*;
7use jacquard::types::string::AtUri;
8
9use super::InviteDialog;
10use super::api::{SentInvite, fetch_sent_invites};
11
12/// Props for the CollaboratorsPanel component.
13#[derive(Props, Clone, PartialEq)]
14pub struct CollaboratorsPanelProps {
15 /// The resource to show collaborators for.
16 pub resource_uri: AtUri<'static>,
17 /// CID of the resource.
18 pub resource_cid: String,
19 /// Optional title for display.
20 #[props(default)]
21 pub resource_title: Option<String>,
22 /// Callback when panel should close (for modal mode).
23 #[props(default)]
24 pub on_close: Option<EventHandler<()>>,
25}
26
27/// Panel showing collaborators and invite button.
28#[component]
29pub fn CollaboratorsPanel(props: CollaboratorsPanelProps) -> Element {
30 let auth_state = use_context::<Signal<AuthState>>();
31 let fetcher = use_context::<Fetcher>();
32 let mut show_invite_dialog = use_signal(|| false);
33
34 // Clone props we need in closures
35 let on_close = props.on_close.clone();
36 let on_close_overlay = props.on_close.clone();
37 let resource_uri = props.resource_uri.clone();
38 let resource_uri_dialog = props.resource_uri.clone();
39 let resource_cid = props.resource_cid.clone();
40 let resource_title = props.resource_title.clone();
41
42 // Fetch invites for this resource to show collaborators
43 let invites_resource = {
44 let fetcher = fetcher.clone();
45 use_resource(move || {
46 let fetcher = fetcher.clone();
47 let resource_uri = resource_uri.clone();
48 let _auth = auth_state.read().did.clone();
49 async move {
50 fetch_sent_invites(&fetcher)
51 .await
52 .ok()
53 .unwrap_or_default()
54 .into_iter()
55 .filter(|i| i.resource_uri == resource_uri)
56 .collect::<Vec<_>>()
57 }
58 })
59 };
60
61 let invites: Vec<SentInvite> = invites_resource().unwrap_or_default();
62 let accepted_count = invites.iter().filter(|i| i.accepted).count();
63 let pending_count = invites.len() - accepted_count;
64
65 let is_modal = on_close.is_some();
66
67 let panel_content = rsx! {
68 div { class: "collaborators-panel",
69 div { class: "collaborators-header",
70 h4 { "Collaborators" }
71 div { class: "collaborators-header-actions",
72 Button {
73 variant: ButtonVariant::Ghost,
74 onclick: move |_| show_invite_dialog.set(true),
75 "Invite"
76 }
77 if let Some(ref handler) = on_close {
78 {
79 let handler = handler.clone();
80 rsx! {
81 Button {
82 variant: ButtonVariant::Ghost,
83 onclick: move |_| handler.call(()),
84 "×"
85 }
86 }
87 }
88 }
89 }
90 }
91
92 if invites.is_empty() {
93 p { class: "empty-state", "No collaborators yet" }
94 } else {
95 div { class: "collaborators-list",
96 for invite in &invites {
97 div {
98 class: if invite.accepted { "collaborator accepted" } else { "collaborator pending" },
99 span { class: "collaborator-did", "{invite.invitee}" }
100 span {
101 class: "collaborator-status",
102 if invite.accepted { "✓" } else { "..." }
103 }
104 }
105 }
106 }
107
108 div { class: "collaborators-summary",
109 "{accepted_count} active, {pending_count} pending"
110 }
111 }
112 }
113
114 InviteDialog {
115 open: show_invite_dialog(),
116 on_close: move |_| show_invite_dialog.set(false),
117 resource_uri: resource_uri_dialog.clone(),
118 resource_cid: resource_cid.clone(),
119 resource_title: resource_title.clone(),
120 }
121 };
122
123 if is_modal {
124 rsx! {
125 div {
126 class: "collaborators-overlay",
127 onclick: move |_| {
128 if let Some(ref handler) = on_close_overlay {
129 handler.call(());
130 }
131 },
132 div {
133 class: "collaborators-modal",
134 onclick: move |e| e.stop_propagation(),
135 {panel_content}
136 }
137 }
138 }
139 } else {
140 panel_content
141 }
142}