feat(myshares): new version of myshares sections, supporting grants Users + Tokens
This commit is contained in:
@@ -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<SharedWithMeItemDto>;
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 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<chrono::Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
/// 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<chrono::Utc>,
|
||||
/// Full resource details. Shape is determined by `resource_type`.
|
||||
pub resource: ResourceContentDto,
|
||||
/// One entry per (subject, permissions) pair.
|
||||
pub grants: Vec<OutgoingResourceGrantDto>,
|
||||
}
|
||||
|
||||
/// Response for `GET /api/grants/outgoing/resources`.
|
||||
pub type MySharesDto = CursorListResponse<OutgoingResourceItemDto>;
|
||||
|
||||
@@ -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<Vec<Grant>, 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<GrantCursor>,
|
||||
sort_by: &str,
|
||||
reverse: bool,
|
||||
) -> Result<(Vec<OutgoingResourceSummary>, Option<GrantCursor>), 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(
|
||||
|
||||
@@ -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<Permission>,
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
/// 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<chrono::Utc>,
|
||||
/// One entry per (subject, permissions) pair.
|
||||
pub grants: Vec<OutgoingGrantEntry>,
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// GrantCursor — opaque pagination cursor for list_incoming_resources_paged
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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<GrantCursor>,
|
||||
sort_by: &str,
|
||||
reverse: bool,
|
||||
) -> Result<(Vec<OutgoingResourceSummary>, Option<GrantCursor>), 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<Utc> — 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<Utc> — this (subject, perm) row
|
||||
// 8 expires_at Option<DateTime<Utc>>
|
||||
// 9 permission String
|
||||
// 10 sort_str Option<String>
|
||||
// 11 sort_int Option<i64>
|
||||
// 12 has_password bool — token: shares.password_hash IS NOT NULL
|
||||
type Row = (
|
||||
String,
|
||||
Uuid,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
String,
|
||||
Uuid,
|
||||
String,
|
||||
Uuid,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
Option<chrono::DateTime<chrono::Utc>>,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
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<Row> = 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<OutgoingResourceSummary> = 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<Uuid> = Vec::new();
|
||||
let mut seen_set: std::collections::HashSet<Uuid> = 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<Uuid> = 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<chrono::Utc>,
|
||||
std::collections::HashMap<Uuid, OutgoingGrantEntry>,
|
||||
);
|
||||
let mut resource_map: std::collections::HashMap<Uuid, ResourceEntry> =
|
||||
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<OutgoingResourceSummary> = seen_resources
|
||||
.into_iter()
|
||||
.filter_map(|rid| {
|
||||
let (resource_type, first_shared_at, subj_map) = resource_map.remove(&rid)?;
|
||||
let mut grants: Vec<OutgoingGrantEntry> = 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<Vec<Grant>, 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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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<GrantDto> = 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<AppStateRef>,
|
||||
auth_user: AuthUser,
|
||||
Query(q): Query<SharedWithMeQuery>,
|
||||
) -> 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::<GrantCursor>()
|
||||
.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<String> = file_summaries
|
||||
.iter()
|
||||
.map(|s| s.resource_id.to_string())
|
||||
.collect();
|
||||
let folder_ids: Vec<String> = 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<OutgoingResourceItemDto> = Vec::with_capacity(summaries.len());
|
||||
|
||||
for summary in &summaries {
|
||||
let grants: Vec<OutgoingResourceGrantDto> = 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)]
|
||||
|
||||
@@ -325,6 +325,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
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())
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
<link rel="stylesheet" href="/css/views/recent.css">
|
||||
<link rel="stylesheet" href="/css/views/shared.css">
|
||||
<link rel="stylesheet" href="/css/views/sharedWithMe.css">
|
||||
<link rel="stylesheet" href="/css/views/mySharesView.css">
|
||||
<link rel="stylesheet" href="/css/views/trash.css">
|
||||
<link rel="stylesheet" href="/css/views/photos.css">
|
||||
<link rel="stylesheet" href="/css/views/photosLightbox.css">
|
||||
|
||||
+26
-2
@@ -162,12 +162,30 @@ const ACTIONS_BAR_TEMPLATES = {
|
||||
<div class="action-buttons" id="default-buttons"></div>
|
||||
${_batchToolbarButons}
|
||||
${_toggleButtons}
|
||||
`,
|
||||
shared: `
|
||||
<div class="action-buttons" id="default-buttons"></div>
|
||||
<div class="view-toggle">
|
||||
<div class="group-by-selector hidden" id="group-by-selector">
|
||||
<button class="toggle-btn group-by-btn" id="group-by-btn"
|
||||
title="Group by" data-i18n-title="groupby.title">
|
||||
<i class="fas fa-layer-group"></i>
|
||||
<span class="group-by-label"></span>
|
||||
</button>
|
||||
<button class="toggle-btn sort-dir-btn" id="sort-dir-btn"
|
||||
title="Sort direction" data-i18n-title="sortdir.title">
|
||||
<i class="fas fa-arrow-up" id="sort-dir-icon"></i>
|
||||
</button>
|
||||
<div class="group-by-menu hidden" id="group-by-menu"></div>
|
||||
</div>
|
||||
<span class="view-toggle-separator hidden" id="group-by-separator"></span>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @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 = `<button class="group-by-option active" data-group-by="">${escapeHtml(i18n.t('groupby.none', 'None'))}</button>`;
|
||||
// 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 = `<button class="group-by-option active" data-group-by="">${escapeHtml(noneLabel)}</button>`;
|
||||
for (const def of defs) {
|
||||
if (def.key === '') continue;
|
||||
menu.insertAdjacentHTML('beforeend', `<button class="group-by-option" data-group-by="${escapeHtml(def.key)}">${escapeHtml(def.label)}</button>`);
|
||||
}
|
||||
|
||||
@@ -640,9 +662,11 @@ function setupEventListeners() {
|
||||
} else {
|
||||
// change is from history, data provided in event
|
||||
switchSectionTo(e.state.section);
|
||||
if (e.state.section === 'files') {
|
||||
app.currentPath = e.state.id;
|
||||
loadFiles({ insertHistory: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Mobile search toggle
|
||||
|
||||
+21
-13
@@ -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() {
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
@@ -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<string,string>} */
|
||||
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 = `<i class="fas fa-pencil-alt"></i> ${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 = '<i class="fas fa-ellipsis-v"></i>';
|
||||
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 = `<i class="${iconClass}"></i> `;
|
||||
}
|
||||
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 };
|
||||
@@ -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 <i>)
|
||||
* • Files: `.file-icon.{specialClass}` + optional thumbnail <img> + <i>
|
||||
*
|
||||
* 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 };
|
||||
@@ -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 ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
|
||||
<div class="name-cell">
|
||||
<div class="file-icon folder-icon">
|
||||
<i class="fas fa-folder"></i>
|
||||
</div>
|
||||
<div class="resource-icon-slot"></div>
|
||||
<span>${escapeHtml(folder.name)}</span>
|
||||
${cfg.showFavorite ? `<div class="file-badge file-badge-favorite${isFav ? '' : ' hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>` : ''}
|
||||
${cfg.showShareBadge ? `<div class="file-badge file-badge-shared${isShared ? '' : ' hidden'}"><i class="fas fa-oxiexport"></i></div>` : ''}
|
||||
@@ -461,6 +459,7 @@ export class ResourceListComponent {
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
|
||||
<div class="name-cell">
|
||||
<div class="file-icon ${iconSpecialClass}">
|
||||
${canThumbnail ? `<img class="file-thumb" src="/api/files/${file.id}/thumbnail/icon" loading="lazy" alt="">` : ''}
|
||||
<i class="${iconClass}"></i>
|
||||
</div>
|
||||
<div class="resource-icon-slot"></div>
|
||||
<span>${escapeHtml(file.name)}</span>
|
||||
${cfg.showFavorite ? `<div class="file-badge file-badge-favorite${isFav ? '' : ' hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>` : ''}
|
||||
${cfg.showShareBadge ? `<div class="file-badge file-badge-shared${isShared ? '' : ' hidden'}"><i class="fas fa-oxiexport"></i></div>` : ''}
|
||||
@@ -514,18 +507,7 @@ export class ResourceListComponent {
|
||||
</div>
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
`<i class="fas fa-clock"></i> ${_formatExpiryDate(current)}` +
|
||||
`<span class="smd-expiry-chip-clear" title="${i18n.t('actions.clear', 'Clear')}">×</span>`;
|
||||
chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
current = null;
|
||||
onChange(null);
|
||||
updateChip();
|
||||
});
|
||||
} else {
|
||||
chip.className = 'smd-expiry-chip';
|
||||
chip.innerHTML = `<i class="fas fa-infinity"></i> ${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 =
|
||||
`<i class="fas fa-lock"></i> ${i18n.t('share.passwordProtected', 'Password')}` +
|
||||
`<span class="smd-expiry-chip-clear" title="${i18n.t('actions.clear', 'Clear')}">×</span>`;
|
||||
chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
hasPassword = false;
|
||||
onChange('');
|
||||
updateChip();
|
||||
});
|
||||
} else {
|
||||
chip.className = 'smd-expiry-chip';
|
||||
chip.innerHTML = `<i class="fas fa-lock-open"></i> ${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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 | <YYYY> | 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
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
|
||||
+32
-3
@@ -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`.
|
||||
|
||||
@@ -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<import('../../core/types.js').ShareItem>}
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -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<OutgoingResourcesResponse>}
|
||||
*/
|
||||
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.
|
||||
|
||||
@@ -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 <input type="date">.
|
||||
* 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 =
|
||||
`<i class="fas fa-clock"></i> ${formatExpiryDate(current)}` +
|
||||
`<span class="smd-expiry-chip-clear" title="${i18n.t('actions.clear', 'Clear')}">×</span>`;
|
||||
chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
current = null;
|
||||
onChange(null);
|
||||
updateChip();
|
||||
});
|
||||
} else {
|
||||
chip.className = 'smd-expiry-chip';
|
||||
chip.innerHTML = `<i class="fas fa-infinity"></i> ${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;
|
||||
}
|
||||
@@ -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 <input type="password">.
|
||||
* 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 =
|
||||
`<i class="fas fa-lock"></i> ${i18n.t('share.passwordProtected', 'Password')}` +
|
||||
`<span class="smd-expiry-chip-clear" title="${i18n.t('actions.clear', 'Clear')}">×</span>`;
|
||||
chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
hasPassword = false;
|
||||
onChange('');
|
||||
updateChip();
|
||||
});
|
||||
} else {
|
||||
chip.className = 'smd-expiry-chip';
|
||||
chip.innerHTML = `<i class="fas fa-lock-open"></i> ${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;
|
||||
}
|
||||
@@ -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(`
|
||||
<i class="fas fa-share-alt empty-state-icon"></i>
|
||||
<p>${i18n.t('myshares.emptyStateTitle', "You haven't shared anything yet")}</p>
|
||||
<p>${i18n.t('myshares.emptyStateDesc', 'Items you share with others will appear here')}</p>
|
||||
`);
|
||||
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(`
|
||||
<i class="fas fa-exclamation-circle empty-state-icon error"></i>
|
||||
<p>${i18n.t('errors_loadFailed', 'Failed to load items')}</p>
|
||||
`);
|
||||
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 };
|
||||
@@ -723,7 +723,9 @@
|
||||
"modifiedAt": "تاريخ التعديل",
|
||||
"createdAt": "تاريخ الإنشاء",
|
||||
"size": "الحجم",
|
||||
"favoriteDate": "تاريخ المفضلة"
|
||||
"favoriteDate": "تاريخ المفضلة",
|
||||
"byFiles": "By files",
|
||||
"sharedWith": "Shared with"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "اليوم",
|
||||
|
||||
@@ -723,7 +723,9 @@
|
||||
"modifiedAt": "Änderungsdatum",
|
||||
"createdAt": "Erstellungsdatum",
|
||||
"size": "Größe",
|
||||
"favoriteDate": "Datum der Markierung"
|
||||
"favoriteDate": "Datum der Markierung",
|
||||
"byFiles": "By files",
|
||||
"sharedWith": "Shared with"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "Heute",
|
||||
|
||||
@@ -714,6 +714,8 @@
|
||||
},
|
||||
"groupby": {
|
||||
"none": "None",
|
||||
"byFiles": "By files",
|
||||
"sharedWith": "Shared with",
|
||||
"title": "Group by",
|
||||
"type": "Type",
|
||||
"type.folders": "Folders",
|
||||
|
||||
@@ -723,7 +723,9 @@
|
||||
"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",
|
||||
|
||||
@@ -723,7 +723,9 @@
|
||||
"modifiedAt": "تاریخ تغییر",
|
||||
"createdAt": "تاریخ ایجاد",
|
||||
"size": "اندازه",
|
||||
"favoriteDate": "تاریخ مورد علاقه"
|
||||
"favoriteDate": "تاریخ مورد علاقه",
|
||||
"byFiles": "By files",
|
||||
"sharedWith": "Shared with"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "امروز",
|
||||
|
||||
@@ -723,7 +723,9 @@
|
||||
"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",
|
||||
|
||||
@@ -723,7 +723,9 @@
|
||||
"modifiedAt": "संशोधन की तारीख",
|
||||
"createdAt": "बनाने की तारीख",
|
||||
"size": "आकार",
|
||||
"favoriteDate": "पसंदीदा की तारीख"
|
||||
"favoriteDate": "पसंदीदा की तारीख",
|
||||
"byFiles": "By files",
|
||||
"sharedWith": "Shared with"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "आज",
|
||||
|
||||
@@ -723,7 +723,9 @@
|
||||
"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",
|
||||
|
||||
@@ -723,7 +723,9 @@
|
||||
"modifiedAt": "更新日",
|
||||
"createdAt": "作成日",
|
||||
"size": "サイズ",
|
||||
"favoriteDate": "お気に入り登録日"
|
||||
"favoriteDate": "お気に入り登録日",
|
||||
"byFiles": "By files",
|
||||
"sharedWith": "Shared with"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "今日",
|
||||
|
||||
@@ -723,7 +723,9 @@
|
||||
"modifiedAt": "수정 날짜",
|
||||
"createdAt": "생성 날짜",
|
||||
"size": "크기",
|
||||
"favoriteDate": "즐겨찾기 날짜"
|
||||
"favoriteDate": "즐겨찾기 날짜",
|
||||
"byFiles": "By files",
|
||||
"sharedWith": "Shared with"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "오늘",
|
||||
|
||||
@@ -723,7 +723,9 @@
|
||||
"modifiedAt": "Wijzigingsdatum",
|
||||
"createdAt": "Aanmaakdatum",
|
||||
"size": "Grootte",
|
||||
"favoriteDate": "Favoritendatum"
|
||||
"favoriteDate": "Favoritendatum",
|
||||
"byFiles": "By files",
|
||||
"sharedWith": "Shared with"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "Vandaag",
|
||||
|
||||
@@ -723,7 +723,9 @@
|
||||
"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",
|
||||
|
||||
@@ -723,7 +723,9 @@
|
||||
"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",
|
||||
|
||||
@@ -723,7 +723,9 @@
|
||||
"modifiedAt": "Дата изменения",
|
||||
"createdAt": "Дата создания",
|
||||
"size": "Размер",
|
||||
"favoriteDate": "Дата добавления в избранное"
|
||||
"favoriteDate": "Дата добавления в избранное",
|
||||
"byFiles": "By files",
|
||||
"sharedWith": "Shared with"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "Сегодня",
|
||||
|
||||
@@ -723,7 +723,9 @@
|
||||
"modifiedAt": "修改日期",
|
||||
"createdAt": "建立日期",
|
||||
"size": "大小",
|
||||
"favoriteDate": "收藏日期"
|
||||
"favoriteDate": "收藏日期",
|
||||
"byFiles": "By files",
|
||||
"sharedWith": "Shared with"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "今天",
|
||||
|
||||
@@ -723,7 +723,9 @@
|
||||
"modifiedAt": "修改日期",
|
||||
"createdAt": "创建日期",
|
||||
"size": "大小",
|
||||
"favoriteDate": "收藏日期"
|
||||
"favoriteDate": "收藏日期",
|
||||
"byFiles": "By files",
|
||||
"sharedWith": "Shared with"
|
||||
},
|
||||
"dateBucket": {
|
||||
"today": "今天",
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user