Merge pull request #390 from EdouardVanbelle/feat/shared-with-me
This commit is contained in:
@@ -54,6 +54,9 @@ db-down:
|
||||
front-dev:
|
||||
PROFILE=dev cargo run
|
||||
|
||||
# front: check all (linter, format, type, ...)
|
||||
front-check: front-fmt front-lint front-type front-rules
|
||||
|
||||
front-fmt:
|
||||
biome format static/
|
||||
|
||||
|
||||
@@ -51,6 +51,11 @@ pub struct FavoriteItemDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub item_path: Option<String>,
|
||||
|
||||
/// UUID of the file/folder's actual owner (may differ from `user_id` when
|
||||
/// the item was shared and then favourited by another user).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub owner_id: Option<String>,
|
||||
|
||||
// ── Pre-computed display fields ──
|
||||
/// FontAwesome icon CSS class (e.g. "fas fa-file-image", "fas fa-folder")
|
||||
pub icon_class: String,
|
||||
|
||||
@@ -119,6 +119,21 @@ impl From<FileDto> for File {
|
||||
}
|
||||
|
||||
impl FileDto {
|
||||
/// Returns a copy of this DTO with the `path` field cleared.
|
||||
///
|
||||
/// Used when a file is returned to a share recipient: `path` reveals the
|
||||
/// full folder hierarchy above the file which the recipient may not have
|
||||
/// access to. `folder_id` and `owner_id` are intentionally kept — the
|
||||
/// former is needed for sub-folder navigation (covered by the cascade
|
||||
/// grant), and the latter is harmless metadata.
|
||||
#[must_use]
|
||||
pub fn without_hierarchy_info(self) -> Self {
|
||||
Self {
|
||||
path: String::new(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an empty file DTO for stub implementations
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
|
||||
@@ -107,6 +107,21 @@ impl From<FolderDto> for Folder {
|
||||
}
|
||||
|
||||
impl FolderDto {
|
||||
/// Returns a copy of this DTO with the `path` field cleared.
|
||||
///
|
||||
/// Used when a folder is returned to a share recipient: `path` reveals the
|
||||
/// full folder hierarchy above the shared folder which the recipient may
|
||||
/// not have access to. `parent_id` and `owner_id` are intentionally kept
|
||||
/// — the former is needed for sub-folder navigation (covered by the
|
||||
/// cascade grant), and the latter is harmless metadata.
|
||||
#[must_use]
|
||||
pub fn without_hierarchy_info(self) -> Self {
|
||||
Self {
|
||||
path: String::new(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an empty folder DTO for stub implementations
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
//! storage-agnostic and DTOs can evolve with the HTTP contract.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -131,9 +133,9 @@ impl From<Permission> for PermissionDto {
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
Viewer,
|
||||
Commenter,
|
||||
//Commenter,
|
||||
Editor,
|
||||
Manager,
|
||||
//Manager,
|
||||
Admin,
|
||||
}
|
||||
|
||||
@@ -144,13 +146,16 @@ impl Role {
|
||||
pub fn expand(self) -> &'static [Permission] {
|
||||
match self {
|
||||
Role::Viewer => &[Permission::Read],
|
||||
/* reserved for future
|
||||
Role::Commenter => &[Permission::Read, Permission::Comment],
|
||||
*/
|
||||
Role::Editor => &[
|
||||
Permission::Read,
|
||||
Permission::Comment,
|
||||
Permission::Create,
|
||||
Permission::Update,
|
||||
],
|
||||
/* reserved for future
|
||||
Role::Manager => &[
|
||||
Permission::Read,
|
||||
Permission::Comment,
|
||||
@@ -158,6 +163,7 @@ impl Role {
|
||||
Permission::Update,
|
||||
Permission::Share,
|
||||
],
|
||||
*/
|
||||
Role::Admin => &[
|
||||
Permission::Read,
|
||||
Permission::Comment,
|
||||
@@ -220,3 +226,52 @@ impl From<Grant> for GrantDto {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Shared-with-me DTOs (GET /api/grants/incoming/resources)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Query parameters for `GET /api/grants/incoming/resources`.
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct SharedWithMeQuery {
|
||||
/// Maximum number of items to return (1–200, default 50).
|
||||
#[serde(default = "shared_with_me_default_limit")]
|
||||
pub limit: u32,
|
||||
/// Comma-separated resource types to include, e.g. `file,folder`.
|
||||
/// Omit to return all known types.
|
||||
pub resource_types: Option<String>,
|
||||
/// Opaque cursor returned by a previous call. Omit to start from the
|
||||
/// most-recently-granted item.
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
fn shared_with_me_default_limit() -> u32 {
|
||||
50
|
||||
}
|
||||
|
||||
/// One item in the shared-with-me list. Exactly one of `file` / `folder` is
|
||||
/// populated, indicated by `resource_type`. Additional optional fields for
|
||||
/// future resource types (playlist, addressbook, …) will be added here.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct SharedWithMeItemDto {
|
||||
pub resource_type: ResourceTypeDto,
|
||||
/// All permissions the caller holds on this resource (aggregated).
|
||||
pub permissions: Vec<PermissionDto>,
|
||||
/// Earliest grant date for this resource.
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
/// UUID of the user who created the (earliest) grant.
|
||||
pub granted_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file: Option<FileDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub folder: Option<FolderDto>,
|
||||
}
|
||||
|
||||
/// Response for `GET /api/grants/incoming/resources`.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct SharedWithMeDto {
|
||||
pub items: Vec<SharedWithMeItemDto>,
|
||||
/// Opaque cursor for the next page. Absent when the last page is reached.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
|
||||
use crate::domain::services::authorization::{
|
||||
Grant, GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject,
|
||||
};
|
||||
|
||||
pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
/// Returns true if `subject` has `permission` on `resource`, considering
|
||||
@@ -67,6 +69,24 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
permission_filter: Option<Permission>,
|
||||
) -> Result<Vec<Grant>, DomainError>;
|
||||
|
||||
/// Cursor-paginated list of resources explicitly granted to `subject`,
|
||||
/// optionally filtered by resource kind. Multiple permission rows for the
|
||||
/// same resource are collapsed into one `IncomingGrantSummary`.
|
||||
///
|
||||
/// Ordered by `MIN(granted_at) DESC, resource_id DESC` — stable across
|
||||
/// concurrent inserts because the cursor encodes both fields.
|
||||
///
|
||||
/// Pass `kinds = &[]` to return all resource kinds.
|
||||
/// Returns `(summaries, next_cursor)` — `next_cursor` is `None` when the
|
||||
/// last page has been reached.
|
||||
async fn list_incoming_resources_paged(
|
||||
&self,
|
||||
subject: Subject,
|
||||
kinds: &[ResourceKind],
|
||||
limit: u32,
|
||||
cursor: Option<GrantCursor>,
|
||||
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError>;
|
||||
|
||||
/// All grants on a specific resource (for "Manage sharing" UI). Caller
|
||||
/// must verify the caller has `Share` on the resource before invoking.
|
||||
async fn list_grants_on_resource(&self, resource: Resource) -> Result<Vec<Grant>, DomainError>;
|
||||
|
||||
@@ -5,10 +5,12 @@ use tokio::sync::Semaphore;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::infrastructure::repositories::pg::SharePgRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
||||
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
use crate::{
|
||||
application::{
|
||||
dtos::{
|
||||
@@ -17,6 +19,7 @@ use crate::{
|
||||
},
|
||||
ports::{
|
||||
auth_ports::PasswordHasherPort,
|
||||
authorization_ports::AuthorizationEngine,
|
||||
share_ports::{ShareStoragePort, ShareUseCase},
|
||||
storage_ports::FileReadPort,
|
||||
},
|
||||
@@ -78,6 +81,9 @@ pub struct ShareService {
|
||||
file_repository: Arc<FileBlobReadRepository>,
|
||||
folder_repository: Arc<FolderDbRepository>,
|
||||
password_hasher: Arc<Argon2PasswordHasher>,
|
||||
/// ReBAC engine — used to create/revoke token grants that mirror public
|
||||
/// share links so that `GET /api/grants/outgoing` reflects them.
|
||||
authorization: Arc<PgAclEngine>,
|
||||
/// Bounds the number of in-flight Argon2 password hashes to avoid
|
||||
/// saturating the blocking thread pool and consuming excessive RAM.
|
||||
hash_semaphore: Arc<Semaphore>,
|
||||
@@ -90,6 +96,7 @@ impl ShareService {
|
||||
file_repository: Arc<FileBlobReadRepository>,
|
||||
folder_repository: Arc<FolderDbRepository>,
|
||||
password_hasher: Arc<Argon2PasswordHasher>,
|
||||
authorization: Arc<PgAclEngine>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
@@ -97,6 +104,7 @@ impl ShareService {
|
||||
file_repository,
|
||||
folder_repository,
|
||||
password_hasher,
|
||||
authorization,
|
||||
hash_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HASHES)),
|
||||
}
|
||||
}
|
||||
@@ -256,6 +264,50 @@ impl ShareUseCase for ShareService {
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
|
||||
// Mirror the share permissions as ReBAC token grants so that
|
||||
// `GET /api/grants/outgoing` picks them up and the UI can show the
|
||||
// share badge without a separate `/api/shares` round-trip.
|
||||
// The DELETE trigger `trg_cleanup_grants_token` handles cleanup when
|
||||
// the share is later removed — no extra service-layer code needed there.
|
||||
{
|
||||
let share_id = saved_share.id();
|
||||
let item_id_uuid = Uuid::parse_str(saved_share.item_id())
|
||||
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
|
||||
|
||||
let resource = match saved_share.item_type() {
|
||||
ShareItemType::File => Resource::File(item_id_uuid),
|
||||
ShareItemType::Folder => Resource::Folder(item_id_uuid),
|
||||
};
|
||||
let subject = Subject::Token(share_id);
|
||||
let perms = saved_share.permissions();
|
||||
|
||||
// Read is always granted
|
||||
self.authorization
|
||||
.grant(user_id, subject, Permission::Read, resource)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
|
||||
// Write permission → Create + Update
|
||||
if perms.write() {
|
||||
self.authorization
|
||||
.grant(user_id, subject, Permission::Create, resource)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
self.authorization
|
||||
.grant(user_id, subject, Permission::Update, resource)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
}
|
||||
|
||||
// Reshare permission → Share
|
||||
if perms.reshare() {
|
||||
self.authorization
|
||||
.grant(user_id, subject, Permission::Share, resource)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the entity to DTO for the response
|
||||
Ok(ShareDto::from_entity(&saved_share, &self.config.base_url()))
|
||||
}
|
||||
|
||||
+3
-1
@@ -514,6 +514,7 @@ impl AppServiceFactory {
|
||||
&self,
|
||||
repos: &RepositoryServices,
|
||||
db_pool: &Arc<PgPool>,
|
||||
authorization: &Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
||||
) -> Option<Arc<ShareService>> {
|
||||
if !self.config.features.enable_file_sharing {
|
||||
tracing::info!("File sharing service is disabled in configuration");
|
||||
@@ -537,6 +538,7 @@ impl AppServiceFactory {
|
||||
repos.file_read_repository.clone(),
|
||||
repos.folder_repository.clone(),
|
||||
password_hasher,
|
||||
authorization.clone(),
|
||||
));
|
||||
|
||||
tracing::info!("File sharing service initialized");
|
||||
@@ -652,7 +654,7 @@ impl AppServiceFactory {
|
||||
self.create_application_services(&core, &repos, trash_service.clone(), &authorization);
|
||||
|
||||
// 5. Share service
|
||||
let share_service = self.create_share_service(&repos, &pool);
|
||||
let share_service = self.create_share_service(&repos, &pool, &authorization);
|
||||
apps.share_service = share_service.clone();
|
||||
|
||||
let share_browse_service = share_service.as_ref().map(|s| {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation
|
||||
//! maps them to / from `storage.access_grants` rows.
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use std::fmt;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -201,6 +202,84 @@ pub struct Grant {
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// ResourceKind — type-only discriminator (no id), used for filtering queries
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Resource type without an id — used to filter paginated grant queries by
|
||||
/// type. Mirrors the `resource_type` column values in `storage.access_grants`.
|
||||
/// Add new variants here when new resource types are supported.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum ResourceKind {
|
||||
File,
|
||||
Folder,
|
||||
// Future: Calendar, AddressBook, Playlist, …
|
||||
}
|
||||
|
||||
impl ResourceKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ResourceKind::File => "file",
|
||||
ResourceKind::Folder => "folder",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"file" => Some(ResourceKind::File),
|
||||
"folder" => Some(ResourceKind::Folder),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// IncomingGrantSummary — aggregated across multiple permission rows
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Multiple `access_grants` rows for the same `(subject, resource)` collapsed
|
||||
/// into one record. Used by `list_incoming_resources_paged` to avoid sending
|
||||
/// duplicate resource items to the caller.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IncomingGrantSummary {
|
||||
pub resource_type: ResourceKind,
|
||||
pub resource_id: Uuid,
|
||||
/// All permissions held on this resource (aggregated).
|
||||
pub permissions: Vec<Permission>,
|
||||
/// Earliest `granted_at` across all permission rows.
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
/// Granter of the earliest grant.
|
||||
pub granted_by: Uuid,
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// GrantCursor — opaque pagination cursor for list_incoming_resources_paged
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Encodes the position of the last seen item in a cursor-paginated grant
|
||||
/// listing. The encoding is opaque to API callers — only the backend
|
||||
/// decodes it. Change the encoding algorithm in a major version bump.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct GrantCursor {
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
pub resource_id: Uuid,
|
||||
}
|
||||
|
||||
impl GrantCursor {
|
||||
/// Encode as a URL-safe base64 JSON string (no padding).
|
||||
pub fn encode(&self) -> String {
|
||||
let json = serde_json::to_vec(self).unwrap_or_default();
|
||||
URL_SAFE_NO_PAD.encode(&json)
|
||||
}
|
||||
|
||||
/// Decode from a URL-safe base64 JSON string. Returns `None` on any
|
||||
/// parse failure — callers treat a bad cursor as "start from the top".
|
||||
pub fn decode(s: &str) -> Option<Self> {
|
||||
let bytes = URL_SAFE_NO_PAD.decode(s).ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -38,7 +38,8 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
||||
WHEN uf.item_type = 'folder' THEN fld.path
|
||||
WHEN uf.item_type = 'file' THEN COALESCE(pfld.path || '/' || f.name, f.name)
|
||||
ELSE NULL
|
||||
END AS "item_path"
|
||||
END AS "item_path",
|
||||
COALESCE(f.user_id, fld.user_id)::TEXT AS "owner_id"
|
||||
FROM auth.user_favorites uf
|
||||
LEFT JOIN storage.files f ON uf.item_type = 'file'
|
||||
AND f.id = uf.item_id::UUID
|
||||
@@ -78,6 +79,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
||||
parent_id: row.try_get("parent_id").ok(),
|
||||
modified_at: row.try_get("modified_at").ok(),
|
||||
item_path: row.try_get("item_path").ok(),
|
||||
owner_id: row.try_get("owner_id").ok(),
|
||||
// Temporary defaults; with_display_fields() computes the real values
|
||||
icon_class: String::new(),
|
||||
icon_special_class: String::new(),
|
||||
|
||||
@@ -35,7 +35,9 @@ use sqlx::PgPool;
|
||||
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
|
||||
use crate::domain::services::authorization::{
|
||||
Grant, GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject,
|
||||
};
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
||||
|
||||
@@ -291,6 +293,107 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
rows.into_iter().map(Self::row_to_grant).collect()
|
||||
}
|
||||
|
||||
async fn list_incoming_resources_paged(
|
||||
&self,
|
||||
subject: Subject,
|
||||
kinds: &[ResourceKind],
|
||||
limit: u32,
|
||||
cursor: Option<GrantCursor>,
|
||||
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError> {
|
||||
// Build kind filter array — NULL means "all kinds".
|
||||
let kind_strs: Option<Vec<&str>> = if kinds.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(kinds.iter().map(|k| k.as_str()).collect())
|
||||
};
|
||||
|
||||
let cursor_at = cursor.as_ref().map(|c| c.granted_at);
|
||||
let cursor_id = cursor.as_ref().map(|c| c.resource_id);
|
||||
|
||||
// Fetch limit+1 rows so we can detect whether a next page exists.
|
||||
let fetch_limit = (limit as i64) + 1;
|
||||
|
||||
// Each row: (resource_type, resource_id, permissions_text_array,
|
||||
// granted_at, granted_by)
|
||||
type Row = (
|
||||
String,
|
||||
Uuid,
|
||||
Vec<String>,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
Uuid,
|
||||
);
|
||||
|
||||
let rows: Vec<Row> = sqlx::query_as(
|
||||
r#"
|
||||
WITH agg AS (
|
||||
SELECT
|
||||
resource_type,
|
||||
resource_id,
|
||||
array_agg(DISTINCT permission ORDER BY permission) AS permissions,
|
||||
MIN(granted_at) AS granted_at,
|
||||
(array_agg(granted_by ORDER BY granted_at))[1] AS granted_by
|
||||
FROM storage.access_grants
|
||||
WHERE subject_type = $1
|
||||
AND subject_id = $2
|
||||
AND ($3::text[] IS NULL OR resource_type = ANY($3))
|
||||
GROUP BY resource_type, resource_id
|
||||
)
|
||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by
|
||||
FROM agg
|
||||
WHERE ( $4::timestamptz IS NULL
|
||||
OR granted_at < $4
|
||||
OR (granted_at = $4 AND resource_id < $5::uuid))
|
||||
ORDER BY granted_at DESC, resource_id DESC
|
||||
LIMIT $6
|
||||
"#,
|
||||
)
|
||||
.bind(subject.type_str())
|
||||
.bind(subject.id())
|
||||
.bind(kind_strs)
|
||||
.bind(cursor_at)
|
||||
.bind(cursor_id)
|
||||
.bind(fetch_limit)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("PgAcl", format!("list_incoming_resources_paged: {e}"))
|
||||
})?;
|
||||
|
||||
let has_next = rows.len() > limit as usize;
|
||||
let rows: Vec<Row> = rows.into_iter().take(limit as usize).collect();
|
||||
|
||||
// Determine the next cursor from the last item we're actually returning.
|
||||
let next_cursor = if has_next {
|
||||
rows.last().map(|r| GrantCursor {
|
||||
granted_at: r.3,
|
||||
resource_id: r.1,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Convert rows into domain summaries.
|
||||
let summaries = rows
|
||||
.into_iter()
|
||||
.filter_map(|(rt, rid, perms_str, granted_at, granted_by)| {
|
||||
let resource_type = ResourceKind::parse(&rt)?;
|
||||
let permissions = perms_str
|
||||
.iter()
|
||||
.filter_map(|s| Permission::parse(s))
|
||||
.collect();
|
||||
Some(IncomingGrantSummary {
|
||||
resource_type,
|
||||
resource_id: rid,
|
||||
permissions,
|
||||
granted_at,
|
||||
granted_by,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok((summaries, next_cursor))
|
||||
}
|
||||
|
||||
async fn list_grants_on_resource(&self, resource: Resource) -> Result<Vec<Grant>, DomainError> {
|
||||
let rows = sqlx::query_as::<
|
||||
_,
|
||||
|
||||
@@ -11,23 +11,32 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use futures::future::join_all;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info};
|
||||
use tracing::{error, info, warn};
|
||||
use utoipa::IntoParams;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::grant_dto::{
|
||||
CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, SubjectDto,
|
||||
UpdateRoleDto,
|
||||
CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, SharedWithMeDto,
|
||||
SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||
use crate::application::ports::folder_ports::FolderUseCase;
|
||||
use crate::common::di::AppState;
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
use crate::domain::errors::ErrorKind;
|
||||
use crate::domain::services::authorization::{
|
||||
GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject,
|
||||
};
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
type AppStateRef = Arc<AppState>;
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// POST /api/grants
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -44,10 +53,11 @@ use crate::interfaces::middleware::auth::AuthUser;
|
||||
tag = "grants"
|
||||
)]
|
||||
pub async fn create_grant(
|
||||
State(authz): State<Arc<PgAclEngine>>,
|
||||
State(state): State<AppStateRef>,
|
||||
auth_user: AuthUser,
|
||||
Json(dto): Json<CreateGrantDto>,
|
||||
) -> impl IntoResponse {
|
||||
let authz = &state.authorization;
|
||||
let caller_id = auth_user.id;
|
||||
|
||||
// Validate: exactly one of permissions/role
|
||||
@@ -118,10 +128,11 @@ pub async fn create_grant(
|
||||
tag = "grants"
|
||||
)]
|
||||
pub async fn revoke_grant(
|
||||
State(authz): State<Arc<PgAclEngine>>,
|
||||
State(state): State<AppStateRef>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let authz = &state.authorization;
|
||||
let caller_id = auth_user.id;
|
||||
let grant_id = match Uuid::parse_str(&id) {
|
||||
Ok(u) => u,
|
||||
@@ -129,7 +140,7 @@ pub async fn revoke_grant(
|
||||
};
|
||||
|
||||
// Look up the grant to find the underlying resource (and granter).
|
||||
let on_resource = match find_grant_resource(&authz, grant_id).await {
|
||||
let on_resource = match authz.find_grant_by_id(grant_id).await {
|
||||
Ok(Some((res, granter))) => (res, granter),
|
||||
Ok(None) => return StatusCode::NO_CONTENT.into_response(), // idempotent
|
||||
Err(e) => return AppError::from(e).into_response(),
|
||||
@@ -151,16 +162,6 @@ pub async fn revoke_grant(
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
/// Look up a grant by id and return (resource, granted_by) so the caller-auth
|
||||
/// check in revoke_grant can determine if the caller is the granter or needs
|
||||
/// the Share permission on the resource. Returns `Ok(None)` if no such grant.
|
||||
async fn find_grant_resource(
|
||||
authz: &PgAclEngine,
|
||||
grant_id: Uuid,
|
||||
) -> Result<Option<(Resource, Uuid)>, DomainError> {
|
||||
authz.find_grant_by_id(grant_id).await
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// PUT /api/grants/role
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -176,10 +177,11 @@ async fn find_grant_resource(
|
||||
tag = "grants"
|
||||
)]
|
||||
pub async fn set_role(
|
||||
State(authz): State<Arc<PgAclEngine>>,
|
||||
State(state): State<AppStateRef>,
|
||||
auth_user: AuthUser,
|
||||
Json(dto): Json<UpdateRoleDto>,
|
||||
) -> impl IntoResponse {
|
||||
let authz = &state.authorization;
|
||||
let caller_id = auth_user.id;
|
||||
let subject: Subject = dto.subject.into();
|
||||
let resource: Resource = dto.resource.into();
|
||||
@@ -262,12 +264,13 @@ pub struct IncomingQuery {
|
||||
tag = "grants"
|
||||
)]
|
||||
pub async fn list_incoming(
|
||||
State(authz): State<Arc<PgAclEngine>>,
|
||||
State(state): State<AppStateRef>,
|
||||
auth_user: AuthUser,
|
||||
Query(q): Query<IncomingQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let caller_id = auth_user.id;
|
||||
match authz
|
||||
match state
|
||||
.authorization
|
||||
.list_incoming_grants(Subject::User(caller_id), q.permission.map(Into::into))
|
||||
.await
|
||||
{
|
||||
@@ -279,6 +282,170 @@ pub async fn list_incoming(
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/grants/incoming/resources
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/grants/incoming/resources",
|
||||
params(SharedWithMeQuery),
|
||||
responses(
|
||||
(status = 200,
|
||||
description = "Cursor-paginated resources shared with the caller. \
|
||||
Each item carries the full file or folder details plus \
|
||||
aggregated permissions. `next_cursor` is absent on the \
|
||||
last page.",
|
||||
body = SharedWithMeDto),
|
||||
),
|
||||
tag = "grants"
|
||||
)]
|
||||
pub async fn list_shared_with_me(
|
||||
State(state): State<AppStateRef>,
|
||||
auth_user: AuthUser,
|
||||
Query(q): Query<SharedWithMeQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let caller_id = auth_user.id;
|
||||
let subject = Subject::User(caller_id);
|
||||
|
||||
// Parse resource_types filter (unknown values silently ignored).
|
||||
let kinds: Vec<ResourceKind> = q
|
||||
.resource_types
|
||||
.as_deref()
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
.filter_map(|t| ResourceKind::parse(t.trim()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Clamp limit to 1–200.
|
||||
let limit = q.limit.clamp(1, 200);
|
||||
|
||||
// Decode cursor (treat invalid cursor as "start from top").
|
||||
let cursor = q.cursor.as_deref().and_then(GrantCursor::decode);
|
||||
|
||||
// Fetch paged summaries from the ACL engine.
|
||||
let (summaries, next_cursor) = match state
|
||||
.authorization
|
||||
.list_incoming_resources_paged(subject, &kinds, limit, cursor)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return AppError::from(e).into_response(),
|
||||
};
|
||||
|
||||
// Split summaries by resource kind for parallel resolution.
|
||||
let file_summaries: Vec<&IncomingGrantSummary> = summaries
|
||||
.iter()
|
||||
.filter(|s| matches!(s.resource_type, ResourceKind::File))
|
||||
.collect();
|
||||
let folder_summaries: Vec<&IncomingGrantSummary> = summaries
|
||||
.iter()
|
||||
.filter(|s| matches!(s.resource_type, ResourceKind::Folder))
|
||||
.collect();
|
||||
|
||||
let file_service = &state.applications.file_retrieval_service;
|
||||
let folder_service = &state.applications.folder_service_concrete;
|
||||
|
||||
// Pre-compute ID strings to avoid temporaries inside async closures.
|
||||
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();
|
||||
|
||||
// Resolve resource details concurrently (files and folders in parallel).
|
||||
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)))
|
||||
);
|
||||
|
||||
// Build the unified item list in original grant order (newest first).
|
||||
// We iterate summaries in order and pick the resolved result from the
|
||||
// appropriate typed bucket.
|
||||
let mut file_idx = 0usize;
|
||||
let mut folder_idx = 0usize;
|
||||
|
||||
let mut items: Vec<SharedWithMeItemDto> = Vec::with_capacity(summaries.len());
|
||||
|
||||
for summary in &summaries {
|
||||
match summary.resource_type {
|
||||
ResourceKind::File => {
|
||||
let result = &file_results[file_idx];
|
||||
file_idx += 1;
|
||||
match result {
|
||||
Ok(file_dto) => {
|
||||
items.push(SharedWithMeItemDto {
|
||||
resource_type: ResourceTypeDto::File,
|
||||
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
|
||||
granted_at: summary.granted_at,
|
||||
granted_by: summary.granted_by,
|
||||
file: Some(file_dto.clone().without_hierarchy_info()),
|
||||
folder: None,
|
||||
});
|
||||
}
|
||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||
// Stale grant (file deleted, trigger not yet fired) — skip silently.
|
||||
warn!(
|
||||
"Skipping stale 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(SharedWithMeItemDto {
|
||||
resource_type: ResourceTypeDto::Folder,
|
||||
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
|
||||
granted_at: summary.granted_at,
|
||||
granted_by: summary.granted_by,
|
||||
file: None,
|
||||
folder: Some(folder_dto.clone().without_hierarchy_info()),
|
||||
});
|
||||
}
|
||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||
warn!(
|
||||
"Skipping stale 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(SharedWithMeDto {
|
||||
items,
|
||||
next_cursor: next_cursor.map(|c| c.encode()),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/grants/outgoing
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -292,11 +459,11 @@ pub async fn list_incoming(
|
||||
tag = "grants"
|
||||
)]
|
||||
pub async fn list_outgoing(
|
||||
State(authz): State<Arc<PgAclEngine>>,
|
||||
State(state): State<AppStateRef>,
|
||||
auth_user: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
let caller_id = auth_user.id;
|
||||
match authz.list_outgoing_grants(caller_id).await {
|
||||
match state.authorization.list_outgoing_grants(caller_id).await {
|
||||
Ok(grants) => {
|
||||
let dtos: Vec<GrantDto> = grants.into_iter().map(Into::into).collect();
|
||||
(StatusCode::OK, Json(dtos)).into_response()
|
||||
@@ -327,10 +494,11 @@ pub struct OnResourceQuery {
|
||||
tag = "grants"
|
||||
)]
|
||||
pub async fn list_on_resource(
|
||||
State(authz): State<Arc<PgAclEngine>>,
|
||||
State(state): State<AppStateRef>,
|
||||
auth_user: AuthUser,
|
||||
Query(q): Query<OnResourceQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let authz = &state.authorization;
|
||||
let caller_id = auth_user.id;
|
||||
let resource: Resource = ResourceDto {
|
||||
kind: q.resource_type,
|
||||
|
||||
@@ -20,6 +20,10 @@ use crate::application::dtos::folder_dto::{
|
||||
CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto,
|
||||
};
|
||||
use crate::application::dtos::folder_listing_dto::FolderListingDto;
|
||||
use crate::application::dtos::grant_dto::{
|
||||
CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, Role, SharedWithMeDto,
|
||||
SharedWithMeItemDto, SubjectDto, SubjectTypeDto, UpdateRoleDto,
|
||||
};
|
||||
use crate::application::dtos::i18n_dto::{
|
||||
LocaleDto, TranslationErrorDto, TranslationRequestDto, TranslationResponseDto,
|
||||
};
|
||||
@@ -202,6 +206,14 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
handlers::admin_handler::complete_migration,
|
||||
handlers::admin_handler::verify_migration,
|
||||
handlers::admin_handler::generate_encryption_key,
|
||||
// Grant / ReBAC handlers (free functions)
|
||||
handlers::grant_handler::create_grant,
|
||||
handlers::grant_handler::revoke_grant,
|
||||
handlers::grant_handler::set_role,
|
||||
handlers::grant_handler::list_incoming,
|
||||
handlers::grant_handler::list_shared_with_me,
|
||||
handlers::grant_handler::list_outgoing,
|
||||
handlers::grant_handler::list_on_resource,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
@@ -275,6 +287,18 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
UpdateContactRequest,
|
||||
GroupNameRequest,
|
||||
AddMemberRequest,
|
||||
// Grant / ReBAC schemas
|
||||
SubjectTypeDto,
|
||||
SubjectDto,
|
||||
ResourceTypeDto,
|
||||
ResourceDto,
|
||||
PermissionDto,
|
||||
Role,
|
||||
CreateGrantDto,
|
||||
UpdateRoleDto,
|
||||
GrantDto,
|
||||
SharedWithMeDto,
|
||||
SharedWithMeItemDto,
|
||||
)
|
||||
),
|
||||
tags(
|
||||
@@ -293,6 +317,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
(name = "playlists", description = "Music playlist endpoints"),
|
||||
(name = "contacts", description = "Address books, contacts, and groups endpoints"),
|
||||
(name = "admin", description = "Admin management endpoints"),
|
||||
(name = "grants", description = "ReBAC grant management endpoints"),
|
||||
),
|
||||
info(
|
||||
title = "OxiCloud API",
|
||||
|
||||
@@ -165,7 +165,8 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
let share_service = app_state.share_service.clone();
|
||||
let favorites_service = app_state.favorites_service.clone();
|
||||
let recent_service = app_state.recent_service.clone();
|
||||
let authorization = app_state.authorization.clone();
|
||||
// authorization is no longer extracted separately — the grants router now
|
||||
// uses app_state directly so handlers can access all services.
|
||||
|
||||
// Initialize the batch operations service
|
||||
let mut batch_service_builder = BatchOperationService::default(
|
||||
@@ -302,7 +303,10 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
};
|
||||
|
||||
// Create routes for ReBAC grants (/api/grants) — single state: the authz engine.
|
||||
// Create routes for ReBAC grants (/api/grants).
|
||||
// State is Arc<AppState> so that the new list_shared_with_me handler can
|
||||
// access file/folder services. Existing handlers still extract
|
||||
// State<Arc<PgAclEngine>> via the FromRef impl in di.rs.
|
||||
let grants_router = {
|
||||
use crate::interfaces::api::handlers::grant_handler;
|
||||
Router::new()
|
||||
@@ -311,8 +315,12 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
.route("/{id}", delete(grant_handler::revoke_grant))
|
||||
.route("/role", put(grant_handler::set_role))
|
||||
.route("/incoming", get(grant_handler::list_incoming))
|
||||
.route(
|
||||
"/incoming/resources",
|
||||
get(grant_handler::list_shared_with_me),
|
||||
)
|
||||
.route("/outgoing", get(grant_handler::list_outgoing))
|
||||
.with_state(authorization.clone())
|
||||
.with_state(app_state.clone())
|
||||
};
|
||||
|
||||
// Create a router without the i18n routes
|
||||
|
||||
@@ -388,6 +388,12 @@
|
||||
--color-badge-success-fill: #047857;
|
||||
--color-badge-success-fill-dark: #064e27;
|
||||
--color-badge-success-fill-faint: #f0fdf4;
|
||||
--color-badge-green-bg: #ecfdf5;
|
||||
--color-badge-green-text: #065f46;
|
||||
|
||||
/* Status badge — light mode (orange/coral) */
|
||||
--color-badge-orange-bg: #fff5f3;
|
||||
--color-badge-orange-text: #ff5e3a;
|
||||
|
||||
/* Status badge — light mode (error/red) */
|
||||
--color-badge-error-border: #fecaca;
|
||||
@@ -395,6 +401,8 @@
|
||||
/* Status badge — light mode (warning/amber) */
|
||||
--color-badge-warning-text: #92400e;
|
||||
--color-badge-warning-border: #fde68a;
|
||||
--color-badge-amber-bg: #fef3c7;
|
||||
--color-badge-amber-text: #f59e0b;
|
||||
|
||||
/* Status badge — light mode (indigo/purple) */
|
||||
--color-badge-indigo-bg: #ede9fe;
|
||||
|
||||
@@ -100,12 +100,32 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-header > div:nth-child(4),
|
||||
/* Size column: always nth-child(5) because .owner-cell is always in the DOM
|
||||
(even when hidden via display:none, it still occupies a child slot). */
|
||||
.list-header > div:nth-child(5),
|
||||
.files-list-view .file-item .size-cell {
|
||||
justify-self: end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Owner column ─────────────────────────────────────────── */
|
||||
|
||||
/* Styles applied whenever the cell is visible (hidden class absent).
|
||||
The .hidden utility class (display:none !important) keeps it invisible
|
||||
by default — it is stamped directly in the HTML templates. */
|
||||
.owner-cell {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Expand the grid track as soon as at least one owner cell is visible. */
|
||||
.files-list-view:has(.owner-cell:not(.hidden)) {
|
||||
--files-list-columns: 36px minmax(200px, 2fr) 120px 100px 110px 130px 72px;
|
||||
}
|
||||
|
||||
.files-list-view {
|
||||
--files-list-columns: 36px minmax(200px, 2fr) 100px 110px 130px 72px;
|
||||
display: flex;
|
||||
@@ -271,7 +291,8 @@
|
||||
|
||||
/* element hidden on grid view */
|
||||
.files-grid-view .file-item .date-cell,
|
||||
.files-grid-view .file-item .size-cell {
|
||||
.files-grid-view .file-item .size-cell,
|
||||
.files-grid-view .file-item .owner-cell {
|
||||
display: none;
|
||||
}
|
||||
/* Selection checkbox */
|
||||
|
||||
@@ -190,6 +190,9 @@
|
||||
color: var(--color-text-heading);
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
@@ -307,3 +310,20 @@
|
||||
.modal-footer .btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ── Panel mode (ShareModal, etc.) ─────────────────────────────────────────── */
|
||||
/* Wider, taller container; body becomes a zero-padding scrollable slot. */
|
||||
|
||||
.modal-container--panel {
|
||||
width: 520px;
|
||||
max-width: 96vw;
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-container--panel .modal-body {
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
/* ── Share Modal — content styles ──────────────────────────────────────────────
|
||||
*
|
||||
* The overlay, container, header, footer, and animations come from modals.css
|
||||
* (via Modal.openPanel()). This file only covers the body content: sections,
|
||||
* member rows, chips, role selects, link rows, and new-link form.
|
||||
*
|
||||
* All colours use CSS custom properties. No raw hex/rgb/named values outside
|
||||
* of :root declarations.
|
||||
* ─────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Body wrapper ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.smd-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Sections ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.smd-section {
|
||||
border-top: 0.5px solid var(--color-border);
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.smd-section:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.smd-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-subtle);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ── Loading skeleton ────────────────────────────────────────────────────────── */
|
||||
|
||||
.smd-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.smd-skeleton-line {
|
||||
height: 14px;
|
||||
background: var(--color-bg-muted);
|
||||
border-radius: 6px;
|
||||
animation: smdSkeletonPulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.smd-skeleton-line--short {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.smd-skeleton-line--medium {
|
||||
width: 65%;
|
||||
}
|
||||
|
||||
@keyframes smdSkeletonPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Search row ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.smd-search-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.smd-search-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.smd-search-input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
font-size: 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-heading);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.smd-search-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-surface);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-ring);
|
||||
}
|
||||
|
||||
/* Suggestion dropdown */
|
||||
.smd-suggestions {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg-surface);
|
||||
border: 0.5px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px var(--color-shadow-xl);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.smd-suggestion-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.smd-suggestion-item:hover,
|
||||
.smd-suggestion-item:focus {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.smd-suggestion-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.smd-suggestion-name {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-heading);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.smd-suggestion-email {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
/* Role picker beside the search box */
|
||||
.smd-role-select {
|
||||
padding: 9px 10px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-heading);
|
||||
cursor: pointer;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
/* Add button */
|
||||
.smd-add-btn {
|
||||
min-height: 36px;
|
||||
min-width: 44px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── Staged chips ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.smd-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.smd-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px 4px 4px;
|
||||
border: 0.5px solid var(--color-border-medium);
|
||||
border-radius: 20px;
|
||||
background: var(--color-bg-hover);
|
||||
font-size: 13px;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.smd-chip-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.smd-chip-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-faint);
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
transition:
|
||||
background 0.1s,
|
||||
color 0.1s;
|
||||
}
|
||||
|
||||
.smd-chip-remove:hover {
|
||||
background: var(--color-bg-muted);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
/* ── Member group headings ───────────────────────────────────────────────────── */
|
||||
|
||||
.smd-group {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.smd-group:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.smd-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-subtle);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.smd-group-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: var(--color-bg-muted);
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
/* ── Member rows ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.smd-member-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.smd-member-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.smd-member-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-heading);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.smd-member-role-select {
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
border: 0.5px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-heading);
|
||||
cursor: pointer;
|
||||
max-width: 33%;
|
||||
}
|
||||
|
||||
.smd-row-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 32px;
|
||||
min-width: 32px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-faint);
|
||||
border-radius: 6px;
|
||||
padding: 0;
|
||||
transition:
|
||||
background 0.1s,
|
||||
color 0.1s;
|
||||
}
|
||||
|
||||
.smd-row-action:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-error-text);
|
||||
}
|
||||
|
||||
/* ── Avatar colour palette ───────────────────────────────────────────────────── */
|
||||
/* Colours are provided by .uv-color-0..4 in userVignette.css (shared palette). */
|
||||
|
||||
/* ── Fallback when user-directory is unavailable ────────────────────────────── */
|
||||
|
||||
.smd-directory-unavailable {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-faint);
|
||||
font-style: italic;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
|
||||
/* ── Link rows ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.smd-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.smd-link-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-bg-muted);
|
||||
border: 0.5px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-subtle);
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.smd-link-info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.smd-link-name {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-heading);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.smd-link-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 3px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.smd-link-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-muted);
|
||||
color: var(--color-text-subtle);
|
||||
border: 0.5px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.smd-link-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Inline edit sub-panel ───────────────────────────────────────────────────── */
|
||||
|
||||
.smd-edit-panel {
|
||||
margin: 4px 0 8px 42px;
|
||||
padding: 12px;
|
||||
background: var(--color-bg-hover);
|
||||
border: 0.5px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.smd-edit-panel label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.smd-edit-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-heading);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.smd-edit-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-ring);
|
||||
}
|
||||
|
||||
.smd-edit-panel-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── New-link creation button + form ─────────────────────────────────────────── */
|
||||
|
||||
.smd-new-link-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-subtle);
|
||||
background: none;
|
||||
border: 1.5px dashed var(--color-border-medium);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.smd-new-link-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.smd-new-link-form {
|
||||
margin-top: 8px;
|
||||
padding: 14px;
|
||||
background: var(--color-bg-hover);
|
||||
border: 0.5px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.smd-new-link-form label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.smd-new-link-form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Password toggle row ─────────────────────────────────────────────────────── */
|
||||
|
||||
.smd-pw-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── Apply spinner ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.smd-spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: smdSpin 0.6s linear infinite;
|
||||
vertical-align: middle;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
@keyframes smdSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/* ── User Vignette — avatar circle + name inline component ────────────────────
|
||||
*
|
||||
* Reusable component that pairs a coloured initials circle with a display
|
||||
* name resolved asynchronously. Used in:
|
||||
* • Owner column (list view, SharedWithMe & Favorites sections)
|
||||
* • ShareModal member rows, chips, suggestion items
|
||||
*
|
||||
* Sizes: --xs (20 px) · --sm (24 px) · --md (32 px) · --lg (40 px)
|
||||
* Colours: .uv-color-0..4 (applied by JS via _colorIndex(userId) % 5)
|
||||
*
|
||||
* All colours use CSS custom properties — no raw hex / rgb / named values.
|
||||
* ─────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.user-vignette {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-vignette__avatar {
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
/* Default size = --sm; overridden by size modifier below */
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.user-vignette__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ── Size variants ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.user-vignette--xs .user-vignette__avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.user-vignette--sm .user-vignette__avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.user-vignette--md .user-vignette__avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-vignette--lg .user-vignette__avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ── Colour palette ────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Colours are shared with the ShareModal avatar palette.
|
||||
Each class maps to a distinct hue via design-token variables. */
|
||||
|
||||
.uv-color-0 {
|
||||
background: var(--color-badge-indigo-bg);
|
||||
color: var(--color-badge-indigo-text);
|
||||
}
|
||||
|
||||
.uv-color-1 {
|
||||
background: var(--color-badge-green-bg);
|
||||
color: var(--color-badge-green-text);
|
||||
}
|
||||
|
||||
.uv-color-2 {
|
||||
background: var(--color-badge-orange-bg);
|
||||
color: var(--color-badge-orange-text);
|
||||
}
|
||||
|
||||
.uv-color-3 {
|
||||
background: var(--color-badge-blue-bg);
|
||||
color: var(--color-badge-blue-text);
|
||||
}
|
||||
|
||||
.uv-color-4 {
|
||||
background: var(--color-badge-amber-bg);
|
||||
color: var(--color-badge-amber-text);
|
||||
}
|
||||
@@ -18,6 +18,8 @@
|
||||
@import url("./components/dialogs.css");
|
||||
@import url("./components/modals.css");
|
||||
@import url("./components/shareDialog.css");
|
||||
@import url("./components/shareModal.css");
|
||||
@import url("./components/userVignette.css");
|
||||
@import url("./components/uploadDropdown.css");
|
||||
@import url("./components/notifications.css");
|
||||
@import url("./components/userMenu.css");
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Shared-with-me view styles.
|
||||
*
|
||||
* The grid/list rendering reuses the standard `.files-container` / `#files-list`
|
||||
* styles from filesView.css. Only view-specific additions are defined here.
|
||||
*/
|
||||
|
||||
/* ── "Load more" button row ───────────────────────────────────────────────── */
|
||||
.swm-load-more-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px 0 24px;
|
||||
}
|
||||
+7
-2
@@ -14,6 +14,7 @@
|
||||
<link rel="stylesheet" href="/css/views/favorites.css">
|
||||
<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/trash.css">
|
||||
<link rel="stylesheet" href="/css/views/photos.css">
|
||||
<link rel="stylesheet" href="/css/views/photosLightbox.css">
|
||||
@@ -24,7 +25,7 @@
|
||||
<script defer type="module" src="/js/core/csrf.js"></script>
|
||||
<script defer type="module" src="/js/core/languageSelector.js"></script>
|
||||
<script defer type="module" src="/js/core/notifications.js"></script>
|
||||
<script defer type="module" src="/js/core/modal.js"></script>
|
||||
<script defer type="module" src="/js/components/modal.js"></script>
|
||||
<script defer type="module" src="/js/core/formatters.js"></script>
|
||||
<script defer type="module" src="/js/app/state.js"></script>
|
||||
<script defer type="module" src="/js/app/uiFileTypes.js"></script>
|
||||
@@ -76,9 +77,13 @@
|
||||
<span data-i18n="nav.files">Files</span>
|
||||
</div>
|
||||
<div class="nav-item" id="nav-shared">
|
||||
<i class="fas fa-share-alt"></i>
|
||||
<i class="fas fa-oxiexport"></i>
|
||||
<span data-i18n="nav.shared">Shared</span>
|
||||
</div>
|
||||
<div class="nav-item" id="nav-sharedwithme">
|
||||
<i class="fas fa-oxiimport"></i>
|
||||
<span data-i18n="nav.sharedwithme">Shared with me</span>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span data-i18n="nav.recent">Recent</span>
|
||||
|
||||
+31
-14
@@ -42,7 +42,17 @@ async function getFolder(id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* rebuild breadcrumb from selected folder (iterate up to root)
|
||||
* Rebuild breadcrumb from selected folder (iterate up to root).
|
||||
*
|
||||
* Stops traversal gracefully when a parent folder is not accessible
|
||||
* (e.g. the user entered via a "Shared with me" grant whose parent
|
||||
* folder they have no permission on). In that case the partial
|
||||
* breadcrumb built so far is kept — the deepest reachable ancestor
|
||||
* acts as the visual root, matching how Google Drive / Dropbox handle
|
||||
* shared subtrees.
|
||||
*
|
||||
* An error on the *target folder itself* (first iteration) is still
|
||||
* treated as a real error and redirects to the home folder.
|
||||
*/
|
||||
async function rebuildBreadCrumb() {
|
||||
/**
|
||||
@@ -69,23 +79,29 @@ async function rebuildBreadCrumb() {
|
||||
currentFolderInfo = folderInfo;
|
||||
}
|
||||
|
||||
// XXX do not enter root into bread crumb updateBreadcrumb() method always display it
|
||||
if (!folderInfo.is_root) {
|
||||
app.breadcrumbPath.unshift({
|
||||
id: folderInfo.id,
|
||||
name: folderInfo.name
|
||||
});
|
||||
}
|
||||
// Add every folder to the breadcrumb, including the root (home folder).
|
||||
// updateBreadcrumb() no longer auto-prepends home — it's our responsibility here.
|
||||
app.breadcrumbPath.unshift({
|
||||
id: folderInfo.id,
|
||||
name: folderInfo.name
|
||||
});
|
||||
|
||||
// iterate to parent folder
|
||||
id = folderInfo.parent_id;
|
||||
} catch (_e) {
|
||||
console.log(`Error loading information from folder ${app.currentPath}, falling back to ${app.userHomeFolderId}`);
|
||||
// fallback of root
|
||||
uiNotifications.show('error: folder not found or permission denied', 'the given folder is not available or you do not have sufficient rights');
|
||||
app.breadcrumbPath = [];
|
||||
id = app.userHomeFolderId;
|
||||
if (id) app.currentPath = id;
|
||||
if (currentFolderInfo === null) {
|
||||
// Failed on the target folder itself — real error, fall back to home.
|
||||
console.warn(`Cannot access target folder ${app.currentPath}, falling back to home`);
|
||||
uiNotifications.show('error: folder not found or permission denied', 'the given folder is not available or you do not have sufficient rights');
|
||||
app.breadcrumbPath = [];
|
||||
id = app.userHomeFolderId;
|
||||
if (id) app.currentPath = id;
|
||||
} else {
|
||||
// Failed on a parent — hit the permission boundary of a shared subtree.
|
||||
// Stop traversal; the partial breadcrumb is the best we can show.
|
||||
console.log(`Stopped breadcrumb traversal at permission boundary (parent of ${currentFolderInfo.id} is not accessible)`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +233,7 @@ async function loadFiles(options = { insertHistory: true }) {
|
||||
} else {
|
||||
ui.renderFolders(folderList);
|
||||
ui.renderFiles(fileList);
|
||||
ui.resolveOwnerCells();
|
||||
|
||||
// check if a file was provided
|
||||
if (app.viewFile) {
|
||||
|
||||
+22
-2
@@ -7,19 +7,21 @@ import { installFetchInterceptor } from '../core/fetchWrapper.js';
|
||||
|
||||
installFetchInterceptor();
|
||||
|
||||
import { Modal } from '../components/modal.js';
|
||||
import { formatFileSize, formatQuotaSize } from '../core/formatters.js';
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { oxiIconsInit } from '../core/icons.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { fileOps } from '../features/files/fileOperations.js';
|
||||
import { multiSelect } from '../features/files/multiSelect.js';
|
||||
import { favorites } from '../features/library/favorites.js';
|
||||
import { recent } from '../features/library/recent.js';
|
||||
import { fileSharing } from '../features/sharing/fileSharing.js';
|
||||
import { grants } from '../model/grants.js';
|
||||
import { sharedView } from '../views/shared/sharedView.js';
|
||||
import { checkAuthentication } from './authSession.js';
|
||||
import { loadFiles } from './filesView.js';
|
||||
import {
|
||||
activateFilesUI,
|
||||
SECTIONS_MAPPER,
|
||||
switchToFavoritesSection,
|
||||
switchToFilesSection,
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
switchToPhotosSection,
|
||||
switchToRecentFilesSection,
|
||||
switchToSharedSection,
|
||||
switchToSharedWithMeSection,
|
||||
switchToTrashSection
|
||||
} from './navigation.js';
|
||||
import { performSearch } from './searchView.js';
|
||||
@@ -140,12 +143,16 @@ const ACTIONS_BAR_TEMPLATES = {
|
||||
</div>
|
||||
${_multiSelectButons}
|
||||
${_toggleButtons}
|
||||
`,
|
||||
sharedwithme: `
|
||||
<div class="action-buttons" id="default-buttons"></div>
|
||||
${_toggleButtons}
|
||||
`
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {'files' | 'trash' | 'favorites' | 'recent' | 'hidden'} mode
|
||||
* @param {'files' | 'trash' | 'favorites' | 'recent' | 'sharedwithme' | 'hidden'} mode
|
||||
* @param {boolean} [force=false]
|
||||
* @returns
|
||||
*/
|
||||
@@ -398,6 +405,9 @@ function initApp() {
|
||||
app.viewFile = hashContext.file;
|
||||
}
|
||||
|
||||
// get grants (xxx: async methods)
|
||||
await grants.fetchIncomingGrants();
|
||||
await grants.fetchOutgoingGrants();
|
||||
loadFiles();
|
||||
}
|
||||
});
|
||||
@@ -652,6 +662,10 @@ function setupEventListeners() {
|
||||
switchToSharedSection();
|
||||
break;
|
||||
|
||||
case 'nav.sharedwithme':
|
||||
switchToSharedWithMeSection();
|
||||
break;
|
||||
|
||||
case 'nav.favorites':
|
||||
// Switch to favorites view
|
||||
switchToFavoritesSection();
|
||||
@@ -723,6 +737,12 @@ function setupEventListeners() {
|
||||
* @param {string} name
|
||||
*/
|
||||
export function selectFolder(id, name) {
|
||||
// When entering from a non-files section (e.g. "Shared with me"),
|
||||
// activate the Files UI (nav active state, breadcrumb, action bar,
|
||||
// container) without resetting the current path.
|
||||
if (app.currentSection !== 'files') {
|
||||
activateFilesUI();
|
||||
}
|
||||
app.breadcrumbPath.push({ id, name });
|
||||
app.currentPath = id;
|
||||
ui.updateBreadcrumb();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { musicView } from '../features/library/music.js';
|
||||
import { photosView } from '../features/library/photos.js';
|
||||
import { recent } from '../features/library/recent.js';
|
||||
import { sharedView } from '../views/shared/sharedView.js';
|
||||
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
|
||||
import { loadFiles } from './filesView.js';
|
||||
import { setActionsBarMode } from './main.js';
|
||||
import { app, appElements } from './state.js';
|
||||
@@ -122,6 +123,7 @@ function getSectionFromNavItem(navItem) {
|
||||
export const SECTIONS_MAPPER = {
|
||||
files: switchToFilesSection,
|
||||
shared: switchToSharedSection,
|
||||
sharedwithme: switchToSharedWithMeSection,
|
||||
recent: switchToRecentFilesSection,
|
||||
favorites: switchToFavoritesSection,
|
||||
trash: switchToTrashSection,
|
||||
@@ -157,6 +159,14 @@ function setCurrentSection(section) {
|
||||
sharedView.hide();
|
||||
}
|
||||
|
||||
// Hide "Load more" button when leaving the sharedwithme section
|
||||
if (section !== 'sharedwithme' && sharedWithMeView) {
|
||||
sharedWithMeView.hide();
|
||||
}
|
||||
|
||||
// Reset owner column — sections that need it re-enable it explicitly below.
|
||||
ui.setOwnerColumnVisible(false);
|
||||
|
||||
// Hide photosView when switching to any other section
|
||||
if (section !== 'photos' && photosView) {
|
||||
photosView.hide();
|
||||
@@ -194,12 +204,38 @@ function switchToSharedSection() {
|
||||
if (multiSelect) multiSelect.clear();
|
||||
}
|
||||
|
||||
function switchToSharedWithMeSection() {
|
||||
if (!setCurrentSection('sharedwithme')) return;
|
||||
|
||||
// Hide breadcrumb (only shown in Files view)
|
||||
const breadcrumb = document.querySelector('.breadcrumb');
|
||||
breadcrumb?.classList.add('hidden');
|
||||
|
||||
// Show actions-bar with view toggle (no upload / new-folder in this view)
|
||||
setActionsBarMode('sharedwithme');
|
||||
|
||||
// Show the Owner column — names are resolved async after render.
|
||||
ui.setOwnerColumnVisible(true);
|
||||
|
||||
// Show the standard files container and respect grid/list preference
|
||||
toggleFileContainer(true);
|
||||
syncViewContainers();
|
||||
|
||||
if (multiSelect) multiSelect.clear();
|
||||
|
||||
// Load and render items into the files container
|
||||
sharedWithMeView.init();
|
||||
}
|
||||
|
||||
function switchToFilesSection() {
|
||||
if (!setCurrentSection('files')) return;
|
||||
|
||||
// Set actions bar mode
|
||||
setActionsBarMode('files', true);
|
||||
|
||||
// Show owner column in the Files section
|
||||
ui.setOwnerColumnVisible(true);
|
||||
|
||||
// Show breadcrumb (only in Files view)
|
||||
const breadcrumb = document.querySelector('.breadcrumb');
|
||||
breadcrumb?.classList.remove('hidden');
|
||||
@@ -231,6 +267,9 @@ function switchToFavoritesSection() {
|
||||
// Set actions bar mode
|
||||
setActionsBarMode('favorites');
|
||||
|
||||
// Show the Owner column — names are resolved async after render.
|
||||
ui.setOwnerColumnVisible(true);
|
||||
|
||||
// Hide breadcrumb (only shown in Files view)
|
||||
const breadcrumb = document.querySelector('.breadcrumb');
|
||||
breadcrumb?.classList.add('hidden');
|
||||
@@ -368,13 +407,34 @@ function switchToMusicSection() {
|
||||
if (multiSelect) multiSelect.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the Files section UI (nav state, breadcrumb, actions bar,
|
||||
* files container, grid/list sync) WITHOUT resetting `app.currentPath`
|
||||
* or `app.breadcrumbPath`.
|
||||
*
|
||||
* Used by `selectFolder` when the user clicks a folder from a
|
||||
* non-files section (e.g. "Shared with me") so the Files view is
|
||||
* fully set up before the folder content loads.
|
||||
*/
|
||||
function activateFilesUI() {
|
||||
setCurrentSection('files');
|
||||
setActionsBarMode('files', true);
|
||||
const breadcrumb = document.querySelector('.breadcrumb');
|
||||
breadcrumb?.classList.remove('hidden');
|
||||
toggleFileContainer(true);
|
||||
syncViewContainers();
|
||||
if (multiSelect) multiSelect.clear();
|
||||
}
|
||||
|
||||
export {
|
||||
activateFilesUI,
|
||||
switchToFavoritesSection,
|
||||
switchToFilesSection,
|
||||
switchToMusicSection,
|
||||
switchToPhotosSection,
|
||||
switchToRecentFilesSection,
|
||||
switchToSharedSection,
|
||||
switchToSharedWithMeSection,
|
||||
switchToTrashSection,
|
||||
syncViewContainers
|
||||
};
|
||||
|
||||
+79
-137
@@ -5,6 +5,8 @@
|
||||
|
||||
// @ts-check
|
||||
|
||||
import { shareModal } from '../components/shareModal.js';
|
||||
import { createUserVignette } from '../components/userVignette.js';
|
||||
import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js';
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { OxiIcons } from '../core/icons.js';
|
||||
@@ -15,12 +17,12 @@ import { multiSelect } from '../features/files/multiSelect.js';
|
||||
import { wopiEditor } from '../features/files/wopiEditor.js';
|
||||
import { favorites } from '../features/library/favorites.js';
|
||||
import { recent } from '../features/library/recent.js';
|
||||
import { fileSharing } from '../features/sharing/fileSharing.js';
|
||||
import { thumbnail } from '../features/thumbnail.js';
|
||||
import { sharedView } from '../views/shared/sharedView.js';
|
||||
import { grants } from '../model/grants.js';
|
||||
import { systemUsers } from '../model/systemUsers.js';
|
||||
import { loadFiles } from './filesView.js';
|
||||
import { updateHistory } from './main.js';
|
||||
import { switchToFilesSection, syncViewContainers } from './navigation.js';
|
||||
import { activateFilesUI, switchToFilesSection, syncViewContainers } from './navigation.js';
|
||||
import { app } from './state.js';
|
||||
import { uiFileTypes } from './uiFileTypes.js';
|
||||
import { uiNotifications } from './uiNotifications.js';
|
||||
@@ -37,6 +39,12 @@ const ui = {
|
||||
/** @type {HTMLDivElement | null} */
|
||||
draggedItems: null,
|
||||
|
||||
/**
|
||||
* Whether the Owner column is currently visible.
|
||||
* Tracked so that newly rendered items can stamp the correct initial class.
|
||||
*/
|
||||
_ownerVisible: false,
|
||||
|
||||
/**
|
||||
* Initialize context menus and dialogs
|
||||
*/
|
||||
@@ -54,7 +62,7 @@ const ui = {
|
||||
<i class="fas fa-star"></i> <span data-i18n="actions.favorite">Add to favorites</span>
|
||||
</div>
|
||||
<div class="context-menu-item" id="share-folder-option">
|
||||
<i class="fas fa-share-alt"></i> <span data-i18n="actions.share">Share</span>
|
||||
<i class="fas fa-oxiexport"></i> <span data-i18n="actions.share">Share</span>
|
||||
</div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" id="rename-folder-option">
|
||||
@@ -98,7 +106,7 @@ const ui = {
|
||||
<i class="fas fa-star"></i> <span data-i18n="actions.favorite">Add to favorites</span>
|
||||
</div>
|
||||
<div class="context-menu-item" id="share-file-option">
|
||||
<i class="fas fa-share-alt"></i> <span data-i18n="actions.share">Share</span>
|
||||
<i class="fas fa-oxiexport"></i> <span data-i18n="actions.share">Share</span>
|
||||
</div>
|
||||
<div class="context-menu-item hidden" id="add-to-playlist-option">
|
||||
<i class="fas fa-compact-disc"></i> <span data-i18n="music.add_to_playlist">Add to Playlist</span>
|
||||
@@ -146,115 +154,7 @@ const ui = {
|
||||
document.body.appendChild(moveDialog);
|
||||
}
|
||||
|
||||
// Share dialog
|
||||
if (!document.getElementById('share-dialog')) {
|
||||
const shareDialog = document.createElement('div');
|
||||
shareDialog.classList.add('share-dialog', 'hidden');
|
||||
shareDialog.id = 'share-dialog';
|
||||
shareDialog.innerHTML = `
|
||||
<div class="share-dialog-content">
|
||||
<div class="share-dialog-header">
|
||||
<i class="fas fa-share-alt dialog-header-icon"></i>
|
||||
<span data-i18n="dialogs.share_file">Share file</span>
|
||||
</div>
|
||||
<div class="shared-item-info">
|
||||
<strong>Item:</strong> <span id="shared-item-name"></span>
|
||||
</div>
|
||||
|
||||
<div id="existing-shares-section" class="share-section hidden">
|
||||
<h3 data-i18n="dialogs.existing_shares">Existing shared links</h3>
|
||||
<div id="existing-shares-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="share-options">
|
||||
<h3 data-i18n="dialogs.share_options">Share options</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="share-password" data-i18n="dialogs.password">Password (optional):</label>
|
||||
<input type="password" id="share-password" placeholder="Protect with password">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="share-expiration" data-i18n="dialogs.expiration">Expiration date (optional):</label>
|
||||
<input type="date" id="share-expiration">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="dialogs.permissions">Permissions:</label>
|
||||
<div class="permission-options">
|
||||
<div class="permission-option">
|
||||
<input type="checkbox" id="share-permission-read" checked>
|
||||
<label for="share-permission-read" data-i18n="permissions.read">Read</label>
|
||||
</div>
|
||||
<div class="permission-option">
|
||||
<input type="checkbox" id="share-permission-write">
|
||||
<label for="share-permission-write" data-i18n="permissions.write">Write</label>
|
||||
</div>
|
||||
<div class="permission-option">
|
||||
<input type="checkbox" id="share-permission-reshare">
|
||||
<label for="share-permission-reshare" data-i18n="permissions.reshare">Allow sharing</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-small" id="share-confirm-btn" data-i18n="actions.share">Share</button>
|
||||
</div>
|
||||
|
||||
<div id="new-share-section" class="share-section hidden">
|
||||
<h3 data-i18n="dialogs.generated_link">Generated link</h3>
|
||||
<div class="form-group">
|
||||
<input type="text" id="generated-share-url" readonly>
|
||||
<div class="share-link-actions">
|
||||
<button class="btn btn-small" id="copy-share-btn">
|
||||
<i class="fas fa-copy"></i> <span data-i18n="actions.copy">Copy</span>
|
||||
</button>
|
||||
<button class="btn btn-small" id="notify-share-btn">
|
||||
<i class="fas fa-envelope"></i> <span data-i18n="actions.notify">Notify</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="share-dialog-buttons">
|
||||
<button class="btn btn-secondary" id="share-close-btn" data-i18n="actions.close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
i18n.translateElement(shareDialog);
|
||||
document.body.appendChild(shareDialog);
|
||||
|
||||
// Add event listeners for share dialog
|
||||
document.getElementById('share-close-btn')?.addEventListener('click', () => {
|
||||
contextMenus.closeShareDialog();
|
||||
});
|
||||
|
||||
document.getElementById('share-confirm-btn')?.addEventListener('click', async () => {
|
||||
await contextMenus.createSharedLink();
|
||||
});
|
||||
|
||||
document.getElementById('copy-share-btn')?.addEventListener('click', async () => {
|
||||
const shareUrl = /** @type {HTMLInputElement | null} */ (document.getElementById('generated-share-url'))?.value;
|
||||
if (shareUrl) await fileSharing.copyLinkToClipboard(shareUrl);
|
||||
});
|
||||
|
||||
document.getElementById('notify-share-btn')?.addEventListener('click', () => {
|
||||
const shareUrl = /** @type {HTMLInputElement | null} */ (document.getElementById('generated-share-url'))?.value;
|
||||
if (shareUrl) contextMenus.showEmailNotificationDialog(shareUrl);
|
||||
});
|
||||
|
||||
// FIXME make generic function (close all dialog / etc)
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const dialog = document.getElementById('share-dialog');
|
||||
if (e.key === 'Escape' && !dialog?.classList.contains('hidden')) {
|
||||
contextMenus.closeShareDialog();
|
||||
}
|
||||
});
|
||||
|
||||
shareDialog.addEventListener('click', (e) => {
|
||||
if (e.target === shareDialog) {
|
||||
contextMenus.closeShareDialog();
|
||||
}
|
||||
});
|
||||
}
|
||||
// Share dialog is now handled by shareModal (components/shareModal.js)
|
||||
|
||||
// Notification dialog
|
||||
if (!document.getElementById('notification-dialog')) {
|
||||
@@ -544,6 +444,47 @@ const ui = {
|
||||
syncViewContainers();
|
||||
},
|
||||
|
||||
/**
|
||||
* Show or hide the Owner column. When hidden, no name-resolution calls are made.
|
||||
* Sections that show owner (SharedWithMe, Favorites) pass `true`; all others `false`.
|
||||
* @param {boolean} visible
|
||||
*/
|
||||
setOwnerColumnVisible(visible) {
|
||||
this._ownerVisible = visible;
|
||||
document
|
||||
.getElementById('files-list')
|
||||
?.querySelectorAll('.owner-cell')
|
||||
.forEach((cell) => {
|
||||
cell.classList.toggle('hidden', !visible);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Asynchronously fill every un-resolved `.owner-cell` in the current list with
|
||||
* the display name for its `data-owner-id` attribute.
|
||||
*
|
||||
* Call this after `renderFiles()` / `renderFolders()` in sections where the owner
|
||||
* column is visible. Idempotent: cells already stamped with `data-owner-resolved`
|
||||
* are skipped (safe to call on each "Load more" page append).
|
||||
*
|
||||
* When the column is hidden nothing calls this function, so `systemUsers` is never
|
||||
* touched and no address-book requests are issued.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async resolveOwnerCells() {
|
||||
const filesList = document.getElementById('files-list');
|
||||
const cells = /** @type {NodeListOf<HTMLElement>} */ (filesList?.querySelectorAll('.owner-cell[data-owner-id]:not([data-owner-resolved])'));
|
||||
if (!cells?.length) return;
|
||||
systemUsers.prefetch(); // warm cache once (idempotent, fire-and-forget)
|
||||
for (const cell of cells) {
|
||||
const id = cell.dataset.ownerId;
|
||||
cell.dataset.ownerResolved = '1';
|
||||
if (!id) continue;
|
||||
cell.replaceChildren(createUserVignette(id, 'sm'));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update breadcrumb navigation from the breadcrumbPath array.
|
||||
* Renders: Home > folder1 > folder2 > ...
|
||||
@@ -574,18 +515,11 @@ const ui = {
|
||||
}
|
||||
breadcrumb?.appendChild(homeIcon);
|
||||
|
||||
// -- Root/Home folder name (if available) is always the first element of the breadcrumb --
|
||||
// TODO clarify the difference between homeIcon & this first element
|
||||
if (app.userHomeFolderName) {
|
||||
if (path.length === 0 || path[0].id !== app.userHomeFolderId) {
|
||||
path.unshift({
|
||||
name: app.userHomeFolderName,
|
||||
id: app.userHomeFolderId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -- Root/Home + Intermediate + current segments --
|
||||
// NOTE: The home folder entry is added by rebuildBreadCrumb() (filesView.js) when it
|
||||
// reaches the root folder during traversal. updateBreadcrumb() just renders app.breadcrumbPath
|
||||
// as-is — no implicit mutation. This allows shared-folder navigation to show only the
|
||||
// reachable subtree without the home prefix leaking in.
|
||||
path.forEach((segment, index) => {
|
||||
const isLast = index === path.length - 1;
|
||||
|
||||
@@ -937,6 +871,11 @@ const ui = {
|
||||
loadFiles();
|
||||
return;
|
||||
}
|
||||
if (app.currentSection === 'sharedwithme') {
|
||||
// Activate Files UI (nav, breadcrumb, actions bar) without
|
||||
// resetting the path — the shared folder becomes the entry point.
|
||||
activateFilesUI();
|
||||
}
|
||||
app.breadcrumbPath.push({ id: folderId, name: folderName });
|
||||
app.currentPath = folderId;
|
||||
this.updateBreadcrumb();
|
||||
@@ -1247,15 +1186,14 @@ const ui = {
|
||||
const itemType = itemElement.dataset.fileId ? 'file' : 'folder';
|
||||
const itemName = itemElement.dataset.fileId ? itemElement.dataset.fileName : itemElement.dataset.folderName;
|
||||
|
||||
// TODO corrently dirty
|
||||
const item = /** @type {unknown} */ ({
|
||||
id: itemId,
|
||||
item_id: itemId,
|
||||
item_type: itemType,
|
||||
item_name: itemName
|
||||
});
|
||||
const item = /** @type {FileItem|FolderItem} */ (
|
||||
/** @type {unknown} */ ({
|
||||
id: itemId,
|
||||
name: itemName
|
||||
})
|
||||
);
|
||||
|
||||
contextMenus.showShareDialog(/** @type {FileItem} */ (item), itemType);
|
||||
shareModal.open(item, /** @type {'file'|'folder'} */ (itemType));
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1334,7 +1272,7 @@ const ui = {
|
||||
if (folder.path) el.dataset.path = folder.path;
|
||||
|
||||
const isFav = favorites?.isFavorite(folder.id, 'folder');
|
||||
const isShared = sharedView.isShared(folder.id, 'folder');
|
||||
const isShared = grants.getOutgoingGrantsFor('folder', folder.id).length > 0; //sharedView.isShared(folder.id, 'folder');
|
||||
const formattedDate = formatDateTime(folder.modified_at);
|
||||
|
||||
el.innerHTML = `
|
||||
@@ -1345,8 +1283,9 @@ const ui = {
|
||||
</div>
|
||||
<span>${escapeHtml(folder.name)}</span>
|
||||
<div class="file-badge file-badge-favorite ${isFav ? '' : 'hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>
|
||||
<div class="file-badge file-badge-shared ${isShared ? '' : 'hidden'}"><i class="fas fa-share-alt"></i></div>
|
||||
<div class="file-badge file-badge-shared ${isShared ? '' : 'hidden'}"><i class="fas fa-oxiexport"></i></div>
|
||||
</div>
|
||||
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(folder.owner_id || '')}"></div>
|
||||
<div class="type-cell">${i18n.t('files.file_types.folder')}</div>
|
||||
<div class="size-cell">--</div>
|
||||
<div class="date-cell">${formattedDate}</div>
|
||||
@@ -1377,7 +1316,8 @@ const ui = {
|
||||
const fileSize = file.size_formatted || formatFileSize(file.size);
|
||||
const formattedDate = formatDateTime(file.modified_at);
|
||||
const isFav = favorites?.isFavorite(file.id, 'file');
|
||||
const isShared = sharedView.isShared(file.id, 'file');
|
||||
const isShared = grants.getOutgoingGrantsFor('file', file.id).length > 0;
|
||||
//const isShared = sharedView.isShared(file.id, 'file');
|
||||
const canThumbnail = thumbnail.canHandle(file);
|
||||
|
||||
const el = document.createElement('div');
|
||||
@@ -1398,8 +1338,9 @@ const ui = {
|
||||
</div>
|
||||
<span>${escapeHtml(file.name)}</span>
|
||||
<div class="file-badge file-badge-favorite ${isFav ? '' : 'hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>
|
||||
<div class="file-badge file-badge-shared ${isShared ? '' : 'hidden'}"><i class="fas fa-share-alt"></i></div>
|
||||
<div class="file-badge file-badge-shared ${isShared ? '' : 'hidden'}"><i class="fas fa-oxiexport"></i></div>
|
||||
</div>
|
||||
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(file.owner_id || '')}"></div>
|
||||
<div class="type-cell">${typeLabel}</div>
|
||||
<div class="size-cell">${fileSize}</div>
|
||||
<div class="date-cell">${formattedDate}</div>
|
||||
@@ -1439,6 +1380,7 @@ const ui = {
|
||||
<div class="list-header">
|
||||
<div class="list-header-checkbox"><input type="checkbox" id="select-all-checkbox" title="Select all"></div>
|
||||
<div data-i18n="files.name">Name</div>
|
||||
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-i18n="files.owner">Owner</div>
|
||||
<div data-i18n="files.type">Type</div>
|
||||
<div data-i18n="files.size">Size</div>
|
||||
<div data-i18n="files.modified">Modified</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { i18n } from './i18n.js';
|
||||
import { replaceIconsInElement } from './icons.js';
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { replaceIconsInElement } from '../core/icons.js';
|
||||
|
||||
/**
|
||||
* Modal System for OxiCloud
|
||||
@@ -40,6 +40,14 @@ const Modal = {
|
||||
// Rename mode: select only name without extension
|
||||
_selectNameOnly: false,
|
||||
|
||||
// Panel mode — openPanel() sets this; skips input-focus logic
|
||||
/** @private */
|
||||
_panelMode: false,
|
||||
|
||||
// Saved modal-body innerHTML to restore when a panel closes
|
||||
/** @private */
|
||||
_savedBodyHTML: '',
|
||||
|
||||
/**
|
||||
* Initialize modal system
|
||||
*/
|
||||
@@ -84,6 +92,13 @@ const Modal = {
|
||||
this.close(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Escape in panel mode (input isn't focused so the above handler won't fire)
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this._panelMode && !this.overlay?.classList.contains('hidden')) {
|
||||
this.close(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/** @param {string} message */
|
||||
@@ -259,6 +274,8 @@ const Modal = {
|
||||
this._action = null;
|
||||
this.overlay.classList.remove('active');
|
||||
|
||||
const wasPanel = this._panelMode;
|
||||
|
||||
setTimeout(() => {
|
||||
this.overlay.classList.add('hidden');
|
||||
|
||||
@@ -269,6 +286,15 @@ const Modal = {
|
||||
// Clear callbacks
|
||||
this.onConfirm = null;
|
||||
this.onCancel = null;
|
||||
|
||||
// Restore original modal-body content after a panel closes
|
||||
if (wasPanel) {
|
||||
const bodyEl = this.overlay?.querySelector('.modal-body');
|
||||
if (bodyEl) bodyEl.innerHTML = this._savedBodyHTML;
|
||||
this.overlay?.querySelector('.modal-container')?.classList.remove('modal-container--panel');
|
||||
this._panelMode = false;
|
||||
this._savedBodyHTML = '';
|
||||
}
|
||||
}, 200);
|
||||
},
|
||||
|
||||
@@ -277,6 +303,13 @@ const Modal = {
|
||||
* until it resolves — closing only on success, showing the error inline on failure.
|
||||
*/
|
||||
async confirm() {
|
||||
// Panel mode: delegate entirely to the caller-supplied onConfirm
|
||||
if (this._panelMode) {
|
||||
if (this.onConfirm) this.onConfirm();
|
||||
this.close(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._action) {
|
||||
if (this.onConfirm) this.onConfirm();
|
||||
this.close(true);
|
||||
@@ -298,6 +331,71 @@ const Modal = {
|
||||
this.confirmBtn.disabled = false;
|
||||
this.input.focus();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the modal with fully custom body content (panel mode).
|
||||
*
|
||||
* The caller supplies a pre-built HTMLElement as `content`; it is injected
|
||||
* into `.modal-body`, replacing the default label/input/error elements for
|
||||
* the lifetime of this panel. The overlay, header, animation, footer
|
||||
* buttons, click-outside, and Escape handling all come from Modal.
|
||||
*
|
||||
* Original `.modal-body` innerHTML is restored automatically when the
|
||||
* panel closes.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.title
|
||||
* @param {string} [options.icon] - Font Awesome class, default 'fa-share-alt'
|
||||
* @param {HTMLElement} options.content - DOM node to inject into .modal-body
|
||||
* @param {string} [options.confirmText] - Confirm button label
|
||||
* @param {string} [options.cancelText] - Cancel button label
|
||||
* @param {() => void} [options.onConfirm] - Called when Confirm is clicked
|
||||
* @param {() => void} [options.onCancel] - Called when Cancel / close is triggered
|
||||
*/
|
||||
openPanel({ title, icon = 'fa-share-alt', content, confirmText = null, cancelText = null, onConfirm = null, onCancel = null }) {
|
||||
if (!this.overlay) return;
|
||||
|
||||
this._panelMode = true;
|
||||
|
||||
// ── Header ──────────────────────────────────────────────────────────
|
||||
const iconContainer = this.overlay.querySelector('.modal-icon');
|
||||
if (iconContainer) {
|
||||
iconContainer.innerHTML = `<i class="fas ${icon}"></i>`;
|
||||
if (replaceIconsInElement) replaceIconsInElement(iconContainer);
|
||||
}
|
||||
if (this.title) this.title.textContent = title;
|
||||
|
||||
// ── Body swap ───────────────────────────────────────────────────────
|
||||
const bodyEl = this.overlay.querySelector('.modal-body');
|
||||
if (bodyEl) {
|
||||
this._savedBodyHTML = bodyEl.innerHTML;
|
||||
bodyEl.replaceChildren(content);
|
||||
}
|
||||
|
||||
// ── Container size modifier ──────────────────────────────────────────
|
||||
this.overlay.querySelector('.modal-container')?.classList.add('modal-container--panel');
|
||||
|
||||
// ── Footer buttons ──────────────────────────────────────────────────
|
||||
if (this.confirmBtn) {
|
||||
this.confirmBtn.textContent = confirmText ?? i18n.t('actions.apply', 'Apply');
|
||||
this.confirmBtn.disabled = false;
|
||||
}
|
||||
if (this.cancelBtn) {
|
||||
this.cancelBtn.textContent = cancelText ?? i18n.t('actions.cancel');
|
||||
}
|
||||
|
||||
// ── Callbacks ───────────────────────────────────────────────────────
|
||||
this.onConfirm = onConfirm;
|
||||
this.onCancel = onCancel;
|
||||
this._action = null;
|
||||
this.clearError();
|
||||
|
||||
// ── Show overlay (same animation as prompt, no input focus) ─────────
|
||||
this.overlay.classList.remove('hidden');
|
||||
requestAnimationFrame(() => {
|
||||
this.overlay.classList.add('active');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* UserVignette — reusable user avatar + name inline component.
|
||||
*
|
||||
* Renders a coloured circle with initials (or photo when available) alongside
|
||||
* an asynchronously-resolved display name. Used in:
|
||||
* • Owner column (list view) via `ui.resolveOwnerCells()`
|
||||
* • ShareModal member rows / chips / suggestion items
|
||||
*
|
||||
* Usage:
|
||||
* import { createUserVignette } from './userVignette.js';
|
||||
* cell.replaceChildren(createUserVignette(userId, 'sm'));
|
||||
*/
|
||||
|
||||
import { systemUsers } from '../model/systemUsers.js';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get initials for an avatar (1-2 characters).
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function _initials(name) {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic color index 0-4 derived from a userId string.
|
||||
* Same userId always maps to the same color across all components.
|
||||
* @param {string} userId
|
||||
* @returns {number}
|
||||
*/
|
||||
export function _colorIndex(userId) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash = (hash * 31 + userId.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash) % 5;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {'xs'|'sm'|'md'|'lg'} VignetteSize
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a user vignette element: a coloured initials circle + async-resolved
|
||||
* display name span. The element is returned immediately with a short-UUID
|
||||
* placeholder; the name resolves in the background via `systemUsers`.
|
||||
*
|
||||
* @param {string} userId UUID of the user
|
||||
* @param {VignetteSize} [size='sm']
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function createUserVignette(userId, size = 'sm') {
|
||||
const colorIdx = _colorIndex(userId);
|
||||
|
||||
const wrapper = /** @type {HTMLElement} */ (document.createElement('span'));
|
||||
wrapper.className = `user-vignette user-vignette--${size}`;
|
||||
|
||||
const avatar = document.createElement('span');
|
||||
avatar.className = `user-vignette__avatar uv-color-${colorIdx}`;
|
||||
// Temporary placeholder: first two chars of UUID
|
||||
avatar.textContent = userId.slice(0, 2).toUpperCase();
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'user-vignette__name';
|
||||
nameEl.textContent = `${userId.slice(0, 8)}…`;
|
||||
|
||||
wrapper.appendChild(avatar);
|
||||
wrapper.appendChild(nameEl);
|
||||
|
||||
// Resolve full name asynchronously and update both avatar initials and name
|
||||
systemUsers.getDisplayName(userId).then((name) => {
|
||||
avatar.textContent = _initials(name);
|
||||
nameEl.textContent = name;
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
@@ -246,6 +246,14 @@ const OxiIcons = {
|
||||
384,
|
||||
'M223.5 32C100 32 0 132.3 0 256S100 480 223.5 480c60.6 0 115.5-24.2 155.8-63.4c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6c-96.9 0-175.5-78.8-175.5-176c0-65.8 36-123.1 89.3-153.3c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z'
|
||||
],
|
||||
oxiexport: [
|
||||
576,
|
||||
'M384.5 24l0 72-64 0c-79.5 0-144 64.5-144 144 0 93.4 82.8 134.8 100.6 142.6 2.2 1 4.6 1.4 7.1 1.4l2.5 0c9.8 0 17.8-8 17.8-17.8 0-8.3-5.9-15.5-12.8-20.3-8.9-6.2-19.2-18.2-19.2-40.5 0-45 36.5-81.5 81.5-81.5l30.5 0 0 72c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2l136-136c9.4-9.4 9.4-24.6 0-33.9L425.5 7c-6.9-6.9-17.2-8.9-26.2-5.2S384.5 14.3 384.5 24zm-272 72c-44.2 0-80 35.8-80 80l0 256c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 32c0 8.8-7.2 16-16 16l-256 0c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l16 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-16 0z'
|
||||
],
|
||||
oxiimport: [
|
||||
576,
|
||||
'm 360.55,24 v 72 h 64 c 79.5,0 144,64.5 144,144 0,93.4 -82.8,134.8 -100.6,142.6 -2.2,1 -4.6,1.4 -7.1,1.4 h -2.5 c -9.8,0 -17.8,-8 -17.8,-17.8 0,-8.3 5.9,-15.5 12.8,-20.3 8.9,-6.2 19.2,-18.2 19.2,-40.5 0,-45 -36.5,-81.5 -81.5,-81.5 h -30.5 v 72 c 0,9.7 -5.8,18.5 -14.8,22.2 -9,3.7 -19.3,1.7 -26.2,-5.2 l -136,-136 c -9.4,-9.4 -9.4,-24.6 0,-33.9 l 136,-136 c 6.9,-6.9 17.2,-8.9 26.2,-5.2 9,3.7 14.8,12.5 14.8,22.2 z M 112.5,96 c -44.2,0 -80,35.8 -80,80 v 256 c 0,44.2 35.8,80 80,80 h 256 c 44.2,0 80,-35.8 80,-80 v -32 c 0,-17.7 -14.3,-32 -32,-32 -17.7,0 -32,14.3 -32,32 v 32 c 0,8.8 -7.2,16 -16,16 h -256 c -8.8,0 -16,-7.2 -16,-16 V 176 c 0,-8.8 7.2,-16 16,-16 h 16 c 17.7,0 32,-14.3 32,-32 0,-17.7 -14.3,-32 -32,-32 z'
|
||||
],
|
||||
pen: [
|
||||
512,
|
||||
'M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z'
|
||||
@@ -258,6 +266,10 @@ const OxiIcons = {
|
||||
384,
|
||||
'M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z'
|
||||
],
|
||||
'pencil-alt': [
|
||||
512,
|
||||
'M36.4 353.2c4.1-14.6 11.8-27.9 22.6-38.7l181.2-181.2 33.9-33.9c16.6 16.6 51.3 51.3 104 104l33.9 33.9-33.9 33.9-181.2 181.2c-10.7 10.7-24.1 18.5-38.7 22.6L30.4 510.6c-8.3 2.3-17.3 0-23.4-6.2S-1.4 489.3 .9 481L36.4 353.2zm55.6-3.7c-4.4 4.7-7.6 10.4-9.3 16.6l-24.1 86.9 86.9-24.1c6.4-1.8 12.2-5.1 17-9.7L91.9 349.5zm354-146.1c-16.6-16.6-51.3-51.3-104-104L308 65.5C334.5 39 349.4 24.1 352.9 20.6 366.4 7 384.8-.6 404-.6S441.6 7 455.1 20.6l35.7 35.7C504.4 69.9 512 88.3 512 107.4s-7.6 37.6-21.2 51.1c-3.5 3.5-18.4 18.4-44.9 44.9z'
|
||||
],
|
||||
shuffle: [
|
||||
512,
|
||||
'M403.8 34.4c12-5 25.7-2.2 34.9 6.9l64 64c6 6 9.4 14.1 9.4 22.6s-3.4 16.6-9.4 22.6l-64 64c-9.2 9.2-22.9 11.9-34.9 6.9S384 204.9 384 192l0-32-32 0c-10.1 0-19.6 4.7-25.6 12.8l-32.4 43.2-40-53.3 21.2-28.3C293.3 110.2 321.8 96 352 96l32 0 0-32c0-12.9 7.8-24.6 19.8-29.6zM154 296l40 53.3-21.2 28.3C154.7 401.8 126.2 416 96 416l-64 0c-17.7 0-32-14.3-32-32s14.3-32 32-32l64 0c10.1 0 19.6-4.7 25.6-12.8L154 296zM438.6 470.6c-9.2 9.2-22.9 11.9-34.9 6.9S384 460.9 384 448l0-32-32 0c-30.2 0-58.7-14.2-76.8-38.4L121.6 172.8c-6-8.1-15.5-12.8-25.6-12.8l-64 0c-17.7 0-32-14.3-32-32S14.3 96 32 96l64 0c30.2 0 58.7 14.2 76.8 38.4L326.4 339.2c6 8.1 15.5 12.8 25.6 12.8l32 0 0-32c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l64 64c6 6 9.4 14.1 9.4 22.6s-3.4 16.6-9.4 22.6l-64 64z'
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
* @property {String} icon_special_class
|
||||
* @property {String} category
|
||||
* @property {String} size_formatted
|
||||
* @property {string|null} owner_id UUID of the file/folder's actual owner
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -253,3 +254,137 @@
|
||||
* @property {number|null} width
|
||||
* @property {number|null} height
|
||||
*/
|
||||
|
||||
// ------------------- grants
|
||||
|
||||
/**
|
||||
* @typedef {'read'|'create'|'share'|'comment'|'delete'|'update'} PermissionTypeEnum
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {'folder'|'file'} ResourceTypeEnum
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Resource
|
||||
* @property {ResourceTypeEnum} type
|
||||
* @property {String} id
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {'user'|'group'|'token'|'external'} SubjectTypeEnum
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Subject
|
||||
* @property {SubjectTypeEnum} type
|
||||
* @property {String} id
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Grant
|
||||
* @property {string} id
|
||||
* @property {number} granted_at
|
||||
* @property {string} granted_by
|
||||
* @property {Subject} subject
|
||||
* @property {PermissionTypeEnum} permission
|
||||
* @property {Resource} resource
|
||||
*/
|
||||
|
||||
/**
|
||||
* Roles: `viewer`, `commenter`, `editor`, `manager`, `admin`
|
||||
*/
|
||||
|
||||
/**
|
||||
* One item returned by `GET /api/grants/incoming/resources`.
|
||||
* Exactly one of `file` / `folder` is populated (indicated by `resource_type`).
|
||||
* @typedef {Object} SharedWithMeItem
|
||||
* @property {ResourceTypeEnum} resource_type
|
||||
* @property {PermissionTypeEnum[]} permissions - All permissions the caller holds on this resource.
|
||||
* @property {string} granted_at - ISO-8601 timestamp of the earliest grant.
|
||||
* @property {string} granted_by - UUID of the user who created the grant.
|
||||
* @property {FileItem|undefined} [file] - Populated when resource_type === 'file'.
|
||||
* @property {FolderItem|undefined} [folder] - Populated when resource_type === 'folder'.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Response for `GET /api/grants/incoming/resources`.
|
||||
* @typedef {Object} SharedWithMeResponse
|
||||
* @property {SharedWithMeItem[]} items
|
||||
* @property {string|undefined} [next_cursor] - Absent when the last page is reached.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ContactEmail
|
||||
* @property {string} email
|
||||
* @property {string} type - e.g. "work", "home"
|
||||
* @property {boolean} is_primary
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mirrors the backend `ContactDto`.
|
||||
* `id` equals the OxiCloud user UUID for contacts from the system address book.
|
||||
* @typedef {Object} ContactItem
|
||||
* @property {string} id
|
||||
* @property {string} address_book_id
|
||||
* @property {string} uid - vCard UID
|
||||
* @property {string|null} [full_name]
|
||||
* @property {string|null} [first_name]
|
||||
* @property {string|null} [last_name]
|
||||
* @property {string|null} [nickname]
|
||||
* @property {ContactEmail[]} email
|
||||
* @property {string|null} [organization]
|
||||
* @property {string|null} [title]
|
||||
* @property {string|null} [photo_url]
|
||||
* @property {string} created_at - ISO-8601
|
||||
* @property {string} updated_at - ISO-8601
|
||||
* @property {string} etag
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mirrors the backend `AddressBookResponse`.
|
||||
* @typedef {Object} AddressBookItem
|
||||
* @property {string} id
|
||||
* @property {string} name
|
||||
* @property {string} owner_id
|
||||
* @property {string|null} [description]
|
||||
* @property {string|null} [color]
|
||||
* @property {boolean} is_public
|
||||
* @property {boolean} is_readonly
|
||||
* @property {boolean} is_system
|
||||
* @property {string} created_at - ISO-8601
|
||||
* @property {string} updated_at - ISO-8601
|
||||
*/
|
||||
|
||||
// ------------------- share modal
|
||||
|
||||
/**
|
||||
* Share roles (DTO-layer sugar for the ReBAC permission sets).
|
||||
* @typedef {'viewer'|'editor'|'admin'} ShareRoleEnum
|
||||
*/
|
||||
|
||||
/**
|
||||
* One collaborator row in the share modal's People section.
|
||||
* @typedef {Object} MemberEntry
|
||||
* @property {Grant} grant - Representative grant (used for subject/resource info).
|
||||
* @property {Grant[]} _grants - All grants for this subject on the resource (may be > 1).
|
||||
* @property {ShareRoleEnum} role - Derived role label shown in the UI.
|
||||
* @property {'keep'|'remove'|'change'|'new'} _op - Pending local operation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Existing public link with a pending local operation.
|
||||
* @typedef {Object} LinkEntry
|
||||
* @property {ShareItem} share - The existing share object.
|
||||
* @property {'keep'|'remove'|'edit'} _op - Pending local operation.
|
||||
* @property {DraftLink|null} _draft - Updated fields when _op === 'edit'.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A public link staged for creation (not yet committed).
|
||||
* @typedef {Object} DraftLink
|
||||
* @property {string} name
|
||||
* @property {string|null} password
|
||||
* @property {string|null} expires_at - ISO-8601 date string or null.
|
||||
*/
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@ import { resolveHomeFolder } from '../../app/authSession.js';
|
||||
import { loadFiles } from '../../app/filesView.js';
|
||||
import { switchToFilesSection } from '../../app/navigation.js';
|
||||
import { app } from '../../app/state.js';
|
||||
import { showConfirmDialog, ui } from '../../app/ui.js';
|
||||
import { ui } from '../../app/ui.js';
|
||||
import { Modal } from '../../components/modal.js';
|
||||
import { shareModal } from '../../components/shareModal.js';
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { escapeHtml } from '../../core/formatters.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { Modal } from '../../core/modal.js';
|
||||
import { favorites } from '../library/favorites.js';
|
||||
import { musicView } from '../library/music.js';
|
||||
import { fileSharing } from '../sharing/fileSharing.js';
|
||||
@@ -157,7 +158,7 @@ const contextMenus = {
|
||||
document.getElementById('share-folder-option').addEventListener('click', () => {
|
||||
const folder = app.contextMenuTargetFolder;
|
||||
if (folder) {
|
||||
this.showShareDialog(folder, 'folder');
|
||||
shareModal.open(folder, 'folder');
|
||||
}
|
||||
ui.closeContextMenu();
|
||||
});
|
||||
@@ -279,7 +280,7 @@ const contextMenus = {
|
||||
document.getElementById('share-file-option').addEventListener('click', () => {
|
||||
const file = app.contextMenuTargetFile;
|
||||
if (file) {
|
||||
this.showShareDialog(file, 'file');
|
||||
shareModal.open(file, 'file');
|
||||
}
|
||||
ui.closeFileContextMenu();
|
||||
});
|
||||
@@ -722,229 +723,6 @@ const contextMenus = {
|
||||
await this.loadMoveDialogFolders(app.userHomeFolderId || null);
|
||||
},
|
||||
|
||||
/**
|
||||
* Show share dialog for files or folders
|
||||
* @param {FileItem | FolderItem} item - File or folder object
|
||||
* @param {ItemTypeEnum} itemType
|
||||
*/
|
||||
async showShareDialog(item, itemType) {
|
||||
try {
|
||||
const shareDialog = document.getElementById('share-dialog');
|
||||
if (!shareDialog) {
|
||||
console.error('Share dialog element not found in DOM');
|
||||
ui.showNotification('Error', 'Share dialog not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update dialog title — use the <span> inside header to preserve <i> icon
|
||||
const dialogHeader = shareDialog.querySelector('.share-dialog-header');
|
||||
if (dialogHeader) {
|
||||
const headerSpan = dialogHeader.querySelector('span');
|
||||
const titleText = itemType === 'file' ? i18n.t('dialogs.share_file') : i18n.t('dialogs.share_folder');
|
||||
if (headerSpan) {
|
||||
headerSpan.textContent = titleText;
|
||||
} else {
|
||||
dialogHeader.textContent = titleText;
|
||||
}
|
||||
}
|
||||
|
||||
const itemName = document.getElementById('shared-item-name');
|
||||
if (itemName) itemName.textContent = item.name;
|
||||
|
||||
// Reset form
|
||||
const pwField = /** @type HTMLInputElement */ (document.getElementById('share-password'));
|
||||
const expField = /** @type HTMLInputElement */ (document.getElementById('share-expiration'));
|
||||
if (pwField) pwField.value = '';
|
||||
if (expField) expField.value = '';
|
||||
const permRead = /** @type HTMLInputElement */ (document.getElementById('share-permission-read'));
|
||||
const permWrite = /** @type HTMLInputElement */ (document.getElementById('share-permission-write'));
|
||||
const permReshare = /** @type HTMLInputElement */ (document.getElementById('share-permission-reshare'));
|
||||
if (permRead) permRead.checked = true;
|
||||
if (permWrite) permWrite.checked = false;
|
||||
if (permReshare) permReshare.checked = false;
|
||||
|
||||
// Store the current item and type for use when creating the share
|
||||
app.shareDialogItem = item;
|
||||
app.shareDialogItemType = itemType;
|
||||
|
||||
// Check if item already has shares (async API call)
|
||||
const existingShares = await fileSharing.getSharedLinksForItem(item.id, itemType);
|
||||
const existingSharesContainer = document.getElementById('existing-shares-container');
|
||||
|
||||
// Clear existing shares container
|
||||
existingSharesContainer.innerHTML = '';
|
||||
|
||||
if (existingShares.length > 0) {
|
||||
document.getElementById('existing-shares-section').classList.remove('hidden');
|
||||
|
||||
// Create elements for each existing share
|
||||
existingShares.forEach((share) => {
|
||||
const shareEl = document.createElement('div');
|
||||
shareEl.className = 'existing-share-item';
|
||||
|
||||
const expiresText = share.expires_at ? `Expires: ${fileSharing.formatExpirationDate(share.expires_at)}` : 'No expiration';
|
||||
|
||||
// Share URL
|
||||
const urlDiv = document.createElement('div');
|
||||
urlDiv.className = 'share-url';
|
||||
urlDiv.textContent = share.url;
|
||||
shareEl.appendChild(urlDiv);
|
||||
|
||||
// Share info
|
||||
const infoDiv = document.createElement('div');
|
||||
infoDiv.className = 'share-info';
|
||||
if (share.has_password) {
|
||||
const protectedSpan = document.createElement('span');
|
||||
protectedSpan.className = 'share-protected';
|
||||
protectedSpan.innerHTML = '<i class="fas fa-lock"></i> Password protected';
|
||||
infoDiv.appendChild(protectedSpan);
|
||||
}
|
||||
const expirationSpan = document.createElement('span');
|
||||
expirationSpan.className = 'share-expiration';
|
||||
expirationSpan.textContent = expiresText;
|
||||
infoDiv.appendChild(expirationSpan);
|
||||
shareEl.appendChild(infoDiv);
|
||||
|
||||
// Share actions
|
||||
const actionsDiv = document.createElement('div');
|
||||
actionsDiv.className = 'share-actions';
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.className = 'btn btn-small copy-link-btn';
|
||||
copyBtn.dataset.shareUrl = share.url;
|
||||
copyBtn.innerHTML = '<i class="fas fa-copy"></i> Copy';
|
||||
actionsDiv.appendChild(copyBtn);
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'btn btn-small btn-danger delete-link-btn';
|
||||
deleteBtn.dataset.shareId = share.id;
|
||||
deleteBtn.innerHTML = '<i class="fas fa-trash"></i> Delete';
|
||||
actionsDiv.appendChild(deleteBtn);
|
||||
|
||||
shareEl.appendChild(actionsDiv);
|
||||
|
||||
existingSharesContainer.appendChild(shareEl);
|
||||
});
|
||||
|
||||
// Add event listeners for copy and delete buttons
|
||||
document.querySelectorAll('.copy-link-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const url = btn.getAttribute('data-share-url');
|
||||
fileSharing.copyLinkToClipboard(url);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.delete-link-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const shareId = btn.getAttribute('data-share-id');
|
||||
|
||||
showConfirmDialog({
|
||||
title: i18n.t('dialogs.confirm_delete_share'),
|
||||
message: i18n.t('dialogs.confirm_delete_share_msg'),
|
||||
confirmText: i18n.t('actions.delete')
|
||||
}).then(async (confirmed) => {
|
||||
if (confirmed) {
|
||||
await fileSharing.removeSharedLink(shareId);
|
||||
btn.closest('.existing-share-item').remove();
|
||||
if (existingSharesContainer.children.length === 0) {
|
||||
document.getElementById('existing-shares-section').classList.add('hidden');
|
||||
ui.setSharedVisualState(item.id, itemType, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
document.getElementById('existing-shares-section').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Hide new-share section from previous use
|
||||
const newShareSection = document.getElementById('new-share-section');
|
||||
if (newShareSection) newShareSection.classList.add('hidden');
|
||||
|
||||
// Show dialog
|
||||
shareDialog.classList.remove('hidden');
|
||||
console.log('Share dialog opened for', itemType, item.name);
|
||||
} catch (error) {
|
||||
console.error('Error opening share dialog:', error);
|
||||
ui.showNotification('Error', 'Could not open share dialog');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a shared link with the configured options
|
||||
*/
|
||||
async createSharedLink() {
|
||||
if (!app.shareDialogItem || !app.shareDialogItemType) {
|
||||
ui.showNotification('Error', 'Could not share the item');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get values from form
|
||||
const password = /** @type HTMLInputElement */ (document.getElementById('share-password')).value;
|
||||
const expirationDate = /** @type HTMLInputElement */ (document.getElementById('share-expiration')).value;
|
||||
const permissionRead = /** @type HTMLInputElement */ (document.getElementById('share-permission-read')).checked;
|
||||
const permissionWrite = /** @type HTMLInputElement */ (document.getElementById('share-permission-write')).checked;
|
||||
const permissionReshare = /** @type HTMLInputElement */ (document.getElementById('share-permission-reshare')).checked;
|
||||
|
||||
const item = app.shareDialogItem;
|
||||
const itemType = app.shareDialogItemType;
|
||||
|
||||
// Build DTO for backend API
|
||||
const createDto = {
|
||||
item_id: item.id,
|
||||
item_name: item.name || null,
|
||||
item_type: itemType,
|
||||
password: password || null,
|
||||
expires_at: expirationDate ? Math.floor(new Date(expirationDate).getTime() / 1000) : null,
|
||||
permissions: {
|
||||
read: permissionRead,
|
||||
write: permissionWrite,
|
||||
reshare: permissionReshare
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...getCsrfHeaders()
|
||||
};
|
||||
|
||||
const response = await fetch('/api/shares', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(createDto)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errBody = await response.json().catch(() => ({}));
|
||||
throw new Error(errBody.error || `Server error ${response.status}`);
|
||||
}
|
||||
|
||||
const shareInfo = await response.json();
|
||||
|
||||
// Update UI with new share
|
||||
const shareUrl = /** @type HTMLInputElement */ (document.getElementById('generated-share-url'));
|
||||
if (shareUrl) {
|
||||
shareUrl.value = shareInfo.url;
|
||||
document.getElementById('new-share-section').classList.remove('hidden');
|
||||
shareUrl.focus();
|
||||
shareUrl.select();
|
||||
}
|
||||
|
||||
// Update Item's shared badge
|
||||
ui.setSharedVisualState(item.id, itemType, true);
|
||||
|
||||
// Show success message
|
||||
ui.showNotification(i18n.t('notifications.link_created'), i18n.t('notifications.share_success'));
|
||||
} catch (error) {
|
||||
console.error('Error creating shared link:', error);
|
||||
ui.showNotification('Error', /** @type {Error} */ (error).message || 'Could not create shared link');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Show email notification dialog
|
||||
* @param {string} shareUrl - URL to share
|
||||
@@ -991,16 +769,6 @@ const contextMenus = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Close share dialog
|
||||
*/
|
||||
closeShareDialog() {
|
||||
const dialog = document.getElementById('share-dialog');
|
||||
if (dialog) dialog.classList.add('hidden');
|
||||
app.shareDialogItem = null;
|
||||
app.shareDialogItemType = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Close notification dialog
|
||||
*/
|
||||
|
||||
@@ -201,7 +201,7 @@ const favorites = {
|
||||
const files = [];
|
||||
|
||||
for (const item of this._cache.values()) {
|
||||
// TODO: cast objects, but for that need to review user_id vs owner_id...
|
||||
// owner_id comes from the backend JOIN (actual file/folder owner, not the favoriter)
|
||||
if (item.item_type === 'folder') {
|
||||
folders.push(
|
||||
// FIXME: better to grab the real values
|
||||
@@ -215,7 +215,7 @@ const favorites = {
|
||||
created_at: item.created_at,
|
||||
icon_class: item.icon_class,
|
||||
icon_special_class: item.icon_special_class,
|
||||
owner_id: item.user_id,
|
||||
owner_id: item.owner_id ?? '',
|
||||
is_root: false
|
||||
}
|
||||
);
|
||||
@@ -234,7 +234,7 @@ const favorites = {
|
||||
size_formatted: item.size_formatted,
|
||||
modified_at: item.modified_at || item.created_at,
|
||||
path: item.item_path || '',
|
||||
owner_id: item.user_id,
|
||||
owner_id: item.owner_id ?? '',
|
||||
created_at: item.created_at,
|
||||
sort_date: item.created_at
|
||||
}
|
||||
@@ -246,6 +246,8 @@ const favorites = {
|
||||
|
||||
const filesList = document.getElementById('files-list');
|
||||
if (filesList) pathTooltip.init(filesList);
|
||||
|
||||
await ui.resolveOwnerCells();
|
||||
} catch (error) {
|
||||
console.error('Error displaying favorites:', error);
|
||||
if (ui?.showNotification) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { app } from '../../app/state.js';
|
||||
import { Modal } from '../../components/modal.js';
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { formatFileSize } from '../../core/formatters.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { oxiIcon } from '../../core/icons.js';
|
||||
import { Modal } from '../../core/modal.js';
|
||||
import { notifications } from '../../core/notifications.js';
|
||||
|
||||
/** @import {FileItem, Musicshare, Playlist, PlaylistItem} from '../../core/types.js' */
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Owner tooltip — shows "Shared by: <display name>" when hovering a
|
||||
* `.file-item[data-owner-id]` element.
|
||||
*
|
||||
* Reuses the existing `#path-tooltip` DOM element (same position and style)
|
||||
* so no extra CSS is needed. The tooltip is hidden immediately on mouseleave
|
||||
* and the display-name resolution is async-but-usually-instant because
|
||||
* `systemUsers` is pre-fetched when the Shared-with-me section is entered.
|
||||
*
|
||||
* Usage:
|
||||
* ownerTooltip.init(containerEl) — call after rendering items
|
||||
* ownerTooltip.destroy(containerEl) — call when leaving the section
|
||||
*/
|
||||
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { systemUsers } from '../model/systemUsers.js';
|
||||
|
||||
// ── Tooltip DOM ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** @returns {HTMLElement} */
|
||||
function _getOrCreateTooltip() {
|
||||
let el = document.getElementById('path-tooltip');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'path-tooltip';
|
||||
el.className = 'path-tooltip hidden';
|
||||
document.querySelector('.main-content')?.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function _hide() {
|
||||
document.getElementById('path-tooltip')?.classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Event handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
async function _onEnter(e) {
|
||||
const item = /** @type {HTMLElement} */ (e.currentTarget);
|
||||
const ownerId = item.dataset.ownerId;
|
||||
if (!ownerId) return;
|
||||
|
||||
if (!systemUsers.isAvailable()) return;
|
||||
|
||||
const tooltip = _getOrCreateTooltip();
|
||||
|
||||
// Show immediately with a placeholder so the tooltip appears without lag.
|
||||
const label = i18n.t('sharedwithme_sharedBy', 'Shared by');
|
||||
tooltip.textContent = `${label}: …`;
|
||||
tooltip.classList.remove('hidden');
|
||||
|
||||
// Resolve the name (usually instant from the pre-fetched cache).
|
||||
const name = await systemUsers.getDisplayName(ownerId);
|
||||
|
||||
// Guard: don't update if the user already moved away.
|
||||
if (!tooltip.classList.contains('hidden')) {
|
||||
tooltip.textContent = `${label}: ${name}`;
|
||||
}
|
||||
}
|
||||
|
||||
function _onLeave() {
|
||||
_hide();
|
||||
}
|
||||
|
||||
// ── Listener registry (WeakMap for leak-free cleanup) ────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {{ enter: (e: MouseEvent) => void, leave: () => void }} Handlers
|
||||
*/
|
||||
|
||||
/** @type {WeakMap<HTMLElement, Handlers>} */
|
||||
const _registry = new WeakMap();
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Attach owner-tooltip listeners to every `.file-item[data-owner-id]`
|
||||
* inside `container`.
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
function init(container) {
|
||||
for (const item of container.querySelectorAll('.file-item[data-owner-id]')) {
|
||||
const el = /** @type {HTMLElement} */ (item);
|
||||
if (_registry.has(el)) continue; // already wired
|
||||
|
||||
/** @type {(e: MouseEvent) => void} */
|
||||
const enter = (e) => {
|
||||
_onEnter(e);
|
||||
}; // intentionally discard the Promise
|
||||
const leave = () => _onLeave();
|
||||
|
||||
el.addEventListener('mouseenter', enter);
|
||||
el.addEventListener('mouseleave', leave);
|
||||
_registry.set(el, { enter, leave });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove owner-tooltip listeners from all `.file-item` elements inside
|
||||
* `container` and hide any visible tooltip.
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
function destroy(container) {
|
||||
for (const item of container.querySelectorAll('.file-item')) {
|
||||
const el = /** @type {HTMLElement} */ (item);
|
||||
const h = _registry.get(el);
|
||||
if (h) {
|
||||
el.removeEventListener('mouseenter', h.enter);
|
||||
el.removeEventListener('mouseleave', h.leave);
|
||||
_registry.delete(el);
|
||||
}
|
||||
}
|
||||
_hide();
|
||||
}
|
||||
|
||||
export const ownerTooltip = { init, destroy };
|
||||
@@ -0,0 +1,179 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Address Book model.
|
||||
*
|
||||
* Provides access to all address books (user-owned + shared + the virtual
|
||||
* system book) and their contacts. Serves as the single source of truth for
|
||||
* contact data across the application — sharing dialogs, owner tooltips, etc.
|
||||
*
|
||||
* Caching strategy
|
||||
* ─────────────────
|
||||
* • System book — cached for the whole session (contacts = OxiCloud users,
|
||||
* changes rarely and requires a page reload to pick up anyway).
|
||||
* • User books — cached on first load; call `invalidate(bookId)` after a
|
||||
* write (create/update/delete contact) to force a re-fetch.
|
||||
*
|
||||
* The system book returns 404 when `OXICLOUD_EXPOSE_SYSTEM_USERS` is disabled.
|
||||
* In that case `isSystemAvailable()` returns false and all callers degrade
|
||||
* gracefully.
|
||||
*/
|
||||
|
||||
/** @import {AddressBookItem, ContactItem} from '../core/types.js' */
|
||||
|
||||
/** Sentinel id for the virtual system address book. */
|
||||
export const SYSTEM_BOOK_ID = 'system';
|
||||
|
||||
/** @type {AddressBookItem[] | null} */
|
||||
let _books = null;
|
||||
|
||||
/** @type {Map<string, ContactItem[]>} bookId → contacts (loaded books) */
|
||||
const _contactCache = new Map();
|
||||
|
||||
/** @type {Map<string, Promise<ContactItem[]>>} bookId → in-flight request */
|
||||
const _inflight = new Map();
|
||||
|
||||
/**
|
||||
* `null` = not yet attempted
|
||||
* `true` = loaded successfully at least once
|
||||
* `false` = 404 / feature disabled
|
||||
* @type {boolean | null}
|
||||
*/
|
||||
let _systemAvailable = null;
|
||||
|
||||
// ── Address books ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List all address books accessible to the current user.
|
||||
* Result is cached for the session.
|
||||
* @returns {Promise<AddressBookItem[]>}
|
||||
*/
|
||||
async function listBooks() {
|
||||
if (_books !== null) return _books;
|
||||
const res = await fetch('/api/address-books', { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`addressBook.listBooks: HTTP ${res.status}`);
|
||||
_books = /** @type {AddressBookItem[]} */ (await res.json());
|
||||
return _books;
|
||||
}
|
||||
|
||||
// ── Contacts ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List contacts in an address book.
|
||||
*
|
||||
* Results are cached per book id. For the system book, a 404 is treated as
|
||||
* "feature disabled" — an empty array is returned and `isSystemAvailable()`
|
||||
* will report false.
|
||||
*
|
||||
* @param {string} bookId
|
||||
* @param {{ limit?: number, offset?: number }} [opts]
|
||||
* @returns {Promise<ContactItem[]>}
|
||||
*/
|
||||
async function listContacts(bookId, opts = {}) {
|
||||
if (_contactCache.has(bookId)) {
|
||||
return /** @type {ContactItem[]} */ (_contactCache.get(bookId));
|
||||
}
|
||||
|
||||
if (_inflight.has(bookId)) {
|
||||
return /** @type {Promise<ContactItem[]>} */ (_inflight.get(bookId));
|
||||
}
|
||||
|
||||
const p = (async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
|
||||
if (opts.offset !== undefined) params.set('offset', String(opts.offset));
|
||||
const qs = params.size ? `?${params}` : '';
|
||||
|
||||
const res = await fetch(`/api/address-books/${encodeURIComponent(bookId)}/contacts${qs}`, {
|
||||
credentials: 'same-origin',
|
||||
cache: 'default'
|
||||
});
|
||||
|
||||
if (res.status === 404 && bookId === SYSTEM_BOOK_ID) {
|
||||
_systemAvailable = false;
|
||||
_contactCache.set(bookId, []);
|
||||
return /** @type {ContactItem[]} */ ([]);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`addressBook.listContacts(${bookId}): HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const contacts = /** @type {ContactItem[]} */ (await res.json());
|
||||
_contactCache.set(bookId, contacts);
|
||||
if (bookId === SYSTEM_BOOK_ID) _systemAvailable = true;
|
||||
return contacts;
|
||||
} finally {
|
||||
_inflight.delete(bookId);
|
||||
}
|
||||
})();
|
||||
|
||||
_inflight.set(bookId, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the contact cache for a given book so the next `listContacts`
|
||||
* call re-fetches from the server. Call after any write operation.
|
||||
* @param {string} bookId
|
||||
*/
|
||||
function invalidate(bookId) {
|
||||
_contactCache.delete(bookId);
|
||||
}
|
||||
|
||||
// ── Search ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Search contacts across one or more address books.
|
||||
*
|
||||
* Matching is case-insensitive against the full name, first+last name, and
|
||||
* primary email. Books are fetched and cached on first use.
|
||||
*
|
||||
* @param {string} query
|
||||
* @param {string[]} [bookIds] - Books to search. Defaults to all cached books.
|
||||
* Pass `[SYSTEM_BOOK_ID]` to restrict to OxiCloud users.
|
||||
* @returns {Promise<ContactItem[]>}
|
||||
*/
|
||||
async function searchContacts(query, bookIds) {
|
||||
const ids = bookIds ?? [..._contactCache.keys()];
|
||||
const q = query.toLowerCase().trim();
|
||||
if (!q) return [];
|
||||
|
||||
/** @type {ContactItem[]} */
|
||||
const results = [];
|
||||
|
||||
for (const id of ids) {
|
||||
const contacts = await listContacts(id);
|
||||
for (const c of contacts) {
|
||||
const fullName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || '';
|
||||
const primaryEmail = c.email?.find((e) => e.is_primary)?.email ?? c.email?.[0]?.email ?? '';
|
||||
|
||||
if (fullName.toLowerCase().includes(q) || primaryEmail.toLowerCase().includes(q)) {
|
||||
results.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── Status ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Whether the system address book is (or may be) available.
|
||||
* Returns `true` when status is unknown (not yet fetched).
|
||||
* Returns `false` only after a confirmed 404.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSystemAvailable() {
|
||||
return _systemAvailable !== false;
|
||||
}
|
||||
|
||||
export const addressBook = {
|
||||
listBooks,
|
||||
listContacts,
|
||||
invalidate,
|
||||
searchContacts,
|
||||
isSystemAvailable
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* @import {Grant, ResourceTypeEnum, SharedWithMeResponse} from '../core/types.js'
|
||||
*/
|
||||
|
||||
import { getCsrfHeaders } from '../core/csrf.js';
|
||||
|
||||
const grants = {
|
||||
/** @type {Record<String, Record<String, Grant[]>>} */
|
||||
outgoingGrants: {},
|
||||
|
||||
/** @type {Record<String, Record<String, Grant[]>>} */
|
||||
incomingGrants: {},
|
||||
|
||||
async fetchOutgoingGrants() {
|
||||
const response = await fetch('/api/grants/outgoing');
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`error ${response.status} while fetching /api/grants/outgoing`);
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {Grant[]} */
|
||||
const outgoingGrants = await response.json();
|
||||
|
||||
// Reset and rebuild cache
|
||||
this.outgoingGrants = {};
|
||||
|
||||
// store grants by type, then by id
|
||||
outgoingGrants.forEach((grant) => {
|
||||
this.outgoingGrants[grant.resource.type] ??= {};
|
||||
this.outgoingGrants[grant.resource.type][grant.resource.id] ??= [];
|
||||
this.outgoingGrants[grant.resource.type][grant.resource.id].push(grant);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* get grant for a resource
|
||||
* @param {ResourceTypeEnum} resourceType
|
||||
* @param {String} id
|
||||
* @returns {Grant[] | null}
|
||||
*/
|
||||
getOutgoingGrantsFor(resourceType, id) {
|
||||
try {
|
||||
return this.outgoingGrants[resourceType][id] ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async fetchIncomingGrants() {
|
||||
const response = await fetch('/api/grants/incoming');
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`error ${response.status} while fetching /api/grants/incoming`);
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {Grant[]} */
|
||||
const incomingGrants = await response.json();
|
||||
|
||||
// store grants by type, then by id
|
||||
incomingGrants.forEach((grant) => {
|
||||
this.incomingGrants[grant.resource.type] ??= {};
|
||||
this.incomingGrants[grant.resource.type][grant.resource.id] ??= [];
|
||||
this.incomingGrants[grant.resource.type][grant.resource.id].push(grant);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* get grant for a resource
|
||||
* @param {ResourceTypeEnum} resourceType
|
||||
* @param {String} id
|
||||
* @returns {Grant[] | null}
|
||||
*/
|
||||
getIncomingGrantsFor(resourceType, id) {
|
||||
try {
|
||||
return this.incomingGrants[resourceType][id] ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a cursor-paginated list of resources shared with the current user,
|
||||
* with full file / folder metadata resolved server-side.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {ResourceTypeEnum[]} [opts.resourceTypes] - Resource types to include (default: ['file','folder']).
|
||||
* @param {number} [opts.limit] - Max items per page (1–200, default 50).
|
||||
* @param {string} [opts.cursor] - Opaque cursor from a previous call; omit for first page.
|
||||
* @returns {Promise<SharedWithMeResponse>}
|
||||
*/
|
||||
async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
resource_types: resourceTypes.join(',')
|
||||
});
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
|
||||
const response = await fetch(`/api/grants/incoming/resources?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch shared-with-me items: 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.
|
||||
*
|
||||
* @param {ResourceTypeEnum} resourceType
|
||||
* @param {string} resourceId
|
||||
* @returns {Promise<Grant[]>}
|
||||
*/
|
||||
async fetchGrantsForResource(resourceType, resourceId) {
|
||||
const params = new URLSearchParams({ resource_type: resourceType, resource_id: resourceId });
|
||||
const response = await fetch(`/api/grants?${params}`, { credentials: 'same-origin' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`fetchGrantsForResource: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
/** @type {Grant[]} */
|
||||
const result = await response.json();
|
||||
|
||||
// Refresh the outgoing cache for this resource
|
||||
this.outgoingGrants[resourceType] ??= {};
|
||||
this.outgoingGrants[resourceType][resourceId] = result;
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new grant.
|
||||
* Body mirrors `CreateGrantDto`: `{ subject, resource, role }` OR `{ subject, resource, permissions }`.
|
||||
*
|
||||
* @param {Object} dto - CreateGrantDto shape
|
||||
* @returns {Promise<Grant[]>}
|
||||
*/
|
||||
async createGrant(dto) {
|
||||
const response = await fetch('/api/grants', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
||||
body: JSON.stringify(dto)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.error || `createGrant: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Reconcile a subject's role on a resource (replaces all their permissions).
|
||||
* Body mirrors `UpdateRoleDto`: `{ subject, resource, role }`.
|
||||
*
|
||||
* @param {Object} dto - UpdateRoleDto shape
|
||||
* @returns {Promise<Grant[]>}
|
||||
*/
|
||||
async updateRole(dto) {
|
||||
const response = await fetch('/api/grants/role', {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
||||
body: JSON.stringify(dto)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.error || `updateRole: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Revoke a single grant by its UUID.
|
||||
*
|
||||
* @param {string} grantId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async revokeGrant(grantId) {
|
||||
const response = await fetch(`/api/grants/${encodeURIComponent(grantId)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`revokeGrant: HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export { grants };
|
||||
@@ -0,0 +1,97 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* System-users convenience layer.
|
||||
*
|
||||
* Thin wrapper over `addressBook.listContacts(SYSTEM_BOOK_ID)` that
|
||||
* provides a userId → display-name index. Used wherever a grant's
|
||||
* `granted_by` UUID needs to be shown as a human-readable name
|
||||
* (owner tooltips, share dialogs, etc.).
|
||||
*
|
||||
* Falls back gracefully when the system address book is disabled
|
||||
* server-side (`OXICLOUD_EXPOSE_SYSTEM_USERS` not set): `isAvailable()`
|
||||
* returns false and `getDisplayName()` returns a shortened UUID.
|
||||
*/
|
||||
|
||||
/** @import {ContactItem} from '../core/types.js' */
|
||||
|
||||
import { addressBook, SYSTEM_BOOK_ID } from './addressBook.js';
|
||||
|
||||
/** @type {Map<string, string> | null} userId → display name, built lazily */
|
||||
let _index = null;
|
||||
|
||||
/**
|
||||
* Derive the best human-readable name from a contact.
|
||||
* Priority: "First Last" → full_name → primary email → shortened id.
|
||||
* @param {ContactItem} c
|
||||
* @returns {string}
|
||||
*/
|
||||
function _nameFor(c) {
|
||||
const parts = /** @type {string[]} */ ([c.first_name, c.last_name].filter(Boolean));
|
||||
if (parts.length) return parts.join(' ');
|
||||
if (c.full_name) return c.full_name;
|
||||
const mail = c.email?.find((e) => e.is_primary)?.email ?? c.email?.[0]?.email;
|
||||
if (mail) return mail;
|
||||
return `${c.id.slice(0, 8)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the index is built (idempotent).
|
||||
* After loading contacts from the system address book, the current user
|
||||
* (from localStorage) is injected so owner cells resolve correctly even
|
||||
* when the server-side address book does not include the logged-in user.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function _ensureIndex() {
|
||||
if (_index !== null) return;
|
||||
const contacts = await addressBook.listContacts(SYSTEM_BOOK_ID);
|
||||
_index = new Map(contacts.map((c) => [c.id, _nameFor(c)]));
|
||||
|
||||
// Inject the current user if they are not already in the index
|
||||
try {
|
||||
const raw = localStorage.getItem('oxicloud_user');
|
||||
if (raw) {
|
||||
const u = /** @type {{id?:string, display_name?:string, username?:string, email?:string}} */ (JSON.parse(raw));
|
||||
if (u?.id && !_index.has(u.id)) {
|
||||
const name = u.display_name || u.username || u.email || `${u.id.slice(0, 8)}…`;
|
||||
_index.set(u.id, name);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available or JSON is invalid — silently skip
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start loading the system address book in the background.
|
||||
* Safe to call multiple times — subsequent calls are no-ops once loaded.
|
||||
*/
|
||||
function prefetch() {
|
||||
if (!addressBook.isSystemAvailable()) return;
|
||||
_ensureIndex(); // intentionally fire-and-forget
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user UUID to a display name.
|
||||
* Awaits the first load if not yet cached; subsequent calls resolve instantly.
|
||||
*
|
||||
* @param {string} userId
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function getDisplayName(userId) {
|
||||
await _ensureIndex();
|
||||
return _index?.get(userId) ?? `${userId.slice(0, 8)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `false` only after a confirmed 404 from the server (feature
|
||||
* disabled). Returns `true` when status is unknown or the book loaded OK.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isAvailable() {
|
||||
return addressBook.isSystemAvailable();
|
||||
}
|
||||
|
||||
export const systemUsers = { prefetch, getDisplayName, isAvailable };
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* OxiCloud – "Shared with me" view.
|
||||
*
|
||||
* Renders files and folders that other users have explicitly granted the
|
||||
* current user access to, using the cursor-paginated
|
||||
* `GET /api/grants/incoming/resources` endpoint.
|
||||
*
|
||||
* Reuses the existing `#files-list` container and `ui.renderFolders` /
|
||||
* `ui.renderFiles` so the grid ↔ list toggle and all card components work
|
||||
* out of the box. A "Load more" button is injected below the files container
|
||||
* for cursor-based pagination.
|
||||
*
|
||||
* NOTE: the grid/list container will be extracted into a reusable component
|
||||
* in a future refactor — this view is intentionally kept thin.
|
||||
*/
|
||||
|
||||
import { ui } from '../../app/ui.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { multiSelect } from '../../features/files/multiSelect.js';
|
||||
import { ownerTooltip } from '../../features/ownerTooltip.js';
|
||||
import { grants } from '../../model/grants.js';
|
||||
import { systemUsers } from '../../model/systemUsers.js';
|
||||
|
||||
/** @import {SharedWithMeItem, FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */
|
||||
|
||||
/** ID of the "Load more" wrapper injected below `.files-container`. */
|
||||
const LOAD_MORE_ID = 'swm-load-more-wrapper';
|
||||
|
||||
const sharedWithMeView = {
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** @type {string|null} */
|
||||
_nextCursor: null,
|
||||
|
||||
_loading: false,
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* (Re-)load from page 1 and render into the existing files container.
|
||||
* Called every time the user switches to this section.
|
||||
*/
|
||||
async init() {
|
||||
this._nextCursor = null;
|
||||
this._loading = false;
|
||||
|
||||
this._ensureLoadMoreButton();
|
||||
|
||||
// Start fetching system users in background so tooltips resolve instantly
|
||||
// by the time the user hovers over an item.
|
||||
systemUsers.prefetch();
|
||||
|
||||
// Standard files-view setup: clear list, show container, init multiselect
|
||||
ui.resetFilesList();
|
||||
multiSelect.init();
|
||||
ui.updateBreadcrumb();
|
||||
|
||||
await this._loadPage();
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide the "Load more" button when leaving this section.
|
||||
* The files container itself is managed by navigation.js.
|
||||
*/
|
||||
hide() {
|
||||
const w = document.getElementById(LOAD_MORE_ID);
|
||||
if (w) w.classList.add('hidden');
|
||||
|
||||
const filesList = document.getElementById('files-list');
|
||||
if (filesList) ownerTooltip.destroy(filesList);
|
||||
},
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch one page, map items → FileItem / FolderItem, render them, then
|
||||
* stamp `data-owner-id` and wire the owner tooltip.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _loadPage() {
|
||||
if (this._loading) return;
|
||||
this._loading = true;
|
||||
|
||||
try {
|
||||
const data = await grants.fetchSharedWithMe({
|
||||
resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']),
|
||||
limit: 50,
|
||||
cursor: this._nextCursor ?? undefined
|
||||
});
|
||||
|
||||
this._nextCursor = data.next_cursor ?? null;
|
||||
|
||||
if (data.items.length === 0 && !this._nextCursor) {
|
||||
// First page came back empty
|
||||
ui.showError(`
|
||||
<i class="fas fa-share-alt empty-state-icon"></i>
|
||||
<p>${i18n.t('sharedwithme_emptyStateTitle', 'Nothing shared with you yet')}</p>
|
||||
<p>${i18n.t('sharedwithme_emptyStateDesc', 'Items shared with you by other users will appear here')}</p>
|
||||
`);
|
||||
this._setLoadMoreVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { folders, files, ownerMap } = this._mapItems(data.items);
|
||||
if (folders.length) ui.renderFolders(folders);
|
||||
if (files.length) ui.renderFiles(files);
|
||||
|
||||
// Stamp data-owner-id on the freshly-rendered cards and attach tooltips.
|
||||
const filesList = document.getElementById('files-list');
|
||||
if (filesList) {
|
||||
this._stampOwnerIds(filesList, ownerMap);
|
||||
ownerTooltip.init(filesList);
|
||||
}
|
||||
|
||||
// Fill the Owner column cells (idempotent: skips already-resolved rows).
|
||||
await ui.resolveOwnerCells();
|
||||
|
||||
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('sharedWithMeView: load error', err);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Map `SharedWithMeItem[]` to separate arrays for rendering plus an
|
||||
* `ownerMap` (itemId → grantedBy userId) used to stamp `data-owner-id`
|
||||
* after the cards are in the DOM.
|
||||
*
|
||||
* The backend already includes all display fields (`icon_class`,
|
||||
* `icon_special_class`, `category`, `size_formatted`) inside the nested
|
||||
* `file` / `folder` objects, so no client-side enrichment is needed.
|
||||
*
|
||||
* @param {SharedWithMeItem[]} items
|
||||
* @returns {{ folders: FolderItem[], files: FileItem[], ownerMap: Map<string,string> }}
|
||||
*/
|
||||
_mapItems(items) {
|
||||
/** @type {FolderItem[]} */
|
||||
const folders = [];
|
||||
|
||||
/** @type {FileItem[]} */
|
||||
const files = [];
|
||||
|
||||
/** @type {Map<string, string>} itemId → grantedBy userId */
|
||||
const ownerMap = new Map();
|
||||
|
||||
for (const item of items) {
|
||||
if (item.resource_type === 'folder' && item.folder) {
|
||||
const f = item.folder;
|
||||
folders.push(
|
||||
/** @type {FolderItem} */ ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
path: f.path ?? '',
|
||||
parent_id: f.parent_id ?? '',
|
||||
owner_id: f.owner_id ?? '',
|
||||
is_root: f.is_root ?? false,
|
||||
created_at: f.created_at,
|
||||
modified_at: f.modified_at,
|
||||
icon_class: f.icon_class,
|
||||
icon_special_class: f.icon_special_class ?? '',
|
||||
category: 'folder'
|
||||
})
|
||||
);
|
||||
ownerMap.set(f.id, item.granted_by);
|
||||
} else if (item.resource_type === 'file' && item.file) {
|
||||
const f = item.file;
|
||||
files.push(
|
||||
/** @type {FileItem} */ ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
path: f.path ?? '',
|
||||
folder_id: f.folder_id ?? '',
|
||||
owner_id: f.owner_id ?? '',
|
||||
mime_type: f.mime_type,
|
||||
size: f.size,
|
||||
size_formatted: f.size_formatted,
|
||||
created_at: f.created_at,
|
||||
modified_at: f.modified_at,
|
||||
sort_date: f.modified_at,
|
||||
icon_class: f.icon_class,
|
||||
icon_special_class: f.icon_special_class ?? '',
|
||||
category: f.category
|
||||
})
|
||||
);
|
||||
ownerMap.set(f.id, item.granted_by);
|
||||
}
|
||||
}
|
||||
|
||||
return { folders, files, ownerMap };
|
||||
},
|
||||
|
||||
/**
|
||||
* Walk `ownerMap` and set `data-owner-id` on matching `.file-item` cards
|
||||
* inside `container`. Must be called after `renderFolders`/`renderFiles`.
|
||||
*
|
||||
* @param {HTMLElement} container
|
||||
* @param {Map<string,string>} ownerMap itemId → grantedBy userId
|
||||
*/
|
||||
_stampOwnerIds(container, ownerMap) {
|
||||
for (const [itemId, ownerId] of ownerMap) {
|
||||
const el = container.querySelector(`[data-folder-id="${itemId}"], [data-file-id="${itemId}"]`);
|
||||
if (el instanceof HTMLElement) {
|
||||
el.dataset.ownerId = ownerId;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── "Load more" button ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create the "Load more" wrapper once and attach it below `.files-container`.
|
||||
* Subsequent calls are no-ops.
|
||||
*/
|
||||
_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 = 'swm-load-more-wrapper hidden';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'swm-load-more';
|
||||
btn.className = 'button secondary';
|
||||
btn.textContent = i18n.t('sharedwithme_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 { sharedWithMeView };
|
||||
@@ -0,0 +1 @@
|
||||
Put here all workers you don't want to be bundled into tha main application js
|
||||
+19
-4
@@ -5,12 +5,13 @@
|
||||
},
|
||||
"nav": {
|
||||
"files": "الملفات",
|
||||
"shared": "المشترك",
|
||||
"shared": "مشاركاتي",
|
||||
"recent": "الأخيرة",
|
||||
"favorites": "المفضلة",
|
||||
"photos": "الصور",
|
||||
"music": "الموسيقى",
|
||||
"trash": "سلة المهملات"
|
||||
"trash": "سلة المهملات",
|
||||
"sharedwithme": "مشتركة معي"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "لا توجد صور بعد",
|
||||
@@ -269,7 +270,8 @@
|
||||
"archive": "أرشيف",
|
||||
"installer": "مثبّت",
|
||||
"code": "كود"
|
||||
}
|
||||
},
|
||||
"owner": "المالك"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "إعادة تسمية المجلد",
|
||||
@@ -682,5 +684,18 @@
|
||||
"files": "ملفات",
|
||||
"complete": "{{count}} / {{total}} تم الرفع"
|
||||
},
|
||||
"storage_quota_exceeded": "تجاوز حصة التخزين"
|
||||
"storage_quota_exceeded": "تجاوز حصة التخزين",
|
||||
"sharedwithme": {
|
||||
"pageTitle": "مشترك معي",
|
||||
"pageDescription": "الملفات والمجلدات التي شاركها معك مستخدمون آخرون",
|
||||
"emptyStateTitle": "لم يُشارك معك أي شيء بعد",
|
||||
"emptyStateDesc": "ستظهر هنا العناصر التي يشاركها معك مستخدمون آخرون",
|
||||
"loadMore": "تحميل المزيد",
|
||||
"sharedBy": "مشترك من قِبل",
|
||||
"colName": "الاسم",
|
||||
"colType": "النوع",
|
||||
"colSharedBy": "مشترك من قِبل",
|
||||
"colDate": "تاريخ المشاركة",
|
||||
"colPermissions": "الصلاحيات"
|
||||
}
|
||||
}
|
||||
|
||||
+19
-4
@@ -5,12 +5,13 @@
|
||||
},
|
||||
"nav": {
|
||||
"files": "Dateien",
|
||||
"shared": "Geteilt",
|
||||
"shared": "Freigaben",
|
||||
"recent": "Zuletzt verwendet",
|
||||
"favorites": "Favoriten",
|
||||
"photos": "Fotos",
|
||||
"music": "Musik",
|
||||
"trash": "Papierkorb"
|
||||
"trash": "Papierkorb",
|
||||
"sharedwithme": "Mit mir geteilt"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Noch keine Fotos",
|
||||
@@ -269,7 +270,8 @@
|
||||
"archive": "Archiv",
|
||||
"installer": "Installationsdatei",
|
||||
"code": "Code"
|
||||
}
|
||||
},
|
||||
"owner": "Eigentümer"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Ordner umbenennen",
|
||||
@@ -682,5 +684,18 @@
|
||||
"files": "Dateien",
|
||||
"complete": "{{count}} / {{total}} hochgeladen"
|
||||
},
|
||||
"storage_quota_exceeded": "Speicherplatz erschöpft"
|
||||
"storage_quota_exceeded": "Speicherplatz erschöpft",
|
||||
"sharedwithme": {
|
||||
"pageTitle": "Mit mir geteilt",
|
||||
"pageDescription": "Dateien und Ordner, die andere Benutzer mit Ihnen geteilt haben",
|
||||
"emptyStateTitle": "Noch nichts mit Ihnen geteilt",
|
||||
"emptyStateDesc": "Elemente, die andere Benutzer mit Ihnen teilen, erscheinen hier",
|
||||
"loadMore": "Mehr laden",
|
||||
"sharedBy": "Geteilt von",
|
||||
"colName": "Name",
|
||||
"colType": "Typ",
|
||||
"colSharedBy": "Geteilt von",
|
||||
"colDate": "Datum der Freigabe",
|
||||
"colPermissions": "Berechtigungen"
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -5,7 +5,8 @@
|
||||
},
|
||||
"nav": {
|
||||
"files": "Files",
|
||||
"shared": "Shared",
|
||||
"shared": "My shares",
|
||||
"sharedwithme": "Shared with me",
|
||||
"recent": "Recent",
|
||||
"favorites": "Favorites",
|
||||
"photos": "Photos",
|
||||
@@ -269,7 +270,8 @@
|
||||
"archive": "Archive",
|
||||
"installer": "Installer",
|
||||
"code": "Code"
|
||||
}
|
||||
},
|
||||
"owner": "Owner"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Rename folder",
|
||||
@@ -682,5 +684,18 @@
|
||||
"files": "files",
|
||||
"complete": "{{count}} / {{total}} uploaded"
|
||||
},
|
||||
"storage_quota_exceeded": "Storage quota exceeded"
|
||||
"storage_quota_exceeded": "Storage quota exceeded",
|
||||
"sharedwithme": {
|
||||
"pageTitle": "Shared with me",
|
||||
"pageDescription": "Files and folders others have shared with you",
|
||||
"emptyStateTitle": "Nothing shared with you yet",
|
||||
"emptyStateDesc": "Items shared with you by other users will appear here",
|
||||
"loadMore": "Load more",
|
||||
"sharedBy": "Shared by",
|
||||
"colName": "Name",
|
||||
"colType": "Type",
|
||||
"colSharedBy": "Shared by",
|
||||
"colDate": "Date shared",
|
||||
"colPermissions": "Permissions"
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -10,7 +10,8 @@
|
||||
"favorites": "Favoritos",
|
||||
"photos": "Fotos",
|
||||
"music": "Música",
|
||||
"trash": "Papelera"
|
||||
"trash": "Papelera",
|
||||
"sharedwithme": "Compartidos conmigo"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Aún no hay fotos",
|
||||
@@ -269,7 +270,8 @@
|
||||
"archive": "Archivo comprimido",
|
||||
"installer": "Instalador",
|
||||
"code": "Código"
|
||||
}
|
||||
},
|
||||
"owner": "Propietario"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Renombrar carpeta",
|
||||
@@ -682,5 +684,18 @@
|
||||
"files": "archivos",
|
||||
"complete": "{{count}} / {{total}} subidos"
|
||||
},
|
||||
"storage_quota_exceeded": "Cuota de almacenamiento superada"
|
||||
"storage_quota_exceeded": "Cuota de almacenamiento superada",
|
||||
"sharedwithme": {
|
||||
"pageTitle": "Compartido conmigo",
|
||||
"pageDescription": "Archivos y carpetas que otros usuarios han compartido contigo",
|
||||
"emptyStateTitle": "Aún no hay nada compartido contigo",
|
||||
"emptyStateDesc": "Los elementos que otros usuarios compartan contigo aparecerán aquí",
|
||||
"loadMore": "Cargar más",
|
||||
"sharedBy": "Compartido por",
|
||||
"colName": "Nombre",
|
||||
"colType": "Tipo",
|
||||
"colSharedBy": "Compartido por",
|
||||
"colDate": "Fecha de compartición",
|
||||
"colPermissions": "Permisos"
|
||||
}
|
||||
}
|
||||
|
||||
+19
-4
@@ -5,12 +5,13 @@
|
||||
},
|
||||
"nav": {
|
||||
"files": "پروندهها",
|
||||
"shared": "همرسانی شده",
|
||||
"shared": "همرسانیهای من",
|
||||
"recent": "اخیر",
|
||||
"favorites": "موردعلاقهها",
|
||||
"photos": "عکسها",
|
||||
"music": "موسیقی",
|
||||
"trash": "سطل زباله"
|
||||
"trash": "سطل زباله",
|
||||
"sharedwithme": "به اشتراکگذاشته شده با من"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "هنوز عکسی نیست",
|
||||
@@ -269,7 +270,8 @@
|
||||
"archive": "بایگانی",
|
||||
"installer": "نصبکننده",
|
||||
"code": "کد"
|
||||
}
|
||||
},
|
||||
"owner": "مالک"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "تغییر نام پوشه",
|
||||
@@ -682,5 +684,18 @@
|
||||
"files": "فایلها",
|
||||
"complete": "{{count}} / {{total}} آپلود شد"
|
||||
},
|
||||
"storage_quota_exceeded": "سهمیه فضای ذخیرهسازی تجاوز کرده است"
|
||||
"storage_quota_exceeded": "سهمیه فضای ذخیرهسازی تجاوز کرده است",
|
||||
"sharedwithme": {
|
||||
"pageTitle": "به اشتراکگذاشته شده با من",
|
||||
"pageDescription": "فایلها و پوشههایی که کاربران دیگر با شما به اشتراک گذاشتهاند",
|
||||
"emptyStateTitle": "هنوز چیزی با شما به اشتراک گذاشته نشده",
|
||||
"emptyStateDesc": "مواردی که کاربران دیگر با شما به اشتراک میگذارند اینجا نمایش داده میشوند",
|
||||
"loadMore": "بارگذاری بیشتر",
|
||||
"sharedBy": "به اشتراکگذاشته توسط",
|
||||
"colName": "نام",
|
||||
"colType": "نوع",
|
||||
"colSharedBy": "به اشتراکگذاشته توسط",
|
||||
"colDate": "تاریخ اشتراکگذاری",
|
||||
"colPermissions": "مجوزها"
|
||||
}
|
||||
}
|
||||
|
||||
+19
-4
@@ -5,12 +5,13 @@
|
||||
},
|
||||
"nav": {
|
||||
"files": "Fichiers",
|
||||
"shared": "Partagés",
|
||||
"shared": "Partages",
|
||||
"recent": "Récents",
|
||||
"favorites": "Favoris",
|
||||
"photos": "Photos",
|
||||
"music": "Musique",
|
||||
"trash": "Corbeille"
|
||||
"trash": "Corbeille",
|
||||
"sharedwithme": "Partages avec moi"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Pas encore de photos",
|
||||
@@ -269,7 +270,8 @@
|
||||
"archive": "Archive",
|
||||
"installer": "Installateur",
|
||||
"code": "Code"
|
||||
}
|
||||
},
|
||||
"owner": "Propriétaire"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Renommer le dossier",
|
||||
@@ -682,5 +684,18 @@
|
||||
"files": "fichiers",
|
||||
"complete": "{{count}} / {{total}} téléchargés"
|
||||
},
|
||||
"storage_quota_exceeded": "Quota de stockage dépassé"
|
||||
"storage_quota_exceeded": "Quota de stockage dépassé",
|
||||
"sharedwithme": {
|
||||
"pageTitle": "Partagé avec moi",
|
||||
"pageDescription": "Fichiers et dossiers que d'autres utilisateurs ont partagés avec vous",
|
||||
"emptyStateTitle": "Rien n'a encore été partagé avec vous",
|
||||
"emptyStateDesc": "Les éléments partagés avec vous par d'autres utilisateurs apparaîtront ici",
|
||||
"loadMore": "Charger plus",
|
||||
"sharedBy": "Partagé par",
|
||||
"colName": "Nom",
|
||||
"colType": "Type",
|
||||
"colSharedBy": "Partagé par",
|
||||
"colDate": "Date de partage",
|
||||
"colPermissions": "Permissions"
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -10,7 +10,8 @@
|
||||
"favorites": "पसंदीदा",
|
||||
"photos": "फ़ोटो",
|
||||
"music": "संगीत",
|
||||
"trash": "रद्दी"
|
||||
"trash": "रद्दी",
|
||||
"sharedwithme": "मेरे साथ साझा किए गए"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "अभी कोई फ़ोटो नहीं",
|
||||
@@ -269,7 +270,8 @@
|
||||
"archive": "संग्रह",
|
||||
"installer": "इंस्टॉलर",
|
||||
"code": "कोड"
|
||||
}
|
||||
},
|
||||
"owner": "स्वामी"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "फ़ोल्डर का नाम बदलें",
|
||||
@@ -682,5 +684,18 @@
|
||||
"files": "फ़ाइलें",
|
||||
"complete": "{{count}} / {{total}} अपलोड हुए"
|
||||
},
|
||||
"storage_quota_exceeded": "स्टोरेज कोटा पार हो गया"
|
||||
"storage_quota_exceeded": "स्टोरेज कोटा पार हो गया",
|
||||
"sharedwithme": {
|
||||
"pageTitle": "मेरे साथ साझा किया",
|
||||
"pageDescription": "फ़ाइलें और फ़ोल्डर जो अन्य उपयोगकर्ताओं ने आपके साथ साझा किए हैं",
|
||||
"emptyStateTitle": "अभी तक आपके साथ कुछ भी साझा नहीं किया गया",
|
||||
"emptyStateDesc": "अन्य उपयोगकर्ताओं द्वारा आपके साथ साझा किए गए आइटम यहाँ दिखाई देंगे",
|
||||
"loadMore": "और लोड करें",
|
||||
"sharedBy": "द्वारा साझा किया",
|
||||
"colName": "नाम",
|
||||
"colType": "प्रकार",
|
||||
"colSharedBy": "द्वारा साझा किया",
|
||||
"colDate": "साझाकरण तिथि",
|
||||
"colPermissions": "अनुमतियाँ"
|
||||
}
|
||||
}
|
||||
|
||||
+19
-4
@@ -5,12 +5,13 @@
|
||||
},
|
||||
"nav": {
|
||||
"files": "File",
|
||||
"shared": "Condivisi",
|
||||
"shared": "Condivisioni",
|
||||
"recent": "Recenti",
|
||||
"favorites": "Preferiti",
|
||||
"photos": "Foto",
|
||||
"music": "Musica",
|
||||
"trash": "Cestino"
|
||||
"trash": "Cestino",
|
||||
"sharedwithme": "Condivisi con me"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Nessuna foto ancora",
|
||||
@@ -269,7 +270,8 @@
|
||||
"archive": "Archivio",
|
||||
"installer": "Programma di installazione",
|
||||
"code": "Codice"
|
||||
}
|
||||
},
|
||||
"owner": "Proprietario"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Rinomina cartella",
|
||||
@@ -682,5 +684,18 @@
|
||||
"files": "file",
|
||||
"complete": "{{count}} / {{total}} caricati"
|
||||
},
|
||||
"storage_quota_exceeded": "Quota di archiviazione superata"
|
||||
"storage_quota_exceeded": "Quota di archiviazione superata",
|
||||
"sharedwithme": {
|
||||
"pageTitle": "Condiviso con me",
|
||||
"pageDescription": "File e cartelle che altri utenti hanno condiviso con te",
|
||||
"emptyStateTitle": "Niente è ancora condiviso con te",
|
||||
"emptyStateDesc": "Gli elementi condivisi con te da altri utenti appariranno qui",
|
||||
"loadMore": "Carica altri",
|
||||
"sharedBy": "Condiviso da",
|
||||
"colName": "Nome",
|
||||
"colType": "Tipo",
|
||||
"colSharedBy": "Condiviso da",
|
||||
"colDate": "Data condivisione",
|
||||
"colPermissions": "Permessi"
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -10,7 +10,8 @@
|
||||
"favorites": "お気に入り",
|
||||
"photos": "写真",
|
||||
"music": "音楽",
|
||||
"trash": "ゴミ箱"
|
||||
"trash": "ゴミ箱",
|
||||
"sharedwithme": "自分と共有"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "写真はまだありません",
|
||||
@@ -269,7 +270,8 @@
|
||||
"archive": "アーカイブ",
|
||||
"installer": "インストーラー",
|
||||
"code": "コード"
|
||||
}
|
||||
},
|
||||
"owner": "オーナー"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "フォルダ名を変更",
|
||||
@@ -682,5 +684,18 @@
|
||||
"files": "ファイル",
|
||||
"complete": "{{count}} / {{total}} アップロード済み"
|
||||
},
|
||||
"storage_quota_exceeded": "ストレージ容量を超過しました"
|
||||
"storage_quota_exceeded": "ストレージ容量を超過しました",
|
||||
"sharedwithme": {
|
||||
"pageTitle": "自分と共有",
|
||||
"pageDescription": "他のユーザーがあなたと共有したファイルとフォルダー",
|
||||
"emptyStateTitle": "まだ何も共有されていません",
|
||||
"emptyStateDesc": "他のユーザーがあなたと共有したアイテムがここに表示されます",
|
||||
"loadMore": "さらに読み込む",
|
||||
"sharedBy": "共有者",
|
||||
"colName": "名前",
|
||||
"colType": "タイプ",
|
||||
"colSharedBy": "共有者",
|
||||
"colDate": "共有日",
|
||||
"colPermissions": "権限"
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -10,7 +10,8 @@
|
||||
"favorites": "즐겨찾기",
|
||||
"photos": "사진",
|
||||
"music": "음악",
|
||||
"trash": "휴지통"
|
||||
"trash": "휴지통",
|
||||
"sharedwithme": "나와 공유됨"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "아직 사진이 없습니다",
|
||||
@@ -269,7 +270,8 @@
|
||||
"archive": "아카이브",
|
||||
"installer": "설치 프로그램",
|
||||
"code": "코드"
|
||||
}
|
||||
},
|
||||
"owner": "소유자"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "폴더 이름 변경",
|
||||
@@ -682,5 +684,18 @@
|
||||
"files": "파일",
|
||||
"complete": "{{count}} / {{total}} 업로드됨"
|
||||
},
|
||||
"storage_quota_exceeded": "저장 공간 할당량 초과"
|
||||
"storage_quota_exceeded": "저장 공간 할당량 초과",
|
||||
"sharedwithme": {
|
||||
"pageTitle": "나와 공유됨",
|
||||
"pageDescription": "다른 사용자가 나와 공유한 파일 및 폴더",
|
||||
"emptyStateTitle": "아직 공유된 항목이 없습니다",
|
||||
"emptyStateDesc": "다른 사용자가 공유한 항목이 여기에 표시됩니다",
|
||||
"loadMore": "더 불러오기",
|
||||
"sharedBy": "공유한 사람",
|
||||
"colName": "이름",
|
||||
"colType": "유형",
|
||||
"colSharedBy": "공유한 사람",
|
||||
"colDate": "공유 날짜",
|
||||
"colPermissions": "권한"
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -10,7 +10,8 @@
|
||||
"favorites": "Favorieten",
|
||||
"photos": "Foto's",
|
||||
"music": "Muziek",
|
||||
"trash": "Prullenbak"
|
||||
"trash": "Prullenbak",
|
||||
"sharedwithme": "Gedeeld met mij"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Nog geen foto's",
|
||||
@@ -269,7 +270,8 @@
|
||||
"archive": "Archief",
|
||||
"installer": "Installatiebestand",
|
||||
"code": "Code"
|
||||
}
|
||||
},
|
||||
"owner": "Eigenaar"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Map hernoemen",
|
||||
@@ -682,5 +684,18 @@
|
||||
"files": "bestanden",
|
||||
"complete": "{{count}} / {{total}} geüpload"
|
||||
},
|
||||
"storage_quota_exceeded": "Opslagquotum overschreden"
|
||||
"storage_quota_exceeded": "Opslagquotum overschreden",
|
||||
"sharedwithme": {
|
||||
"pageTitle": "Gedeeld met mij",
|
||||
"pageDescription": "Bestanden en mappen die andere gebruikers met u hebben gedeeld",
|
||||
"emptyStateTitle": "Er is nog niets met u gedeeld",
|
||||
"emptyStateDesc": "Items die andere gebruikers met u delen, verschijnen hier",
|
||||
"loadMore": "Meer laden",
|
||||
"sharedBy": "Gedeeld door",
|
||||
"colName": "Naam",
|
||||
"colType": "Type",
|
||||
"colSharedBy": "Gedeeld door",
|
||||
"colDate": "Datum gedeeld",
|
||||
"colPermissions": "Machtigingen"
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -10,7 +10,8 @@
|
||||
"favorites": "Ulubione",
|
||||
"photos": "Zdjęcia",
|
||||
"music": "Muzyka",
|
||||
"trash": "Kosz"
|
||||
"trash": "Kosz",
|
||||
"sharedwithme": "Udostępnione dla mnie"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Brak zdjęć",
|
||||
@@ -269,7 +270,8 @@
|
||||
"archive": "Archiwum",
|
||||
"installer": "Instalator",
|
||||
"code": "Kod"
|
||||
}
|
||||
},
|
||||
"owner": "Właściciel"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Zmień nazwę folderu",
|
||||
@@ -682,5 +684,18 @@
|
||||
"files": "plików",
|
||||
"complete": "{{count}} / {{total}} przesłano"
|
||||
},
|
||||
"storage_quota_exceeded": "Przekroczono limit pamięci masowej"
|
||||
"storage_quota_exceeded": "Przekroczono limit pamięci masowej",
|
||||
"sharedwithme": {
|
||||
"pageTitle": "Udostępnione dla mnie",
|
||||
"pageDescription": "Pliki i foldery, które inni użytkownicy udostępnili Ci",
|
||||
"emptyStateTitle": "Nic nie zostało Ci jeszcze udostępnione",
|
||||
"emptyStateDesc": "Elementy udostępnione Ci przez innych użytkowników pojawią się tutaj",
|
||||
"loadMore": "Załaduj więcej",
|
||||
"sharedBy": "Udostępnione przez",
|
||||
"colName": "Nazwa",
|
||||
"colType": "Typ",
|
||||
"colSharedBy": "Udostępnione przez",
|
||||
"colDate": "Data udostępnienia",
|
||||
"colPermissions": "Uprawnienia"
|
||||
}
|
||||
}
|
||||
|
||||
+19
-4
@@ -5,12 +5,13 @@
|
||||
},
|
||||
"nav": {
|
||||
"files": "Arquivos",
|
||||
"shared": "Compartilhados",
|
||||
"shared": "Compartilhamentos",
|
||||
"recent": "Recentes",
|
||||
"favorites": "Favoritos",
|
||||
"photos": "Fotos",
|
||||
"music": "Música",
|
||||
"trash": "Lixeira"
|
||||
"trash": "Lixeira",
|
||||
"sharedwithme": "Compartilhados comigo"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Nenhuma foto ainda",
|
||||
@@ -269,7 +270,8 @@
|
||||
"archive": "Arquivo compactado",
|
||||
"installer": "Instalador",
|
||||
"code": "Código"
|
||||
}
|
||||
},
|
||||
"owner": "Proprietário"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Renomear pasta",
|
||||
@@ -682,5 +684,18 @@
|
||||
"files": "ficheiros",
|
||||
"complete": "{{count}} / {{total}} carregados"
|
||||
},
|
||||
"storage_quota_exceeded": "Cota de armazenamento excedida"
|
||||
"storage_quota_exceeded": "Cota de armazenamento excedida",
|
||||
"sharedwithme": {
|
||||
"pageTitle": "Compartilhado comigo",
|
||||
"pageDescription": "Arquivos e pastas que outros usuários compartilharam com você",
|
||||
"emptyStateTitle": "Nada compartilhado com você ainda",
|
||||
"emptyStateDesc": "Itens compartilhados com você por outros usuários aparecerão aqui",
|
||||
"loadMore": "Carregar mais",
|
||||
"sharedBy": "Compartilhado por",
|
||||
"colName": "Nome",
|
||||
"colType": "Tipo",
|
||||
"colSharedBy": "Compartilhado por",
|
||||
"colDate": "Data de compartilhamento",
|
||||
"colPermissions": "Permissões"
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -10,7 +10,8 @@
|
||||
"favorites": "Избранное",
|
||||
"photos": "Фото",
|
||||
"music": "Музыка",
|
||||
"trash": "Корзина"
|
||||
"trash": "Корзина",
|
||||
"sharedwithme": "Доступно мне"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Фотографий пока нет",
|
||||
@@ -269,7 +270,8 @@
|
||||
"archive": "Архив",
|
||||
"installer": "Установщик",
|
||||
"code": "Код"
|
||||
}
|
||||
},
|
||||
"owner": "Владелец"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Переименовать папку",
|
||||
@@ -682,5 +684,18 @@
|
||||
"files": "файлов",
|
||||
"complete": "{{count}} / {{total}} загружено"
|
||||
},
|
||||
"storage_quota_exceeded": "Превышена квота хранилища"
|
||||
"storage_quota_exceeded": "Превышена квота хранилища",
|
||||
"sharedwithme": {
|
||||
"pageTitle": "Доступно мне",
|
||||
"pageDescription": "Файлы и папки, которые другие пользователи предоставили вам",
|
||||
"emptyStateTitle": "Вам ещё ничего не предоставлено",
|
||||
"emptyStateDesc": "Элементы, которые другие пользователи предоставят вам, появятся здесь",
|
||||
"loadMore": "Загрузить ещё",
|
||||
"sharedBy": "Предоставлено",
|
||||
"colName": "Имя",
|
||||
"colType": "Тип",
|
||||
"colSharedBy": "Предоставлено",
|
||||
"colDate": "Дата предоставления",
|
||||
"colPermissions": "Права"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"favorites": "收藏",
|
||||
"photos": "照片",
|
||||
"music": "音樂",
|
||||
"trash": "回收站"
|
||||
"trash": "回收站",
|
||||
"sharedwithme": "與我共享"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "還沒有照片",
|
||||
@@ -269,7 +270,8 @@
|
||||
"archive": "壓縮檔案",
|
||||
"installer": "安裝程式",
|
||||
"code": "程式碼"
|
||||
}
|
||||
},
|
||||
"owner": "擁有者"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "重新命名資料夾",
|
||||
@@ -682,5 +684,18 @@
|
||||
"files": "個檔案",
|
||||
"complete": "已上傳 {{count}} / {{total}}"
|
||||
},
|
||||
"storage_quota_exceeded": "儲存配額已超限"
|
||||
"storage_quota_exceeded": "儲存配額已超限",
|
||||
"sharedwithme": {
|
||||
"pageTitle": "與我共享",
|
||||
"pageDescription": "其他使用者與您共享的檔案和資料夾",
|
||||
"emptyStateTitle": "目前沒有內容與您共享",
|
||||
"emptyStateDesc": "其他使用者與您共享的項目將顯示在這裡",
|
||||
"loadMore": "載入更多",
|
||||
"sharedBy": "共享者",
|
||||
"colName": "名稱",
|
||||
"colType": "類型",
|
||||
"colSharedBy": "共享者",
|
||||
"colDate": "共享日期",
|
||||
"colPermissions": "權限"
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -10,7 +10,8 @@
|
||||
"favorites": "收藏",
|
||||
"photos": "照片",
|
||||
"music": "音乐",
|
||||
"trash": "回收站"
|
||||
"trash": "回收站",
|
||||
"sharedwithme": "与我共享"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "还没有照片",
|
||||
@@ -269,7 +270,8 @@
|
||||
"archive": "压缩文件",
|
||||
"installer": "安装程序",
|
||||
"code": "代码"
|
||||
}
|
||||
},
|
||||
"owner": "所有者"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "重命名文件夹",
|
||||
@@ -682,5 +684,18 @@
|
||||
"files": "个文件",
|
||||
"complete": "已上传 {{count}} / {{total}}"
|
||||
},
|
||||
"storage_quota_exceeded": "存储配额已超限"
|
||||
"storage_quota_exceeded": "存储配额已超限",
|
||||
"sharedwithme": {
|
||||
"pageTitle": "与我共享",
|
||||
"pageDescription": "其他用户与您共享的文件和文件夹",
|
||||
"emptyStateTitle": "暂无内容与您共享",
|
||||
"emptyStateDesc": "其他用户与您共享的项目将显示在此处",
|
||||
"loadMore": "加载更多",
|
||||
"sharedBy": "共享者",
|
||||
"colName": "名称",
|
||||
"colType": "类型",
|
||||
"colSharedBy": "共享者",
|
||||
"colDate": "共享日期",
|
||||
"colPermissions": "权限"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ jsonpath "$[?(@.resource.id=='{{shared_folder_id}}')].permission" == "read"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — Promote Bob to Manager (adds comment, create, update, share).
|
||||
# Step 9 — Promote Bob to Admin (adds comment, create, update, share, delete).
|
||||
# PUT /api/grants/role reconciles the row set in one call.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/api/grants/role
|
||||
@@ -159,12 +159,12 @@ Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{dave_user_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{shared_folder_id}}" },
|
||||
"role": "manager"
|
||||
"role": "admin"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$" count == 5
|
||||
jsonpath "$" count == 6
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@@ -173,7 +173,7 @@ jsonpath "$" count == 5
|
||||
PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename
|
||||
Authorization: Bearer {{dave_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "renamed-by-bob-as-manager" }
|
||||
{ "name": "renamed-by-bob-as-admin" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
@@ -192,7 +192,7 @@ HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 12 — Bob re-shares to Carol (he has Share via Manager).
|
||||
# Step 12 — Bob re-shares to Carol (he has Share via Admin).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{dave_token}}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
iPurpose of this directory: implement load test and identify response time under:
|
||||
- heavy load
|
||||
- many content
|
||||
- many sub folders and sharing
|
||||
|
||||
Goal is to identify inflections and regression when a new feature is added
|
||||
|
||||
No accemtance criteria yet
|
||||
|
||||
Load test via k6 ?
|
||||
or drill (written in Rust) ?
|
||||
Reference in New Issue
Block a user