feat(list view): show the owner of the File or Folder in list view
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -334,32 +334,8 @@
|
|||||||
color: var(--color-error-text);
|
color: var(--color-error-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Avatar colour palette (cycled by memberIndex % 5) ──────────────────────── */
|
/* ── Avatar colour palette ───────────────────────────────────────────────────── */
|
||||||
|
/* Colours are provided by .uv-color-0..4 in userVignette.css (shared palette). */
|
||||||
.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 ────────────────────────────── */
|
/* ── Fallback when user-directory is unavailable ────────────────────────────── */
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
@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/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");
|
||||||
|
|||||||
@@ -233,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) {
|
||||||
|
|||||||
@@ -164,6 +164,9 @@ function setCurrentSection(section) {
|
|||||||
sharedWithMeView.hide();
|
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();
|
||||||
@@ -211,6 +214,9 @@ function switchToSharedWithMeSection() {
|
|||||||
// Show actions-bar with view toggle (no upload / new-folder in this view)
|
// Show actions-bar with view toggle (no upload / new-folder in this view)
|
||||||
setActionsBarMode('sharedwithme');
|
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
|
// Show the standard files container and respect grid/list preference
|
||||||
toggleFileContainer(true);
|
toggleFileContainer(true);
|
||||||
syncViewContainers();
|
syncViewContainers();
|
||||||
@@ -227,6 +233,9 @@ function switchToFilesSection() {
|
|||||||
// 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');
|
||||||
@@ -258,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');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import { shareModal } from '../components/shareModal.js';
|
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';
|
||||||
@@ -18,6 +19,7 @@ import { favorites } from '../features/library/favorites.js';
|
|||||||
import { recent } from '../features/library/recent.js';
|
import { recent } from '../features/library/recent.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 { 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 { activateFilesUI, switchToFilesSection, syncViewContainers } from './navigation.js';
|
import { activateFilesUI, switchToFilesSection, syncViewContainers } from './navigation.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
|
||||||
*/
|
*/
|
||||||
@@ -436,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 > ...
|
||||||
@@ -1236,6 +1285,7 @@ const ui = {
|
|||||||
<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-oxiexport"></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>
|
||||||
@@ -1290,6 +1340,7 @@ const ui = {
|
|||||||
<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-oxiexport"></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>
|
||||||
@@ -1329,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>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { addressBook, SYSTEM_BOOK_ID } from '../model/addressBook.js';
|
|||||||
import { grants } from '../model/grants.js';
|
import { grants } from '../model/grants.js';
|
||||||
import { systemUsers } from '../model/systemUsers.js';
|
import { systemUsers } from '../model/systemUsers.js';
|
||||||
import { Modal } from './modal.js';
|
import { Modal } from './modal.js';
|
||||||
|
import { _colorIndex, _initials } from './userVignette.js';
|
||||||
|
|
||||||
/** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.js' */
|
/** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.js' */
|
||||||
|
|
||||||
@@ -73,17 +74,6 @@ function _buildMembers(grantList) {
|
|||||||
return members;
|
return members;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get initials for an avatar (up to 2 chars).
|
|
||||||
* @param {string} name
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Component ──────────────────────────────────────────────────────────────────
|
// ── Component ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const shareModal = {
|
const shareModal = {
|
||||||
@@ -291,7 +281,16 @@ const shareModal = {
|
|||||||
}
|
}
|
||||||
debounce = setTimeout(async () => {
|
debounce = setTimeout(async () => {
|
||||||
const results = await addressBook.searchContacts(q, [SYSTEM_BOOK_ID]);
|
const results = await addressBook.searchContacts(q, [SYSTEM_BOOK_ID]);
|
||||||
this._renderSuggestions(dropdown, results.slice(0, 8), (contact) => {
|
// Filter out the currently logged-in user — they cannot share with themselves
|
||||||
|
const currentUserId = (() => {
|
||||||
|
try {
|
||||||
|
return /** @type {{id?:string}} */ (JSON.parse(localStorage.getItem('oxicloud_user') ?? '{}'))?.id ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const filtered = currentUserId ? results.filter((c) => c.id !== currentUserId) : results;
|
||||||
|
this._renderSuggestions(dropdown, filtered.slice(0, 8), (contact) => {
|
||||||
this._stageUser(contact, input, dropdown, addBtn);
|
this._stageUser(contact, input, dropdown, addBtn);
|
||||||
});
|
});
|
||||||
}, 200);
|
}, 200);
|
||||||
@@ -332,13 +331,13 @@ const shareModal = {
|
|||||||
container.classList.add('hidden');
|
container.classList.add('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
results.forEach((c, i) => {
|
results.forEach((c) => {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'smd-suggestion-item';
|
item.className = 'smd-suggestion-item';
|
||||||
item.tabIndex = 0;
|
item.tabIndex = 0;
|
||||||
|
|
||||||
const avatar = document.createElement('div');
|
const avatar = document.createElement('div');
|
||||||
avatar.className = `smd-suggestion-avatar smd-avatar--${i % 5}`;
|
avatar.className = `smd-suggestion-avatar uv-color-${_colorIndex(c.id)}`;
|
||||||
const displayName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || c.id.slice(0, 8);
|
const displayName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || c.id.slice(0, 8);
|
||||||
avatar.textContent = _initials(displayName);
|
avatar.textContent = _initials(displayName);
|
||||||
|
|
||||||
@@ -411,12 +410,12 @@ const shareModal = {
|
|||||||
*/
|
*/
|
||||||
_renderChipsInto(container) {
|
_renderChipsInto(container) {
|
||||||
container.replaceChildren();
|
container.replaceChildren();
|
||||||
this._stagedUsers.forEach((c, i) => {
|
this._stagedUsers.forEach((c) => {
|
||||||
const chip = document.createElement('div');
|
const chip = document.createElement('div');
|
||||||
chip.className = 'smd-chip';
|
chip.className = 'smd-chip';
|
||||||
|
|
||||||
const avatar = document.createElement('div');
|
const avatar = document.createElement('div');
|
||||||
avatar.className = `smd-chip-avatar smd-avatar--${i % 5}`;
|
avatar.className = `smd-chip-avatar uv-color-${_colorIndex(c.id)}`;
|
||||||
const displayName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || c.id.slice(0, 8);
|
const displayName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || c.id.slice(0, 8);
|
||||||
avatar.textContent = _initials(displayName);
|
avatar.textContent = _initials(displayName);
|
||||||
|
|
||||||
@@ -516,15 +515,15 @@ const shareModal = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {MemberEntry} entry
|
* @param {MemberEntry} entry
|
||||||
* @param {number} idx
|
* @param {number} _idx (unused — color is now derived deterministically from userId)
|
||||||
* @returns {HTMLElement}
|
* @returns {HTMLElement}
|
||||||
*/
|
*/
|
||||||
_buildMemberRow(entry, idx) {
|
_buildMemberRow(entry, _idx) {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'smd-member-row';
|
row.className = 'smd-member-row';
|
||||||
|
|
||||||
const avatar = document.createElement('div');
|
const avatar = document.createElement('div');
|
||||||
avatar.className = `smd-member-avatar smd-avatar--${idx % 5}`;
|
avatar.className = `smd-member-avatar uv-color-${_colorIndex(entry.grant.subject.id)}`;
|
||||||
|
|
||||||
// Resolve display name async
|
// Resolve display name async
|
||||||
systemUsers.getDisplayName(entry.grant.subject.id).then((name) => {
|
systemUsers.getDisplayName(entry.grant.subject.id).then((name) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -37,12 +37,29 @@ function _nameFor(c) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure the index is built (idempotent).
|
* 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>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function _ensureIndex() {
|
async function _ensureIndex() {
|
||||||
if (_index !== null) return;
|
if (_index !== null) return;
|
||||||
const contacts = await addressBook.listContacts(SYSTEM_BOOK_ID);
|
const contacts = await addressBook.listContacts(SYSTEM_BOOK_ID);
|
||||||
_index = new Map(contacts.map((c) => [c.id, _nameFor(c)]));
|
_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 ────────────────────────────────────────────────────────────────
|
// ── Public API ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ const sharedWithMeView = {
|
|||||||
ownerTooltip.init(filesList);
|
ownerTooltip.init(filesList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fill the Owner column cells (idempotent: skips already-resolved rows).
|
||||||
|
await ui.resolveOwnerCells();
|
||||||
|
|
||||||
this._setLoadMoreVisible(!!this._nextCursor);
|
this._setLoadMoreVisible(!!this._nextCursor);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ui.showError(`
|
ui.showError(`
|
||||||
|
|||||||
@@ -270,7 +270,8 @@
|
|||||||
"archive": "أرشيف",
|
"archive": "أرشيف",
|
||||||
"installer": "مثبّت",
|
"installer": "مثبّت",
|
||||||
"code": "كود"
|
"code": "كود"
|
||||||
}
|
},
|
||||||
|
"owner": "المالك"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "إعادة تسمية المجلد",
|
"rename_folder": "إعادة تسمية المجلد",
|
||||||
|
|||||||
@@ -270,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",
|
||||||
|
|||||||
@@ -270,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",
|
||||||
|
|||||||
@@ -270,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",
|
||||||
|
|||||||
@@ -270,7 +270,8 @@
|
|||||||
"archive": "بایگانی",
|
"archive": "بایگانی",
|
||||||
"installer": "نصبکننده",
|
"installer": "نصبکننده",
|
||||||
"code": "کد"
|
"code": "کد"
|
||||||
}
|
},
|
||||||
|
"owner": "مالک"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "تغییر نام پوشه",
|
"rename_folder": "تغییر نام پوشه",
|
||||||
|
|||||||
@@ -270,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",
|
||||||
|
|||||||
@@ -270,7 +270,8 @@
|
|||||||
"archive": "संग्रह",
|
"archive": "संग्रह",
|
||||||
"installer": "इंस्टॉलर",
|
"installer": "इंस्टॉलर",
|
||||||
"code": "कोड"
|
"code": "कोड"
|
||||||
}
|
},
|
||||||
|
"owner": "स्वामी"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "फ़ोल्डर का नाम बदलें",
|
"rename_folder": "फ़ोल्डर का नाम बदलें",
|
||||||
|
|||||||
@@ -270,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",
|
||||||
|
|||||||
@@ -270,7 +270,8 @@
|
|||||||
"archive": "アーカイブ",
|
"archive": "アーカイブ",
|
||||||
"installer": "インストーラー",
|
"installer": "インストーラー",
|
||||||
"code": "コード"
|
"code": "コード"
|
||||||
}
|
},
|
||||||
|
"owner": "オーナー"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "フォルダ名を変更",
|
"rename_folder": "フォルダ名を変更",
|
||||||
|
|||||||
@@ -270,7 +270,8 @@
|
|||||||
"archive": "아카이브",
|
"archive": "아카이브",
|
||||||
"installer": "설치 프로그램",
|
"installer": "설치 프로그램",
|
||||||
"code": "코드"
|
"code": "코드"
|
||||||
}
|
},
|
||||||
|
"owner": "소유자"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "폴더 이름 변경",
|
"rename_folder": "폴더 이름 변경",
|
||||||
|
|||||||
@@ -270,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",
|
||||||
|
|||||||
@@ -270,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",
|
||||||
|
|||||||
@@ -270,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",
|
||||||
|
|||||||
@@ -270,7 +270,8 @@
|
|||||||
"archive": "Архив",
|
"archive": "Архив",
|
||||||
"installer": "Установщик",
|
"installer": "Установщик",
|
||||||
"code": "Код"
|
"code": "Код"
|
||||||
}
|
},
|
||||||
|
"owner": "Владелец"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "Переименовать папку",
|
"rename_folder": "Переименовать папку",
|
||||||
|
|||||||
@@ -270,7 +270,8 @@
|
|||||||
"archive": "壓縮檔案",
|
"archive": "壓縮檔案",
|
||||||
"installer": "安裝程式",
|
"installer": "安裝程式",
|
||||||
"code": "程式碼"
|
"code": "程式碼"
|
||||||
}
|
},
|
||||||
|
"owner": "擁有者"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "重新命名資料夾",
|
"rename_folder": "重新命名資料夾",
|
||||||
|
|||||||
@@ -270,7 +270,8 @@
|
|||||||
"archive": "压缩文件",
|
"archive": "压缩文件",
|
||||||
"installer": "安装程序",
|
"installer": "安装程序",
|
||||||
"code": "代码"
|
"code": "代码"
|
||||||
}
|
},
|
||||||
|
"owner": "所有者"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"rename_folder": "重命名文件夹",
|
"rename_folder": "重命名文件夹",
|
||||||
|
|||||||
Reference in New Issue
Block a user