diff --git a/src/application/dtos/grant_dto.rs b/src/application/dtos/grant_dto.rs index 2b2697e2..ec0575c7 100644 --- a/src/application/dtos/grant_dto.rs +++ b/src/application/dtos/grant_dto.rs @@ -312,5 +312,54 @@ pub struct SharedWithMeItemDto { pub resource: ResourceContentDto, } +/// Derive the closest-matching role label from a set of permissions. +/// Maps the permission set to `"admin"`, `"editor"`, or `"viewer"`. +pub fn role_from_permissions(perms: &[Permission]) -> &'static str { + if perms.contains(&Permission::Delete) && perms.contains(&Permission::Share) { + "admin" + } else if perms.contains(&Permission::Create) || perms.contains(&Permission::Update) { + "editor" + } else { + "viewer" + } +} + /// Response for `GET /api/grants/incoming/resources`. pub type SharedWithMeDto = CursorListResponse; + +// ════════════════════════════════════════════════════════════════════════════ +// My-Shares DTOs (GET /api/grants/outgoing/resources) +// ════════════════════════════════════════════════════════════════════════════ + +/// One (subject, permissions) entry within an outgoing resource item. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct OutgoingResourceGrantDto { + pub grant_id: Uuid, + /// `"user"` | `"token"` + pub subject_type: String, + pub subject_id: Uuid, + /// Human-readable label (username for users, share name for tokens). + pub subject_display: String, + /// Derived role label: `"viewer"` | `"editor"` | `"admin"`. + pub role: String, + pub granted_at: chrono::DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, + /// Whether the token has a password set. Always `false` for user subjects. + pub has_password: bool, +} + +/// One item in the my-shares list. +#[derive(Debug, Serialize, ToSchema)] +pub struct OutgoingResourceItemDto { + pub resource_type: ResourceTypeDto, + /// Earliest grant date across all subjects on this resource. + pub first_shared_at: chrono::DateTime, + /// Full resource details. Shape is determined by `resource_type`. + pub resource: ResourceContentDto, + /// One entry per (subject, permissions) pair. + pub grants: Vec, +} + +/// Response for `GET /api/grants/outgoing/resources`. +pub type MySharesDto = CursorListResponse; diff --git a/src/application/ports/authorization_ports.rs b/src/application/ports/authorization_ports.rs index bd579923..ecd8831c 100644 --- a/src/application/ports/authorization_ports.rs +++ b/src/application/ports/authorization_ports.rs @@ -12,7 +12,8 @@ use uuid::Uuid; use crate::common::errors::DomainError; use crate::domain::services::authorization::{ - Grant, GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject, + Grant, GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource, + ResourceKind, Subject, }; pub trait AuthorizationEngine: Send + Sync + 'static { @@ -97,6 +98,21 @@ pub trait AuthorizationEngine: Send + Sync + 'static { /// `GET /api/grants/outgoing` ("things I've shared with others"). async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result, DomainError>; + /// Cursor-paginated list of resources that `granted_by` has shared with + /// others. Multiple permission rows for the same (subject, resource) pair + /// are collapsed into one `OutgoingGrantEntry`; multiple subjects on the + /// same resource are grouped into one `OutgoingResourceSummary`. + /// + /// Returns `(summaries, next_cursor)`. + async fn list_outgoing_resources_paged( + &self, + granted_by: Uuid, + limit: u32, + cursor: Option, + sort_by: &str, + reverse: bool, + ) -> Result<(Vec, Option), DomainError>; + /// Create a grant. Idempotent — duplicates are absorbed by the UNIQUE /// constraint; if the row already exists its `expires_at` is updated. async fn grant( diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index 51de60b8..b75fb784 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -205,8 +205,7 @@ pub struct Grant { impl Grant { pub fn is_expired(&self) -> bool { - self.expires_at - .is_some_and(|exp| exp < chrono::Utc::now()) + self.expires_at.is_some_and(|exp| exp < chrono::Utc::now()) } } @@ -260,6 +259,42 @@ pub struct IncomingGrantSummary { pub granted_by: Uuid, } +// ════════════════════════════════════════════════════════════════════════════ +// OutgoingGrantEntry / OutgoingResourceSummary — per-subject grant within a +// resource that the current user shared with others +// ════════════════════════════════════════════════════════════════════════════ + +/// One (subject, permissions) pair within an outgoing resource summary. +/// The `subject_display` field is resolved by the SQL layer: username for +/// `user` subjects, share item_name for `token` subjects. +#[derive(Debug, Clone)] +pub struct OutgoingGrantEntry { + pub grant_id: Uuid, + pub subject_type: String, + pub subject_id: Uuid, + /// Human-readable label: username (users) or share name (tokens). + pub subject_display: String, + /// All permissions held by this subject on the resource (aggregated). + pub permissions: Vec, + pub granted_at: chrono::DateTime, + pub expires_at: Option>, + /// True when the token subject has a password set (`storage.shares.password_hash IS NOT NULL`). + /// Always `false` for `user` subjects. + pub has_password: bool, +} + +/// All subjects that the current user has shared a single resource with, +/// together with the resource type, id, and when it was first shared. +#[derive(Debug, Clone)] +pub struct OutgoingResourceSummary { + pub resource_type: ResourceKind, + pub resource_id: Uuid, + /// Earliest `granted_at` across all grants on this resource. + pub first_shared_at: chrono::DateTime, + /// One entry per (subject, permissions) pair. + pub grants: Vec, +} + // ════════════════════════════════════════════════════════════════════════════ // GrantCursor — opaque pagination cursor for list_incoming_resources_paged // ════════════════════════════════════════════════════════════════════════════ diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index e98c05b5..afa9532b 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -36,7 +36,8 @@ use sqlx::PgPool; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::common::errors::DomainError; use crate::domain::services::authorization::{ - Grant, GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject, + Grant, GrantCursor, IncomingGrantSummary, OutgoingGrantEntry, OutgoingResourceSummary, + Permission, Resource, ResourceKind, Subject, }; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; @@ -664,6 +665,619 @@ impl AuthorizationEngine for PgAclEngine { rows.into_iter().map(Self::row_to_grant).collect() } + async fn list_outgoing_resources_paged( + &self, + granted_by: Uuid, + limit: u32, + cursor: Option, + sort_by: &str, + reverse: bool, + ) -> Result<(Vec, Option), DomainError> { + let fetch_limit = (limit as i64) + 1; + + // Row shape — one row per (resource, subject, permission). + // Columns: + // 0 resource_type String + // 1 resource_id Uuid + // 2 first_shared_at DateTime — MIN(granted_at) across resource + // 3 subject_type String + // 4 subject_id Uuid + // 5 subject_display String — username or share item_name + // 6 grant_id Uuid + // 7 granted_at DateTime — this (subject, perm) row + // 8 expires_at Option> + // 9 permission String + // 10 sort_str Option + // 11 sort_int Option + // 12 has_password bool — token: shares.password_hash IS NOT NULL + type Row = ( + String, + Uuid, + chrono::DateTime, + String, + Uuid, + String, + Uuid, + chrono::DateTime, + Option>, + String, + Option, + Option, + bool, + ); + + let cursor_str = cursor.as_ref().and_then(|c| c.resource_name.clone()); + let cursor_int = cursor.as_ref().and_then(|c| c.sort_int); + let cursor_at = cursor.as_ref().map(|c| c.granted_at); + let cursor_id = cursor.as_ref().map(|c| c.resource_id); + + // ── Resource-page CTE (one row per resource, cursor-paginated) ───────── + // We page on resources (by first_shared_at + resource_id) so that the + // limit/cursor semantics are consistent with the incoming endpoint. + // All grants for each paged resource are then retrieved in the same query. + // + // $1 = granted_by + // $2 = cursor_str (resource_name for name/type, owner_name for granted_by) + // $3 = cursor_int (category_order for type, size for size) + // $4 = cursor_at (first_shared_at) + // $5 = cursor_id (resource_id) + // $6 = fetch_limit + let sql = match sort_by { + "name" | "type" => { + let sort_int_expr = if sort_by == "type" { + "CASE WHEN ag.resource_type = 'folder' THEN 0 ELSE fi.category_order::bigint END" + } else { + "NULL::bigint" + }; + let (page_where, page_order) = if sort_by == "type" { + if reverse { + ( + r#"( $3::integer IS NULL + OR sort_int < $3 + OR (sort_int = $3 AND LOWER(sort_str) < $2) + OR (sort_int = $3 AND LOWER(sort_str) = $2 AND resource_id < $5::uuid))"#, + "sort_int DESC, LOWER(sort_str) DESC, resource_id DESC", + ) + } else { + ( + r#"( $3::integer IS NULL + OR sort_int > $3 + OR (sort_int = $3 AND LOWER(sort_str) > $2) + OR (sort_int = $3 AND LOWER(sort_str) = $2 AND resource_id > $5::uuid))"#, + "sort_int ASC, LOWER(sort_str) ASC, resource_id ASC", + ) + } + } else if reverse { + ( + r#"( $2::text IS NULL + OR LOWER(sort_str) < $2 + OR (LOWER(sort_str) = $2 AND resource_id < $5::uuid))"#, + "LOWER(sort_str) DESC, resource_id DESC", + ) + } else { + ( + r#"( $2::text IS NULL + OR LOWER(sort_str) > $2 + OR (LOWER(sort_str) = $2 AND resource_id > $5::uuid))"#, + "LOWER(sort_str) ASC, resource_id ASC", + ) + }; + format!( + r#"WITH resource_page AS ( + SELECT ag.resource_type, ag.resource_id, MIN(ag.granted_at) AS first_shared_at, + COALESCE( + CASE WHEN ag.resource_type = 'folder' THEN f.name END, + CASE WHEN ag.resource_type = 'file' THEN fi.name END + ) AS sort_str, + {sort_int_expr} AS sort_int + FROM storage.access_grants ag + LEFT JOIN storage.folders f ON f.id = ag.resource_id AND ag.resource_type = 'folder' + LEFT JOIN storage.files fi ON fi.id = ag.resource_id AND ag.resource_type = 'file' + WHERE ag.granted_by = $1 + GROUP BY ag.resource_type, ag.resource_id, f.name, fi.name, fi.category_order + ), + rp AS ( + SELECT * FROM resource_page + WHERE {page_where} + ORDER BY {page_order} + LIMIT $6 + ) + SELECT ag.resource_type, ag.resource_id, rp.first_shared_at, + ag.subject_type, ag.subject_id, + COALESCE(u.username, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display, + ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission, + rp.sort_str, rp.sort_int, + (sh.password_hash IS NOT NULL) AS has_password + FROM rp + JOIN storage.access_grants ag + ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id + AND ag.granted_by = $1 + LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id + LEFT JOIN storage.shares sh ON ag.subject_type = 'token' AND sh.id = ag.subject_id + LEFT JOIN storage.files fi ON ag.subject_type = 'token' AND ag.resource_type = 'file' AND fi.id = ag.resource_id + LEFT JOIN storage.folders fld ON ag.subject_type = 'token' AND ag.resource_type = 'folder' AND fld.id = ag.resource_id + ORDER BY {page_order}, ag.subject_id, ag.granted_at"# + ) + } + "subject" => { + // Page on (subject_type_order, subject_display, resource_id) triples so + // every swimlane is always contiguous across cursor pages. + // + // subject_type_order: 0 = user, 1 = token without password, 2 = token with password + // + // Cursor encodes: sort_int = subject_type_order, resource_name = LOWER(subject_display), + // resource_id = last resource_id. + let (page_where, page_order) = if reverse { + ( + r#"( $3::bigint IS NULL + OR sort_int < $3 + OR (sort_int = $3 AND LOWER(subject_display) < $2) + OR (sort_int = $3 AND LOWER(subject_display) = $2 AND resource_id < $5::uuid))"#, + "sort_int DESC, LOWER(subject_display) DESC, resource_id DESC", + ) + } else { + ( + r#"( $3::bigint IS NULL + OR sort_int > $3 + OR (sort_int = $3 AND LOWER(subject_display) > $2) + OR (sort_int = $3 AND LOWER(subject_display) = $2 AND resource_id > $5::uuid))"#, + "sort_int ASC, LOWER(subject_display) ASC, resource_id ASC", + ) + }; + format!( + r#"WITH pairs AS ( + SELECT + ag.resource_type, + ag.resource_id, + ag.subject_type, + ag.subject_id, + MAX(COALESCE(u.username, sh.item_name, ag.subject_id::text)) AS subject_display, + BOOL_OR(sh.password_hash IS NOT NULL) AS has_password, + MAX(CASE + WHEN ag.subject_type = 'user' THEN 0 + WHEN ag.subject_type = 'token' AND sh.password_hash IS NULL THEN 1 + ELSE 2 + END)::bigint AS sort_int, + MIN(ag.granted_at) AS first_granted_at + FROM storage.access_grants ag + LEFT JOIN auth.users u + ON ag.subject_type = 'user' AND u.id = ag.subject_id + LEFT JOIN storage.shares sh + ON ag.subject_type = 'token' AND sh.id = ag.subject_id + LEFT JOIN storage.files fi + ON ag.subject_type = 'token' AND ag.resource_type = 'file' AND fi.id = ag.resource_id + LEFT JOIN storage.folders fld + ON ag.subject_type = 'token' AND ag.resource_type = 'folder' AND fld.id = ag.resource_id + WHERE ag.granted_by = $1 + AND (ag.expires_at IS NULL OR ag.expires_at > NOW()) + GROUP BY ag.resource_type, ag.resource_id, ag.subject_type, ag.subject_id + ), + rp AS ( + SELECT * FROM pairs + WHERE {page_where} + ORDER BY {page_order} + LIMIT $6 + ) + SELECT + ag.resource_type, + ag.resource_id, + rp.first_granted_at AS first_shared_at, + ag.subject_type, + ag.subject_id, + rp.subject_display, + ag.id AS grant_id, + ag.granted_at, + ag.expires_at, + ag.permission, + LOWER(rp.subject_display) AS sort_str, + rp.sort_int, + rp.has_password + FROM rp + JOIN storage.access_grants ag + ON ag.resource_type = rp.resource_type + AND ag.resource_id = rp.resource_id + AND ag.subject_type = rp.subject_type + AND ag.subject_id = rp.subject_id + AND ag.granted_by = $1 + AND (ag.expires_at IS NULL OR ag.expires_at > NOW()) + ORDER BY {page_order}"# + ) + } + "role" => { + // Page on (role_order, subject_display, resource_id) triples so that all + // of one person's grants within a role are contiguous — enabling aggregation + // ("Bob on Folder A, Folder B") to work correctly across cursor pages. + // role_order: 0 = admin (has delete+share), 1 = editor (has create or update), 2 = viewer + // Cursor: sort_int=role_order, resource_name=LOWER(subject_display), resource_id + let (page_where, page_order) = if reverse { + ( + r#"( $3::bigint IS NULL + OR sort_int < $3 + OR (sort_int = $3 AND LOWER(subject_display) < $2) + OR (sort_int = $3 AND LOWER(subject_display) = $2 AND resource_id < $5::uuid))"#, + "sort_int DESC, LOWER(subject_display) DESC, resource_id DESC", + ) + } else { + ( + r#"( $3::bigint IS NULL + OR sort_int > $3 + OR (sort_int = $3 AND LOWER(subject_display) > $2) + OR (sort_int = $3 AND LOWER(subject_display) = $2 AND resource_id > $5::uuid))"#, + "sort_int ASC, LOWER(subject_display) ASC, resource_id ASC", + ) + }; + format!( + r#"WITH pairs AS ( + SELECT + ag.resource_type, + ag.resource_id, + ag.subject_type, + ag.subject_id, + MAX(COALESCE(u.username, sh.item_name, ag.subject_id::text)) AS subject_display, + BOOL_OR(sh.password_hash IS NOT NULL) AS has_password, + CASE + WHEN BOOL_OR(ag.permission = 'delete') + AND BOOL_OR(ag.permission = 'share') THEN 0 + WHEN BOOL_OR(ag.permission = 'create') + OR BOOL_OR(ag.permission = 'update') THEN 1 + ELSE 2 + END::bigint AS sort_int, + MIN(ag.granted_at) AS first_granted_at + FROM storage.access_grants ag + LEFT JOIN auth.users u + ON ag.subject_type = 'user' AND u.id = ag.subject_id + LEFT JOIN storage.shares sh + ON ag.subject_type = 'token' AND sh.id = ag.subject_id + LEFT JOIN storage.files fi + ON ag.subject_type = 'token' AND ag.resource_type = 'file' AND fi.id = ag.resource_id + LEFT JOIN storage.folders fld + ON ag.subject_type = 'token' AND ag.resource_type = 'folder' AND fld.id = ag.resource_id + WHERE ag.granted_by = $1 + AND (ag.expires_at IS NULL OR ag.expires_at > NOW()) + GROUP BY ag.resource_type, ag.resource_id, ag.subject_type, ag.subject_id + ), + rp AS ( + SELECT * FROM pairs + WHERE {page_where} + ORDER BY {page_order} + LIMIT $6 + ) + SELECT + ag.resource_type, + ag.resource_id, + rp.first_granted_at AS first_shared_at, + ag.subject_type, + ag.subject_id, + rp.subject_display, + ag.id AS grant_id, + ag.granted_at, + ag.expires_at, + ag.permission, + LOWER(rp.subject_display) AS sort_str, + rp.sort_int, + rp.has_password + FROM rp + JOIN storage.access_grants ag + ON ag.resource_type = rp.resource_type + AND ag.resource_id = rp.resource_id + AND ag.subject_type = rp.subject_type + AND ag.subject_id = rp.subject_id + AND ag.granted_by = $1 + AND (ag.expires_at IS NULL OR ag.expires_at > NOW()) + ORDER BY {page_order}"# + ) + } + _ => { + // Default: sort by first_shared_at DESC (newest resource shared first). + let (page_where, page_order) = if reverse { + ( + r#"( $4::timestamptz IS NULL + OR first_shared_at > $4 + OR (first_shared_at = $4 AND resource_id > $5::uuid))"#, + "first_shared_at ASC, resource_id ASC", + ) + } else { + ( + r#"( $4::timestamptz IS NULL + OR first_shared_at < $4 + OR (first_shared_at = $4 AND resource_id < $5::uuid))"#, + "first_shared_at DESC, resource_id DESC", + ) + }; + format!( + r#"WITH resource_page AS ( + SELECT resource_type, resource_id, MIN(granted_at) AS first_shared_at, + NULL::text AS sort_str, + NULL::bigint AS sort_int + FROM storage.access_grants + WHERE granted_by = $1 + GROUP BY resource_type, resource_id + ), + rp AS ( + SELECT * FROM resource_page + WHERE {page_where} + ORDER BY {page_order} + LIMIT $6 + ) + SELECT ag.resource_type, ag.resource_id, rp.first_shared_at, + ag.subject_type, ag.subject_id, + COALESCE(u.username, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display, + ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission, + NULL::text AS sort_str, NULL::bigint AS sort_int, + (sh.password_hash IS NOT NULL) AS has_password + FROM rp + JOIN storage.access_grants ag + ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id + AND ag.granted_by = $1 + LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id + LEFT JOIN storage.shares sh ON ag.subject_type = 'token' AND sh.id = ag.subject_id + LEFT JOIN storage.files fi ON ag.subject_type = 'token' AND ag.resource_type = 'file' AND fi.id = ag.resource_id + LEFT JOIN storage.folders fld ON ag.subject_type = 'token' AND ag.resource_type = 'folder' AND fld.id = ag.resource_id + ORDER BY {page_order}, ag.subject_id, ag.granted_at"# + ) + } + }; + + let rows: Vec = sqlx::query_as::<_, Row>(&sql) + .bind(granted_by) // $1 + .bind(&cursor_str) // $2 sort_str cursor + .bind(cursor_int) // $3 sort_int cursor + .bind(cursor_at) // $4 first_shared_at cursor + .bind(cursor_id) // $5 resource_id cursor + .bind(fetch_limit) // $6 + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error( + "PgAcl", + format!("list_outgoing_resources_paged ({sort_by}): {e}"), + ) + })?; + + // ── Subject / Role sorts: page on (resource_id, subject_id) pairs ─────── + // Each pair becomes one OutgoingResourceSummary with exactly one grant, + // preserving the SQL-ordered swimlane sequence across cursor pages. + if matches!(sort_by, "subject" | "role") { + let mut seen_pairs: Vec<(Uuid, Uuid)> = Vec::new(); + let mut seen_pair_set: std::collections::HashSet<(Uuid, Uuid)> = + std::collections::HashSet::new(); + for r in &rows { + if seen_pair_set.insert((r.1, r.4)) { + seen_pairs.push((r.1, r.4)); + } + } + let has_next = seen_pairs.len() > limit as usize; + seen_pairs.truncate(limit as usize); + let keep: std::collections::HashSet<(Uuid, Uuid)> = + seen_pairs.iter().copied().collect(); + + let last_row = rows.iter().rfind(|r| keep.contains(&(r.1, r.4))); + let next_cursor = if has_next { + last_row.map(|r| { + let resource_name = r.10.clone(); // LOWER(subject_display) for both subject and role sort + GrantCursor { + sort_by: sort_by.to_owned(), + granted_at: r.2, + resource_id: r.1, + resource_name, + sort_int: r.11, + reverse, + } + }) + } else { + None + }; + + // Group rows: (resource_id, subject_id) → OutgoingGrantEntry. + let mut entry_map: std::collections::HashMap< + (Uuid, Uuid), + (ResourceKind, OutgoingGrantEntry), + > = std::collections::HashMap::new(); + for r in rows.into_iter().filter(|r| keep.contains(&(r.1, r.4))) { + let ( + rt_str, + resource_id, + _first_shared_at, + subj_type, + subj_id, + subj_display, + grant_id, + granted_at, + expires_at, + perm_str, + _, + _, + has_password, + ) = r; + let Some(resource_type) = ResourceKind::parse(&rt_str) else { + continue; + }; + let Some(perm) = Permission::parse(&perm_str) else { + continue; + }; + let key = (resource_id, subj_id); + let (_, entry) = entry_map.entry(key).or_insert_with(|| { + ( + resource_type, + OutgoingGrantEntry { + grant_id, + subject_type: subj_type.clone(), + subject_id: subj_id, + subject_display: subj_display.clone(), + permissions: Vec::new(), + granted_at, + expires_at, + has_password, + }, + ) + }); + if !entry.permissions.contains(&perm) { + entry.permissions.push(perm); + } + } + + let summaries: Vec = seen_pairs + .into_iter() + .filter_map(|(rid, sid)| { + let (resource_type, grant) = entry_map.remove(&(rid, sid))?; + Some(OutgoingResourceSummary { + resource_type, + resource_id: rid, + first_shared_at: grant.granted_at, + grants: vec![grant], + }) + }) + .collect(); + + return Ok((summaries, next_cursor)); + } + + // ── All other sorts: page on distinct resource_ids ──────────────────── + let mut seen_resources: Vec = Vec::new(); + let mut seen_set: std::collections::HashSet = std::collections::HashSet::new(); + for r in &rows { + if seen_set.insert(r.1) { + seen_resources.push(r.1); + } + } + + let has_next = seen_resources.len() > limit as usize; + seen_resources.truncate(limit as usize); + let keep: std::collections::HashSet = seen_resources.iter().copied().collect(); + + let last_row = rows.iter().rfind(|r| keep.contains(&r.1)); + let next_cursor = if has_next { + last_row.map(|r| { + let sort_str_lc = r.10.as_deref().map(str::to_lowercase); + match sort_by { + "name" => GrantCursor { + sort_by: "name".to_owned(), + granted_at: r.2, + resource_id: r.1, + resource_name: sort_str_lc, + sort_int: None, + reverse, + }, + "type" => GrantCursor { + sort_by: "type".to_owned(), + granted_at: r.2, + resource_id: r.1, + resource_name: sort_str_lc, + sort_int: r.11, + reverse, + }, + _ => GrantCursor { + sort_by: "first_shared_at".to_owned(), + granted_at: r.2, + resource_id: r.1, + resource_name: None, + sort_int: None, + reverse, + }, + } + }) + } else { + None + }; + + // Group flat rows by resource_id → (ResourceKind, first_shared_at, subjects). + type ResourceEntry = ( + ResourceKind, + chrono::DateTime, + std::collections::HashMap, + ); + let mut resource_map: std::collections::HashMap = + std::collections::HashMap::new(); + + for r in rows.into_iter().filter(|r| keep.contains(&r.1)) { + let ( + rt_str, + resource_id, + first_shared_at, + subj_type, + subj_id, + subj_display, + grant_id, + granted_at, + expires_at, + perm_str, + _, + _, + has_password, + ) = r; + let Some(resource_type) = ResourceKind::parse(&rt_str) else { + continue; + }; + let Some(perm) = Permission::parse(&perm_str) else { + continue; + }; + + let (_, _, subj_map) = resource_map.entry(resource_id).or_insert_with(|| { + ( + resource_type, + first_shared_at, + std::collections::HashMap::new(), + ) + }); + let entry = subj_map + .entry(subj_id) + .or_insert_with(|| OutgoingGrantEntry { + grant_id, + subject_type: subj_type.clone(), + subject_id: subj_id, + subject_display: subj_display.clone(), + permissions: Vec::new(), + granted_at, + expires_at, + has_password, + }); + if !entry.permissions.contains(&perm) { + entry.permissions.push(perm); + } + } + + let summaries: Vec = seen_resources + .into_iter() + .filter_map(|rid| { + let (resource_type, first_shared_at, subj_map) = resource_map.remove(&rid)?; + let mut grants: Vec = subj_map.into_values().collect(); + let role_rank = |perms: &[Permission]| -> u8 { + if perms.contains(&Permission::Delete) && perms.contains(&Permission::Share) { + 0 // admin → Can manage + } else if perms.contains(&Permission::Create) + || perms.contains(&Permission::Update) + { + 1 // editor → Can edit + } else { + 2 // viewer → Can view + } + }; + grants.sort_by(|a, b| { + role_rank(&a.permissions) + .cmp(&role_rank(&b.permissions)) + .then_with(|| { + // users before tokens + let type_rank = |st: &str| if st == "user" { 0u8 } else { 1 }; + type_rank(&a.subject_type).cmp(&type_rank(&b.subject_type)) + }) + .then_with(|| { + a.subject_display + .to_lowercase() + .cmp(&b.subject_display.to_lowercase()) + }) + }); + Some(OutgoingResourceSummary { + resource_type, + resource_id: rid, + first_shared_at, + grants, + }) + }) + .collect(); + + Ok((summaries, next_cursor)) + } + async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result, DomainError> { let rows = sqlx::query_as::< _, @@ -776,7 +1390,9 @@ impl AuthorizationEngine for PgAclEngine { .bind(resource.id()) .execute(self.pool.as_ref()) .await - .map_err(|e| DomainError::internal_error("PgAcl", format!("set_expiry_on_resource: {e}")))?; + .map_err(|e| { + DomainError::internal_error("PgAcl", format!("set_expiry_on_resource: {e}")) + })?; Ok(()) } diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index 17b04b0d..eac659e3 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -20,8 +20,9 @@ use uuid::Uuid; use crate::application::dtos::cursor::PageCursor; use crate::application::dtos::grant_dto::{ - CreateGrantDto, GrantDto, PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto, - SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto, + CreateGrantDto, GrantDto, MySharesDto, OutgoingResourceGrantDto, OutgoingResourceItemDto, + PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto, SharedWithMeDto, + SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto, role_from_permissions, }; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::file_ports::FileRetrievalUseCase; @@ -31,7 +32,8 @@ use crate::common::di::AppState; use crate::common::errors::DomainError; use crate::domain::errors::ErrorKind; use crate::domain::services::authorization::{ - GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject, + GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource, ResourceKind, + Subject, }; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; @@ -98,7 +100,10 @@ pub async fn create_grant( let mut results: Vec = Vec::with_capacity(permissions.len()); for perm in permissions { - match authz.grant(caller_id, subject, perm, resource, expires_at).await { + match authz + .grant(caller_id, subject, perm, resource, expires_at) + .await + { Ok(grant) => results.push(grant.into()), Err(err) => { error!("grant insert failed for {perm:?}: {err}"); @@ -227,7 +232,10 @@ pub async fn set_role( } } for perm in &to_add { - if let Err(e) = authz.grant(caller_id, subject, *perm, resource, expires_at).await { + if let Err(e) = authz + .grant(caller_id, subject, *perm, resource, expires_at) + .await + { return AppError::from(e).into_response(); } } @@ -236,7 +244,10 @@ pub async fn set_role( // includes newly added ones and any that were already present (retained). // Callers that omit expires_at will clear any existing expiry; this is // intentional: it keeps all permission rows for the pair consistent. - if let Err(e) = authz.set_expiry_on_resource(subject, resource, expires_at).await { + if let Err(e) = authz + .set_expiry_on_resource(subject, resource, expires_at) + .await + { return AppError::from(e).into_response(); } @@ -560,6 +571,180 @@ pub async fn list_on_resource( } } +// ════════════════════════════════════════════════════════════════════════════ +// GET /api/grants/outgoing/resources +// ════════════════════════════════════════════════════════════════════════════ + +#[utoipa::path( + get, + path = "/api/grants/outgoing/resources", + params(SharedWithMeQuery), + responses( + (status = 200, + description = "Cursor-paginated resources the caller has shared with others. \ + Each item carries the full resource details plus all subjects \ + (users and tokens) the resource was shared with. \ + `next_cursor` is absent on the last page.", + body = MySharesDto), + ), + security(("bearerAuth" = [])), + tag = "grants" +)] +pub async fn list_my_shares( + State(state): State, + auth_user: AuthUser, + Query(q): Query, +) -> impl IntoResponse { + let caller_id = auth_user.id; + + let limit = q.limit_clamped() as u32; + + let sort_by = q.sort_by.as_deref().unwrap_or("first_shared_at"); + if !matches!( + sort_by, + "first_shared_at" | "name" | "type" | "subject" | "role" + ) { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "invalid sort_by; valid values: first_shared_at, name, type, subject, role"})), + ) + .into_response(); + } + + let reverse = q.reverse; + + let cursor = q + .decode_cursor::() + .filter(|c| c.sort_by == sort_by && c.reverse == reverse); + + let (summaries, next_cursor) = match state + .authorization + .list_outgoing_resources_paged(caller_id, limit, cursor, sort_by, reverse) + .await + { + Ok(r) => r, + Err(e) => return AppError::from(e).into_response(), + }; + + let file_service = &state.applications.file_retrieval_service; + let folder_service = &state.applications.folder_service_concrete; + + // Split summaries by resource kind for parallel resolution. + let file_summaries: Vec<&OutgoingResourceSummary> = summaries + .iter() + .filter(|s| matches!(s.resource_type, ResourceKind::File)) + .collect(); + let folder_summaries: Vec<&OutgoingResourceSummary> = summaries + .iter() + .filter(|s| matches!(s.resource_type, ResourceKind::Folder)) + .collect(); + + let file_ids: Vec = file_summaries + .iter() + .map(|s| s.resource_id.to_string()) + .collect(); + let folder_ids: Vec = folder_summaries + .iter() + .map(|s| s.resource_id.to_string()) + .collect(); + + let (file_results, folder_results) = tokio::join!( + join_all(file_ids.iter().map(|id| file_service.get_file(id))), + join_all(folder_ids.iter().map(|id| folder_service.get_folder(id))) + ); + + let mut file_idx = 0usize; + let mut folder_idx = 0usize; + let mut items: Vec = Vec::with_capacity(summaries.len()); + + for summary in &summaries { + let grants: Vec = summary + .grants + .iter() + .map(|g| OutgoingResourceGrantDto { + grant_id: g.grant_id, + subject_type: g.subject_type.clone(), + subject_id: g.subject_id, + subject_display: g.subject_display.clone(), + role: role_from_permissions(&g.permissions).to_owned(), + granted_at: g.granted_at, + expires_at: g.expires_at, + has_password: g.has_password, + }) + .collect(); + + match summary.resource_type { + ResourceKind::File => { + let result = &file_results[file_idx]; + file_idx += 1; + match result { + Ok(file_dto) => { + items.push(OutgoingResourceItemDto { + resource_type: ResourceTypeDto::File, + first_shared_at: summary.first_shared_at, + resource: ResourceContentDto::File( + file_dto.clone().without_hierarchy_info(), + ), + grants, + }); + } + Err(e) if e.kind == ErrorKind::NotFound => { + warn!( + "Skipping stale outgoing file grant for resource_id={}: not found", + summary.resource_id + ); + } + Err(e) => { + return AppError::internal_error(format!( + "Failed to fetch file {}: {e}", + summary.resource_id + )) + .into_response(); + } + } + } + ResourceKind::Folder => { + let result = &folder_results[folder_idx]; + folder_idx += 1; + match result { + Ok(folder_dto) => { + items.push(OutgoingResourceItemDto { + resource_type: ResourceTypeDto::Folder, + first_shared_at: summary.first_shared_at, + resource: ResourceContentDto::Folder( + folder_dto.clone().without_hierarchy_info(), + ), + grants, + }); + } + Err(e) if e.kind == ErrorKind::NotFound => { + warn!( + "Skipping stale outgoing folder grant for resource_id={}: not found", + summary.resource_id + ); + } + Err(e) => { + return AppError::internal_error(format!( + "Failed to fetch folder {}: {e}", + summary.resource_id + )) + .into_response(); + } + } + } + } + } + + ( + StatusCode::OK, + Json(MySharesDto::with_cursor( + items, + next_cursor.map(|c| c.encode()), + )), + ) + .into_response() +} + // Silence unused-import warnings for SubjectDto when only certain endpoints // touch it directly. #[allow(dead_code)] diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 82186958..da26f78d 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -325,6 +325,7 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { get(grant_handler::list_shared_with_me), ) .route("/outgoing", get(grant_handler::list_outgoing)) + .route("/outgoing/resources", get(grant_handler::list_my_shares)) .with_state(app_state.clone()) }; diff --git a/static/css/components/linkChip.css b/static/css/components/linkChip.css new file mode 100644 index 00000000..71320fd7 --- /dev/null +++ b/static/css/components/linkChip.css @@ -0,0 +1,46 @@ +/* ── Link chip ─────────────────────────────────────────────────────────────── + * + * Inline clickable element representing a share link. + * Usage: buildLinkChip(grant) → HTMLButtonElement with class .link-chip + * ─────────────────────────────────────────────────────────────────────────── */ + +.link-chip { + display: inline-flex; + align-items: center; + gap: 5px; + max-width: 100%; + padding: 2px 0; + border: none; + background: transparent; + color: var(--color-text); + cursor: pointer; + font-size: 13px; + text-align: left; + transition: color 0.12s; +} + +.link-chip:hover { + color: var(--color-accent); +} + +.link-chip:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.link-chip__icon { + font-size: 12px; + color: var(--color-text-faint); + flex-shrink: 0; + transition: color 0.12s; +} + +.link-chip:hover .link-chip__icon { + color: var(--color-accent); +} + +.link-chip__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/static/css/main.css b/static/css/main.css index 158ed1ee..44c2bbcb 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -21,6 +21,7 @@ @import url("./components/shareDialog.css"); @import url("./components/shareModal.css"); @import url("./components/userVignette.css"); +@import url("./components/linkChip.css"); @import url("./components/uploadDropdown.css"); @import url("./components/notifications.css"); @import url("./components/userMenu.css"); diff --git a/static/css/views/mySharesView.css b/static/css/views/mySharesView.css new file mode 100644 index 00000000..95768af9 --- /dev/null +++ b/static/css/views/mySharesView.css @@ -0,0 +1,334 @@ +/* ── My Shares view ───────────────────────────────────────────────────────── + * + * BEM classes for the row-per-grant MySharesList component. + * All colors via var(--*). Mobile-first layout. + * ─────────────────────────────────────────────────────────────────────────── */ + +/* ── Load-more wrapper ───────────────────────────────────────────────────── */ + +.ms-load-more-wrapper { + display: flex; + justify-content: center; + padding: 16px 0 8px; +} + +.ms-load-more-wrapper.hidden { + display: none; +} + +/* ── Container: dissolve when lanes are present ──────────────────────────── */ + +.files-list-view:has(.ms-lane) { + background-color: transparent; + box-shadow: none; + border-radius: 0; + overflow: visible; + gap: 10px; + display: flex; + flex-direction: column; +} + +/* ── Lane (swimlane card) ────────────────────────────────────────────────── */ + +.ms-lane { + background-color: var(--color-item); + border-radius: 10px; + box-shadow: 0 1px 3px var(--color-shadow-xs); + overflow: hidden; +} + +.ms-lane__header { + background: var(--color-bg-muted); + border-bottom: 1px solid var(--color-border-faint); +} + +/* Vignette lane headers need the same padding as the resource row */ +.ms-lane__header .user-vignette { + padding: 10px 14px; + font-weight: 600; +} + +.ms-lane__header .user-vignette .user-vignette__name { + font-size: 13px; + color: var(--color-text-heading); + font-weight: 600; +} + +.ms-lane__body { + /* rows are direct children */ +} + +/* ── Resource lane header (items mode) ───────────────────────────────────── */ + +.ms-resource-row { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; +} + +/* Size the shared .file-icon component for inline use in myShares rows */ +.ms-resource-row .file-icon { + width: 36px; + height: 36px; + border-radius: 6px; + font-size: 14px; + flex-shrink: 0; +} + +/* Smaller icon for child grant rows and inline resource links */ +.ms-grant-row__identity .file-icon, +.ms-link-identity__resource .file-icon { + width: 24px; + height: 24px; + border-radius: 4px; + font-size: 11px; + flex-shrink: 0; +} + +.ms-resource-row__name { + flex: 1; + font-weight: 600; + color: var(--color-text); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ms-resource-row__name:hover { + color: var(--color-accent); + text-decoration: underline; +} + +.ms-resource-row__edit { + flex-shrink: 0; + font-size: 12px; + padding: 3px 8px; + opacity: 0.6; + transition: opacity 0.15s; +} + +.ms-resource-row__edit:hover { + opacity: 1; +} + +/* ── Subject lane header (sharedWith mode — link bucket) ─────────────────── */ + +.ms-link-lane-label { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + font-size: 12px; + font-weight: 600; + color: var(--color-text-secondary); +} + +.ms-link-lane-label__icon { + color: var(--color-text-faint); + font-size: 12px; +} + +/* ── Grant row ───────────────────────────────────────────────────────────── */ + +.ms-grant-row { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 14px 7px 28px; + border-bottom: 1px solid var(--color-border-faint); + transition: background 0.1s; +} + +.ms-grant-row:last-child { + border-bottom: none; +} + +.ms-grant-row:hover { + background: var(--color-bg-hover); +} + +.ms-grant-row--expired { + opacity: 0.6; +} + +/* ── Grant row — identity ─────────────────────────────────────────────────── */ + +.ms-grant-row__identity { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; +} + +.ms-identity__name { + font-size: 13px; + font-weight: 500; + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ms-identity__resource-name { + font-size: 13px; + font-weight: 500; + color: var(--color-text); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ms-identity__resource-name:hover { + color: var(--color-accent); + text-decoration: underline; +} + +/* Token in sharedWith mode: arrow + resource link */ + +.ms-link-identity__arrow { + font-size: 11px; + color: var(--color-text-faint); + flex-shrink: 0; +} + +.ms-link-identity__resource { + font-size: 12px; + color: var(--color-text-secondary); + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 3px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ms-link-identity__resource:hover { + color: var(--color-accent); + text-decoration: underline; +} + +/* ── Role pill ───────────────────────────────────────────────────────────── */ + +.ms-role-pill { + display: inline-block; + flex-shrink: 0; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.ms-role-pill--manage { + background: var(--color-badge-orange-bg); + color: var(--color-badge-orange-text); +} + +.ms-role-pill--edit { + background: var(--color-badge-blue-bg); + color: var(--color-badge-blue-text); +} + +.ms-role-pill--view { + background: var(--color-bg-muted); + color: var(--color-text-muted); +} + +/* ── Expiry chip ─────────────────────────────────────────────────────────── */ + +.ms-expiry-chip { + display: inline-flex; + align-items: center; + flex-shrink: 0; + gap: 4px; + padding: 2px 7px; + border-radius: 10px; + font-size: 11px; + white-space: nowrap; +} + +.ms-expiry-chip--never { + background: var(--color-bg-muted); + color: var(--color-text-faint); +} + +.ms-expiry-chip--active { + background: var(--color-bg-muted); + color: var(--color-text-muted); +} + +.ms-expiry-chip--soon { + background: var(--color-badge-amber-bg); + color: var(--color-badge-amber-text); +} + +.ms-expiry-chip--expired { + background: var(--color-danger-lighter); + color: var(--color-danger-text-alt); +} + +/* ── Kebab / icon buttons ────────────────────────────────────────────────── */ + +.ms-btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + font-size: 11px; + flex-shrink: 0; + transition: background 0.12s, color 0.12s; +} + +.ms-btn-icon:hover { + background: var(--color-bg-hover); + color: var(--color-text); +} + +.ms-btn-icon:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.ms-kebab-btn { + margin-left: auto; +} + +/* ── Context menu current-item indicator ─────────────────────────────────── */ + +.ms-menu-item--current { + font-weight: 600; +} + +/* ── Context menu expiry row ─────────────────────────────────────────────── */ + +.ms-menu-expiry-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 12px; +} + +.ms-menu-expiry-label { + font-size: 12px; + color: var(--color-text-muted); + white-space: nowrap; + flex-shrink: 0; +} + +/* Stretch the chip to fill remaining space in the expiry row */ +.ms-menu-expiry-row .smd-expiry-chip-wrap { + flex: 1; + min-width: 0; +} diff --git a/static/index.html b/static/index.html index 129ae26e..204d0884 100644 --- a/static/index.html +++ b/static/index.html @@ -15,6 +15,7 @@ + diff --git a/static/js/app/main.js b/static/js/app/main.js index cfc21802..e991c638 100644 --- a/static/js/app/main.js +++ b/static/js/app/main.js @@ -162,12 +162,30 @@ const ACTIONS_BAR_TEMPLATES = {
${_batchToolbarButons} ${_toggleButtons} + `, + shared: ` +
+
+ + +
` }; /** * - * @param {'files' | 'trash' | 'favorites' | 'recent' | 'sharedwithme' | 'hidden'} mode + * @param {'files' | 'trash' | 'favorites' | 'recent' | 'sharedwithme' | 'shared' | 'hidden'} mode * @param {boolean} [force=false] * @returns */ @@ -259,8 +277,12 @@ function syncGroupByMenu(defs = []) { // Rebuild menu options — call i18n.t() directly so each label is resolved // at call time (translations are loaded by the time any section switch runs). - menu.innerHTML = ``; + // A def with key='' lets the section override the default "None" label. + const noneOverride = defs.find((d) => d.key === ''); + const noneLabel = noneOverride ? noneOverride.label : i18n.t('groupby.none', 'None'); + menu.innerHTML = ``; for (const def of defs) { + if (def.key === '') continue; menu.insertAdjacentHTML('beforeend', ``); } @@ -640,8 +662,10 @@ function setupEventListeners() { } else { // change is from history, data provided in event switchSectionTo(e.state.section); - app.currentPath = e.state.id; - loadFiles({ insertHistory: false }); + if (e.state.section === 'files') { + app.currentPath = e.state.id; + loadFiles({ insertHistory: false }); + } } }); diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index 0274f44a..c58cca24 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -11,6 +11,7 @@ import { favorites } from '../features/library/favorites.js'; import { musicView } from '../features/library/music.js'; import { photosView } from '../features/library/photos.js'; import { favoritesView } from '../views/favorites/favoritesView.js'; +import { mySharesView } from '../views/myShares/mySharesView.js'; import { recentView } from '../views/recent/recentView.js'; import { sharedView } from '../views/shared/sharedView.js'; import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js'; @@ -165,9 +166,10 @@ function setCurrentSection(section) { appElements.pageTitle.setAttribute('data-i18n', titleKey); } - // Hide sharedView when switching to any other section - if (section !== 'shared' && sharedView) { - sharedView.hide(); + // Hide sharedView and mySharesView when switching to any other section + if (section !== 'shared') { + if (sharedView) sharedView.hide(); + mySharesView.hide(); } // Hide "Load more" button when leaving the sharedwithme section @@ -208,21 +210,27 @@ function switchToSharedSection() { const breadcrumb = document.querySelector('.breadcrumb'); breadcrumb?.classList.add('hidden'); - // Hide actions-bar for shared view - setActionsBarMode('hidden'); + // Show actions-bar with group-by controls only (no grid/list toggle — + // MySharesList is always in list mode). + setActionsBarMode('shared'); - //reset files view + remove any error - ui.resetFilesList(); + // Populate the group-by dropdown with this section's dimensions. + setGroupByView(mySharesView); + syncGroupByMenu(mySharesView.groupByDefs); - // Hide file containers - toggleFileContainer(false); + // Restore the saved group-by selection in the dropdown. + const msPrefs = viewPrefs.load('shared'); + applyGroupByMenuState(msPrefs.groupBy, msPrefs.reversed); - // Show shared view - sharedView.init().then(() => { - sharedView.show(); - }); + // Show the files container always in list view — grid is not applicable here. + toggleFileContainer(true); + app.currentView = 'list'; + syncViewContainers(); if (batchToolbar) batchToolbar.clear(); + + // Load and render items into the files container + mySharesView.init(); } function switchToSharedWithMeSection() { diff --git a/static/js/app/ui.js b/static/js/app/ui.js index 991280a3..d16854e5 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -882,7 +882,7 @@ const ui = { loadFiles(); return; } - if (app.currentSection === 'sharedwithme') { + if (app.currentSection === 'sharedwithme' || app.currentSection === 'shared') { // Activate Files UI (nav, breadcrumb, actions bar) without // resetting the path — the shared folder becomes the entry point. activateFilesUI(); diff --git a/static/js/components/linkChip.js b/static/js/components/linkChip.js new file mode 100644 index 00000000..1ee863fb --- /dev/null +++ b/static/js/components/linkChip.js @@ -0,0 +1,53 @@ +/** + * linkChip — inline clickable element representing a share link. + * + * Renders: [🔒/🔗] Link - ...{last4 of UUID} - {name} + * Clicking copies the share URL to the clipboard. + */ + +import { i18n } from '../core/i18n.js'; +import { fileSharing } from '../features/sharing/fileSharing.js'; + +/** @import {OutgoingResourceGrant} from '../core/types.js' */ + +/** + * Build an inline link chip. Clicking it copies the share URL. + * @param {OutgoingResourceGrant} grant + * @returns {HTMLButtonElement} + */ +function buildLinkChip(grant) { + const btn = /** @type {HTMLButtonElement} */ (document.createElement('button')); + btn.className = `link-chip${grant.has_password ? ' link-chip--locked' : ''}`; + btn.type = 'button'; + btn.title = i18n.t('share.copyLink', 'Copy link'); + + const icon = document.createElement('i'); + icon.className = grant.has_password ? 'fas fa-lock link-chip__icon' : 'fas fa-link link-chip__icon'; + btn.appendChild(icon); + + const last4 = grant.subject_id.slice(-4); + const label = `${i18n.t('share.link', 'Link')} - ...${last4} - ${grant.subject_display}`; + + const text = document.createElement('span'); + text.className = 'link-chip__label'; + text.textContent = label; + btn.appendChild(text); + + btn.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + btn.disabled = true; + try { + const share = await fileSharing.getShareById(grant.subject_id); + await fileSharing.copyLinkToClipboard(share.url); + } catch (err) { + console.error('linkChip: copy failed', err); + } finally { + btn.disabled = false; + } + }); + + return btn; +} + +export { buildLinkChip }; diff --git a/static/js/components/mySharesList.js b/static/js/components/mySharesList.js new file mode 100644 index 00000000..49a4ae2e --- /dev/null +++ b/static/js/components/mySharesList.js @@ -0,0 +1,600 @@ +/** + * MySharesList — row-per-grant list for the My Shares view. + * + * Both view modes emit one row per grant. Lane headers are emitted on + * grouping-key change — the server guarantees ORDER BY group key first. + * + * Modes: + * 'items' — lane = resource; row identity = subject + * 'sharedWith' — lane = user | 'links:public' | 'links:password'; row identity = resource + */ + +import { i18n } from '../core/i18n.js'; +import { fileSharing } from '../features/sharing/fileSharing.js'; +import { grants } from '../model/grants.js'; +import { buildExpiryChip } from '../utils/expiryChip.js'; +import { buildPasswordChip } from '../utils/passwordChip.js'; +import { buildLinkChip } from './linkChip.js'; +import { buildResourceIcon } from './resourceIcon.js'; +import { createUserVignette } from './userVignette.js'; + +/** + * @import {OutgoingResourceItem, OutgoingResourceGrant, FileItem, FolderItem} from '../core/types.js' + * @typedef {'items'|'sharedWith'} ViewMode + * @typedef {'never'|'active'|'soon'|'expired'} ExpiryState + */ + +const SOON_DAYS = 30; + +/** + * @param {string|null|undefined} expiresAt + * @returns {ExpiryState} + */ +function _expiryState(expiresAt) { + if (!expiresAt) return 'never'; + const ms = new Date(expiresAt).getTime() - Date.now(); + if (ms < 0) return 'expired'; + if (ms <= SOON_DAYS * 86_400_000) return 'soon'; + return 'active'; +} + +/** @param {string} role @returns {string} */ +function _roleLabel(role) { + /** @type {Record} */ + const m = { + admin: i18n.t('share.role.canManage', 'Can manage'), + editor: i18n.t('share.role.canEdit', 'Can edit'), + viewer: i18n.t('share.role.canView', 'Can view') + }; + return m[role] ?? role; +} + +/** @param {string} role @returns {'manage'|'edit'|'view'} */ +function _roleMod(role) { + if (role === 'admin') return 'manage'; + if (role === 'editor') return 'edit'; + return 'view'; +} + +class MySharesList { + /** + * @param {HTMLElement} container + * @param {{ + * onResourceOpen: (resource: FileItem|FolderItem, resourceType: string) => void, + * onShareEdit: (resource: FileItem|FolderItem, resourceType: string) => void, + * }} config + */ + constructor(container, config) { + this._container = container; + this._config = config; + /** @type {string|null} */ + this._lastSwimKey = null; + /** @type {HTMLElement|null} */ + this._lastSwimEl = null; + } + + clear() { + this._container.innerHTML = ''; + this._lastSwimKey = null; + this._lastSwimEl = null; + } + + /** + * Full re-render (page 1). + * @param {OutgoingResourceItem[]} items + * @param {ViewMode} viewMode + */ + render(items, viewMode) { + this.clear(); + this._ingest(items, viewMode); + } + + /** + * Cursor append (page 2+). + * @param {OutgoingResourceItem[]} items + * @param {ViewMode} viewMode + */ + append(items, viewMode) { + this._ingest(items, viewMode); + } + + // ── Core ingest ─────────────────────────────────────────────────────────── + + /** + * @param {OutgoingResourceItem[]} items + * @param {ViewMode} viewMode + */ + _ingest(items, viewMode) { + for (const item of items) { + if (viewMode === 'items') { + this._ingestItemsMode(item); + } else { + this._ingestSharedWithMode(item); + } + } + } + + /** + * Items mode — one lane per resource, one grant row per grant. + * @param {OutgoingResourceItem} item + */ + _ingestItemsMode(item) { + const swimKey = `resource:${item.resource.id}`; + const laneBody = this._ensureLane(swimKey, () => this._buildResourceLaneHeader(item)); + for (const grant of item.grants) { + laneBody.appendChild(this._buildGrantRow(grant, item, 'items')); + } + } + + /** + * SharedWith mode — one lane per user or per link bucket, one row per grant. + * @param {OutgoingResourceItem} item + */ + _ingestSharedWithMode(item) { + for (const grant of item.grants) { + let swimKey; + if (grant.subject_type === 'user') { + swimKey = `user:${grant.subject_id}`; + } else if (grant.has_password) { + swimKey = 'links:password'; + } else { + swimKey = 'links:public'; + } + const laneBody = this._ensureLane(swimKey, () => this._buildSubjectLaneHeader(swimKey, grant)); + laneBody.appendChild(this._buildGrantRow(grant, item, 'sharedWith')); + } + } + + // ── Lane management ─────────────────────────────────────────────────────── + + /** + * Return the existing lane body when swimKey matches, else create a new lane. + * @param {string} swimKey + * @param {() => HTMLElement} buildHeader + * @returns {HTMLElement} + */ + _ensureLane(swimKey, buildHeader) { + if (swimKey === this._lastSwimKey && this._lastSwimEl) return this._lastSwimEl; + + const lane = document.createElement('div'); + lane.className = 'ms-lane'; + lane.dataset.swimKey = swimKey; + + const header = document.createElement('div'); + header.className = 'ms-lane__header'; + header.appendChild(buildHeader()); + lane.appendChild(header); + + const body = document.createElement('div'); + body.className = 'ms-lane__body'; + lane.appendChild(body); + + this._container.appendChild(lane); + this._lastSwimKey = swimKey; + this._lastSwimEl = body; + return body; + } + + /** + * Lane header for items mode: resource icon + name link + Edit sharing button. + * @param {OutgoingResourceItem} item + * @returns {HTMLElement} + */ + _buildResourceLaneHeader(item) { + const row = document.createElement('div'); + row.className = 'ms-resource-row'; + + row.appendChild(buildResourceIcon(item.resource, item.resource_type)); + + const nameLink = document.createElement('a'); + nameLink.className = 'ms-resource-row__name'; + nameLink.href = '#'; + nameLink.textContent = item.resource.name; + nameLink.addEventListener('click', (e) => { + e.preventDefault(); + this._config.onResourceOpen(item.resource, item.resource_type); + }); + row.appendChild(nameLink); + + const editBtn = document.createElement('button'); + editBtn.className = 'ms-resource-row__edit button ghost'; + editBtn.innerHTML = ` ${i18n.t('myshares.editSharing', 'Edit sharing')}`; + editBtn.addEventListener('click', () => this._config.onShareEdit(item.resource, item.resource_type)); + row.appendChild(editBtn); + + return row; + } + + /** + * Lane header for sharedWith mode: user vignette or link bucket label. + * @param {string} swimKey + * @param {OutgoingResourceGrant} grant + * @returns {HTMLElement} + */ + _buildSubjectLaneHeader(swimKey, grant) { + if (swimKey.startsWith('user:')) { + return createUserVignette(grant.subject_id, 'list'); + } + const el = document.createElement('div'); + el.className = 'ms-link-lane-label'; + const icon = document.createElement('i'); + if (swimKey === 'links:password') { + icon.className = 'fas fa-lock ms-link-lane-label__icon'; + el.appendChild(icon); + el.appendChild(document.createTextNode(` ${i18n.t('myshares.passwordLinks', 'Password-protected links')}`)); + } else { + icon.className = 'fas fa-link ms-link-lane-label__icon'; + el.appendChild(icon); + el.appendChild(document.createTextNode(` ${i18n.t('myshares.publicLinks', 'Public links')}`)); + } + return el; + } + + // ── Grant row ───────────────────────────────────────────────────────────── + + /** + * One grant row: identity + role pill + expiry chip + ⋯ button. + * @param {OutgoingResourceGrant} grant + * @param {OutgoingResourceItem} item + * @param {ViewMode} viewMode + * @returns {HTMLElement} + */ + _buildGrantRow(grant, item, viewMode) { + const row = document.createElement('div'); + row.className = 'ms-grant-row'; + if (_expiryState(grant.expires_at ?? null) === 'expired') { + row.classList.add('ms-grant-row--expired'); + } + + row.appendChild(this._buildIdentity(grant, item, viewMode)); + row.appendChild(this._buildRolePill(grant.role)); + row.appendChild(this._buildExpiryChip(grant.expires_at ?? null)); + row.appendChild(this._buildKebabBtn(grant, item, row)); + + return row; + } + + /** + * Identity: user vignette or link icon + name; tokens in sharedWith mode add → resource. + * @param {OutgoingResourceGrant} grant + * @param {OutgoingResourceItem} item + * @param {ViewMode} viewMode + * @returns {HTMLElement} + */ + _buildIdentity(grant, item, viewMode) { + const el = document.createElement('div'); + el.className = 'ms-grant-row__identity'; + + if (grant.subject_type === 'user' && viewMode === 'sharedWith') { + // Lane header is already the user — show the resource instead + el.appendChild(buildResourceIcon(item.resource, item.resource_type)); + const nameLink = document.createElement('a'); + nameLink.className = 'ms-identity__resource-name'; + nameLink.href = '#'; + nameLink.textContent = item.resource.name; + nameLink.addEventListener('click', (e) => { + e.preventDefault(); + this._config.onResourceOpen(item.resource, item.resource_type); + }); + el.appendChild(nameLink); + } else if (grant.subject_type === 'user') { + el.appendChild(createUserVignette(grant.subject_id, 'xs')); + } else { + // Token — link chip handles icon + label + copy-on-click + el.appendChild(buildLinkChip(grant)); + + if (viewMode === 'sharedWith') { + const arrow = document.createElement('span'); + arrow.className = 'ms-link-identity__arrow'; + arrow.textContent = '→'; + el.appendChild(arrow); + + const resLink = document.createElement('a'); + resLink.className = 'ms-link-identity__resource'; + resLink.href = '#'; + resLink.appendChild(buildResourceIcon(item.resource, item.resource_type)); + resLink.appendChild(document.createTextNode(` ${item.resource.name}`)); + resLink.addEventListener('click', (e) => { + e.preventDefault(); + this._config.onResourceOpen(item.resource, item.resource_type); + }); + el.appendChild(resLink); + } + } + + return el; + } + + /** @param {string} role @returns {HTMLElement} */ + _buildRolePill(role) { + const pill = document.createElement('span'); + pill.className = `ms-role-pill ms-role-pill--${_roleMod(role)}`; + pill.textContent = _roleLabel(role); + return pill; + } + + /** + * 4-state expiry chip: never / active / soon / expired. + * @param {string|null} expiresAt + * @returns {HTMLElement} + */ + _buildExpiryChip(expiresAt) { + const state = _expiryState(expiresAt); + const chip = document.createElement('span'); + chip.className = `ms-expiry-chip ms-expiry-chip--${state}`; + + const icon = document.createElement('i'); + const text = document.createTextNode(''); + + if (state === 'never') { + icon.className = 'fas fa-infinity'; + chip.appendChild(icon); + chip.appendChild(document.createTextNode(` ${i18n.t('myshares.neverExpires', 'Never expires')}`)); + } else if (state === 'expired') { + icon.className = 'fas fa-exclamation-triangle'; + chip.appendChild(icon); + chip.appendChild(document.createTextNode(` ${i18n.t('myshares.expired', 'Expired')}`)); + } else if (state === 'soon' && expiresAt) { + icon.className = 'fas fa-clock'; + const days = Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86_400_000); + const label = + days <= 1 + ? i18n.t('myshares.expiresTomorrow', 'Expires tomorrow') + : i18n.t('myshares.expiresInDays', 'Expires in {n} days').replace('{n}', String(days)); + chip.appendChild(icon); + chip.appendChild(document.createTextNode(` ${label}`)); + } else if (expiresAt) { + icon.className = 'fas fa-clock'; + const d = new Date(expiresAt); + const fmt = d.toLocaleDateString('default', { day: 'numeric', month: 'short', year: 'numeric' }); + chip.appendChild(icon); + chip.appendChild(document.createTextNode(` ${i18n.t('myshares.until', 'Until')} ${fmt}`)); + } + + // unused ref kept to avoid TS unused-var warning suppression + void text; + return chip; + } + + // ── Kebab menu ──────────────────────────────────────────────────────────── + + /** + * @param {OutgoingResourceGrant} grant + * @param {OutgoingResourceItem} item + * @param {HTMLElement} rowEl + * @returns {HTMLButtonElement} + */ + _buildKebabBtn(grant, item, rowEl) { + const btn = /** @type {HTMLButtonElement} */ (document.createElement('button')); + btn.className = 'ms-kebab-btn ms-btn-icon'; + btn.setAttribute('aria-label', i18n.t('myshares.manageAccess', 'Manage access')); + btn.innerHTML = ''; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this._openGrantMenu(btn, grant, item, rowEl); + }); + return btn; + } + + /** + * Build and show a dynamic context menu positioned below the trigger button. + * @param {HTMLButtonElement} btn + * @param {OutgoingResourceGrant} grant + * @param {OutgoingResourceItem} item + * @param {HTMLElement} rowEl + */ + _openGrantMenu(btn, grant, item, rowEl) { + document.querySelector('.ms-grant-menu')?.remove(); + + const menu = document.createElement('div'); + menu.className = 'context-menu ms-grant-menu'; + + // Current expiry as YYYY-MM-DD (or null) + const initialExpiry = grant.expires_at ? String(grant.expires_at).slice(0, 10) : null; + + if (grant.subject_type === 'user') { + for (const role of /** @type {('admin'|'editor'|'viewer')[]} */ (['admin', 'editor', 'viewer'])) { + const isCurrent = grant.role === role; + const mi = this._menuItem(isCurrent ? 'fas fa-check' : '', _roleLabel(role), false, async () => { + menu.remove(); + if (isCurrent) return; + await grants.updateRole({ + subject: { type: grant.subject_type, id: grant.subject_id }, + resource: { type: item.resource_type, id: item.resource.id }, + role + }); + const pill = rowEl.querySelector('.ms-role-pill'); + if (pill) { + pill.className = `ms-role-pill ms-role-pill--${_roleMod(role)}`; + pill.textContent = _roleLabel(role); + } + grant.role = role; + }); + if (isCurrent) mi.classList.add('ms-menu-item--current'); + menu.appendChild(mi); + } + menu.appendChild(this._menuSeparator()); + menu.appendChild(this._menuExpiryRow(grant, item, rowEl, initialExpiry)); + menu.appendChild(this._menuSeparator()); + menu.appendChild( + this._menuItem('fas fa-user-times', i18n.t('myshares.removeAccess', 'Remove access'), true, async () => { + menu.remove(); + await grants.revokeGrant(grant.grant_id); + this._removeRowAndCleanLane(rowEl); + }) + ); + } else { + menu.appendChild( + this._menuItem('fas fa-copy', i18n.t('myshares.copyLink', 'Copy link'), false, async () => { + menu.remove(); + const share = await fileSharing.getShareById(grant.subject_id); + await fileSharing.copyLinkToClipboard(share.url); + }) + ); + menu.appendChild(this._menuSeparator()); + menu.appendChild(this._menuExpiryRow(grant, item, rowEl, initialExpiry)); + menu.appendChild(this._menuPasswordRow(grant, rowEl)); + menu.appendChild(this._menuSeparator()); + menu.appendChild( + this._menuItem('fas fa-trash', i18n.t('myshares.deleteLink', 'Delete link'), true, async () => { + menu.remove(); + await fileSharing.removeSharedLink(grant.subject_id); + this._removeRowAndCleanLane(rowEl); + }) + ); + } + + document.body.appendChild(menu); + + // Position below the trigger, right-aligned to it, clamped to viewport + const rect = btn.getBoundingClientRect(); + const mw = menu.offsetWidth || 200; + const left = Math.min(rect.right - mw, window.innerWidth - mw - 8); + menu.style.position = 'absolute'; + menu.style.top = `${rect.bottom + window.scrollY + 4}px`; + menu.style.left = `${Math.max(8, left)}px`; + + const close = (/** @type {Event} */ e) => { + if (e.type === 'keydown' && /** @type {KeyboardEvent} */ (e).key !== 'Escape') return; + // Keep menu open when interacting with elements inside it (e.g. the date input) + if (e.type === 'click' && menu.contains(/** @type {Node} */ (e.target))) return; + menu.remove(); + document.removeEventListener('click', close, true); + document.removeEventListener('keydown', close, true); + }; + setTimeout(() => { + document.addEventListener('click', close, true); + document.addEventListener('keydown', close, true); + }, 0); + } + + /** + * Non-closing expiry row embedded in the context menu. + * Uses the shared smd-expiry-chip; saves on blur/Enter. + * @param {OutgoingResourceGrant} grant + * @param {OutgoingResourceItem} item + * @param {HTMLElement} rowEl + * @param {string|null} initialExpiry YYYY-MM-DD or null + * @returns {HTMLElement} + */ + _menuExpiryRow(grant, item, rowEl, initialExpiry) { + const row = document.createElement('div'); + row.className = 'ms-menu-expiry-row'; + + const label = document.createElement('span'); + label.className = 'ms-menu-expiry-label'; + label.textContent = i18n.t('share.expiry', 'Expiry'); + row.appendChild(label); + + const chip = buildExpiryChip(initialExpiry, async (dateStr) => { + const expiresIso = dateStr ? new Date(`${dateStr}T00:00:00Z`).toISOString() : null; + try { + await grants.updateRole({ + subject: { type: grant.subject_type, id: grant.subject_id }, + resource: { type: item.resource_type, id: item.resource.id }, + role: grant.role, + expires_at: expiresIso + }); + grant.expires_at = expiresIso; + // Replace the display chip in the grant row + const displayChip = rowEl.querySelector('.ms-expiry-chip'); + if (displayChip) { + const newChip = this._buildExpiryChip(expiresIso); + displayChip.replaceWith(newChip); + } + } catch (err) { + console.error('mySharesList: setExpiry failed', err); + } + }); + row.appendChild(chip); + + return row; + } + + /** + * Non-closing password row embedded in the link context menu. + * Saves immediately on confirm (blur / Enter). + * @param {OutgoingResourceGrant} grant + * @param {HTMLElement} rowEl + * @returns {HTMLElement} + */ + _menuPasswordRow(grant, rowEl) { + const row = document.createElement('div'); + row.className = 'ms-menu-expiry-row'; + + const label = document.createElement('span'); + label.className = 'ms-menu-expiry-label'; + label.textContent = i18n.t('share.password', 'Password'); + row.appendChild(label); + + const chip = buildPasswordChip(grant.has_password, async (newPassword) => { + try { + await fileSharing.updateSharedLink(grant.subject_id, { + password: newPassword || null + }); + grant.has_password = !!newPassword; + // Update the lock icon on the link chip in the row + const linkChipEl = rowEl.querySelector('.link-chip'); + if (linkChipEl) { + linkChipEl.classList.toggle('link-chip--locked', grant.has_password); + const iconEl = linkChipEl.querySelector('.link-chip__icon'); + if (iconEl) { + iconEl.className = grant.has_password ? 'fas fa-lock link-chip__icon' : 'fas fa-link link-chip__icon'; + } + } + } catch (err) { + console.error('mySharesList: setPassword failed', err); + } + }); + row.appendChild(chip); + + return row; + } + + /** + * @param {string} iconClass + * @param {string} label + * @param {boolean} danger + * @param {() => void} onClick + * @returns {HTMLElement} + */ + _menuItem(iconClass, label, danger, onClick) { + const el = document.createElement('div'); + el.className = danger ? 'context-menu-item context-menu-item-danger' : 'context-menu-item'; + el.setAttribute('role', 'menuitem'); + if (iconClass) { + el.innerHTML = ` `; + } + el.appendChild(document.createTextNode(label)); + el.addEventListener('click', /** @type {EventListener} */ (onClick)); + return el; + } + + /** @returns {HTMLElement} */ + _menuSeparator() { + const el = document.createElement('div'); + el.className = 'context-menu-separator'; + return el; + } + + /** + * Remove the row; if the lane body is now empty, remove the whole lane. + * @param {HTMLElement} rowEl + */ + _removeRowAndCleanLane(rowEl) { + const laneBody = rowEl.closest('.ms-lane__body'); + rowEl.remove(); + if (laneBody instanceof HTMLElement && laneBody.children.length === 0) { + const lane = laneBody.closest('.ms-lane'); + if (lane instanceof HTMLElement) { + if (lane.dataset.swimKey === this._lastSwimKey) { + this._lastSwimKey = null; + this._lastSwimEl = null; + } + lane.remove(); + } + } + } +} + +export { MySharesList }; diff --git a/static/js/components/resourceIcon.js b/static/js/components/resourceIcon.js new file mode 100644 index 00000000..ec0a797d --- /dev/null +++ b/static/js/components/resourceIcon.js @@ -0,0 +1,61 @@ +/** + * resourceIcon — shared resource icon builder. + * + * Returns a `.file-icon` element identical to the one in resourceList: + * • Folders: `.file-icon.folder-icon` with CSS tab (no visible ) + * • Files: `.file-icon.{specialClass}` + optional thumbnail + + * + * CSS lives in fileType.css (folder/file type colours) and resourceList.css + * (base size in grid/list context). Consumer views add their own size overrides. + */ + +import { thumbnail } from '../features/thumbnail.js'; + +/** @import {FileItem, FolderItem} from '../core/types.js' */ + +/** + * @param {FileItem|FolderItem} item + * @param {'file'|'folder'} resourceType + * @returns {HTMLElement} + */ +function buildResourceIcon(item, resourceType) { + const el = document.createElement('div'); + + if (resourceType === 'folder') { + el.className = 'file-icon folder-icon'; + const i = document.createElement('i'); + i.className = 'fas fa-folder'; + el.appendChild(i); + return el; + } + + const file = /** @type {FileItem} */ (item); + const iconClass = file.icon_class || 'fas fa-file'; + const iconSpecialClass = file.icon_special_class || ''; + el.className = `file-icon${iconSpecialClass ? ` ${iconSpecialClass}` : ''}`; + + const canThumbnail = thumbnail?.canHandle(file) ?? false; + if (canThumbnail) { + const img = document.createElement('img'); + img.className = 'file-thumb'; + img.src = `/api/files/${file.id}/thumbnail/icon`; + img.loading = 'lazy'; + img.alt = ''; + img.addEventListener('error', () => { + img.classList.add('hidden'); + thumbnail?.queueGenerate(file, (dataUrl) => { + img.src = dataUrl; + img.classList.remove('hidden'); + }); + }); + el.appendChild(img); + } + + const i = document.createElement('i'); + i.className = iconClass; + el.appendChild(i); + + return el; +} + +export { buildResourceIcon }; diff --git a/static/js/components/resourceList.js b/static/js/components/resourceList.js index a160ac3c..4f96b9cf 100644 --- a/static/js/components/resourceList.js +++ b/static/js/components/resourceList.js @@ -20,8 +20,8 @@ import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; -import { thumbnail } from '../features/thumbnail.js'; import { systemUsers } from '../model/systemUsers.js'; +import { buildResourceIcon } from './resourceIcon.js'; import { createUserVignette } from './userVignette.js'; /** @@ -444,9 +444,7 @@ export class ResourceListComponent { el.innerHTML = ` ${cfg.selectable ? '
' : ''}
-
- -
+
${escapeHtml(folder.name)} ${cfg.showFavorite ? `
` : ''} ${cfg.showShareBadge ? `
` : ''} @@ -461,6 +459,7 @@ export class ResourceListComponent {
`; + el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(folder, 'folder')); this._bindItemEvents(el, folder); return el; } @@ -472,8 +471,6 @@ export class ResourceListComponent { */ _createFileItem(file) { const cfg = this._cfg; - const iconClass = file.icon_class || 'fas fa-file'; - const iconSpecialClass = file.icon_special_class || ''; const cat = file.category || ''; const typeLabel = cat ? i18n.t(`files.file_types.${cat.toLowerCase()}`) || cat : i18n.t('files.file_types.document'); const fileSize = file.size_formatted || formatFileSize(file.size); @@ -481,7 +478,6 @@ export class ResourceListComponent { const formattedDate = formatDateTime(new Date(dateVal)); const isFav = cfg.isFavorite ? cfg.isFavorite(file.id, 'file') : false; const isShared = cfg.isShared ? cfg.isShared(file.id, 'file') : false; - const canThumbnail = thumbnail?.canHandle(file) ?? false; const el = document.createElement('div'); const modClass = cfg.itemModifierClass ? ` ${cfg.itemModifierClass}` : ''; @@ -496,10 +492,7 @@ export class ResourceListComponent { el.innerHTML = ` ${cfg.selectable ? '
' : ''}
-
- ${canThumbnail ? `` : ''} - -
+
${escapeHtml(file.name)} ${cfg.showFavorite ? `
` : ''} ${cfg.showShareBadge ? `
` : ''} @@ -514,18 +507,7 @@ export class ResourceListComponent {
`; - const thumb = /** @type {HTMLImageElement | null} */ (el.querySelector('.file-thumb')); - if (thumb) { - thumb.addEventListener('error', () => { - console.log(`no thumbnail for ${file.id} (${file.name}), request thumbnail generation from client side`); - thumb.classList.add('hidden'); - thumbnail?.queueGenerate(file, (dataUrl) => { - thumb.src = dataUrl; - thumb.classList.remove('hidden'); - }); - }); - } - + el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(file, 'file')); this._bindItemEvents(el, file); return el; } diff --git a/static/js/components/shareModal.js b/static/js/components/shareModal.js index a8a6e33a..dd6635f7 100644 --- a/static/js/components/shareModal.js +++ b/static/js/components/shareModal.js @@ -20,23 +20,13 @@ import { fileSharing } from '../features/sharing/fileSharing.js'; import { addressBook, SYSTEM_BOOK_ID } from '../model/addressBook.js'; import { grants } from '../model/grants.js'; import { systemUsers } from '../model/systemUsers.js'; +import { buildExpiryChip } from '../utils/expiryChip.js'; +import { buildPasswordChip } from '../utils/passwordChip.js'; import { Modal } from './modal.js'; import { createUserVignette } from './userVignette.js'; /** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.js' */ -// ── Helpers ──────────────────────────────────────────────────────────────────── - -/** - * Format a YYYY-MM-DD date string for display ("Dec 31, 2026"). - * @param {string} dateStr - * @returns {string} - */ -function _formatExpiryDate(dateStr) { - const d = new Date(`${dateStr}T00:00:00`); - return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); -} - /** Permissions that belong to each role (must mirror the Rust DTO). */ const ROLE_PERMISSIONS = { viewer: ['read'], @@ -274,9 +264,9 @@ const shareModal = { const roleSelect = document.createElement('select'); roleSelect.className = 'smd-role-select'; for (const [val, label] of [ - ['viewer', i18n.t('share.role.viewer', 'Viewer')], - ['editor', i18n.t('share.role.editor', 'Editor')], - ['admin', i18n.t('share.role.admin', 'Admin')] + ['viewer', i18n.t('share.role.canView', 'Can view')], + ['editor', i18n.t('share.role.canEdit', 'Can edit')], + ['admin', i18n.t('share.role.canManage', 'Can manage')] ]) { const opt = document.createElement('option'); opt.value = val; @@ -505,9 +495,9 @@ const shareModal = { header.className = 'smd-group-header'; const labelMap = { - admin: i18n.t('share.role.admin', 'Admin'), - editor: i18n.t('share.role.editor', 'Editor'), - viewer: i18n.t('share.role.viewer', 'Viewer') + admin: i18n.t('share.role.canManage', 'Can manage'), + editor: i18n.t('share.role.canEdit', 'Can edit'), + viewer: i18n.t('share.role.canView', 'Can view') }; const badge = document.createElement('span'); badge.className = 'smd-group-badge'; @@ -538,9 +528,9 @@ const shareModal = { const roleSelect = document.createElement('select'); roleSelect.className = 'smd-member-role-select'; for (const [val, label] of [ - ['viewer', i18n.t('share.role.viewer', 'Viewer')], - ['editor', i18n.t('share.role.editor', 'Editor')], - ['admin', i18n.t('share.role.admin', 'Admin')] + ['viewer', i18n.t('share.role.canView', 'Can view')], + ['editor', i18n.t('share.role.canEdit', 'Can edit')], + ['admin', i18n.t('share.role.canManage', 'Can manage')] ]) { const opt = document.createElement('option'); opt.value = val; @@ -587,78 +577,12 @@ const shareModal = { // ── Expiry chip toggle ───────────────────────────────────────────────────── /** - * Build a compact expiry chip that toggles to an inline date input on click. - * - * Chip states: - * • "∞ No expiry" — dashed border, faint text (value is null) - * • "⏱ Dec 31, 2026 ×" — solid border, with a clear button (value is set) - * * @param {string|null} initialValue - YYYY-MM-DD or null - * @param {(v: string|null) => void} onChange - called whenever the value changes + * @param {(v: string|null) => void} onChange * @returns {HTMLElement} */ _buildExpiryChip(initialValue, onChange) { - let current = initialValue; - - const wrap = document.createElement('div'); - wrap.className = 'smd-expiry-chip-wrap'; - - const chip = document.createElement('button'); - chip.type = 'button'; - - const dateInput = document.createElement('input'); - dateInput.type = 'date'; - dateInput.className = 'smd-expiry-date-input hidden'; - - const updateChip = () => { - if (current) { - chip.className = 'smd-expiry-chip smd-expiry-chip--set'; - chip.innerHTML = - ` ${_formatExpiryDate(current)}` + - `×`; - chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => { - e.stopPropagation(); - current = null; - onChange(null); - updateChip(); - }); - } else { - chip.className = 'smd-expiry-chip'; - chip.innerHTML = ` ${i18n.t('share.noExpiry', 'No expiry')}`; - } - }; - - chip.addEventListener('click', () => { - chip.classList.add('hidden'); - if (current) dateInput.value = current; - dateInput.classList.remove('hidden'); - dateInput.focus(); - }); - - const confirm = () => { - const val = dateInput.value || null; - current = val; - onChange(val); - dateInput.classList.add('hidden'); - chip.classList.remove('hidden'); - updateChip(); - }; - dateInput.addEventListener('blur', confirm); - dateInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - confirm(); - } - if (e.key === 'Escape') { - dateInput.classList.add('hidden'); - chip.classList.remove('hidden'); - } - }); - - updateChip(); - wrap.appendChild(chip); - wrap.appendChild(dateInput); - return wrap; + return buildExpiryChip(initialValue, onChange); }, /** @@ -667,72 +591,7 @@ const shareModal = { * @returns {HTMLElement} */ _buildPasswordChip(initialHasPassword, onChange) { - let hasPassword = initialHasPassword; - - const wrap = document.createElement('div'); - wrap.className = 'smd-expiry-chip-wrap'; - - const chip = document.createElement('button'); - chip.type = 'button'; - - const pwInput = document.createElement('input'); - pwInput.type = 'password'; - pwInput.className = 'smd-expiry-date-input hidden'; - pwInput.placeholder = i18n.t('dialogs.password', 'Password'); - pwInput.autocomplete = 'new-password'; - - const updateChip = () => { - if (hasPassword) { - chip.className = 'smd-expiry-chip smd-expiry-chip--set'; - chip.innerHTML = - ` ${i18n.t('share.passwordProtected', 'Password')}` + - `×`; - chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => { - e.stopPropagation(); - hasPassword = false; - onChange(''); - updateChip(); - }); - } else { - chip.className = 'smd-expiry-chip'; - chip.innerHTML = ` ${i18n.t('share.noPassword', 'No password')}`; - } - }; - - chip.addEventListener('click', () => { - chip.classList.add('hidden'); - pwInput.value = ''; - pwInput.classList.remove('hidden'); - pwInput.focus(); - }); - - const confirm = () => { - const val = pwInput.value; - pwInput.classList.add('hidden'); - chip.classList.remove('hidden'); - if (val) { - hasPassword = true; - onChange(val); - } - updateChip(); - }; - - pwInput.addEventListener('blur', confirm); - pwInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - confirm(); - } - if (e.key === 'Escape') { - pwInput.classList.add('hidden'); - chip.classList.remove('hidden'); - } - }); - - updateChip(); - wrap.appendChild(chip); - wrap.appendChild(pwInput); - return wrap; + return buildPasswordChip(initialHasPassword, onChange); }, // ── Links section ────────────────────────────────────────────────────────── diff --git a/static/js/core/formatters.js b/static/js/core/formatters.js index 84b069a3..2b328dc1 100644 --- a/static/js/core/formatters.js +++ b/static/js/core/formatters.js @@ -138,6 +138,36 @@ function normalizeDateBucket(value) { return String(date.getFullYear()); } +/** + * Normalize a future expiry value into a human-readable bucket label. + * Buckets (soonest-first): Expired | Tomorrow | In less than 7 days | In less than 30 days | | No expiration + * + * Accepts the same input types as `normalizeDateBucket`. + * + * @param {string | number | Date | null | undefined} value + * @returns {string} + */ +function normalizeExpiryBucket(value) { + if (value === null || value === undefined) { + return i18n.t('expiryBucket.noExpiry', 'No expiration'); + } + /** @type {Date} */ + let date; + if (value instanceof Date) { + date = value; + } else if (typeof value === 'number') { + date = new Date(value < 1e12 ? value * 1000 : value); + } else { + date = new Date(value); + } + const daysUntil = Math.floor((date.getTime() - Date.now()) / 86_400_000); + if (daysUntil < 0) return i18n.t('expiryBucket.expired', 'Expired'); + if (daysUntil <= 1) return i18n.t('expiryBucket.tomorrow', 'Tomorrow'); + if (daysUntil <= 7) return i18n.t('expiryBucket.week', 'In less than 7 days'); + if (daysUntil <= 30) return i18n.t('expiryBucket.month', 'In less than 30 days'); + return String(date.getFullYear()); +} + /** * Maps a file size in bytes to a coarse, human-readable bucket label. * @@ -167,4 +197,15 @@ function sizeBucket(bytes) { return i18n.t('sizeBucket.huge', '> 5 GB'); } -export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isEmailValid, isTextViewable, normalizeDateBucket, sizeBucket }; +export { + escapeHtml, + formatDateShort, + formatDateTime, + formatFileSize, + formatQuotaSize, + isEmailValid, + isTextViewable, + normalizeDateBucket, + normalizeExpiryBucket, + sizeBucket +}; diff --git a/static/js/core/icons.js b/static/js/core/icons.js index 125d945f..a8ca5c3b 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -239,6 +239,10 @@ const OxiIcons = { 576, 'M160 32c-35.3 0-64 28.7-64 64l0 224c0 35.3 28.7 64 64 64l352 0c35.3 0 64-28.7 64-64l0-224c0-35.3-28.7-64-64-64L160 32zM396 138.7l96 144c4.9 7.4 5.4 16.8 1.2 24.6S480.9 320 472 320l-144 0-48 0-80 0c-9.2 0-17.6-5.3-21.6-13.6s-2.9-18.2 2.9-25.4l64-80c4.6-5.7 11.4-9 18.7-9s14.2 3.3 18.7 9l17.3 21.6 56-84C360.5 132 368 128 376 128s15.5 4 20 10.7zM192 128a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zM48 120c0-13.3-10.7-24-24-24S0 106.7 0 120L0 344c0 75.1 60.9 136 136 136l320 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-320 0c-48.6 0-88-39.4-88-88l0-224z' ], + 'infinity': [ + 640, + 'M0 256c0-88.4 71.6-160 160-160 50.4 0 97.8 23.7 128 64l32 42.7 32-42.7c30.2-40.3 77.6-64 128-64 88.4 0 160 71.6 160 160S568.4 416 480 416c-50.4 0-97.8-23.7-128-64l-32-42.7-32 42.7c-30.2 40.3-77.6 64-128 64-88.4 0-160-71.6-160-160zm280 0l-43.2-57.6c-18.1-24.2-46.6-38.4-76.8-38.4-53 0-96 43-96 96s43 96 96 96c30.2 0 58.7-14.2 76.8-38.4L280 256zm80 0l43.2 57.6c18.1 24.2 46.6 38.4 76.8 38.4 53 0 96-43 96-96s-43-96-96-96c-30.2 0-58.7 14.2-76.8 38.4L360 256z' + ], 'info-circle': [ 512, 'M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336l24 0 0-64-24 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l48 0c13.3 0 24 10.7 24 24l0 88 8 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z' diff --git a/static/js/core/types.js b/static/js/core/types.js index 06c84168..4a533d4c 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -81,9 +81,9 @@ /** * @typedef {Object} UpdateShare - * @property {string|null} password - * @property {number|null} expires_at - timestamp - * @property {SharePermissions|null} permissions + * @property {string|null} [password] + * @property {number|null} [expires_at] + * @property {SharePermissions|null} [permissions] */ /** @@ -334,6 +334,35 @@ * @property {string|undefined} [next_cursor] - Absent when the last page is reached. */ +/** + * One (subject, permissions) entry within an outgoing resource item. + * @typedef {Object} OutgoingResourceGrant + * @property {string} grant_id + * @property {'user'|'token'} subject_type + * @property {string} subject_id + * @property {string} subject_display - Username (users) or share name (tokens). + * @property {'viewer'|'editor'|'admin'} role + * @property {string} granted_at - ISO-8601 + * @property {string|null} [expires_at] - ISO-8601 or absent. + * @property {boolean} has_password - True when a token subject has a password set. + */ + +/** + * One item returned by `GET /api/grants/outgoing/resources`. + * @typedef {Object} OutgoingResourceItem + * @property {ResourceTypeEnum} resource_type + * @property {string} first_shared_at - ISO-8601 earliest grant date. + * @property {FileItem|FolderItem} resource - Full resource details. + * @property {OutgoingResourceGrant[]} grants - One entry per (subject, permissions). + */ + +/** + * Response for `GET /api/grants/outgoing/resources`. + * @typedef {Object} OutgoingResourcesResponse + * @property {OutgoingResourceItem[]} items + * @property {string|undefined} [next_cursor] - Absent when the last page is reached. + */ + /** * One item returned by `GET /api/favorites/resources`. * `resource_type` discriminates the shape of `resource`. diff --git a/static/js/features/sharing/fileSharing.js b/static/js/features/sharing/fileSharing.js index fd54d0e8..98117154 100644 --- a/static/js/features/sharing/fileSharing.js +++ b/static/js/features/sharing/fileSharing.js @@ -154,6 +154,20 @@ const fileSharing = { } }, + /** + * Fetch a single share by its UUID and return the full ShareItem. + * Used to resolve a token's URL on demand (lazy fetch on copy-link click). + * @param {string} shareId + * @returns {Promise} + */ + async getShareById(shareId) { + const res = await fetch(`/api/shares/${shareId}`, { + headers: this._headers(false) + }); + if (!res.ok) throw new Error(`getShareById ${shareId}: HTTP ${res.status}`); + return res.json(); + }, + /** * Copy a shared link to clipboard * @param {string} url diff --git a/static/js/model/grants.js b/static/js/model/grants.js index 4eddcbe7..f3b6b7de 100644 --- a/static/js/model/grants.js +++ b/static/js/model/grants.js @@ -1,5 +1,5 @@ /** - * @import {Grant, ResourceTypeEnum, SharedWithMeResponse} from '../core/types.js' + * @import {Grant, ResourceTypeEnum, SharedWithMeResponse, OutgoingResourcesResponse} from '../core/types.js' */ import { getCsrfHeaders } from '../core/csrf.js'; @@ -110,6 +110,32 @@ const grants = { return response.json(); }, + /** + * Fetch a cursor-paginated list of resources the current user has shared + * with others, with full file / folder metadata resolved server-side. + * + * @param {object} [opts] + * @param {number} [opts.limit] - Max items per page (1–200, default 50). + * @param {string} [opts.cursor] - Opaque cursor from a previous call. + * @param {string} [opts.orderBy] - Sort: 'first_shared_at' | 'name' | 'type' | 'subject'. + * @param {boolean} [opts.reverse] - Reverse sort order. + * @returns {Promise} + */ + async fetchMySharesPage({ limit = 50, cursor, orderBy, reverse = false } = {}) { + const params = new URLSearchParams({ limit: String(limit) }); + if (cursor) params.set('cursor', cursor); + if (orderBy) params.set('sort_by', orderBy); + if (reverse) params.set('reverse', 'true'); + + const response = await fetch(`/api/grants/outgoing/resources?${params}`); + + if (!response.ok) { + throw new Error(`Failed to fetch my shares: HTTP ${response.status}`); + } + + return response.json(); + }, + /** * Fetch all grants on a specific resource (for the "Manage sharing" panel). * Refreshes the outgoingGrants cache for this resource. diff --git a/static/js/utils/expiryChip.js b/static/js/utils/expiryChip.js new file mode 100644 index 00000000..248412b6 --- /dev/null +++ b/static/js/utils/expiryChip.js @@ -0,0 +1,93 @@ +/** + * buildExpiryChip — shared compact expiry editor chip. + * + * Chip states: + * • "∞ No expiry" — dashed border, faint text (value is null) + * • "⏱ Dec 31, 2026 ×" — solid border, with a clear button (value is set) + * + * Clicking the chip toggles to an inline . + * CSS classes (.smd-expiry-chip-wrap, .smd-expiry-chip, .smd-expiry-date-input) + * live in shareModal.css. + */ + +import { i18n } from '../core/i18n.js'; + +/** + * Format a YYYY-MM-DD string for display ("Dec 31, 2026"). + * @param {string} dateStr + * @returns {string} + */ +export function formatExpiryDate(dateStr) { + const d = new Date(`${dateStr}T00:00:00`); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +/** + * Build an interactive expiry chip. + * @param {string|null} initialValue YYYY-MM-DD or null + * @param {(v: string|null) => void} onChange called whenever the value changes + * @returns {HTMLElement} + */ +export function buildExpiryChip(initialValue, onChange) { + let current = initialValue; + + const wrap = document.createElement('div'); + wrap.className = 'smd-expiry-chip-wrap'; + + const chip = document.createElement('button'); + chip.type = 'button'; + + const dateInput = document.createElement('input'); + dateInput.type = 'date'; + dateInput.className = 'smd-expiry-date-input hidden'; + + const updateChip = () => { + if (current) { + chip.className = 'smd-expiry-chip smd-expiry-chip--set'; + chip.innerHTML = + ` ${formatExpiryDate(current)}` + + `×`; + chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => { + e.stopPropagation(); + current = null; + onChange(null); + updateChip(); + }); + } else { + chip.className = 'smd-expiry-chip'; + chip.innerHTML = ` ${i18n.t('share.noExpiry', 'No expiry')}`; + } + }; + + chip.addEventListener('click', () => { + chip.classList.add('hidden'); + if (current) dateInput.value = current; + dateInput.classList.remove('hidden'); + dateInput.focus(); + }); + + const confirm = () => { + const val = dateInput.value || null; + current = val; + onChange(val); + dateInput.classList.add('hidden'); + chip.classList.remove('hidden'); + updateChip(); + }; + dateInput.addEventListener('blur', confirm); + dateInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + confirm(); + } + if (e.key === 'Escape') { + dateInput.classList.add('hidden'); + chip.classList.remove('hidden'); + } + }); + + updateChip(); + wrap.appendChild(chip); + wrap.appendChild(dateInput); + return wrap; +} diff --git a/static/js/utils/passwordChip.js b/static/js/utils/passwordChip.js new file mode 100644 index 00000000..f5a8d480 --- /dev/null +++ b/static/js/utils/passwordChip.js @@ -0,0 +1,88 @@ +/** + * buildPasswordChip — shared inline password editor chip. + * + * States: + * • "🔓 No password" — unset (default) + * • "🔒 Password ×" — set; × clears it + * + * Clicking the chip shows a hidden . + * Blur / Enter confirms; Escape cancels. + * CSS classes (.smd-expiry-chip-wrap, .smd-expiry-chip, .smd-expiry-chip--set, + * .smd-expiry-date-input) live in shareModal.css. + */ + +import { i18n } from '../core/i18n.js'; + +/** + * @param {boolean} initialHasPassword + * @param {(v: string) => void} onChange '' = remove, non-empty = set new password + * @returns {HTMLElement} + */ +export function buildPasswordChip(initialHasPassword, onChange) { + let hasPassword = initialHasPassword; + + const wrap = document.createElement('div'); + wrap.className = 'smd-expiry-chip-wrap'; + + const chip = document.createElement('button'); + chip.type = 'button'; + + const pwInput = document.createElement('input'); + pwInput.type = 'password'; + pwInput.className = 'smd-expiry-date-input hidden'; + pwInput.placeholder = i18n.t('dialogs.password', 'Password'); + pwInput.autocomplete = 'new-password'; + + const updateChip = () => { + if (hasPassword) { + chip.className = 'smd-expiry-chip smd-expiry-chip--set'; + chip.innerHTML = + ` ${i18n.t('share.passwordProtected', 'Password')}` + + `×`; + chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => { + e.stopPropagation(); + hasPassword = false; + onChange(''); + updateChip(); + }); + } else { + chip.className = 'smd-expiry-chip'; + chip.innerHTML = ` ${i18n.t('share.noPassword', 'No password')}`; + } + }; + + chip.addEventListener('click', () => { + chip.classList.add('hidden'); + pwInput.value = ''; + pwInput.classList.remove('hidden'); + pwInput.focus(); + }); + + const confirm = () => { + const val = pwInput.value; + pwInput.classList.add('hidden'); + chip.classList.remove('hidden'); + if (val) { + hasPassword = true; + onChange(val); + } + updateChip(); + }; + + pwInput.addEventListener('blur', confirm); + pwInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + confirm(); + } + if (e.key === 'Escape') { + pwInput.classList.add('hidden'); + chip.classList.remove('hidden'); + } + }); + + updateChip(); + wrap.appendChild(chip); + wrap.appendChild(pwInput); + return wrap; +} diff --git a/static/js/views/myShares/mySharesView.js b/static/js/views/myShares/mySharesView.js new file mode 100644 index 00000000..f33ddc79 --- /dev/null +++ b/static/js/views/myShares/mySharesView.js @@ -0,0 +1,235 @@ +/** + * OxiCloud – "My Shares" view. + * + * Renders resources the current user has shared with others, using the + * cursor-paginated `GET /api/grants/outgoing/resources` endpoint. + * + * Group-by modes exposed to the navigation toolbar: + * 'items' — one card per resource (sort_by=type) [default / None] + * 'sharedWith' — swimlanes per subject (sort_by=subject) + * + * The "None" option (key='') from the toolbar maps to the default 'items' mode. + */ + +import { ui } from '../../app/ui.js'; +import { MySharesList } from '../../components/mySharesList.js'; +import { shareModal } from '../../components/shareModal.js'; +import { i18n } from '../../core/i18n.js'; +import * as viewPrefs from '../../core/viewPrefs.js'; +import * as itemTooltip from '../../features/itemTooltip.js'; +import { grants } from '../../model/grants.js'; + +/** @import {FileItem, FolderItem} from '../../core/types.js' */ + +/** + * @typedef {{ key: string, label: string, orderBy: string }} GroupByDef + * @typedef {'items'|'sharedWith'} ViewMode + */ + +/** + * @type {{ [key: string]: { orderBy: string, viewMode: ViewMode } }} + */ +const MODE_MAP = { + '': { orderBy: 'type', viewMode: 'items' }, + items: { orderBy: 'type', viewMode: 'items' }, + sharedWith: { orderBy: 'subject', viewMode: 'sharedWith' } +}; + +/** @type {GroupByDef[]} */ +const GROUP_BY_DEFS = [ + { + key: '', + get label() { + return i18n.t('groupby.byFiles', 'By files'); + }, + orderBy: 'type' + }, + { + key: 'sharedWith', + get label() { + return i18n.t('groupby.sharedWith', 'Shared with'); + }, + orderBy: 'subject' + } +]; + +/** ID of the "Load more" wrapper injected below `.files-container`. */ +const LOAD_MORE_ID = 'ms-load-more-wrapper'; + +const mySharesView = { + // ── State ───────────────────────────────────────────────────────────────── + + /** @type {string|null} */ + _nextCursor: null, + + _loading: false, + + /** @type {MySharesList|null} */ + _component: null, + + /** @type {string} */ + _groupBy: '', + + /** @type {boolean} */ + _reversed: false, + + // ── Public API ──────────────────────────────────────────────────────────── + + /** @returns {GroupByDef[]} */ + get groupByDefs() { + return GROUP_BY_DEFS; + }, + + /** + * Change the active group-by dimension and reload from page 1. + * Called by navigation.js when the user picks a pill. + * Empty string '' maps to the default items mode. + * @param {string} key + */ + setGroupBy(key) { + if (this._groupBy === key) return; + this._groupBy = key; + viewPrefs.save('shared', this._groupBy, this._reversed, viewPrefs.load('shared').view); + this._nextCursor = null; + this._component?.clear(); + this._loadPage(); + }, + + /** + * Flip sort direction and reload from page 1. + * @param {boolean} reversed + */ + setDirection(reversed) { + if (this._reversed === reversed) return; + this._reversed = reversed; + viewPrefs.save('shared', this._groupBy, this._reversed, viewPrefs.load('shared').view); + this._nextCursor = null; + this._component?.clear(); + this._loadPage(); + }, + + async init() { + this._nextCursor = null; + this._loading = false; + const saved = viewPrefs.load('shared'); + this._groupBy = saved.groupBy || ''; + this._reversed = saved.reversed; + + this._ensureLoadMoreButton(); + + ui.resetFilesList(); + ui.updateBreadcrumb(); + + const filesList = document.getElementById('files-list'); + if (filesList) { + if (!this._component) { + this._component = new MySharesList(filesList, { + onResourceOpen: (resource, resourceType) => { + if (resourceType === 'folder') { + ui.openItem(resource); + } else { + // File: navigate to the parent folder in Files section. + const file = /** @type {FileItem} */ (resource); + const parts = (file.path || '').split('/').filter(Boolean); + const parentName = parts.length >= 2 ? parts[parts.length - 2] : ''; + ui.openItem(/** @type {FolderItem} */ ({ id: file.folder_id, name: parentName })); + } + }, + onShareEdit: (resource, resourceType) => { + shareModal.open(resource, /** @type {'file'|'folder'} */ (resourceType)); + } + }); + } + } + + await this._loadPage(); + }, + + hide() { + const w = document.getElementById(LOAD_MORE_ID); + if (w) w.classList.add('hidden'); + const filesList = document.getElementById('files-list'); + if (filesList) itemTooltip.destroy(filesList); + }, + + // ── Internal helpers ────────────────────────────────────────────────────── + + async _loadPage() { + if (this._loading) return; + this._loading = true; + + const isFirstPage = this._nextCursor === null; + + try { + const mode = MODE_MAP[this._groupBy] ?? MODE_MAP['']; + + const data = await grants.fetchMySharesPage({ + limit: 50, + cursor: this._nextCursor ?? undefined, + orderBy: mode.orderBy, + reverse: this._reversed + }); + + this._nextCursor = data.next_cursor ?? null; + + if (data.items.length === 0 && isFirstPage) { + ui.showError(` + +

${i18n.t('myshares.emptyStateTitle', "You haven't shared anything yet")}

+

${i18n.t('myshares.emptyStateDesc', 'Items you share with others will appear here')}

+ `); + this._setLoadMoreVisible(false); + return; + } + + if (isFirstPage) { + this._component?.render(data.items, mode.viewMode); + } else { + this._component?.append(data.items, mode.viewMode); + } + + const filesList = document.getElementById('files-list'); + if (filesList) itemTooltip.init(filesList); + + this._setLoadMoreVisible(!!this._nextCursor); + } catch (err) { + ui.showError(` + +

${i18n.t('errors_loadFailed', 'Failed to load items')}

+ `); + console.error('mySharesView: load error', err); + } finally { + this._loading = false; + } + }, + + // ── "Load more" button ──────────────────────────────────────────────────── + + _ensureLoadMoreButton() { + if (document.getElementById(LOAD_MORE_ID)) return; + + const filesContainer = document.querySelector('.files-container'); + if (!filesContainer) return; + + const wrapper = document.createElement('div'); + wrapper.id = LOAD_MORE_ID; + wrapper.className = 'ms-load-more-wrapper hidden'; + + const btn = document.createElement('button'); + btn.id = 'ms-load-more'; + btn.className = 'button secondary'; + btn.textContent = i18n.t('myshares.loadMore', 'Load more'); + btn.addEventListener('click', () => this._loadPage()); + + wrapper.appendChild(btn); + filesContainer.after(wrapper); + }, + + /** @param {boolean} visible */ + _setLoadMoreVisible(visible) { + const w = document.getElementById(LOAD_MORE_ID); + if (w) w.classList.toggle('hidden', !visible); + } +}; + +export { mySharesView }; diff --git a/static/locales/ar.json b/static/locales/ar.json index 7a433772..3c374b4c 100644 --- a/static/locales/ar.json +++ b/static/locales/ar.json @@ -723,11 +723,13 @@ "modifiedAt": "تاريخ التعديل", "createdAt": "تاريخ الإنشاء", "size": "الحجم", - "favoriteDate": "تاريخ المفضلة" + "favoriteDate": "تاريخ المفضلة", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "اليوم", "last7days": "آخر 7 أيام", "last30days": "آخر 30 يومًا" } -} +} \ No newline at end of file diff --git a/static/locales/de.json b/static/locales/de.json index afee44b1..eed46b8f 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -723,11 +723,13 @@ "modifiedAt": "Änderungsdatum", "createdAt": "Erstellungsdatum", "size": "Größe", - "favoriteDate": "Datum der Markierung" + "favoriteDate": "Datum der Markierung", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Heute", "last7days": "Letzte 7 Tage", "last30days": "Letzte 30 Tage" } -} +} \ No newline at end of file diff --git a/static/locales/en.json b/static/locales/en.json index e1889828..5e1fa75c 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -714,6 +714,8 @@ }, "groupby": { "none": "None", + "byFiles": "By files", + "sharedWith": "Shared with", "title": "Group by", "type": "Type", "type.folders": "Folders", diff --git a/static/locales/es.json b/static/locales/es.json index 9a29e8ae..4630de55 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -723,11 +723,13 @@ "modifiedAt": "Fecha de modificación", "createdAt": "Fecha de creación", "size": "Tamaño", - "favoriteDate": "Fecha de favorito" + "favoriteDate": "Fecha de favorito", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Hoy", "last7days": "Últimos 7 días", "last30days": "Últimos 30 días" } -} +} \ No newline at end of file diff --git a/static/locales/fa.json b/static/locales/fa.json index 83c636ef..0f1b9dea 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -723,11 +723,13 @@ "modifiedAt": "تاریخ تغییر", "createdAt": "تاریخ ایجاد", "size": "اندازه", - "favoriteDate": "تاریخ مورد علاقه" + "favoriteDate": "تاریخ مورد علاقه", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "امروز", "last7days": "۷ روز گذشته", "last30days": "۳۰ روز گذشته" } -} +} \ No newline at end of file diff --git a/static/locales/fr.json b/static/locales/fr.json index 51bf3a90..997951b0 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -723,11 +723,13 @@ "accessedAt": "Date d'accès", "modifiedAt": "Date de modification", "createdAt": "Date de création", - "size": "Taille" + "size": "Taille", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Aujourd'hui", "last7days": "7 derniers jours", "last30days": "30 derniers jours" } -} +} \ No newline at end of file diff --git a/static/locales/hi.json b/static/locales/hi.json index fb827113..bb071227 100644 --- a/static/locales/hi.json +++ b/static/locales/hi.json @@ -723,11 +723,13 @@ "modifiedAt": "संशोधन की तारीख", "createdAt": "बनाने की तारीख", "size": "आकार", - "favoriteDate": "पसंदीदा की तारीख" + "favoriteDate": "पसंदीदा की तारीख", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "आज", "last7days": "पिछले 7 दिन", "last30days": "पिछले 30 दिन" } -} +} \ No newline at end of file diff --git a/static/locales/it.json b/static/locales/it.json index bf8eb86f..d4fd3c28 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -723,11 +723,13 @@ "modifiedAt": "Data di modifica", "createdAt": "Data di creazione", "size": "Dimensione", - "favoriteDate": "Data preferito" + "favoriteDate": "Data preferito", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Oggi", "last7days": "Ultimi 7 giorni", "last30days": "Ultimi 30 giorni" } -} +} \ No newline at end of file diff --git a/static/locales/ja.json b/static/locales/ja.json index 720eb836..6a3d54db 100644 --- a/static/locales/ja.json +++ b/static/locales/ja.json @@ -723,11 +723,13 @@ "modifiedAt": "更新日", "createdAt": "作成日", "size": "サイズ", - "favoriteDate": "お気に入り登録日" + "favoriteDate": "お気に入り登録日", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "今日", "last7days": "過去7日間", "last30days": "過去30日間" } -} +} \ No newline at end of file diff --git a/static/locales/ko.json b/static/locales/ko.json index a4428fbd..fd0b63b3 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -723,11 +723,13 @@ "modifiedAt": "수정 날짜", "createdAt": "생성 날짜", "size": "크기", - "favoriteDate": "즐겨찾기 날짜" + "favoriteDate": "즐겨찾기 날짜", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "오늘", "last7days": "최근 7일", "last30days": "최근 30일" } -} +} \ No newline at end of file diff --git a/static/locales/nl.json b/static/locales/nl.json index 4b4f3c72..6dce12fd 100644 --- a/static/locales/nl.json +++ b/static/locales/nl.json @@ -723,11 +723,13 @@ "modifiedAt": "Wijzigingsdatum", "createdAt": "Aanmaakdatum", "size": "Grootte", - "favoriteDate": "Favoritendatum" + "favoriteDate": "Favoritendatum", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Vandaag", "last7days": "Afgelopen 7 dagen", "last30days": "Afgelopen 30 dagen" } -} +} \ No newline at end of file diff --git a/static/locales/pl.json b/static/locales/pl.json index 724125f4..1123ab46 100644 --- a/static/locales/pl.json +++ b/static/locales/pl.json @@ -723,11 +723,13 @@ "modifiedAt": "Data modyfikacji", "createdAt": "Data utworzenia", "size": "Rozmiar", - "favoriteDate": "Data dodania do ulubionych" + "favoriteDate": "Data dodania do ulubionych", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Dzisiaj", "last7days": "Ostatnie 7 dni", "last30days": "Ostatnie 30 dni" } -} +} \ No newline at end of file diff --git a/static/locales/pt.json b/static/locales/pt.json index 63cada28..abf41dfe 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -723,11 +723,13 @@ "modifiedAt": "Data de modificação", "createdAt": "Data de criação", "size": "Tamanho", - "favoriteDate": "Data de favorito" + "favoriteDate": "Data de favorito", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Hoje", "last7days": "Últimos 7 dias", "last30days": "Últimos 30 dias" } -} +} \ No newline at end of file diff --git a/static/locales/ru.json b/static/locales/ru.json index 1232cdf4..f5107676 100644 --- a/static/locales/ru.json +++ b/static/locales/ru.json @@ -723,11 +723,13 @@ "modifiedAt": "Дата изменения", "createdAt": "Дата создания", "size": "Размер", - "favoriteDate": "Дата добавления в избранное" + "favoriteDate": "Дата добавления в избранное", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "Сегодня", "last7days": "Последние 7 дней", "last30days": "Последние 30 дней" } -} +} \ No newline at end of file diff --git a/static/locales/zh-TW.json b/static/locales/zh-TW.json index 7e053115..c4bf8c6d 100644 --- a/static/locales/zh-TW.json +++ b/static/locales/zh-TW.json @@ -723,11 +723,13 @@ "modifiedAt": "修改日期", "createdAt": "建立日期", "size": "大小", - "favoriteDate": "收藏日期" + "favoriteDate": "收藏日期", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "今天", "last7days": "近7天", "last30days": "近30天" } -} +} \ No newline at end of file diff --git a/static/locales/zh.json b/static/locales/zh.json index 81a0ab32..4edc74d8 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -723,11 +723,13 @@ "modifiedAt": "修改日期", "createdAt": "创建日期", "size": "大小", - "favoriteDate": "收藏日期" + "favoriteDate": "收藏日期", + "byFiles": "By files", + "sharedWith": "Shared with" }, "dateBucket": { "today": "今天", "last7days": "近7天", "last30days": "近30天" } -} +} \ No newline at end of file diff --git a/static/sw.js b/static/sw.js index 6bb430c5..ef92a431 100644 --- a/static/sw.js +++ b/static/sw.js @@ -1,6 +1,6 @@ // OxiCloud Service Worker // FIXME: generate cache name according build ? -const CACHE_NAME = 'oxicloud-cache-v21'; +const CACHE_NAME = 'oxicloud-cache-v22'; // Only cache static assets — NOT HTML files. // HTML files are served network-first so browsers always get the latest