Merge pull request #390 from EdouardVanbelle/feat/shared-with-me
This commit is contained in:
@@ -54,6 +54,9 @@ db-down:
|
|||||||
front-dev:
|
front-dev:
|
||||||
PROFILE=dev cargo run
|
PROFILE=dev cargo run
|
||||||
|
|
||||||
|
# front: check all (linter, format, type, ...)
|
||||||
|
front-check: front-fmt front-lint front-type front-rules
|
||||||
|
|
||||||
front-fmt:
|
front-fmt:
|
||||||
biome format static/
|
biome format static/
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ pub struct FavoriteItemDto {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub item_path: Option<String>,
|
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 ──
|
// ── Pre-computed display fields ──
|
||||||
/// FontAwesome icon CSS class (e.g. "fas fa-file-image", "fas fa-folder")
|
/// FontAwesome icon CSS class (e.g. "fas fa-file-image", "fas fa-folder")
|
||||||
pub icon_class: String,
|
pub icon_class: String,
|
||||||
|
|||||||
@@ -119,6 +119,21 @@ impl From<FileDto> for File {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FileDto {
|
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
|
/// Creates an empty file DTO for stub implementations
|
||||||
pub fn empty() -> Self {
|
pub fn empty() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -107,6 +107,21 @@ impl From<FolderDto> for Folder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FolderDto {
|
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
|
/// Creates an empty folder DTO for stub implementations
|
||||||
pub fn empty() -> Self {
|
pub fn empty() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
//! storage-agnostic and DTOs can evolve with the HTTP contract.
|
//! storage-agnostic and DTOs can evolve with the HTTP contract.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::{IntoParams, ToSchema};
|
||||||
use uuid::Uuid;
|
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};
|
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -131,9 +133,9 @@ impl From<Permission> for PermissionDto {
|
|||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum Role {
|
pub enum Role {
|
||||||
Viewer,
|
Viewer,
|
||||||
Commenter,
|
//Commenter,
|
||||||
Editor,
|
Editor,
|
||||||
Manager,
|
//Manager,
|
||||||
Admin,
|
Admin,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,13 +146,16 @@ impl Role {
|
|||||||
pub fn expand(self) -> &'static [Permission] {
|
pub fn expand(self) -> &'static [Permission] {
|
||||||
match self {
|
match self {
|
||||||
Role::Viewer => &[Permission::Read],
|
Role::Viewer => &[Permission::Read],
|
||||||
|
/* reserved for future
|
||||||
Role::Commenter => &[Permission::Read, Permission::Comment],
|
Role::Commenter => &[Permission::Read, Permission::Comment],
|
||||||
|
*/
|
||||||
Role::Editor => &[
|
Role::Editor => &[
|
||||||
Permission::Read,
|
Permission::Read,
|
||||||
Permission::Comment,
|
Permission::Comment,
|
||||||
Permission::Create,
|
Permission::Create,
|
||||||
Permission::Update,
|
Permission::Update,
|
||||||
],
|
],
|
||||||
|
/* reserved for future
|
||||||
Role::Manager => &[
|
Role::Manager => &[
|
||||||
Permission::Read,
|
Permission::Read,
|
||||||
Permission::Comment,
|
Permission::Comment,
|
||||||
@@ -158,6 +163,7 @@ impl Role {
|
|||||||
Permission::Update,
|
Permission::Update,
|
||||||
Permission::Share,
|
Permission::Share,
|
||||||
],
|
],
|
||||||
|
*/
|
||||||
Role::Admin => &[
|
Role::Admin => &[
|
||||||
Permission::Read,
|
Permission::Read,
|
||||||
Permission::Comment,
|
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 uuid::Uuid;
|
||||||
|
|
||||||
use crate::common::errors::DomainError;
|
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 {
|
pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||||
/// Returns true if `subject` has `permission` on `resource`, considering
|
/// Returns true if `subject` has `permission` on `resource`, considering
|
||||||
@@ -67,6 +69,24 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
|||||||
permission_filter: Option<Permission>,
|
permission_filter: Option<Permission>,
|
||||||
) -> Result<Vec<Grant>, DomainError>;
|
) -> 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
|
/// All grants on a specific resource (for "Manage sharing" UI). Caller
|
||||||
/// must verify the caller has `Share` on the resource before invoking.
|
/// must verify the caller has `Share` on the resource before invoking.
|
||||||
async fn list_grants_on_resource(&self, resource: Resource) -> Result<Vec<Grant>, DomainError>;
|
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 uuid::Uuid;
|
||||||
|
|
||||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
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::SharePgRepository;
|
||||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
||||||
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
|
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
|
||||||
|
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||||
use crate::{
|
use crate::{
|
||||||
application::{
|
application::{
|
||||||
dtos::{
|
dtos::{
|
||||||
@@ -17,6 +19,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
ports::{
|
ports::{
|
||||||
auth_ports::PasswordHasherPort,
|
auth_ports::PasswordHasherPort,
|
||||||
|
authorization_ports::AuthorizationEngine,
|
||||||
share_ports::{ShareStoragePort, ShareUseCase},
|
share_ports::{ShareStoragePort, ShareUseCase},
|
||||||
storage_ports::FileReadPort,
|
storage_ports::FileReadPort,
|
||||||
},
|
},
|
||||||
@@ -78,6 +81,9 @@ pub struct ShareService {
|
|||||||
file_repository: Arc<FileBlobReadRepository>,
|
file_repository: Arc<FileBlobReadRepository>,
|
||||||
folder_repository: Arc<FolderDbRepository>,
|
folder_repository: Arc<FolderDbRepository>,
|
||||||
password_hasher: Arc<Argon2PasswordHasher>,
|
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
|
/// Bounds the number of in-flight Argon2 password hashes to avoid
|
||||||
/// saturating the blocking thread pool and consuming excessive RAM.
|
/// saturating the blocking thread pool and consuming excessive RAM.
|
||||||
hash_semaphore: Arc<Semaphore>,
|
hash_semaphore: Arc<Semaphore>,
|
||||||
@@ -90,6 +96,7 @@ impl ShareService {
|
|||||||
file_repository: Arc<FileBlobReadRepository>,
|
file_repository: Arc<FileBlobReadRepository>,
|
||||||
folder_repository: Arc<FolderDbRepository>,
|
folder_repository: Arc<FolderDbRepository>,
|
||||||
password_hasher: Arc<Argon2PasswordHasher>,
|
password_hasher: Arc<Argon2PasswordHasher>,
|
||||||
|
authorization: Arc<PgAclEngine>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
@@ -97,6 +104,7 @@ impl ShareService {
|
|||||||
file_repository,
|
file_repository,
|
||||||
folder_repository,
|
folder_repository,
|
||||||
password_hasher,
|
password_hasher,
|
||||||
|
authorization,
|
||||||
hash_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HASHES)),
|
hash_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HASHES)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,6 +264,50 @@ impl ShareUseCase for ShareService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
.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
|
// Convert the entity to DTO for the response
|
||||||
Ok(ShareDto::from_entity(&saved_share, &self.config.base_url()))
|
Ok(ShareDto::from_entity(&saved_share, &self.config.base_url()))
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -514,6 +514,7 @@ impl AppServiceFactory {
|
|||||||
&self,
|
&self,
|
||||||
repos: &RepositoryServices,
|
repos: &RepositoryServices,
|
||||||
db_pool: &Arc<PgPool>,
|
db_pool: &Arc<PgPool>,
|
||||||
|
authorization: &Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
||||||
) -> Option<Arc<ShareService>> {
|
) -> Option<Arc<ShareService>> {
|
||||||
if !self.config.features.enable_file_sharing {
|
if !self.config.features.enable_file_sharing {
|
||||||
tracing::info!("File sharing service is disabled in configuration");
|
tracing::info!("File sharing service is disabled in configuration");
|
||||||
@@ -537,6 +538,7 @@ impl AppServiceFactory {
|
|||||||
repos.file_read_repository.clone(),
|
repos.file_read_repository.clone(),
|
||||||
repos.folder_repository.clone(),
|
repos.folder_repository.clone(),
|
||||||
password_hasher,
|
password_hasher,
|
||||||
|
authorization.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
tracing::info!("File sharing service initialized");
|
tracing::info!("File sharing service initialized");
|
||||||
@@ -652,7 +654,7 @@ impl AppServiceFactory {
|
|||||||
self.create_application_services(&core, &repos, trash_service.clone(), &authorization);
|
self.create_application_services(&core, &repos, trash_service.clone(), &authorization);
|
||||||
|
|
||||||
// 5. Share service
|
// 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();
|
apps.share_service = share_service.clone();
|
||||||
|
|
||||||
let share_browse_service = share_service.as_ref().map(|s| {
|
let share_browse_service = share_service.as_ref().map(|s| {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
//! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation
|
//! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation
|
||||||
//! maps them to / from `storage.access_grants` rows.
|
//! maps them to / from `storage.access_grants` rows.
|
||||||
|
|
||||||
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -201,6 +202,84 @@ pub struct Grant {
|
|||||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
|||||||
WHEN uf.item_type = 'folder' THEN fld.path
|
WHEN uf.item_type = 'folder' THEN fld.path
|
||||||
WHEN uf.item_type = 'file' THEN COALESCE(pfld.path || '/' || f.name, f.name)
|
WHEN uf.item_type = 'file' THEN COALESCE(pfld.path || '/' || f.name, f.name)
|
||||||
ELSE NULL
|
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
|
FROM auth.user_favorites uf
|
||||||
LEFT JOIN storage.files f ON uf.item_type = 'file'
|
LEFT JOIN storage.files f ON uf.item_type = 'file'
|
||||||
AND f.id = uf.item_id::UUID
|
AND f.id = uf.item_id::UUID
|
||||||
@@ -78,6 +79,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
|||||||
parent_id: row.try_get("parent_id").ok(),
|
parent_id: row.try_get("parent_id").ok(),
|
||||||
modified_at: row.try_get("modified_at").ok(),
|
modified_at: row.try_get("modified_at").ok(),
|
||||||
item_path: row.try_get("item_path").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
|
// Temporary defaults; with_display_fields() computes the real values
|
||||||
icon_class: String::new(),
|
icon_class: String::new(),
|
||||||
icon_special_class: String::new(),
|
icon_special_class: String::new(),
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ use sqlx::PgPool;
|
|||||||
|
|
||||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||||
use crate::common::errors::DomainError;
|
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::file_blob_read_repository::FileBlobReadRepository;
|
||||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
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()
|
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> {
|
async fn list_grants_on_resource(&self, resource: Resource) -> Result<Vec<Grant>, DomainError> {
|
||||||
let rows = sqlx::query_as::<
|
let rows = sqlx::query_as::<
|
||||||
_,
|
_,
|
||||||
|
|||||||
@@ -11,23 +11,32 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
|
use futures::future::join_all;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info, warn};
|
||||||
use utoipa::IntoParams;
|
use utoipa::IntoParams;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::application::dtos::grant_dto::{
|
use crate::application::dtos::grant_dto::{
|
||||||
CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, SubjectDto,
|
CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, SharedWithMeDto,
|
||||||
UpdateRoleDto,
|
SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto,
|
||||||
};
|
};
|
||||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
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::common::errors::DomainError;
|
||||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
use crate::domain::errors::ErrorKind;
|
||||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
use crate::domain::services::authorization::{
|
||||||
|
GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject,
|
||||||
|
};
|
||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
use crate::interfaces::middleware::auth::AuthUser;
|
use crate::interfaces::middleware::auth::AuthUser;
|
||||||
|
|
||||||
|
type AppStateRef = Arc<AppState>;
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
// POST /api/grants
|
// POST /api/grants
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -44,10 +53,11 @@ use crate::interfaces::middleware::auth::AuthUser;
|
|||||||
tag = "grants"
|
tag = "grants"
|
||||||
)]
|
)]
|
||||||
pub async fn create_grant(
|
pub async fn create_grant(
|
||||||
State(authz): State<Arc<PgAclEngine>>,
|
State(state): State<AppStateRef>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Json(dto): Json<CreateGrantDto>,
|
Json(dto): Json<CreateGrantDto>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let authz = &state.authorization;
|
||||||
let caller_id = auth_user.id;
|
let caller_id = auth_user.id;
|
||||||
|
|
||||||
// Validate: exactly one of permissions/role
|
// Validate: exactly one of permissions/role
|
||||||
@@ -118,10 +128,11 @@ pub async fn create_grant(
|
|||||||
tag = "grants"
|
tag = "grants"
|
||||||
)]
|
)]
|
||||||
pub async fn revoke_grant(
|
pub async fn revoke_grant(
|
||||||
State(authz): State<Arc<PgAclEngine>>,
|
State(state): State<AppStateRef>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let authz = &state.authorization;
|
||||||
let caller_id = auth_user.id;
|
let caller_id = auth_user.id;
|
||||||
let grant_id = match Uuid::parse_str(&id) {
|
let grant_id = match Uuid::parse_str(&id) {
|
||||||
Ok(u) => u,
|
Ok(u) => u,
|
||||||
@@ -129,7 +140,7 @@ pub async fn revoke_grant(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Look up the grant to find the underlying resource (and granter).
|
// 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(Some((res, granter))) => (res, granter),
|
||||||
Ok(None) => return StatusCode::NO_CONTENT.into_response(), // idempotent
|
Ok(None) => return StatusCode::NO_CONTENT.into_response(), // idempotent
|
||||||
Err(e) => return AppError::from(e).into_response(),
|
Err(e) => return AppError::from(e).into_response(),
|
||||||
@@ -151,16 +162,6 @@ pub async fn revoke_grant(
|
|||||||
StatusCode::NO_CONTENT.into_response()
|
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
|
// PUT /api/grants/role
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -176,10 +177,11 @@ async fn find_grant_resource(
|
|||||||
tag = "grants"
|
tag = "grants"
|
||||||
)]
|
)]
|
||||||
pub async fn set_role(
|
pub async fn set_role(
|
||||||
State(authz): State<Arc<PgAclEngine>>,
|
State(state): State<AppStateRef>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Json(dto): Json<UpdateRoleDto>,
|
Json(dto): Json<UpdateRoleDto>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let authz = &state.authorization;
|
||||||
let caller_id = auth_user.id;
|
let caller_id = auth_user.id;
|
||||||
let subject: Subject = dto.subject.into();
|
let subject: Subject = dto.subject.into();
|
||||||
let resource: Resource = dto.resource.into();
|
let resource: Resource = dto.resource.into();
|
||||||
@@ -262,12 +264,13 @@ pub struct IncomingQuery {
|
|||||||
tag = "grants"
|
tag = "grants"
|
||||||
)]
|
)]
|
||||||
pub async fn list_incoming(
|
pub async fn list_incoming(
|
||||||
State(authz): State<Arc<PgAclEngine>>,
|
State(state): State<AppStateRef>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Query(q): Query<IncomingQuery>,
|
Query(q): Query<IncomingQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let caller_id = auth_user.id;
|
let caller_id = auth_user.id;
|
||||||
match authz
|
match state
|
||||||
|
.authorization
|
||||||
.list_incoming_grants(Subject::User(caller_id), q.permission.map(Into::into))
|
.list_incoming_grants(Subject::User(caller_id), q.permission.map(Into::into))
|
||||||
.await
|
.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
|
// GET /api/grants/outgoing
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -292,11 +459,11 @@ pub async fn list_incoming(
|
|||||||
tag = "grants"
|
tag = "grants"
|
||||||
)]
|
)]
|
||||||
pub async fn list_outgoing(
|
pub async fn list_outgoing(
|
||||||
State(authz): State<Arc<PgAclEngine>>,
|
State(state): State<AppStateRef>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let caller_id = auth_user.id;
|
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) => {
|
Ok(grants) => {
|
||||||
let dtos: Vec<GrantDto> = grants.into_iter().map(Into::into).collect();
|
let dtos: Vec<GrantDto> = grants.into_iter().map(Into::into).collect();
|
||||||
(StatusCode::OK, Json(dtos)).into_response()
|
(StatusCode::OK, Json(dtos)).into_response()
|
||||||
@@ -327,10 +494,11 @@ pub struct OnResourceQuery {
|
|||||||
tag = "grants"
|
tag = "grants"
|
||||||
)]
|
)]
|
||||||
pub async fn list_on_resource(
|
pub async fn list_on_resource(
|
||||||
State(authz): State<Arc<PgAclEngine>>,
|
State(state): State<AppStateRef>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Query(q): Query<OnResourceQuery>,
|
Query(q): Query<OnResourceQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let authz = &state.authorization;
|
||||||
let caller_id = auth_user.id;
|
let caller_id = auth_user.id;
|
||||||
let resource: Resource = ResourceDto {
|
let resource: Resource = ResourceDto {
|
||||||
kind: q.resource_type,
|
kind: q.resource_type,
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ use crate::application::dtos::folder_dto::{
|
|||||||
CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto,
|
CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto,
|
||||||
};
|
};
|
||||||
use crate::application::dtos::folder_listing_dto::FolderListingDto;
|
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::{
|
use crate::application::dtos::i18n_dto::{
|
||||||
LocaleDto, TranslationErrorDto, TranslationRequestDto, TranslationResponseDto,
|
LocaleDto, TranslationErrorDto, TranslationRequestDto, TranslationResponseDto,
|
||||||
};
|
};
|
||||||
@@ -202,6 +206,14 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
|||||||
handlers::admin_handler::complete_migration,
|
handlers::admin_handler::complete_migration,
|
||||||
handlers::admin_handler::verify_migration,
|
handlers::admin_handler::verify_migration,
|
||||||
handlers::admin_handler::generate_encryption_key,
|
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(
|
components(
|
||||||
schemas(
|
schemas(
|
||||||
@@ -275,6 +287,18 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
|||||||
UpdateContactRequest,
|
UpdateContactRequest,
|
||||||
GroupNameRequest,
|
GroupNameRequest,
|
||||||
AddMemberRequest,
|
AddMemberRequest,
|
||||||
|
// Grant / ReBAC schemas
|
||||||
|
SubjectTypeDto,
|
||||||
|
SubjectDto,
|
||||||
|
ResourceTypeDto,
|
||||||
|
ResourceDto,
|
||||||
|
PermissionDto,
|
||||||
|
Role,
|
||||||
|
CreateGrantDto,
|
||||||
|
UpdateRoleDto,
|
||||||
|
GrantDto,
|
||||||
|
SharedWithMeDto,
|
||||||
|
SharedWithMeItemDto,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
@@ -293,6 +317,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
|||||||
(name = "playlists", description = "Music playlist endpoints"),
|
(name = "playlists", description = "Music playlist endpoints"),
|
||||||
(name = "contacts", description = "Address books, contacts, and groups endpoints"),
|
(name = "contacts", description = "Address books, contacts, and groups endpoints"),
|
||||||
(name = "admin", description = "Admin management endpoints"),
|
(name = "admin", description = "Admin management endpoints"),
|
||||||
|
(name = "grants", description = "ReBAC grant management endpoints"),
|
||||||
),
|
),
|
||||||
info(
|
info(
|
||||||
title = "OxiCloud API",
|
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 share_service = app_state.share_service.clone();
|
||||||
let favorites_service = app_state.favorites_service.clone();
|
let favorites_service = app_state.favorites_service.clone();
|
||||||
let recent_service = app_state.recent_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
|
// Initialize the batch operations service
|
||||||
let mut batch_service_builder = BatchOperationService::default(
|
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()
|
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 = {
|
let grants_router = {
|
||||||
use crate::interfaces::api::handlers::grant_handler;
|
use crate::interfaces::api::handlers::grant_handler;
|
||||||
Router::new()
|
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("/{id}", delete(grant_handler::revoke_grant))
|
||||||
.route("/role", put(grant_handler::set_role))
|
.route("/role", put(grant_handler::set_role))
|
||||||
.route("/incoming", get(grant_handler::list_incoming))
|
.route("/incoming", get(grant_handler::list_incoming))
|
||||||
|
.route(
|
||||||
|
"/incoming/resources",
|
||||||
|
get(grant_handler::list_shared_with_me),
|
||||||
|
)
|
||||||
.route("/outgoing", get(grant_handler::list_outgoing))
|
.route("/outgoing", get(grant_handler::list_outgoing))
|
||||||
.with_state(authorization.clone())
|
.with_state(app_state.clone())
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create a router without the i18n routes
|
// Create a router without the i18n routes
|
||||||
|
|||||||
@@ -388,6 +388,12 @@
|
|||||||
--color-badge-success-fill: #047857;
|
--color-badge-success-fill: #047857;
|
||||||
--color-badge-success-fill-dark: #064e27;
|
--color-badge-success-fill-dark: #064e27;
|
||||||
--color-badge-success-fill-faint: #f0fdf4;
|
--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) */
|
/* Status badge — light mode (error/red) */
|
||||||
--color-badge-error-border: #fecaca;
|
--color-badge-error-border: #fecaca;
|
||||||
@@ -395,6 +401,8 @@
|
|||||||
/* Status badge — light mode (warning/amber) */
|
/* Status badge — light mode (warning/amber) */
|
||||||
--color-badge-warning-text: #92400e;
|
--color-badge-warning-text: #92400e;
|
||||||
--color-badge-warning-border: #fde68a;
|
--color-badge-warning-border: #fde68a;
|
||||||
|
--color-badge-amber-bg: #fef3c7;
|
||||||
|
--color-badge-amber-text: #f59e0b;
|
||||||
|
|
||||||
/* Status badge — light mode (indigo/purple) */
|
/* Status badge — light mode (indigo/purple) */
|
||||||
--color-badge-indigo-bg: #ede9fe;
|
--color-badge-indigo-bg: #ede9fe;
|
||||||
|
|||||||
@@ -100,12 +100,32 @@
|
|||||||
min-width: 0;
|
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 {
|
.files-list-view .file-item .size-cell {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
text-align: right;
|
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-view {
|
||||||
--files-list-columns: 36px minmax(200px, 2fr) 100px 110px 130px 72px;
|
--files-list-columns: 36px minmax(200px, 2fr) 100px 110px 130px 72px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -271,7 +291,8 @@
|
|||||||
|
|
||||||
/* element hidden on grid view */
|
/* element hidden on grid view */
|
||||||
.files-grid-view .file-item .date-cell,
|
.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;
|
display: none;
|
||||||
}
|
}
|
||||||
/* Selection checkbox */
|
/* Selection checkbox */
|
||||||
|
|||||||
@@ -190,6 +190,9 @@
|
|||||||
color: var(--color-text-heading);
|
color: var(--color-text-heading);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close-btn {
|
.modal-close-btn {
|
||||||
@@ -307,3 +310,20 @@
|
|||||||
.modal-footer .btn-primary:active {
|
.modal-footer .btn-primary:active {
|
||||||
transform: translateY(0);
|
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/dialogs.css");
|
||||||
@import url("./components/modals.css");
|
@import url("./components/modals.css");
|
||||||
@import url("./components/shareDialog.css");
|
@import url("./components/shareDialog.css");
|
||||||
|
@import url("./components/shareModal.css");
|
||||||
|
@import url("./components/userVignette.css");
|
||||||
@import url("./components/uploadDropdown.css");
|
@import url("./components/uploadDropdown.css");
|
||||||
@import url("./components/notifications.css");
|
@import url("./components/notifications.css");
|
||||||
@import url("./components/userMenu.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/favorites.css">
|
||||||
<link rel="stylesheet" href="/css/views/recent.css">
|
<link rel="stylesheet" href="/css/views/recent.css">
|
||||||
<link rel="stylesheet" href="/css/views/shared.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/trash.css">
|
||||||
<link rel="stylesheet" href="/css/views/photos.css">
|
<link rel="stylesheet" href="/css/views/photos.css">
|
||||||
<link rel="stylesheet" href="/css/views/photosLightbox.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/csrf.js"></script>
|
||||||
<script defer type="module" src="/js/core/languageSelector.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/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/core/formatters.js"></script>
|
||||||
<script defer type="module" src="/js/app/state.js"></script>
|
<script defer type="module" src="/js/app/state.js"></script>
|
||||||
<script defer type="module" src="/js/app/uiFileTypes.js"></script>
|
<script defer type="module" src="/js/app/uiFileTypes.js"></script>
|
||||||
@@ -76,9 +77,13 @@
|
|||||||
<span data-i18n="nav.files">Files</span>
|
<span data-i18n="nav.files">Files</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item" id="nav-shared">
|
<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>
|
<span data-i18n="nav.shared">Shared</span>
|
||||||
</div>
|
</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">
|
<div class="nav-item">
|
||||||
<i class="fas fa-clock"></i>
|
<i class="fas fa-clock"></i>
|
||||||
<span data-i18n="nav.recent">Recent</span>
|
<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() {
|
async function rebuildBreadCrumb() {
|
||||||
/**
|
/**
|
||||||
@@ -69,23 +79,29 @@ async function rebuildBreadCrumb() {
|
|||||||
currentFolderInfo = folderInfo;
|
currentFolderInfo = folderInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
// XXX do not enter root into bread crumb updateBreadcrumb() method always display it
|
// Add every folder to the breadcrumb, including the root (home folder).
|
||||||
if (!folderInfo.is_root) {
|
// updateBreadcrumb() no longer auto-prepends home — it's our responsibility here.
|
||||||
app.breadcrumbPath.unshift({
|
app.breadcrumbPath.unshift({
|
||||||
id: folderInfo.id,
|
id: folderInfo.id,
|
||||||
name: folderInfo.name
|
name: folderInfo.name
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// iterate to parent folder
|
// iterate to parent folder
|
||||||
id = folderInfo.parent_id;
|
id = folderInfo.parent_id;
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
console.log(`Error loading information from folder ${app.currentPath}, falling back to ${app.userHomeFolderId}`);
|
if (currentFolderInfo === null) {
|
||||||
// fallback of root
|
// Failed on the target folder itself — real error, fall 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');
|
console.warn(`Cannot access target folder ${app.currentPath}, falling back to home`);
|
||||||
app.breadcrumbPath = [];
|
uiNotifications.show('error: folder not found or permission denied', 'the given folder is not available or you do not have sufficient rights');
|
||||||
id = app.userHomeFolderId;
|
app.breadcrumbPath = [];
|
||||||
if (id) app.currentPath = id;
|
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 {
|
} else {
|
||||||
ui.renderFolders(folderList);
|
ui.renderFolders(folderList);
|
||||||
ui.renderFiles(fileList);
|
ui.renderFiles(fileList);
|
||||||
|
ui.resolveOwnerCells();
|
||||||
|
|
||||||
// check if a file was provided
|
// check if a file was provided
|
||||||
if (app.viewFile) {
|
if (app.viewFile) {
|
||||||
|
|||||||
+22
-2
@@ -7,19 +7,21 @@ import { installFetchInterceptor } from '../core/fetchWrapper.js';
|
|||||||
|
|
||||||
installFetchInterceptor();
|
installFetchInterceptor();
|
||||||
|
|
||||||
|
import { Modal } from '../components/modal.js';
|
||||||
import { formatFileSize, formatQuotaSize } from '../core/formatters.js';
|
import { formatFileSize, formatQuotaSize } from '../core/formatters.js';
|
||||||
import { i18n } from '../core/i18n.js';
|
import { i18n } from '../core/i18n.js';
|
||||||
import { oxiIconsInit } from '../core/icons.js';
|
import { oxiIconsInit } from '../core/icons.js';
|
||||||
import { Modal } from '../core/modal.js';
|
|
||||||
import { fileOps } from '../features/files/fileOperations.js';
|
import { fileOps } from '../features/files/fileOperations.js';
|
||||||
import { multiSelect } from '../features/files/multiSelect.js';
|
import { multiSelect } from '../features/files/multiSelect.js';
|
||||||
import { favorites } from '../features/library/favorites.js';
|
import { favorites } from '../features/library/favorites.js';
|
||||||
import { recent } from '../features/library/recent.js';
|
import { recent } from '../features/library/recent.js';
|
||||||
import { fileSharing } from '../features/sharing/fileSharing.js';
|
import { fileSharing } from '../features/sharing/fileSharing.js';
|
||||||
|
import { grants } from '../model/grants.js';
|
||||||
import { sharedView } from '../views/shared/sharedView.js';
|
import { sharedView } from '../views/shared/sharedView.js';
|
||||||
import { checkAuthentication } from './authSession.js';
|
import { checkAuthentication } from './authSession.js';
|
||||||
import { loadFiles } from './filesView.js';
|
import { loadFiles } from './filesView.js';
|
||||||
import {
|
import {
|
||||||
|
activateFilesUI,
|
||||||
SECTIONS_MAPPER,
|
SECTIONS_MAPPER,
|
||||||
switchToFavoritesSection,
|
switchToFavoritesSection,
|
||||||
switchToFilesSection,
|
switchToFilesSection,
|
||||||
@@ -27,6 +29,7 @@ import {
|
|||||||
switchToPhotosSection,
|
switchToPhotosSection,
|
||||||
switchToRecentFilesSection,
|
switchToRecentFilesSection,
|
||||||
switchToSharedSection,
|
switchToSharedSection,
|
||||||
|
switchToSharedWithMeSection,
|
||||||
switchToTrashSection
|
switchToTrashSection
|
||||||
} from './navigation.js';
|
} from './navigation.js';
|
||||||
import { performSearch } from './searchView.js';
|
import { performSearch } from './searchView.js';
|
||||||
@@ -140,12 +143,16 @@ const ACTIONS_BAR_TEMPLATES = {
|
|||||||
</div>
|
</div>
|
||||||
${_multiSelectButons}
|
${_multiSelectButons}
|
||||||
${_toggleButtons}
|
${_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]
|
* @param {boolean} [force=false]
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
@@ -398,6 +405,9 @@ function initApp() {
|
|||||||
app.viewFile = hashContext.file;
|
app.viewFile = hashContext.file;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// get grants (xxx: async methods)
|
||||||
|
await grants.fetchIncomingGrants();
|
||||||
|
await grants.fetchOutgoingGrants();
|
||||||
loadFiles();
|
loadFiles();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -652,6 +662,10 @@ function setupEventListeners() {
|
|||||||
switchToSharedSection();
|
switchToSharedSection();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'nav.sharedwithme':
|
||||||
|
switchToSharedWithMeSection();
|
||||||
|
break;
|
||||||
|
|
||||||
case 'nav.favorites':
|
case 'nav.favorites':
|
||||||
// Switch to favorites view
|
// Switch to favorites view
|
||||||
switchToFavoritesSection();
|
switchToFavoritesSection();
|
||||||
@@ -723,6 +737,12 @@ function setupEventListeners() {
|
|||||||
* @param {string} name
|
* @param {string} name
|
||||||
*/
|
*/
|
||||||
export function selectFolder(id, 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.breadcrumbPath.push({ id, name });
|
||||||
app.currentPath = id;
|
app.currentPath = id;
|
||||||
ui.updateBreadcrumb();
|
ui.updateBreadcrumb();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { musicView } from '../features/library/music.js';
|
|||||||
import { photosView } from '../features/library/photos.js';
|
import { photosView } from '../features/library/photos.js';
|
||||||
import { recent } from '../features/library/recent.js';
|
import { recent } from '../features/library/recent.js';
|
||||||
import { sharedView } from '../views/shared/sharedView.js';
|
import { sharedView } from '../views/shared/sharedView.js';
|
||||||
|
import { sharedWithMeView } from '../views/sharedWithMe/sharedWithMeView.js';
|
||||||
import { loadFiles } from './filesView.js';
|
import { loadFiles } from './filesView.js';
|
||||||
import { setActionsBarMode } from './main.js';
|
import { setActionsBarMode } from './main.js';
|
||||||
import { app, appElements } from './state.js';
|
import { app, appElements } from './state.js';
|
||||||
@@ -122,6 +123,7 @@ function getSectionFromNavItem(navItem) {
|
|||||||
export const SECTIONS_MAPPER = {
|
export const SECTIONS_MAPPER = {
|
||||||
files: switchToFilesSection,
|
files: switchToFilesSection,
|
||||||
shared: switchToSharedSection,
|
shared: switchToSharedSection,
|
||||||
|
sharedwithme: switchToSharedWithMeSection,
|
||||||
recent: switchToRecentFilesSection,
|
recent: switchToRecentFilesSection,
|
||||||
favorites: switchToFavoritesSection,
|
favorites: switchToFavoritesSection,
|
||||||
trash: switchToTrashSection,
|
trash: switchToTrashSection,
|
||||||
@@ -157,6 +159,14 @@ function setCurrentSection(section) {
|
|||||||
sharedView.hide();
|
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
|
// Hide photosView when switching to any other section
|
||||||
if (section !== 'photos' && photosView) {
|
if (section !== 'photos' && photosView) {
|
||||||
photosView.hide();
|
photosView.hide();
|
||||||
@@ -194,12 +204,38 @@ function switchToSharedSection() {
|
|||||||
if (multiSelect) multiSelect.clear();
|
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() {
|
function switchToFilesSection() {
|
||||||
if (!setCurrentSection('files')) return;
|
if (!setCurrentSection('files')) return;
|
||||||
|
|
||||||
// Set actions bar mode
|
// Set actions bar mode
|
||||||
setActionsBarMode('files', true);
|
setActionsBarMode('files', true);
|
||||||
|
|
||||||
|
// Show owner column in the Files section
|
||||||
|
ui.setOwnerColumnVisible(true);
|
||||||
|
|
||||||
// Show breadcrumb (only in Files view)
|
// Show breadcrumb (only in Files view)
|
||||||
const breadcrumb = document.querySelector('.breadcrumb');
|
const breadcrumb = document.querySelector('.breadcrumb');
|
||||||
breadcrumb?.classList.remove('hidden');
|
breadcrumb?.classList.remove('hidden');
|
||||||
@@ -231,6 +267,9 @@ function switchToFavoritesSection() {
|
|||||||
// Set actions bar mode
|
// Set actions bar mode
|
||||||
setActionsBarMode('favorites');
|
setActionsBarMode('favorites');
|
||||||
|
|
||||||
|
// Show the Owner column — names are resolved async after render.
|
||||||
|
ui.setOwnerColumnVisible(true);
|
||||||
|
|
||||||
// Hide breadcrumb (only shown in Files view)
|
// Hide breadcrumb (only shown in Files view)
|
||||||
const breadcrumb = document.querySelector('.breadcrumb');
|
const breadcrumb = document.querySelector('.breadcrumb');
|
||||||
breadcrumb?.classList.add('hidden');
|
breadcrumb?.classList.add('hidden');
|
||||||
@@ -368,13 +407,34 @@ function switchToMusicSection() {
|
|||||||
if (multiSelect) multiSelect.clear();
|
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 {
|
export {
|
||||||
|
activateFilesUI,
|
||||||
switchToFavoritesSection,
|
switchToFavoritesSection,
|
||||||
switchToFilesSection,
|
switchToFilesSection,
|
||||||
switchToMusicSection,
|
switchToMusicSection,
|
||||||
switchToPhotosSection,
|
switchToPhotosSection,
|
||||||
switchToRecentFilesSection,
|
switchToRecentFilesSection,
|
||||||
switchToSharedSection,
|
switchToSharedSection,
|
||||||
|
switchToSharedWithMeSection,
|
||||||
switchToTrashSection,
|
switchToTrashSection,
|
||||||
syncViewContainers
|
syncViewContainers
|
||||||
};
|
};
|
||||||
|
|||||||
+79
-137
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
|
import { shareModal } from '../components/shareModal.js';
|
||||||
|
import { createUserVignette } from '../components/userVignette.js';
|
||||||
import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js';
|
import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js';
|
||||||
import { i18n } from '../core/i18n.js';
|
import { i18n } from '../core/i18n.js';
|
||||||
import { OxiIcons } from '../core/icons.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 { wopiEditor } from '../features/files/wopiEditor.js';
|
||||||
import { favorites } from '../features/library/favorites.js';
|
import { favorites } from '../features/library/favorites.js';
|
||||||
import { recent } from '../features/library/recent.js';
|
import { recent } from '../features/library/recent.js';
|
||||||
import { fileSharing } from '../features/sharing/fileSharing.js';
|
|
||||||
import { thumbnail } from '../features/thumbnail.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 { loadFiles } from './filesView.js';
|
||||||
import { updateHistory } from './main.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 { app } from './state.js';
|
||||||
import { uiFileTypes } from './uiFileTypes.js';
|
import { uiFileTypes } from './uiFileTypes.js';
|
||||||
import { uiNotifications } from './uiNotifications.js';
|
import { uiNotifications } from './uiNotifications.js';
|
||||||
@@ -37,6 +39,12 @@ const ui = {
|
|||||||
/** @type {HTMLDivElement | null} */
|
/** @type {HTMLDivElement | null} */
|
||||||
draggedItems: 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
|
* 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>
|
<i class="fas fa-star"></i> <span data-i18n="actions.favorite">Add to favorites</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item" id="share-folder-option">
|
<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>
|
||||||
<div class="context-menu-separator"></div>
|
<div class="context-menu-separator"></div>
|
||||||
<div class="context-menu-item" id="rename-folder-option">
|
<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>
|
<i class="fas fa-star"></i> <span data-i18n="actions.favorite">Add to favorites</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item" id="share-file-option">
|
<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>
|
||||||
<div class="context-menu-item hidden" id="add-to-playlist-option">
|
<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>
|
<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);
|
document.body.appendChild(moveDialog);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Share dialog
|
// Share dialog is now handled by shareModal (components/shareModal.js)
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notification dialog
|
// Notification dialog
|
||||||
if (!document.getElementById('notification-dialog')) {
|
if (!document.getElementById('notification-dialog')) {
|
||||||
@@ -544,6 +444,47 @@ const ui = {
|
|||||||
syncViewContainers();
|
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.
|
* Update breadcrumb navigation from the breadcrumbPath array.
|
||||||
* Renders: Home > folder1 > folder2 > ...
|
* Renders: Home > folder1 > folder2 > ...
|
||||||
@@ -574,18 +515,11 @@ const ui = {
|
|||||||
}
|
}
|
||||||
breadcrumb?.appendChild(homeIcon);
|
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 --
|
// -- 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) => {
|
path.forEach((segment, index) => {
|
||||||
const isLast = index === path.length - 1;
|
const isLast = index === path.length - 1;
|
||||||
|
|
||||||
@@ -937,6 +871,11 @@ const ui = {
|
|||||||
loadFiles();
|
loadFiles();
|
||||||
return;
|
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.breadcrumbPath.push({ id: folderId, name: folderName });
|
||||||
app.currentPath = folderId;
|
app.currentPath = folderId;
|
||||||
this.updateBreadcrumb();
|
this.updateBreadcrumb();
|
||||||
@@ -1247,15 +1186,14 @@ const ui = {
|
|||||||
const itemType = itemElement.dataset.fileId ? 'file' : 'folder';
|
const itemType = itemElement.dataset.fileId ? 'file' : 'folder';
|
||||||
const itemName = itemElement.dataset.fileId ? itemElement.dataset.fileName : itemElement.dataset.folderName;
|
const itemName = itemElement.dataset.fileId ? itemElement.dataset.fileName : itemElement.dataset.folderName;
|
||||||
|
|
||||||
// TODO corrently dirty
|
const item = /** @type {FileItem|FolderItem} */ (
|
||||||
const item = /** @type {unknown} */ ({
|
/** @type {unknown} */ ({
|
||||||
id: itemId,
|
id: itemId,
|
||||||
item_id: itemId,
|
name: itemName
|
||||||
item_type: itemType,
|
})
|
||||||
item_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;
|
if (folder.path) el.dataset.path = folder.path;
|
||||||
|
|
||||||
const isFav = favorites?.isFavorite(folder.id, 'folder');
|
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);
|
const formattedDate = formatDateTime(folder.modified_at);
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
@@ -1345,8 +1283,9 @@ const ui = {
|
|||||||
</div>
|
</div>
|
||||||
<span>${escapeHtml(folder.name)}</span>
|
<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-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>
|
||||||
|
<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="type-cell">${i18n.t('files.file_types.folder')}</div>
|
||||||
<div class="size-cell">--</div>
|
<div class="size-cell">--</div>
|
||||||
<div class="date-cell">${formattedDate}</div>
|
<div class="date-cell">${formattedDate}</div>
|
||||||
@@ -1377,7 +1316,8 @@ const ui = {
|
|||||||
const fileSize = file.size_formatted || formatFileSize(file.size);
|
const fileSize = file.size_formatted || formatFileSize(file.size);
|
||||||
const formattedDate = formatDateTime(file.modified_at);
|
const formattedDate = formatDateTime(file.modified_at);
|
||||||
const isFav = favorites?.isFavorite(file.id, 'file');
|
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 canThumbnail = thumbnail.canHandle(file);
|
||||||
|
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
@@ -1398,8 +1338,9 @@ const ui = {
|
|||||||
</div>
|
</div>
|
||||||
<span>${escapeHtml(file.name)}</span>
|
<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-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>
|
||||||
|
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(file.owner_id || '')}"></div>
|
||||||
<div class="type-cell">${typeLabel}</div>
|
<div class="type-cell">${typeLabel}</div>
|
||||||
<div class="size-cell">${fileSize}</div>
|
<div class="size-cell">${fileSize}</div>
|
||||||
<div class="date-cell">${formattedDate}</div>
|
<div class="date-cell">${formattedDate}</div>
|
||||||
@@ -1439,6 +1380,7 @@ const ui = {
|
|||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<div class="list-header-checkbox"><input type="checkbox" id="select-all-checkbox" title="Select all"></div>
|
<div class="list-header-checkbox"><input type="checkbox" id="select-all-checkbox" title="Select all"></div>
|
||||||
<div data-i18n="files.name">Name</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.type">Type</div>
|
||||||
<div data-i18n="files.size">Size</div>
|
<div data-i18n="files.size">Size</div>
|
||||||
<div data-i18n="files.modified">Modified</div>
|
<div data-i18n="files.modified">Modified</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { i18n } from './i18n.js';
|
import { i18n } from '../core/i18n.js';
|
||||||
import { replaceIconsInElement } from './icons.js';
|
import { replaceIconsInElement } from '../core/icons.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modal System for OxiCloud
|
* Modal System for OxiCloud
|
||||||
@@ -40,6 +40,14 @@ const Modal = {
|
|||||||
// Rename mode: select only name without extension
|
// Rename mode: select only name without extension
|
||||||
_selectNameOnly: false,
|
_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
|
* Initialize modal system
|
||||||
*/
|
*/
|
||||||
@@ -84,6 +92,13 @@ const Modal = {
|
|||||||
this.close(false);
|
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 */
|
/** @param {string} message */
|
||||||
@@ -259,6 +274,8 @@ const Modal = {
|
|||||||
this._action = null;
|
this._action = null;
|
||||||
this.overlay.classList.remove('active');
|
this.overlay.classList.remove('active');
|
||||||
|
|
||||||
|
const wasPanel = this._panelMode;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.overlay.classList.add('hidden');
|
this.overlay.classList.add('hidden');
|
||||||
|
|
||||||
@@ -269,6 +286,15 @@ const Modal = {
|
|||||||
// Clear callbacks
|
// Clear callbacks
|
||||||
this.onConfirm = null;
|
this.onConfirm = null;
|
||||||
this.onCancel = 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);
|
}, 200);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -277,6 +303,13 @@ const Modal = {
|
|||||||
* until it resolves — closing only on success, showing the error inline on failure.
|
* until it resolves — closing only on success, showing the error inline on failure.
|
||||||
*/
|
*/
|
||||||
async confirm() {
|
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._action) {
|
||||||
if (this.onConfirm) this.onConfirm();
|
if (this.onConfirm) this.onConfirm();
|
||||||
this.close(true);
|
this.close(true);
|
||||||
@@ -298,6 +331,71 @@ const Modal = {
|
|||||||
this.confirmBtn.disabled = false;
|
this.confirmBtn.disabled = false;
|
||||||
this.input.focus();
|
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,
|
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'
|
'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: [
|
pen: [
|
||||||
512,
|
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'
|
'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,
|
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'
|
'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: [
|
shuffle: [
|
||||||
512,
|
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'
|
'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} icon_special_class
|
||||||
* @property {String} category
|
* @property {String} category
|
||||||
* @property {String} size_formatted
|
* @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} width
|
||||||
* @property {number|null} height
|
* @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 { loadFiles } from '../../app/filesView.js';
|
||||||
import { switchToFilesSection } from '../../app/navigation.js';
|
import { switchToFilesSection } from '../../app/navigation.js';
|
||||||
import { app } from '../../app/state.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 { getCsrfHeaders } from '../../core/csrf.js';
|
||||||
import { escapeHtml } from '../../core/formatters.js';
|
import { escapeHtml } from '../../core/formatters.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
import { Modal } from '../../core/modal.js';
|
|
||||||
import { favorites } from '../library/favorites.js';
|
import { favorites } from '../library/favorites.js';
|
||||||
import { musicView } from '../library/music.js';
|
import { musicView } from '../library/music.js';
|
||||||
import { fileSharing } from '../sharing/fileSharing.js';
|
import { fileSharing } from '../sharing/fileSharing.js';
|
||||||
@@ -157,7 +158,7 @@ const contextMenus = {
|
|||||||
document.getElementById('share-folder-option').addEventListener('click', () => {
|
document.getElementById('share-folder-option').addEventListener('click', () => {
|
||||||
const folder = app.contextMenuTargetFolder;
|
const folder = app.contextMenuTargetFolder;
|
||||||
if (folder) {
|
if (folder) {
|
||||||
this.showShareDialog(folder, 'folder');
|
shareModal.open(folder, 'folder');
|
||||||
}
|
}
|
||||||
ui.closeContextMenu();
|
ui.closeContextMenu();
|
||||||
});
|
});
|
||||||
@@ -279,7 +280,7 @@ const contextMenus = {
|
|||||||
document.getElementById('share-file-option').addEventListener('click', () => {
|
document.getElementById('share-file-option').addEventListener('click', () => {
|
||||||
const file = app.contextMenuTargetFile;
|
const file = app.contextMenuTargetFile;
|
||||||
if (file) {
|
if (file) {
|
||||||
this.showShareDialog(file, 'file');
|
shareModal.open(file, 'file');
|
||||||
}
|
}
|
||||||
ui.closeFileContextMenu();
|
ui.closeFileContextMenu();
|
||||||
});
|
});
|
||||||
@@ -722,229 +723,6 @@ const contextMenus = {
|
|||||||
await this.loadMoveDialogFolders(app.userHomeFolderId || null);
|
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
|
* Show email notification dialog
|
||||||
* @param {string} shareUrl - URL to share
|
* @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
|
* Close notification dialog
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ const favorites = {
|
|||||||
const files = [];
|
const files = [];
|
||||||
|
|
||||||
for (const item of this._cache.values()) {
|
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') {
|
if (item.item_type === 'folder') {
|
||||||
folders.push(
|
folders.push(
|
||||||
// FIXME: better to grab the real values
|
// FIXME: better to grab the real values
|
||||||
@@ -215,7 +215,7 @@ const favorites = {
|
|||||||
created_at: item.created_at,
|
created_at: item.created_at,
|
||||||
icon_class: item.icon_class,
|
icon_class: item.icon_class,
|
||||||
icon_special_class: item.icon_special_class,
|
icon_special_class: item.icon_special_class,
|
||||||
owner_id: item.user_id,
|
owner_id: item.owner_id ?? '',
|
||||||
is_root: false
|
is_root: false
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -234,7 +234,7 @@ const favorites = {
|
|||||||
size_formatted: item.size_formatted,
|
size_formatted: item.size_formatted,
|
||||||
modified_at: item.modified_at || item.created_at,
|
modified_at: item.modified_at || item.created_at,
|
||||||
path: item.item_path || '',
|
path: item.item_path || '',
|
||||||
owner_id: item.user_id,
|
owner_id: item.owner_id ?? '',
|
||||||
created_at: item.created_at,
|
created_at: item.created_at,
|
||||||
sort_date: item.created_at
|
sort_date: item.created_at
|
||||||
}
|
}
|
||||||
@@ -246,6 +246,8 @@ const favorites = {
|
|||||||
|
|
||||||
const filesList = document.getElementById('files-list');
|
const filesList = document.getElementById('files-list');
|
||||||
if (filesList) pathTooltip.init(filesList);
|
if (filesList) pathTooltip.init(filesList);
|
||||||
|
|
||||||
|
await ui.resolveOwnerCells();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error displaying favorites:', error);
|
console.error('Error displaying favorites:', error);
|
||||||
if (ui?.showNotification) {
|
if (ui?.showNotification) {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { app } from '../../app/state.js';
|
import { app } from '../../app/state.js';
|
||||||
|
import { Modal } from '../../components/modal.js';
|
||||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||||
import { formatFileSize } from '../../core/formatters.js';
|
import { formatFileSize } from '../../core/formatters.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
import { oxiIcon } from '../../core/icons.js';
|
import { oxiIcon } from '../../core/icons.js';
|
||||||
import { Modal } from '../../core/modal.js';
|
|
||||||
import { notifications } from '../../core/notifications.js';
|
import { notifications } from '../../core/notifications.js';
|
||||||
|
|
||||||
/** @import {FileItem, Musicshare, Playlist, PlaylistItem} from '../../core/types.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": {
|
"nav": {
|
||||||
"files": "الملفات",
|
"files": "الملفات",
|
||||||
"shared": "المشترك",
|
"shared": "مشاركاتي",
|
||||||
"recent": "الأخيرة",
|
"recent": "الأخيرة",
|
||||||
"favorites": "المفضلة",
|
"favorites": "المفضلة",
|
||||||
"photos": "الصور",
|
"photos": "الصور",
|
||||||
"music": "الموسيقى",
|
"music": "الموسيقى",
|
||||||
"trash": "سلة المهملات"
|
"trash": "سلة المهملات",
|
||||||
|
"sharedwithme": "مشتركة معي"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"empty_state": "لا توجد صور بعد",
|
"empty_state": "لا توجد صور بعد",
|
||||||
@@ -269,7 +270,8 @@
|
|||||||
"archive": "أرشيف",
|
"archive": "أرشيف",
|
||||||
"installer": "مثبّت",
|
"installer": "مثبّت",
|
||||||
"code": "كود"
|
"code": "كود"
|
||||||
}
|
},
|
||||||
|
"owner": "المالك"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "إعادة تسمية المجلد",
|
"rename_folder": "إعادة تسمية المجلد",
|
||||||
@@ -682,5 +684,18 @@
|
|||||||
"files": "ملفات",
|
"files": "ملفات",
|
||||||
"complete": "{{count}} / {{total}} تم الرفع"
|
"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": {
|
"nav": {
|
||||||
"files": "Dateien",
|
"files": "Dateien",
|
||||||
"shared": "Geteilt",
|
"shared": "Freigaben",
|
||||||
"recent": "Zuletzt verwendet",
|
"recent": "Zuletzt verwendet",
|
||||||
"favorites": "Favoriten",
|
"favorites": "Favoriten",
|
||||||
"photos": "Fotos",
|
"photos": "Fotos",
|
||||||
"music": "Musik",
|
"music": "Musik",
|
||||||
"trash": "Papierkorb"
|
"trash": "Papierkorb",
|
||||||
|
"sharedwithme": "Mit mir geteilt"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"empty_state": "Noch keine Fotos",
|
"empty_state": "Noch keine Fotos",
|
||||||
@@ -269,7 +270,8 @@
|
|||||||
"archive": "Archiv",
|
"archive": "Archiv",
|
||||||
"installer": "Installationsdatei",
|
"installer": "Installationsdatei",
|
||||||
"code": "Code"
|
"code": "Code"
|
||||||
}
|
},
|
||||||
|
"owner": "Eigentümer"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "Ordner umbenennen",
|
"rename_folder": "Ordner umbenennen",
|
||||||
@@ -682,5 +684,18 @@
|
|||||||
"files": "Dateien",
|
"files": "Dateien",
|
||||||
"complete": "{{count}} / {{total}} hochgeladen"
|
"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": {
|
"nav": {
|
||||||
"files": "Files",
|
"files": "Files",
|
||||||
"shared": "Shared",
|
"shared": "My shares",
|
||||||
|
"sharedwithme": "Shared with me",
|
||||||
"recent": "Recent",
|
"recent": "Recent",
|
||||||
"favorites": "Favorites",
|
"favorites": "Favorites",
|
||||||
"photos": "Photos",
|
"photos": "Photos",
|
||||||
@@ -269,7 +270,8 @@
|
|||||||
"archive": "Archive",
|
"archive": "Archive",
|
||||||
"installer": "Installer",
|
"installer": "Installer",
|
||||||
"code": "Code"
|
"code": "Code"
|
||||||
}
|
},
|
||||||
|
"owner": "Owner"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "Rename folder",
|
"rename_folder": "Rename folder",
|
||||||
@@ -682,5 +684,18 @@
|
|||||||
"files": "files",
|
"files": "files",
|
||||||
"complete": "{{count}} / {{total}} uploaded"
|
"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",
|
"favorites": "Favoritos",
|
||||||
"photos": "Fotos",
|
"photos": "Fotos",
|
||||||
"music": "Música",
|
"music": "Música",
|
||||||
"trash": "Papelera"
|
"trash": "Papelera",
|
||||||
|
"sharedwithme": "Compartidos conmigo"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"empty_state": "Aún no hay fotos",
|
"empty_state": "Aún no hay fotos",
|
||||||
@@ -269,7 +270,8 @@
|
|||||||
"archive": "Archivo comprimido",
|
"archive": "Archivo comprimido",
|
||||||
"installer": "Instalador",
|
"installer": "Instalador",
|
||||||
"code": "Código"
|
"code": "Código"
|
||||||
}
|
},
|
||||||
|
"owner": "Propietario"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "Renombrar carpeta",
|
"rename_folder": "Renombrar carpeta",
|
||||||
@@ -682,5 +684,18 @@
|
|||||||
"files": "archivos",
|
"files": "archivos",
|
||||||
"complete": "{{count}} / {{total}} subidos"
|
"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": {
|
"nav": {
|
||||||
"files": "پروندهها",
|
"files": "پروندهها",
|
||||||
"shared": "همرسانی شده",
|
"shared": "همرسانیهای من",
|
||||||
"recent": "اخیر",
|
"recent": "اخیر",
|
||||||
"favorites": "موردعلاقهها",
|
"favorites": "موردعلاقهها",
|
||||||
"photos": "عکسها",
|
"photos": "عکسها",
|
||||||
"music": "موسیقی",
|
"music": "موسیقی",
|
||||||
"trash": "سطل زباله"
|
"trash": "سطل زباله",
|
||||||
|
"sharedwithme": "به اشتراکگذاشته شده با من"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"empty_state": "هنوز عکسی نیست",
|
"empty_state": "هنوز عکسی نیست",
|
||||||
@@ -269,7 +270,8 @@
|
|||||||
"archive": "بایگانی",
|
"archive": "بایگانی",
|
||||||
"installer": "نصبکننده",
|
"installer": "نصبکننده",
|
||||||
"code": "کد"
|
"code": "کد"
|
||||||
}
|
},
|
||||||
|
"owner": "مالک"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "تغییر نام پوشه",
|
"rename_folder": "تغییر نام پوشه",
|
||||||
@@ -682,5 +684,18 @@
|
|||||||
"files": "فایلها",
|
"files": "فایلها",
|
||||||
"complete": "{{count}} / {{total}} آپلود شد"
|
"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": {
|
"nav": {
|
||||||
"files": "Fichiers",
|
"files": "Fichiers",
|
||||||
"shared": "Partagés",
|
"shared": "Partages",
|
||||||
"recent": "Récents",
|
"recent": "Récents",
|
||||||
"favorites": "Favoris",
|
"favorites": "Favoris",
|
||||||
"photos": "Photos",
|
"photos": "Photos",
|
||||||
"music": "Musique",
|
"music": "Musique",
|
||||||
"trash": "Corbeille"
|
"trash": "Corbeille",
|
||||||
|
"sharedwithme": "Partages avec moi"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"empty_state": "Pas encore de photos",
|
"empty_state": "Pas encore de photos",
|
||||||
@@ -269,7 +270,8 @@
|
|||||||
"archive": "Archive",
|
"archive": "Archive",
|
||||||
"installer": "Installateur",
|
"installer": "Installateur",
|
||||||
"code": "Code"
|
"code": "Code"
|
||||||
}
|
},
|
||||||
|
"owner": "Propriétaire"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "Renommer le dossier",
|
"rename_folder": "Renommer le dossier",
|
||||||
@@ -682,5 +684,18 @@
|
|||||||
"files": "fichiers",
|
"files": "fichiers",
|
||||||
"complete": "{{count}} / {{total}} téléchargés"
|
"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": "पसंदीदा",
|
"favorites": "पसंदीदा",
|
||||||
"photos": "फ़ोटो",
|
"photos": "फ़ोटो",
|
||||||
"music": "संगीत",
|
"music": "संगीत",
|
||||||
"trash": "रद्दी"
|
"trash": "रद्दी",
|
||||||
|
"sharedwithme": "मेरे साथ साझा किए गए"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"empty_state": "अभी कोई फ़ोटो नहीं",
|
"empty_state": "अभी कोई फ़ोटो नहीं",
|
||||||
@@ -269,7 +270,8 @@
|
|||||||
"archive": "संग्रह",
|
"archive": "संग्रह",
|
||||||
"installer": "इंस्टॉलर",
|
"installer": "इंस्टॉलर",
|
||||||
"code": "कोड"
|
"code": "कोड"
|
||||||
}
|
},
|
||||||
|
"owner": "स्वामी"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "फ़ोल्डर का नाम बदलें",
|
"rename_folder": "फ़ोल्डर का नाम बदलें",
|
||||||
@@ -682,5 +684,18 @@
|
|||||||
"files": "फ़ाइलें",
|
"files": "फ़ाइलें",
|
||||||
"complete": "{{count}} / {{total}} अपलोड हुए"
|
"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": {
|
"nav": {
|
||||||
"files": "File",
|
"files": "File",
|
||||||
"shared": "Condivisi",
|
"shared": "Condivisioni",
|
||||||
"recent": "Recenti",
|
"recent": "Recenti",
|
||||||
"favorites": "Preferiti",
|
"favorites": "Preferiti",
|
||||||
"photos": "Foto",
|
"photos": "Foto",
|
||||||
"music": "Musica",
|
"music": "Musica",
|
||||||
"trash": "Cestino"
|
"trash": "Cestino",
|
||||||
|
"sharedwithme": "Condivisi con me"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"empty_state": "Nessuna foto ancora",
|
"empty_state": "Nessuna foto ancora",
|
||||||
@@ -269,7 +270,8 @@
|
|||||||
"archive": "Archivio",
|
"archive": "Archivio",
|
||||||
"installer": "Programma di installazione",
|
"installer": "Programma di installazione",
|
||||||
"code": "Codice"
|
"code": "Codice"
|
||||||
}
|
},
|
||||||
|
"owner": "Proprietario"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "Rinomina cartella",
|
"rename_folder": "Rinomina cartella",
|
||||||
@@ -682,5 +684,18 @@
|
|||||||
"files": "file",
|
"files": "file",
|
||||||
"complete": "{{count}} / {{total}} caricati"
|
"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": "お気に入り",
|
"favorites": "お気に入り",
|
||||||
"photos": "写真",
|
"photos": "写真",
|
||||||
"music": "音楽",
|
"music": "音楽",
|
||||||
"trash": "ゴミ箱"
|
"trash": "ゴミ箱",
|
||||||
|
"sharedwithme": "自分と共有"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"empty_state": "写真はまだありません",
|
"empty_state": "写真はまだありません",
|
||||||
@@ -269,7 +270,8 @@
|
|||||||
"archive": "アーカイブ",
|
"archive": "アーカイブ",
|
||||||
"installer": "インストーラー",
|
"installer": "インストーラー",
|
||||||
"code": "コード"
|
"code": "コード"
|
||||||
}
|
},
|
||||||
|
"owner": "オーナー"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "フォルダ名を変更",
|
"rename_folder": "フォルダ名を変更",
|
||||||
@@ -682,5 +684,18 @@
|
|||||||
"files": "ファイル",
|
"files": "ファイル",
|
||||||
"complete": "{{count}} / {{total}} アップロード済み"
|
"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": "즐겨찾기",
|
"favorites": "즐겨찾기",
|
||||||
"photos": "사진",
|
"photos": "사진",
|
||||||
"music": "음악",
|
"music": "음악",
|
||||||
"trash": "휴지통"
|
"trash": "휴지통",
|
||||||
|
"sharedwithme": "나와 공유됨"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"empty_state": "아직 사진이 없습니다",
|
"empty_state": "아직 사진이 없습니다",
|
||||||
@@ -269,7 +270,8 @@
|
|||||||
"archive": "아카이브",
|
"archive": "아카이브",
|
||||||
"installer": "설치 프로그램",
|
"installer": "설치 프로그램",
|
||||||
"code": "코드"
|
"code": "코드"
|
||||||
}
|
},
|
||||||
|
"owner": "소유자"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "폴더 이름 변경",
|
"rename_folder": "폴더 이름 변경",
|
||||||
@@ -682,5 +684,18 @@
|
|||||||
"files": "파일",
|
"files": "파일",
|
||||||
"complete": "{{count}} / {{total}} 업로드됨"
|
"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",
|
"favorites": "Favorieten",
|
||||||
"photos": "Foto's",
|
"photos": "Foto's",
|
||||||
"music": "Muziek",
|
"music": "Muziek",
|
||||||
"trash": "Prullenbak"
|
"trash": "Prullenbak",
|
||||||
|
"sharedwithme": "Gedeeld met mij"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"empty_state": "Nog geen foto's",
|
"empty_state": "Nog geen foto's",
|
||||||
@@ -269,7 +270,8 @@
|
|||||||
"archive": "Archief",
|
"archive": "Archief",
|
||||||
"installer": "Installatiebestand",
|
"installer": "Installatiebestand",
|
||||||
"code": "Code"
|
"code": "Code"
|
||||||
}
|
},
|
||||||
|
"owner": "Eigenaar"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "Map hernoemen",
|
"rename_folder": "Map hernoemen",
|
||||||
@@ -682,5 +684,18 @@
|
|||||||
"files": "bestanden",
|
"files": "bestanden",
|
||||||
"complete": "{{count}} / {{total}} geüpload"
|
"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",
|
"favorites": "Ulubione",
|
||||||
"photos": "Zdjęcia",
|
"photos": "Zdjęcia",
|
||||||
"music": "Muzyka",
|
"music": "Muzyka",
|
||||||
"trash": "Kosz"
|
"trash": "Kosz",
|
||||||
|
"sharedwithme": "Udostępnione dla mnie"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"empty_state": "Brak zdjęć",
|
"empty_state": "Brak zdjęć",
|
||||||
@@ -269,7 +270,8 @@
|
|||||||
"archive": "Archiwum",
|
"archive": "Archiwum",
|
||||||
"installer": "Instalator",
|
"installer": "Instalator",
|
||||||
"code": "Kod"
|
"code": "Kod"
|
||||||
}
|
},
|
||||||
|
"owner": "Właściciel"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "Zmień nazwę folderu",
|
"rename_folder": "Zmień nazwę folderu",
|
||||||
@@ -682,5 +684,18 @@
|
|||||||
"files": "plików",
|
"files": "plików",
|
||||||
"complete": "{{count}} / {{total}} przesłano"
|
"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": {
|
"nav": {
|
||||||
"files": "Arquivos",
|
"files": "Arquivos",
|
||||||
"shared": "Compartilhados",
|
"shared": "Compartilhamentos",
|
||||||
"recent": "Recentes",
|
"recent": "Recentes",
|
||||||
"favorites": "Favoritos",
|
"favorites": "Favoritos",
|
||||||
"photos": "Fotos",
|
"photos": "Fotos",
|
||||||
"music": "Música",
|
"music": "Música",
|
||||||
"trash": "Lixeira"
|
"trash": "Lixeira",
|
||||||
|
"sharedwithme": "Compartilhados comigo"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"empty_state": "Nenhuma foto ainda",
|
"empty_state": "Nenhuma foto ainda",
|
||||||
@@ -269,7 +270,8 @@
|
|||||||
"archive": "Arquivo compactado",
|
"archive": "Arquivo compactado",
|
||||||
"installer": "Instalador",
|
"installer": "Instalador",
|
||||||
"code": "Código"
|
"code": "Código"
|
||||||
}
|
},
|
||||||
|
"owner": "Proprietário"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "Renomear pasta",
|
"rename_folder": "Renomear pasta",
|
||||||
@@ -682,5 +684,18 @@
|
|||||||
"files": "ficheiros",
|
"files": "ficheiros",
|
||||||
"complete": "{{count}} / {{total}} carregados"
|
"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": "Избранное",
|
"favorites": "Избранное",
|
||||||
"photos": "Фото",
|
"photos": "Фото",
|
||||||
"music": "Музыка",
|
"music": "Музыка",
|
||||||
"trash": "Корзина"
|
"trash": "Корзина",
|
||||||
|
"sharedwithme": "Доступно мне"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"empty_state": "Фотографий пока нет",
|
"empty_state": "Фотографий пока нет",
|
||||||
@@ -269,7 +270,8 @@
|
|||||||
"archive": "Архив",
|
"archive": "Архив",
|
||||||
"installer": "Установщик",
|
"installer": "Установщик",
|
||||||
"code": "Код"
|
"code": "Код"
|
||||||
}
|
},
|
||||||
|
"owner": "Владелец"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "Переименовать папку",
|
"rename_folder": "Переименовать папку",
|
||||||
@@ -682,5 +684,18 @@
|
|||||||
"files": "файлов",
|
"files": "файлов",
|
||||||
"complete": "{{count}} / {{total}} загружено"
|
"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": "收藏",
|
"favorites": "收藏",
|
||||||
"photos": "照片",
|
"photos": "照片",
|
||||||
"music": "音樂",
|
"music": "音樂",
|
||||||
"trash": "回收站"
|
"trash": "回收站",
|
||||||
|
"sharedwithme": "與我共享"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"empty_state": "還沒有照片",
|
"empty_state": "還沒有照片",
|
||||||
@@ -269,7 +270,8 @@
|
|||||||
"archive": "壓縮檔案",
|
"archive": "壓縮檔案",
|
||||||
"installer": "安裝程式",
|
"installer": "安裝程式",
|
||||||
"code": "程式碼"
|
"code": "程式碼"
|
||||||
}
|
},
|
||||||
|
"owner": "擁有者"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "重新命名資料夾",
|
"rename_folder": "重新命名資料夾",
|
||||||
@@ -682,5 +684,18 @@
|
|||||||
"files": "個檔案",
|
"files": "個檔案",
|
||||||
"complete": "已上傳 {{count}} / {{total}}"
|
"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": "收藏",
|
"favorites": "收藏",
|
||||||
"photos": "照片",
|
"photos": "照片",
|
||||||
"music": "音乐",
|
"music": "音乐",
|
||||||
"trash": "回收站"
|
"trash": "回收站",
|
||||||
|
"sharedwithme": "与我共享"
|
||||||
},
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"empty_state": "还没有照片",
|
"empty_state": "还没有照片",
|
||||||
@@ -269,7 +270,8 @@
|
|||||||
"archive": "压缩文件",
|
"archive": "压缩文件",
|
||||||
"installer": "安装程序",
|
"installer": "安装程序",
|
||||||
"code": "代码"
|
"code": "代码"
|
||||||
}
|
},
|
||||||
|
"owner": "所有者"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "重命名文件夹",
|
"rename_folder": "重命名文件夹",
|
||||||
@@ -682,5 +684,18 @@
|
|||||||
"files": "个文件",
|
"files": "个文件",
|
||||||
"complete": "已上传 {{count}} / {{total}}"
|
"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 /api/grants/role reconciles the row set in one call.
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
PUT {{base_url}}/api/grants/role
|
PUT {{base_url}}/api/grants/role
|
||||||
@@ -159,12 +159,12 @@ Content-Type: application/json
|
|||||||
{
|
{
|
||||||
"subject": { "type": "user", "id": "{{dave_user_id}}" },
|
"subject": { "type": "user", "id": "{{dave_user_id}}" },
|
||||||
"resource": { "type": "folder", "id": "{{shared_folder_id}}" },
|
"resource": { "type": "folder", "id": "{{shared_folder_id}}" },
|
||||||
"role": "manager"
|
"role": "admin"
|
||||||
}
|
}
|
||||||
|
|
||||||
HTTP 200
|
HTTP 200
|
||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$" count == 5
|
jsonpath "$" count == 6
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
@@ -173,7 +173,7 @@ jsonpath "$" count == 5
|
|||||||
PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename
|
PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename
|
||||||
Authorization: Bearer {{dave_token}}
|
Authorization: Bearer {{dave_token}}
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
{ "name": "renamed-by-bob-as-manager" }
|
{ "name": "renamed-by-bob-as-admin" }
|
||||||
|
|
||||||
HTTP 200
|
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
|
POST {{base_url}}/api/grants
|
||||||
Authorization: Bearer {{dave_token}}
|
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