feat(ui): 1 modal to manage shares (users & public share)
fix(share): ensure Authz parent is created/updated on publicShare create/update fix(ShareModal): do not show Token (public) grants in People section
This commit is contained in:
@@ -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| {
|
||||||
|
|||||||
@@ -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,561 @@
|
|||||||
|
/* ── 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 (cycled by memberIndex % 5) ──────────────────────── */
|
||||||
|
|
||||||
|
.smd-avatar--0 {
|
||||||
|
background: var(--color-badge-indigo-bg);
|
||||||
|
color: var(--color-badge-indigo-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.smd-avatar--1 {
|
||||||
|
background: var(--color-badge-success-bg);
|
||||||
|
color: var(--color-badge-success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.smd-avatar--2 {
|
||||||
|
background: var(--color-accent-tint);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.smd-avatar--3 {
|
||||||
|
background: var(--color-badge-blue-bg);
|
||||||
|
color: var(--color-badge-blue-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.smd-avatar--4 {
|
||||||
|
background: var(--color-warning-bg-light);
|
||||||
|
color: var(--color-warning-text-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
@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/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");
|
||||||
|
|||||||
+1
-1
@@ -25,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>
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ 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 '../components/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';
|
||||||
|
|||||||
+9
-118
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
|
import { shareModal } from '../components/shareModal.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,7 +16,6 @@ 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 { grants } from '../model/grants.js';
|
import { grants } from '../model/grants.js';
|
||||||
import { loadFiles } from './filesView.js';
|
import { loadFiles } from './filesView.js';
|
||||||
@@ -146,115 +146,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-oxiexport 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')) {
|
||||||
@@ -1245,15 +1137,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));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -266,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'
|
||||||
|
|||||||
+32
-1
@@ -271,7 +271,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {'user'|'group'|'external'} SubjectTypeEnum
|
* @typedef {'user'|'group'|'token'|'external'} SubjectTypeEnum
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -355,3 +355,34 @@
|
|||||||
* @property {string} updated_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 - The underlying grant (id, subject, resource, etc.)
|
||||||
|
* @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 '../../components/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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 '../../components/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' */
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* @import {Grant, ResourceTypeEnum, SharedWithMeResponse} from '../core/types.js'
|
* @import {Grant, ResourceTypeEnum, SharedWithMeResponse} from '../core/types.js'
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { getCsrfHeaders } from '../core/csrf.js';
|
||||||
|
|
||||||
const grants = {
|
const grants = {
|
||||||
/** @type {Record<String, Record<String, Grant[]>>} */
|
/** @type {Record<String, Record<String, Grant[]>>} */
|
||||||
outgoingGrants: {},
|
outgoingGrants: {},
|
||||||
@@ -13,14 +15,15 @@ const grants = {
|
|||||||
const response = await fetch('/api/grants/outgoing');
|
const response = await fetch('/api/grants/outgoing');
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.log(`error ${response.status} while fetching /api/grants/outgoing:`, await response.json());
|
console.error(`error ${response.status} while fetching /api/grants/outgoing`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {Grant[]} */
|
/** @type {Grant[]} */
|
||||||
const outgoingGrants = await response.json();
|
const outgoingGrants = await response.json();
|
||||||
|
|
||||||
console.log(outgoingGrants);
|
// Reset and rebuild cache
|
||||||
|
this.outgoingGrants = {};
|
||||||
|
|
||||||
// store grants by type, then by id
|
// store grants by type, then by id
|
||||||
outgoingGrants.forEach((grant) => {
|
outgoingGrants.forEach((grant) => {
|
||||||
@@ -28,8 +31,6 @@ const grants = {
|
|||||||
this.outgoingGrants[grant.resource.type][grant.resource.id] ??= [];
|
this.outgoingGrants[grant.resource.type][grant.resource.id] ??= [];
|
||||||
this.outgoingGrants[grant.resource.type][grant.resource.id].push(grant);
|
this.outgoingGrants[grant.resource.type][grant.resource.id].push(grant);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`outgoing grants: `, this.outgoingGrants);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +51,7 @@ const grants = {
|
|||||||
const response = await fetch('/api/grants/incoming');
|
const response = await fetch('/api/grants/incoming');
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.log(`error ${response.status} while fetching /api/grants/incoming:`, await response.json);
|
console.error(`error ${response.status} while fetching /api/grants/incoming`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +64,6 @@ const grants = {
|
|||||||
this.incomingGrants[grant.resource.type][grant.resource.id] ??= [];
|
this.incomingGrants[grant.resource.type][grant.resource.id] ??= [];
|
||||||
this.incomingGrants[grant.resource.type][grant.resource.id].push(grant);
|
this.incomingGrants[grant.resource.type][grant.resource.id].push(grant);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`incoming grants: `, this.incomingGrants);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,6 +104,96 @@ const grants = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user