Merge pull request #374 from EdouardVanbelle/style/type

This commit is contained in:
Dionisio Pozo
2026-05-19 18:21:20 +02:00
committed by GitHub
99 changed files with 2476 additions and 620 deletions
+7 -3
View File
@@ -42,7 +42,7 @@ jobs:
- 'tests/**' - 'tests/**'
frontend-check: frontend-check:
name: Frontend — CSS and JS checks (format, lint, rules) name: Frontend — CSS and JS checks (format, lint, css-rules, types)
needs: changes needs: changes
if: needs.changes.outputs.frontend == 'true' if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -61,16 +61,20 @@ jobs:
node-version: 25 node-version: 25
# because we are not using package.json # because we are not using package.json
- name: Install Stylelint and plugins - name: Install Stylelint, TypeScript and plugins
run: | run: |
npm install --global \ npm install --global \
stylelint@17 \ stylelint@17 \
postcss@8 \ postcss@8 \
stylelint-value-no-unknown-custom-properties@6 stylelint-value-no-unknown-custom-properties@6 \
typescript
- name: Run Stylelint - name: Run Stylelint
run: npx stylelint "static/css/**/*.{css,scss}" run: npx stylelint "static/css/**/*.{css,scss}"
- name: Run TypeScript check
run: tsc -p jsconfig.json --noEmit
rust-fmt: rust-fmt:
name: Rustfmt name: Rustfmt
needs: changes needs: changes
+12
View File
@@ -130,6 +130,7 @@ Never duplicate logic across handlers or services. If the same behaviour is need
- Naming: `camelCase` for variables/functions, `PascalCase` for classes - Naming: `camelCase` for variables/functions, `PascalCase` for classes
- No `var` — use `const`/`let` only - No `var` — use `const`/`let` only
- **JSDoc required** on all public functions — `jsconfig.json` enables `checkJs` globally (equivalent to `@ts-check` on every file) - **JSDoc required** on all public functions — `jsconfig.json` enables `checkJs` globally (equivalent to `@ts-check` on every file)
- Always us static/js/core/types.js to mapp OxCcloud API structure
- Type parameters, return types, and complex types via `@typedef`: - Type parameters, return types, and complex types via `@typedef`:
```js ```js
@@ -162,6 +163,17 @@ Never duplicate logic across JS modules. If the same behaviour is needed in more
- One CSS file per logical component in `/static/css/` - One CSS file per logical component in `/static/css/`
- [data-theme="dark"] is permitted only in /static/css/themes/dark.css - [data-theme="dark"] is permitted only in /static/css/themes/dark.css
## Frontend Pre-commit checks
Always run these before committing, in this order:
```bash
biome check --fix # Auto-format
biome lint --fix # Lint (must pass)
stylelint static/css/ # Css rules
tsc -p jsconfig.json --noEmit # Ensure JS is always typed
```
# What Claude must NOT do # What Claude must NOT do
- Edit `Cargo.lock` directly - Edit `Cargo.lock` directly
- Use npm dependencies not listed in this file - Use npm dependencies not listed in this file
+155 -15
View File
@@ -173,6 +173,7 @@ fn resolve_css_imports(entry: &Path, css_dir: &Path) -> String {
if let Some(rel) = extract_import_path(t) { if let Some(rel) = extract_import_path(t) {
let resolved = css_dir.join(rel.trim_start_matches("./")); let resolved = css_dir.join(rel.trim_start_matches("./"));
if resolved.exists() { if resolved.exists() {
println!("cargo:warning=CSS importing: {}", resolved.display());
out.push_str(&fs::read_to_string(&resolved).unwrap_or_default()); out.push_str(&fs::read_to_string(&resolved).unwrap_or_default());
out.push('\n'); out.push('\n');
} else { } else {
@@ -238,6 +239,7 @@ fn minify_tree_css(dir: &Path) {
if fname.starts_with("app.") || fname == "main.css" { if fname.starts_with("app.") || fname == "main.css" {
continue; continue;
} }
println!("cargo:warning=CSS importing: {}", p.display());
if let Ok(src) = fs::read_to_string(&p) { if let Ok(src) = fs::read_to_string(&p) {
let _ = fs::write(&p, css_minify_safe(&src)); let _ = fs::write(&p, css_minify_safe(&src));
} }
@@ -284,12 +286,23 @@ fn build_js_module_bundle(entry_scripts: &[String], static_dir: &Path) -> String
collect_module_deps(&path, &mut order, &mut seen); collect_module_deps(&path, &mut order, &mut seen);
} }
println!(
"cargo:warning=bundle: {} files in dependency order:",
order.len()
);
let mut bundle = String::with_capacity(2 * 1024 * 1024); let mut bundle = String::with_capacity(2 * 1024 * 1024);
bundle.push_str("(function(){\n\"use strict\";\n"); bundle.push_str("(function(){\n\"use strict\";\n");
for file in &order { let mut declared_namespaces = std::collections::HashSet::new();
for (i, file) in order.iter().enumerate() {
println!(
"cargo:warning=bundle [{:>3}/{}] {}",
i + 1,
order.len(),
file.display()
);
match fs::read_to_string(file) { match fs::read_to_string(file) {
Ok(src) => { Ok(src) => {
bundle.push_str(&strip_esm_syntax(&src)); bundle.push_str(&strip_esm_syntax(&src, file, &mut declared_namespaces));
bundle.push('\n'); bundle.push('\n');
} }
Err(e) => eprintln!("cargo:warning=bundle: cannot read {}: {e}", file.display()), Err(e) => eprintln!("cargo:warning=bundle: cannot read {}: {e}", file.display()),
@@ -326,7 +339,11 @@ fn collect_module_deps(
// Skip vendor bundles: they may use top-level await or other ESM // Skip vendor bundles: they may use top-level await or other ESM
// patterns that are incompatible with IIFE wrapping. They must be // patterns that are incompatible with IIFE wrapping. They must be
// loaded via dynamic import() at runtime instead. // loaded via dynamic import() at runtime instead.
if !target.components().any(|c| c.as_os_str() == "vendors") { // Skip also workers path
if !target
.components()
.any(|c| c.as_os_str() == "vendors" || c.as_os_str() == "workers")
{
collect_module_deps(&target, order, seen); collect_module_deps(&target, order, seen);
} }
} }
@@ -381,20 +398,82 @@ fn extract_from_clause(s: &str) -> Option<String> {
Some(rest[1..end].to_string()) Some(rest[1..end].to_string())
} }
/// Extract all names that a JS module source exports.
///
/// Handles:
/// - `export { X, Y };` and `export { X as Z };`
/// - `export function f`, `export async function f`, `export class C`
/// - `export const X`, `export let X`, `export var X`
///
/// Does NOT follow `export { X } from '...'` re-exports.
fn extract_exported_names(source: &str) -> Vec<String> {
let mut names = Vec::new();
for line in source.lines() {
let t = line.trim();
// export { X, Y } — skip re-exports from other modules
if (t.starts_with("export {") || t.starts_with("export{")) && !t.contains(" from ") {
if let (Some(start), Some(end)) = (t.find('{'), t.find('}')) {
for binding in t[start + 1..end].split(',') {
let b = binding.trim();
let exported = if let Some(pos) = b.find(" as ") {
b[pos + 4..].trim()
} else {
b
};
if !exported.is_empty() {
names.push(exported.to_string());
}
}
}
continue;
}
const DECL_PREFIXES: &[&str] = &[
"export async function ",
"export function ",
"export class ",
"export const ",
"export let ",
"export var ",
];
for prefix in DECL_PREFIXES {
if let Some(rest) = t.strip_prefix(prefix) {
let name: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
.collect();
if !name.is_empty() {
names.push(name);
}
break;
}
}
}
names
}
/// Strip ES-module syntax from a single file so it can be inlined into an IIFE. /// Strip ES-module syntax from a single file so it can be inlined into an IIFE.
/// ///
/// | Input | Output | /// | Input | Output |
/// |------------------------------------------|------------------------------| /// |------------------------------------------|---------------------------------------------|
/// | `import { X } from './y.js';` | *(empty line)* | /// | `import { X } from './y.js';` | *(empty line)* |
/// | `import { X as Y } from './y.js';` | `const Y = X;` | /// | `import { X as Y } from './y.js';` | `const Y = X;` |
/// | `export { X, Y };` | *(empty line)* | /// | `import * as ns from './y.js';` | `const ns = { export1, export2, … };` |
/// | `export { X } from './y.js';` | *(empty line)* | /// | `export { X, Y };` | *(empty line)* |
/// | `export const X = …` | `const X = …` | /// | `export { X } from './y.js';` | *(empty line)* |
/// | `export function f() {…}` | `function f() {…}` | /// | `export const X = …` | `const X = …` |
/// | `export async function f() {…}` | `async function f() {…}` | /// | `export function f() {…}` | `function f() {…}` |
/// | `export class C {…}` | `class C {…}` | /// | `export async function f() {…}` | `async function f() {…}` |
/// | `export default expr;` | `const _default = expr;` | /// | `export class C {…}` | `class C {…}` |
fn strip_esm_syntax(source: &str) -> String { /// | `export default expr;` | `const _default = expr;` |
fn strip_esm_syntax(
source: &str,
file: &Path,
declared_namespaces: &mut std::collections::HashSet<String>,
) -> String {
let mut out = String::with_capacity(source.len()); let mut out = String::with_capacity(source.len());
// True while we are inside a multi-line import/export-list that has not yet // True while we are inside a multi-line import/export-list that has not yet
// seen its terminating `;`. // seen its terminating `;`.
@@ -412,6 +491,66 @@ fn strip_esm_syntax(source: &str) -> String {
continue; continue;
} }
// ── import * as ns from './path.js' ───────────────────────────────────
// Build a synthetic namespace object from the module's exports so that
// `ns.foo()` calls resolve correctly inside the IIFE scope.
// If multiple files import the same namespace name, only the first
// declaration is emitted — subsequent ones become empty lines to avoid
// `SyntaxError: Identifier already declared`.
if t.starts_with("import * as ") {
let stmt = (|| -> Option<String> {
// Extract the namespace identifier
let after_as = t.strip_prefix("import * as ")?;
let name_end = after_as.find(' ')?;
let ns_name = &after_as[..name_end];
// Already declared earlier in the bundle — skip re-declaration.
if declared_namespaces.contains(ns_name) {
return Some(String::new());
}
// Extract the module path from the `from '…'` clause
let module_path = extract_from_clause(t)?;
if !module_path.starts_with('.') {
return None; // bare specifier — not bundled
}
// Skip vendor/worker bundles (dynamically loaded at runtime)
let base = file.parent().unwrap_or(Path::new("."));
let target = base.join(&module_path);
if target
.components()
.any(|c| c.as_os_str() == "vendors" || c.as_os_str() == "workers")
{
return None;
}
let module_src = fs::read_to_string(&target).ok()?;
let exports = extract_exported_names(&module_src);
if exports.is_empty() {
return None;
}
declared_namespaces.insert(ns_name.to_string());
let indent = &line[..line.len() - line.trim_start().len()];
Some(format!(
"{}const {} = {{ {} }};",
indent,
ns_name,
exports.join(", ")
))
})();
match stmt {
Some(s) => out.push_str(&s),
None => {
println!("cargo:warning=bundle: could not resolve namespace import: {t}");
}
}
out.push('\n');
continue;
}
// ── import … ────────────────────────────────────────────────────────── // ── import … ──────────────────────────────────────────────────────────
// Emit `const Y = X;` for any `import { X as Y }` aliases so that code // Emit `const Y = X;` for any `import { X as Y }` aliases so that code
// using the aliased name still resolves inside the IIFE scope. // using the aliased name still resolves inside the IIFE scope.
@@ -600,6 +739,7 @@ fn minify_tree_js(dir: &Path) {
continue; continue;
} }
if let Ok(src) = fs::read_to_string(&p) { if let Ok(src) = fs::read_to_string(&p) {
println!("cargo:warning=minify-js: {}", p.display());
let _ = fs::write(&p, js_minify_safe(&src)); let _ = fs::write(&p, js_minify_safe(&src));
} }
} }
+6 -3
View File
@@ -5,10 +5,13 @@
"allowJs": true, "allowJs": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
"noImplicitAny": false, "noImplicitAny": true,
"noImplicitThis": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"strictFunctionTypes": true,
"lib": ["ES2022", "DOM"], "lib": ["ES2022", "DOM"],
// Treat all JS files as modules // Treat all JS files as modules
"moduleDetection": "force", "moduleDetection": "force",
@@ -17,6 +20,6 @@
"skipLibCheck": true, "skipLibCheck": true,
"target": "ESNext" "target": "ESNext"
}, },
"include": ["static/js/**/*.js"], "include": ["static/js/**/*.js" ],
"exclude": ["static/js/vendors/**", "static/js/vendors/**/*.mjs"] "exclude": ["static/js/vendors/**", "static/js/vendors/**/*.mjs", "static/js/vendors/**/*.js" ]
} }
+4
View File
@@ -60,6 +60,10 @@ front-fmt:
front-lint: front-lint:
biome lint static/ biome lint static/
# test types (JSDOC), using typescript
front-type:
tsc -p jsconfig.json --noEmit
# check CSS rules # check CSS rules
front-rules: front-rules:
stylelint static/css/ stylelint static/css/
+1 -7
View File
@@ -155,13 +155,7 @@ impl FolderUseCase for FolderService {
let folder = self let folder = self
.folder_storage .folder_storage
.create_folder(dto.name, dto.parent_id) .create_folder(dto.name, dto.parent_id)
.await .await?;
.map_err(|e| {
DomainError::internal_error(
"FolderStorage",
format!("Failed to create folder: {}", e),
)
})?;
// Convert to DTO // Convert to DTO
Ok(FolderDto::from(folder)) Ok(FolderDto::from(folder))
+2 -2
View File
@@ -841,8 +841,8 @@ impl AppServiceFactory {
tracing::warn!("╔══════════════════════════════════════════════════════════╗"); tracing::warn!("╔══════════════════════════════════════════════════════════╗");
tracing::warn!("║ SYSTEM NOT INITIALIZED — first admin setup required ║"); tracing::warn!("║ SYSTEM NOT INITIALIZED — first admin setup required ║");
tracing::warn!("║ ║"); tracing::warn!("║ ║");
tracing::warn!("║ Open the web UI to create the first admin account. ║"); tracing::warn!("║ Open the web UI to create the first admin account. ║");
tracing::warn!("║ The setup page is available until an admin is created. ║"); tracing::warn!("║ The setup page is available until an admin is created. ║");
tracing::warn!("╚══════════════════════════════════════════════════════════╝"); tracing::warn!("╚══════════════════════════════════════════════════════════╝");
} else { } else {
tracing::info!("System already initialized — setup endpoint disabled"); tracing::info!("System already initialized — setup endpoint disabled");
+1
View File
@@ -27,6 +27,7 @@
@import url("./components/search.css"); @import url("./components/search.css");
@import url("./components/icons.css"); @import url("./components/icons.css");
@import url("./components/csp-utilities.css"); @import url("./components/csp-utilities.css");
@import url("./components/pathTooltip.css");
/* Theme */ /* Theme */
@import url("./themes/dark.css"); @import url("./themes/dark.css");
-1
View File
@@ -18,7 +18,6 @@
<link rel="stylesheet" href="/css/views/photos.css"> <link rel="stylesheet" href="/css/views/photos.css">
<link rel="stylesheet" href="/css/views/photosLightbox.css"> <link rel="stylesheet" href="/css/views/photosLightbox.css">
<link rel="stylesheet" href="/css/views/music.css"> <link rel="stylesheet" href="/css/views/music.css">
<link rel="stylesheet" href="/css/components/pathTooltip.css">
<!-- Scripts (defer: download in parallel, execute in order, after HTML parsed) --> <!-- Scripts (defer: download in parallel, execute in order, after HTML parsed) -->
<script defer type="module" src="/js/core/i18n.js"></script> <script defer type="module" src="/js/core/i18n.js"></script>
+10
View File
@@ -8,6 +8,14 @@ import { updateStorageUsageDisplay } from './main.js';
import { app } from './state.js'; import { app } from './state.js';
import { ui } from './ui.js'; import { ui } from './ui.js';
/**
* @import {User} from '../core/types.js'
*/
/**
*
* @returns {Promise<User | null>}
*/
async function refreshUserData() { async function refreshUserData() {
const USER_DATA_KEY = 'oxicloud_user'; const USER_DATA_KEY = 'oxicloud_user';
@@ -25,6 +33,7 @@ async function refreshUserData() {
return null; return null;
} }
/** @type {User} */
const userData = await response.json(); const userData = await response.json();
console.log('Refreshed user data from server:', userData); console.log('Refreshed user data from server:', userData);
console.log('Storage from server: used=', userData.storage_used_bytes, 'quota=', userData.storage_quota_bytes); console.log('Storage from server: used=', userData.storage_used_bytes, 'quota=', userData.storage_quota_bytes);
@@ -83,6 +92,7 @@ async function checkAuthentication() {
// Check session validity by calling /api/auth/me (cookie auto-sent) // Check session validity by calling /api/auth/me (cookie auto-sent)
console.log('Checking session via /api/auth/me...'); console.log('Checking session via /api/auth/me...');
/** @type {User} */
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}'); const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
if (userData.username) { if (userData.username) {
// We have cached user data — render immediately, refresh in background // We have cached user data — render immediately, refresh in background
+10 -6
View File
@@ -9,14 +9,14 @@ import { app } from './state.js';
import { ui } from './ui.js'; import { ui } from './ui.js';
import { uiNotifications } from './uiNotifications.js'; import { uiNotifications } from './uiNotifications.js';
/** @import {FileInfo, FolderInfo} from '../core/types.js' */ /** @import {FileItem, FolderItem} from '../core/types.js' */
let isLoadingFiles = false; let isLoadingFiles = false;
/** /**
* getFolder information * getFolder information
* @param {string} id the id of the folder * @param {string} id the id of the folder
* @returns {Promise<FolderInfo>} * @returns {Promise<FolderItem>}
*/ */
async function getFolder(id) { async function getFolder(id) {
/** @type {HeadersInit} */ /** @type {HeadersInit} */
@@ -47,7 +47,7 @@ async function getFolder(id) {
async function rebuildBreadCrumb() { async function rebuildBreadCrumb() {
/** /**
* Store the leaf (this is the current displayed folder) * Store the leaf (this is the current displayed folder)
* @type {FolderInfo | null} * @type {FolderItem | null}
*/ */
let currentFolderInfo = null; let currentFolderInfo = null;
@@ -172,7 +172,11 @@ async function loadFiles(options = { insertHistory: true }) {
if (forceRefresh) { if (forceRefresh) {
url += `&force_refresh=true`; url += `&force_refresh=true`;
if (requestOptions.headers) requestOptions.headers['X-Force-Refresh'] = 'true'; if (requestOptions.headers) {
const headers = new Headers(requestOptions.headers);
headers.set('X-Force-Refresh', 'true');
requestOptions.headers = headers;
}
console.log('Forcing complete refresh ignoring cache'); console.log('Forcing complete refresh ignoring cache');
} }
@@ -202,10 +206,10 @@ async function loadFiles(options = { insertHistory: true }) {
multiSelect.init(); // this will wire buttons & select-all-checkbox multiSelect.init(); // this will wire buttons & select-all-checkbox
} }
/** @type {FolderInfo[]} */ /** @type {FolderItem[]} */
const folderList = Array.isArray(listing.folders) ? listing.folders : []; const folderList = Array.isArray(listing.folders) ? listing.folders : [];
/** @type {FileInfo[]} */ /** @type {FileItem[]} */
const fileList = Array.isArray(listing.files) ? listing.files : []; const fileList = Array.isArray(listing.files) ? listing.files : [];
if (folderList.length === 0 && fileList.length === 0) { if (folderList.length === 0 && fileList.length === 0) {
+7 -2
View File
@@ -35,6 +35,10 @@ import { loadTrashItems } from './trashView.js';
import { ui } from './ui.js'; import { ui } from './ui.js';
import { setupUserMenu } from './userMenu.js'; import { setupUserMenu } from './userMenu.js';
/**
* @import {User} from '../core/types.js'
*/
// Upload dropdown listener state (prevents accumulated listeners) // Upload dropdown listener state (prevents accumulated listeners)
/** @type {((e: MouseEvent) => void) | null} */ /** @type {((e: MouseEvent) => void) | null} */
let uploadDropdownDocumentClickHandler = null; let uploadDropdownDocumentClickHandler = null;
@@ -141,7 +145,7 @@ const ACTIONS_BAR_TEMPLATES = {
/** /**
* *
* @param {string} mode * @param {'files' | 'trash' | 'favorites' | 'recent' | 'hidden'} mode
* @param {boolean} [force=false] * @param {boolean} [force=false]
* @returns * @returns
*/ */
@@ -494,6 +498,7 @@ function setupEventListeners() {
ui.setupDragAndDrop(); ui.setupDragAndDrop();
// Debounce timer for live search // Debounce timer for live search
/** @type {ReturnType<typeof setTimeout>} */
let searchDebounceTimer = null; let searchDebounceTimer = null;
const SEARCH_DEBOUNCE_MS = 300; const SEARCH_DEBOUNCE_MS = 300;
const SEARCH_MIN_CHARS = 3; const SEARCH_MIN_CHARS = 3;
@@ -726,7 +731,7 @@ export function selectFolder(id, name) {
/** /**
* Update the storage usage display with the user's actual storage usage * Update the storage usage display with the user's actual storage usage
* @param {Object} userData - The user data object * @param {User} userData - The user data object
*/ */
function updateStorageUsageDisplay(userData) { function updateStorageUsageDisplay(userData) {
// Default values // Default values
-6
View File
@@ -136,12 +136,6 @@ export const SECTIONS_MAPPER = {
*/ */
function setCurrentSection(section) { function setCurrentSection(section) {
if (app.currentSection === section) return false; if (app.currentSection === section) return false;
// Set all view flags - true for active section, false for others
Object.entries(SECTIONS_MAPPER).forEach(([key, flag]) => {
app[flag] = key === section;
});
app.currentSection = section; app.currentSection = section;
// Update nav item active classes by finding matching item from DOM // Update nav item active classes by finding matching item from DOM
+11 -3
View File
@@ -8,9 +8,14 @@ import { app } from './state.js';
import { ui } from './ui.js'; import { ui } from './ui.js';
/** /**
* @param {string} query * @import {SearchCriteria, SortByEnnum} from '../core/types.js'
* @param {string} [sortBy]
*/ */
/**
* @param {string} query
* @param {SortByEnnum} [sortBy]
*/
// FIXME: refactor with search.js ?
async function performSearch(query, sortBy) { async function performSearch(query, sortBy) {
console.log(`Performing search for: "${query}" (sort: ${sortBy || 'relevance'})`); console.log(`Performing search for: "${query}" (sort: ${sortBy || 'relevance'})`);
@@ -20,9 +25,11 @@ async function performSearch(query, sortBy) {
ui.showError(`<h3><i class="fas fa-spinner fa-spin search-spinner"></i> Searching for "${query}"...</h3>`); ui.showError(`<h3><i class="fas fa-spinner fa-spin search-spinner"></i> Searching for "${query}"...</h3>`);
/** @type {SearchCriteria} */
const options = { const options = {
recursive: true, recursive: true,
limit: 100, limit: 100,
offset: 0,
sort_by: sortBy || 'relevance' sort_by: sortBy || 'relevance'
}; };
@@ -51,7 +58,8 @@ document.addEventListener('search-resort', (e) => {
const event = /** @type {CustomEvent<{sort_by: string}>} */ (e); const event = /** @type {CustomEvent<{sort_by: string}>} */ (e);
const searchInput = /** @type {HTMLInputElement} */ (document.querySelector('.search-container input')); const searchInput = /** @type {HTMLInputElement} */ (document.querySelector('.search-container input'));
if (searchInput?.value.trim()) { if (searchInput?.value.trim()) {
performSearch(searchInput.value.trim(), event.detail.sort_by); const sortBy = /** @type {SortByEnnum} */ (event.detail.sort_by);
performSearch(searchInput.value.trim(), sortBy);
} }
}); });
+37 -6
View File
@@ -3,39 +3,70 @@
* Centralized mutable state for app and cached DOM references. * Centralized mutable state for app and cached DOM references.
*/ */
/** @import {FolderInfo} from '../core/types.js' */ /** @import {FileItem, FolderItem, LightItem} from '../core/types.js' */
export const app = { export const app = {
currentView: 'grid', currentView: 'grid',
/** @type {string | null} */ /** @type {string | null} */
currentPath: '', currentPath: '',
/** @type {string | null} */
currentFolder: null, currentFolder: null,
/** @type {FolderInfo | null} */ /** @type {FolderItem | null} */
currentFolderInfo: null, currentFolderInfo: null,
/** @type {Object | null} */ /** @type {FolderItem | null} */
contextMenuTargetFolder: null, contextMenuTargetFolder: null,
/** @type {Object | null} */ /** @type {FileItem | null} */
contextMenuTargetFile: null, contextMenuTargetFile: null,
selectedTargetFolderId: '', selectedTargetFolderId: '',
moveDialogMode: 'file', moveDialogMode: 'file',
/** @type {string | null} */
moveDialogItemId: null,
/** @type {'file' | 'folder' | null} */
moveDialogItemMode: null,
/** @type {string | null} */
moveDialogCurrentFolderId: null,
/** @type {Array<{id: string, name: string}>} */
moveDialogBreadcrumb: [],
/** @type {FileItem[] | null} */
playlistDialogFiles: null,
/** @type {String | null} */ /** @type {String | null} */
currentSection: null, // will be defined on first call currentSection: null, // will be defined on first call
isSearchMode: false, isSearchMode: false,
/** @type {FileItem | FolderItem | null} */
shareDialogItem: null, shareDialogItem: null,
/** @type {'file' | 'folder' | null} */
shareDialogItemType: null, shareDialogItemType: null,
/** @type {String | null} */
notificationShareUrl: null, notificationShareUrl: null,
/** @type {string | null} */
userHomeFolderId: null, userHomeFolderId: null,
/** @type {string | null} */
userHomeFolderName: null, userHomeFolderName: null,
/** @type {Object[]} */
/** @type {Array<{id: string, name: string}>} */
breadcrumbPath: [], // Array of {id, name} tracking folder navigation hierarchy breadcrumbPath: [], // Array of {id, name} tracking folder navigation hierarchy
/** @type {String | null} */ /** @type {String | null} */
viewFile: null // current file in inline view viewFile: null, // current file in inline view
/** @type {LightItem[] | null} */
batchMoveItems: null
}; };
export const appElements = { export const appElements = {
+10 -1
View File
@@ -9,6 +9,11 @@ import { multiSelect } from '../features/files/multiSelect.js';
import { appElements } from './state.js'; import { appElements } from './state.js';
import { ui } from './ui.js'; import { ui } from './ui.js';
/**
*
* @import {TrashItem} from '../core/types.js'
*/
async function loadTrashItems() { async function loadTrashItems() {
const elements = appElements; const elements = appElements;
@@ -25,7 +30,7 @@ async function loadTrashItems() {
</div> </div>
`; `;
ui.updateBreadcrumb(''); ui.updateBreadcrumb();
const trashItems = await fileOps.getTrashItems(); const trashItems = await fileOps.getTrashItems();
@@ -46,6 +51,10 @@ async function loadTrashItems() {
} }
} }
/**
*
* @param {TrashItem} item
*/
function addTrashItemToView(item) { function addTrashItemToView(item) {
const elements = appElements; const elements = appElements;
const isFile = item.item_type === 'file'; const isFile = item.item_type === 'file';
+125 -53
View File
@@ -25,10 +25,17 @@ import { app } from './state.js';
import { uiFileTypes } from './uiFileTypes.js'; import { uiFileTypes } from './uiFileTypes.js';
import { uiNotifications } from './uiNotifications.js'; import { uiNotifications } from './uiNotifications.js';
/**
* @import {FileItem, FolderItem} from '../core/types.js'
* @import {BatchResult} from '../features/files/fileOperations.js'
*/
// UI Module // UI Module
const ui = { const ui = {
/** @type {HTMLDListElement | null} */ /** @type {HTMLDivElement | null} */
//dragPreview, dragPreview: null,
/** @type {HTMLDivElement | null} */
draggedItems: null,
/** /**
* Initialize context menus and dialogs * Initialize context menus and dialogs
@@ -338,21 +345,31 @@ const ui = {
const dropzone = document.getElementById('dropzone'); const dropzone = document.getElementById('dropzone');
/**
*
* @param {DataTransfer} dataTransfer
* @returns {Promise<any[]|null>}
*/
const collectDroppedEntries = async (dataTransfer) => { const collectDroppedEntries = async (dataTransfer) => {
const items = Array.from(dataTransfer?.items || []); const items = Array.from(dataTransfer?.items || []);
const rootEntries = items.map((it) => (typeof it.webkitGetAsEntry === 'function' ? it.webkitGetAsEntry() : null)).filter(Boolean); const rootEntries = items.map((it) => (typeof it.webkitGetAsEntry === 'function' ? it.webkitGetAsEntry() : null)).filter(Boolean);
if (rootEntries.length === 0) return null; if (rootEntries.length === 0) return null;
/** @type {Array<{file: File, relativePath: string}>} */
const out = []; const out = [];
/**
* @param {FileSystemEntry} entry
* @param {string} prefix
*/
const walkEntry = async (entry, prefix = '') => { const walkEntry = async (entry, prefix = '') => {
if (!entry) return; if (!entry) return;
if (entry.isFile) { if (entry.isFile) {
await new Promise((resolve) => { await new Promise((resolve) => {
entry.file( /** @type {FileSystemFileEntry} */ (entry).file(
(file) => { (/** @type {File} */ file) => {
out.push({ file, relativePath: `${prefix}${file.name}` }); out.push({ file, relativePath: `${prefix}${file.name}` });
resolve(undefined); resolve(undefined);
}, },
@@ -364,7 +381,7 @@ const ui = {
if (entry.isDirectory) { if (entry.isDirectory) {
const dirPrefix = `${prefix}${entry.name}/`; const dirPrefix = `${prefix}${entry.name}/`;
const reader = entry.createReader(); const reader = /** @type {FileSystemDirectoryEntry} */ (entry).createReader();
while (true) { while (true) {
const children = await new Promise((resolve) => { const children = await new Promise((resolve) => {
@@ -636,7 +653,7 @@ const ui = {
/** /**
* Check if a file can be previewed in the viewer * Check if a file can be previewed in the viewer
* @param {Object} file - File object with mime_type property * @param {FileItem} file
* @returns {boolean} * @returns {boolean}
*/ */
isViewableFile(file) { isViewableFile(file) {
@@ -647,6 +664,7 @@ const ui = {
* Get FontAwesome icon class for a filename based on its extension. * Get FontAwesome icon class for a filename based on its extension.
* Used as fallback when the backend DTO doesn't include icon_class * Used as fallback when the backend DTO doesn't include icon_class
* (e.g. trash items). * (e.g. trash items).
* @param {string} fileName
*/ */
getIconClass(fileName) { getIconClass(fileName) {
return uiFileTypes.getIconClass(fileName); return uiFileTypes.getIconClass(fileName);
@@ -655,6 +673,7 @@ const ui = {
/** /**
* Get CSS special class for icon styling based on filename extension. * Get CSS special class for icon styling based on filename extension.
* Used as fallback when the backend DTO doesn't include icon_special_class. * Used as fallback when the backend DTO doesn't include icon_special_class.
* @param {string} fileName
*/ */
getIconSpecialClass(fileName) { getIconSpecialClass(fileName) {
return uiFileTypes.getIconSpecialClass(fileName); return uiFileTypes.getIconSpecialClass(fileName);
@@ -695,13 +714,13 @@ const ui = {
* Data store + event delegation (replaces per-item listeners) * Data store + event delegation (replaces per-item listeners)
* ================================================================ */ * ================================================================ */
/** @type {Map<string, Object>} item data keyed by id */ /** @type {Map<string, FolderItem | FileItem>} item data keyed by id */
_items: new Map(), _items: new Map(),
/** @type {Array<Object>} last rendered folder dataset */ /** @type {FolderItem[]} last rendered folder dataset */
_lastFolders: [], _lastFolders: [],
/** @type {Array<Object>} last rendered file dataset */ /** @type {FileItem[]} last rendered file dataset */
_lastFiles: [], _lastFiles: [],
/** @type {boolean} */ /** @type {boolean} */
@@ -716,9 +735,7 @@ const ui = {
}, },
/** /**
* * @param {FolderItem[]} folders
* @param {Object[]} folders
* @returns
*/ */
_renderFoldersToView(folders) { _renderFoldersToView(folders) {
if (!Array.isArray(folders) || folders.length === 0) return; if (!Array.isArray(folders) || folders.length === 0) return;
@@ -727,15 +744,17 @@ const ui = {
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
for (const folder of folders) { for (const folder of folders) {
frag.appendChild(this._createFolderItem(folder)); try {
frag.appendChild(this._createFolderItem(folder));
} catch (e) {
console.warn(`Error building folder item `, folder, `reason: `, e);
}
} }
target.appendChild(frag); target.appendChild(frag);
}, },
/** /**
* * @param {FileItem[]} files
* @param {Object[]} files
* @returns
*/ */
_renderFilesToView(files) { _renderFilesToView(files) {
if (!Array.isArray(files) || files.length === 0) return; if (!Array.isArray(files) || files.length === 0) return;
@@ -744,11 +763,19 @@ const ui = {
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
for (const file of files) { for (const file of files) {
frag.appendChild(this._createFileItem(file)); try {
frag.appendChild(this._createFileItem(file));
} catch (e) {
console.warn(`Error building file item `, file, `reason: `, e);
}
} }
target.appendChild(frag); target.appendChild(frag);
}, },
/**
* @param {any[]} arr
* @param {any} item
*/
_upsertById(arr, item) { _upsertById(arr, item) {
if (!Array.isArray(arr) || !item?.id) return; if (!Array.isArray(arr) || !item?.id) return;
const idx = arr.findIndex((x) => x && x.id === item.id); const idx = arr.findIndex((x) => x && x.id === item.id);
@@ -796,6 +823,7 @@ const ui = {
await fileOps.moveFile(sourceId, targetFolderId); await fileOps.moveFile(sourceId, targetFolderId);
*/ */
/** @type {BatchResult} */
let result; let result;
switch (action) { switch (action) {
case 'copy': case 'copy':
@@ -843,6 +871,7 @@ const ui = {
this._delegationReady = true; this._delegationReady = true;
// ── helpers ──────────────────────────────────────────────── // ── helpers ────────────────────────────────────────────────
/** @param {HTMLDivElement} card */
const itemInfo = (card) => { const itemInfo = (card) => {
if (!card) return null; if (!card) return null;
const fileId = card.dataset.fileId; const fileId = card.dataset.fileId;
@@ -864,6 +893,7 @@ const ui = {
return null; return null;
}; };
/** @param {FileItem} file */
const openFile = async (file) => { const openFile = async (file) => {
if (!file) return; if (!file) return;
if (recent) { if (recent) {
@@ -897,6 +927,7 @@ const ui = {
} }
}; };
/** @param {HTMLElement} card */
const navigateFolder = (card) => { const navigateFolder = (card) => {
const folderId = card.dataset.folderId; const folderId = card.dataset.folderId;
const folderName = card.dataset.folderName; const folderName = card.dataset.folderName;
@@ -912,27 +943,31 @@ const ui = {
loadFiles(); loadFiles();
}; };
/**
* @param {HTMLElement} card
* @param {{ type: string, id: string, name: string | undefined, data: FolderItem | FileItem | undefined }} info
*/
const setContextTarget = (card, info) => { const setContextTarget = (card, info) => {
if (info.type === 'folder') { if (info.type === 'folder') {
app.contextMenuTargetFolder = { app.contextMenuTargetFolder = /** @type {FolderItem} */ ({
id: info.id, id: info.id,
name: card.dataset.folderName, name: card.dataset.folderName,
parent_id: card.dataset.parentId || '' parent_id: card.dataset.parentId || ''
}; });
} else { } else {
const fileData = info.data || this._items.get(info.id); const fileData = /** @type {FileItem | undefined} */ (info.data || this._items.get(info.id));
app.contextMenuTargetFile = { app.contextMenuTargetFile = /** @type {FileItem} */ ({
id: info.id, id: info.id,
name: card.dataset.fileName, name: card.dataset.fileName,
folder_id: card.dataset.folderId || '', folder_id: card.dataset.folderId || '',
mime_type: fileData?.mime_type || null mime_type: fileData?.mime_type || null
}; });
} }
}; };
// ── click (open / navigate; select only via checkbox) ── // ── click (open / navigate; select only via checkbox) ──
filesList.addEventListener('click', (e) => { filesList.addEventListener('click', (e) => {
const card = /** @type {HTMLElement} */ (e.target).closest('.file-item'); const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item'));
if (!card) return; if (!card) return;
if (/** @type {HTMLElement} */ (e.target).closest('.file-actions')) { if (/** @type {HTMLElement} */ (e.target).closest('.file-actions')) {
@@ -975,7 +1010,7 @@ const ui = {
if (info.type === 'folder') { if (info.type === 'folder') {
navigateFolder(card); navigateFolder(card);
} else { } else {
openFile(info.data); openFile(/** @type {FileItem} */ (info.data));
} }
}); });
@@ -989,7 +1024,7 @@ const ui = {
// ── shared events ────────────────────── // ── shared events ──────────────────────
filesList.addEventListener('contextmenu', (e) => { filesList.addEventListener('contextmenu', (e) => {
const card = /** @type {HTMLElement} */ (e.target).closest('.file-item'); const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item'));
if (!card) return; if (!card) return;
e.preventDefault(); e.preventDefault();
const info = itemInfo(card); const info = itemInfo(card);
@@ -1008,7 +1043,7 @@ const ui = {
// dragstart // dragstart
filesList.addEventListener('dragstart', (e) => { filesList.addEventListener('dragstart', (e) => {
const card = /** @type {HTMLElement} */ (e.target).closest('.file-item'); const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item'));
if (!card) { if (!card) {
e.preventDefault(); e.preventDefault();
return; return;
@@ -1088,9 +1123,11 @@ const ui = {
// TODO better naming like ("selection in ${parent.name}") modulo i18n ? ... // TODO better naming like ("selection in ${parent.name}") modulo i18n ? ...
const now = new Date().toISOString().replace(/T/, ' ').replace(/\.*/, '').replaceAll(/:/g, '-'); const now = new Date().toISOString().replace(/T/, ' ').replace(/\.*/, '').replaceAll(/:/g, '-');
nameEncoded = `oxicloud ${now}.zip`; nameEncoded = `oxicloud ${now}.zip`;
/** @type {string[]} */
const folders = []; const folders = [];
/** @type {string[]} */
const files = []; const files = [];
filesList.querySelectorAll(`div.selected`).forEach((e) => { /** @type {NodeListOf<HTMLDivElement>} */ (filesList.querySelectorAll(`div.selected`)).forEach((e) => {
const item = itemInfo(e); const item = itemInfo(e);
if (item.type === 'file') { if (item.type === 'file') {
files.push(item.id); files.push(item.id);
@@ -1164,6 +1201,7 @@ const ui = {
* Favorite star helper – attaches a direct click handler to a * Favorite star helper – attaches a direct click handler to a
* star <button> so the event never bubbles to the card. * star <button> so the event never bubbles to the card.
* ================================================================ */ * ================================================================ */
/** @param {HTMLElement} el */
_bindStarClick(el) { _bindStarClick(el) {
const star = el.querySelector('.favorite-star'); const star = el.querySelector('.favorite-star');
star?.addEventListener('click', (e) => { star?.addEventListener('click', (e) => {
@@ -1174,7 +1212,8 @@ const ui = {
if (!favorites) return; if (!favorites) return;
// FIXME: make a function // FIXME: make a function
const itemElement = shared?.closest('.file-item'); const itemElement = /** @type {HTMLElement | null} */ (shared?.closest('.file-item'));
if (!itemElement) return;
const itemId = itemElement.dataset.fileId ? itemElement.dataset.fileId : itemElement.dataset.folderId; const itemId = itemElement.dataset.fileId ? itemElement.dataset.fileId : itemElement.dataset.folderId;
const itemType = itemElement.dataset.fileId ? 'file' : 'folder'; const itemType = itemElement.dataset.fileId ? 'file' : 'folder';
@@ -1187,7 +1226,7 @@ const ui = {
favorites.removeFromFavorites(itemId, itemType); favorites.removeFromFavorites(itemId, itemType);
} else { } else {
this.setFavoriteVisualState(itemId, itemType, true); this.setFavoriteVisualState(itemId, itemType, true);
favorites.addToFavorites(itemId, itemName, itemType); favorites.addToFavorites(itemId, itemName, itemType, null);
} }
// Keep context-menu label in sync if available // Keep context-menu label in sync if available
@@ -1201,26 +1240,30 @@ const ui = {
e.preventDefault(); e.preventDefault();
// FIXME: make a function // FIXME: make a function
const itemElement = shared?.closest('.file-item'); const itemElement = /** @type {HTMLElement | null} */ (shared?.closest('.file-item'));
if (!itemElement) return;
const itemId = itemElement.dataset.fileId ? itemElement.dataset.fileId : itemElement.dataset.folderId; const itemId = itemElement.dataset.fileId ? itemElement.dataset.fileId : itemElement.dataset.folderId;
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 // TODO corrently dirty
const item = { const item = /** @type {unknown} */ ({
id: itemId, id: itemId,
item_id: itemId, item_id: itemId,
item_type: itemType, item_type: itemType,
item_name: itemName item_name: itemName
}; });
contextMenus.showShareDialog(item, itemType); contextMenus.showShareDialog(/** @type {FileItem} */ (item), itemType);
}); });
}, },
/** /**
* Sync favorite visuals for a file/folder across grid and list views. * Sync favorite visuals for a file/folder across grid and list views.
* @param {string} itemId
* @param {string} itemType
* @param {boolean} isFavorite
*/ */
setFavoriteVisualState(itemId, itemType, isFavorite) { setFavoriteVisualState(itemId, itemType, isFavorite) {
const selector = itemType === 'folder' ? `#files-list .file-item[data-folder-id="${itemId}"]` : `#files-list .file-item[data-file-id="${itemId}"]`; const selector = itemType === 'folder' ? `#files-list .file-item[data-folder-id="${itemId}"]` : `#files-list .file-item[data-file-id="${itemId}"]`;
@@ -1258,6 +1301,11 @@ const ui = {
} }
}, },
/**
* @param {string} itemId
* @param {string} itemType
* @param {boolean} isShared
*/
setSharedVisualState(itemId, itemType, isShared) { setSharedVisualState(itemId, itemType, isShared) {
console.log(`setSharedVisual call for ${itemId} ${itemType} to ${isShared}`); console.log(`setSharedVisual call for ${itemId} ${itemType} to ${isShared}`);
const selector = itemType === 'folder' ? `#files-list .file-item[data-folder-id="${itemId}"]` : `#files-list .file-item[data-file-id="${itemId}"]`; const selector = itemType === 'folder' ? `#files-list .file-item[data-folder-id="${itemId}"]` : `#files-list .file-item[data-file-id="${itemId}"]`;
@@ -1273,7 +1321,10 @@ const ui = {
* Element-creation helpers * Element-creation helpers
* ================================================================ */ * ================================================================ */
/** Create a list row for a folder */ /**
* Create a list row for a folder
* @param {FolderItem} folder
*/
_createFolderItem(folder) { _createFolderItem(folder) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'file-item'; el.className = 'file-item';
@@ -1314,7 +1365,10 @@ const ui = {
return el; return el;
}, },
/** Create a grid card for a file */ /**
* Create a grid card for a file
* @param {FileItem} file
*/
_createFileItem(file) { _createFileItem(file) {
const iconClass = file.icon_class || this.getIconClass(file.name); const iconClass = file.icon_class || this.getIconClass(file.name);
const iconSpecialClass = file.icon_special_class || this.getIconSpecialClass(file.name); const iconSpecialClass = file.icon_special_class || this.getIconSpecialClass(file.name);
@@ -1425,7 +1479,7 @@ const ui = {
* Render an array of folders into both grid and list views * Render an array of folders into both grid and list views
* using DocumentFragment for minimal reflows. * using DocumentFragment for minimal reflows.
* *
* @param {FolderInfo[]} folders * @param {FolderItem[]} folders
*/ */
renderFolders(folders) { renderFolders(folders) {
if (!this._delegationReady) this.initDelegation(); if (!this._delegationReady) this.initDelegation();
@@ -1442,6 +1496,7 @@ const ui = {
/** /**
* Render an array of files into both grid and list views * Render an array of files into both grid and list views
* using DocumentFragment for minimal reflows. * using DocumentFragment for minimal reflows.
* @param {FileItem[]} files
*/ */
renderFiles(files) { renderFiles(files) {
if (!this._delegationReady) this.initDelegation(); if (!this._delegationReady) this.initDelegation();
@@ -1461,7 +1516,7 @@ const ui = {
/** /**
* Add a single folder to the active view. * Add a single folder to the active view.
* @param {Object} folder - Folder object * @param {FolderItem} folder
*/ */
addFolderToView(folder) { addFolderToView(folder) {
if (!this._delegationReady) this.initDelegation(); if (!this._delegationReady) this.initDelegation();
@@ -1472,6 +1527,7 @@ const ui = {
return; return;
} }
this._clearEmptyState();
this._items.set(folder.id, folder); this._items.set(folder.id, folder);
this._upsertById(this._lastFolders, folder); this._upsertById(this._lastFolders, folder);
this._renderFoldersToView([folder]); this._renderFoldersToView([folder]);
@@ -1479,7 +1535,7 @@ const ui = {
/** /**
* Add a single file to the active view. * Add a single file to the active view.
* @param {Object} file - File object * @param {FileItem} file
*/ */
addFileToView(file) { addFileToView(file) {
if (!this._delegationReady) this.initDelegation(); if (!this._delegationReady) this.initDelegation();
@@ -1490,9 +1546,23 @@ const ui = {
return; return;
} }
this._clearEmptyState();
this._items.set(file.id, file); this._items.set(file.id, file);
this._upsertById(this._lastFiles, file); this._upsertById(this._lastFiles, file);
this._renderFilesToView([file]); this._renderFilesToView([file]);
},
/**
* If the empty-state placeholder is showing, switch back to the file list.
* Called before adding any new item so the card is not appended to a hidden list.
*/
_clearEmptyState() {
const filesList = document.getElementById('files-list');
const filesContainerError = document.getElementById('files-container-error');
if (filesList?.classList.contains('hidden')) {
filesList.classList.remove('hidden');
filesContainerError?.classList.add('hidden');
}
} }
}; };
@@ -1501,6 +1571,8 @@ const ui = {
/** /**
* Toggle selection state of a file/folder card. * Toggle selection state of a file/folder card.
* Routes through the multiSelect module so batch actions know about selected items. * Routes through the multiSelect module so batch actions know about selected items.
* @param {HTMLDivElement} card
* @param {MouseEvent} event
*/ */
function toggleCardSelection(card, event) { function toggleCardSelection(card, event) {
if (multiSelect) { if (multiSelect) {
@@ -1512,6 +1584,8 @@ function toggleCardSelection(card, event) {
/** /**
* Show the context menu anchored next to a trigger element (the 3-dot button). * Show the context menu anchored next to a trigger element (the 3-dot button).
* @param {HTMLElement} triggerElement
* @param {string} menuId
*/ */
function showContextMenuAtElement(triggerElement, menuId) { function showContextMenuAtElement(triggerElement, menuId) {
// Hide any open menus first // Hide any open menus first
@@ -1542,8 +1616,6 @@ function showContextMenuAtElement(triggerElement, menuId) {
menu.classList.remove('hidden'); menu.classList.remove('hidden');
} }
let __rubberBandJustFinished = false;
/** /**
* Rubber band (lasso) selection — click + drag on empty grid area * Rubber band (lasso) selection — click + drag on empty grid area
* to draw a rectangle and select all cards it touches. * to draw a rectangle and select all cards it touches.
@@ -1567,16 +1639,18 @@ function initRubberBandSelection() {
if (!container) return; if (!container) return;
container.addEventListener('mousedown', (e) => { container.addEventListener('mousedown', (e) => {
if (!(e instanceof MouseEvent)) return;
// Only start if clicking empty area (not on a card, button, menu, input…) // Only start if clicking empty area (not on a card, button, menu, input…)
if (e.button !== 0) return; // left click only if (e.button !== 0) return; // left click only
const target = /** @type {Element} */ (e.target);
if ( if (
e.target.closest('.file-item') || target.closest('.file-item') ||
e.target.closest('.context-menu') || target.closest('.context-menu') ||
e.target.closest('.upload-dropdown') || target.closest('.upload-dropdown') ||
e.target.closest('button') || target.closest('button') ||
e.target.closest('input') || target.closest('input') ||
e.target.closest('.breadcrumb') || target.closest('.breadcrumb') ||
e.target.closest('.list-header') target.closest('.list-header')
) )
return; return;
@@ -1627,14 +1701,14 @@ function initRubberBandSelection() {
// Sync with multiSelect module // Sync with multiSelect module
if (multiSelect) { if (multiSelect) {
const info = multiSelect._extractInfo(card); const info = multiSelect._extractInfo(/** @type {HTMLDivElement} */ (card));
if (info) multiSelect.select(info.id, info.name, info.type, info.parentId); if (info) multiSelect.select(info.id, info.name, info.type, info.parentId);
} }
} else { } else {
card.classList.remove('selected'); card.classList.remove('selected');
// Deselect from multiSelect module // Deselect from multiSelect module
if (multiSelect) { if (multiSelect) {
const info = multiSelect._extractInfo(card); const info = multiSelect._extractInfo(/** @type {HTMLDivElement} */ (card));
if (info) multiSelect.deselect(info.id); if (info) multiSelect.deselect(info.id);
} }
} }
@@ -1651,10 +1725,7 @@ function initRubberBandSelection() {
// Suppress the click event that follows mouseup so the global // Suppress the click event that follows mouseup so the global
// deselect handler doesn't immediately clear the selection. // deselect handler doesn't immediately clear the selection.
if (hadSelection) { if (hadSelection) {
__rubberBandJustFinished = true; requestAnimationFrame(() => {});
requestAnimationFrame(() => {
__rubberBandJustFinished = false;
});
} }
}); });
} }
@@ -1709,6 +1780,7 @@ function showConfirmDialog({ title, message, confirmText, cancelText, danger = t
overlay.classList.add('active'); overlay.classList.add('active');
}); });
/** @param {boolean} result */
const cleanup = (result) => { const cleanup = (result) => {
overlay.classList.remove('active'); overlay.classList.remove('active');
setTimeout(() => overlay.remove(), 200); setTimeout(() => overlay.remove(), 200);
+2 -2
View File
@@ -5,7 +5,7 @@
import { isTextViewable } from '../core/formatters.js'; import { isTextViewable } from '../core/formatters.js';
/** @import {FileInfo} from '../core/types.js' */ /** @import {FileItem} from '../core/types.js' */
/** @type {Record<string, string>} */ /** @type {Record<string, string>} */
const ICON_CLASS_MAP = { const ICON_CLASS_MAP = {
@@ -158,7 +158,7 @@ const uiFileTypes = {
// TODO: 'd better to use a canViw() method in inlineViewer // TODO: 'd better to use a canViw() method in inlineViewer
/** /**
* *
* @param {FileInfo} file * @param {FileItem} file
* @returns {boolean} * @returns {boolean}
*/ */
isViewableFile(file) { isViewableFile(file) {
+4
View File
@@ -18,6 +18,10 @@ function getCsrfToken() {
return match ? match.split('=')[1] : ''; return match ? match.split('=')[1] : '';
} }
/**
* returns headers to add, this includes the X-CSRF-Token
* @returns {Record<String, String>}
*/
function getCsrfHeaders() { function getCsrfHeaders() {
const token = getCsrfToken(); const token = getCsrfToken();
return token ? { 'X-CSRF-Token': token } : {}; return token ? { 'X-CSRF-Token': token } : {};
+1
View File
@@ -37,6 +37,7 @@ const WRAPPER_USER_DATA_KEY = 'oxicloud_user';
let _originalFetch = window.fetch.bind(window); let _originalFetch = window.fetch.bind(window);
/** Deduplicates concurrent refresh attempts into a single in-flight promise. */ /** Deduplicates concurrent refresh attempts into a single in-flight promise. */
/** @type {Promise<boolean> | null} */
let _refreshInFlight = null; let _refreshInFlight = null;
async function _refresh() { async function _refresh() {
+11 -1
View File
@@ -1,6 +1,7 @@
/** /**
* OxiCloud - Shared format and escaping utilities * OxiCloud - Shared format and escaping utilities
* Centralized global helpers for date/size/text formatting and XSS-safe escaping. * Centralized global helpers for date/size/text formatting and XSS-safe escaping.
* Contains also checkers
*/ */
/** /**
@@ -97,4 +98,13 @@ function isTextViewable(mimeType) {
return TEXT_TYPES.includes(mimeType); return TEXT_TYPES.includes(mimeType);
} }
export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isTextViewable }; /**
* Chekif an email is valid
* @param {string} email
* @returns boolean
*/
function isEmailValid(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isEmailValid, isTextViewable };
+15 -7
View File
@@ -37,7 +37,7 @@ function resolveBrowserLocale() {
let currentLocale = resolveBrowserLocale(); let currentLocale = resolveBrowserLocale();
// Cache for translations // Cache for translations
/** @type {Record<string, Object>} */ /** @type {Record<string, any>} */
const translations = {}; const translations = {};
/** /**
@@ -71,7 +71,7 @@ async function loadTranslations(locale) {
/** /**
* Get a nested translation value * Get a nested translation value
* @param {object} obj - The translations object * @param {Record<string, any>} obj - The translations object
* @param {string} path - The dot-notation path to the translation * @param {string} path - The dot-notation path to the translation
* @returns {string|null} - The translation value or null if not found * @returns {string|null} - The translation value or null if not found
*/ */
@@ -110,7 +110,7 @@ function getNestedValue(obj, path) {
/** /**
* Replace parameters in a translation string * Replace parameters in a translation string
* @param {string} text - The translation string with placeholders * @param {string} text - The translation string with placeholders
* @param {object} params - The parameters to replace * @param {Record<string, any>} params - The parameters to replace
* @returns {string} - The interpolated string * @returns {string} - The interpolated string
*/ */
function interpolate(text, params) { function interpolate(text, params) {
@@ -244,11 +244,19 @@ document.addEventListener('DOMContentLoaded', async () => {
// Self-contained t wrapper — does NOT call the global t() because other // Self-contained t wrapper — does NOT call the global t() because other
// scripts (e.g. admin.js) may shadow it, which would cause infinite recursion. // scripts (e.g. admin.js) may shadow it, which would cause infinite recursion.
function safeT(key, params = {}) { /**
* @param {string} key
* @param {string | Record<string, any>} [paramsOrFallback] - interpolation params object, or a string fallback used when the key is missing
* @returns {string}
*/
function safeT(key, paramsOrFallback = {}) {
const fallback = typeof paramsOrFallback === 'string' ? paramsOrFallback : null;
const params = typeof paramsOrFallback === 'object' ? paramsOrFallback : {};
const localeData = translations[currentLocale]; const localeData = translations[currentLocale];
if (!localeData) { if (!localeData) {
// Translations not loaded yet — return humanised key suffix // Translations not loaded yet — return fallback or humanised key suffix
return key.split('.').pop() || key; return fallback ?? key.split('.').pop() ?? key;
} }
let value = getNestedValue(localeData, key); let value = getNestedValue(localeData, key);
@@ -258,7 +266,7 @@ function safeT(key, params = {}) {
value = getNestedValue(translations.en, key); value = getNestedValue(translations.en, key);
} }
if (!value) return key; if (!value) return fallback ?? key;
return interpolate(value, params); return interpolate(value, params);
} }
+2 -1
View File
@@ -15,6 +15,7 @@
// All icons use viewBox="0 0 {width} 512" and fill="currentColor". // All icons use viewBox="0 0 {width} 512" and fill="currentColor".
// Keys use FA5 class names (without "fa-" prefix) for backward compatibility. // Keys use FA5 class names (without "fa-" prefix) for backward compatibility.
/** @type {Record<String, Array<number | String>>} */
const OxiIcons = { const OxiIcons = {
'arrow-left': [ 'arrow-left': [
448, 448,
@@ -503,7 +504,7 @@ function replaceIconsInElement(container) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('fill', 'currentColor'); path.setAttribute('fill', 'currentColor');
path.setAttribute('d', d); path.setAttribute('d', /** @type {string} */ (d));
svg.appendChild(path); svg.appendChild(path);
el.replaceWith(svg); el.replaceWith(svg);
+12 -1
View File
@@ -34,6 +34,10 @@ function getAvailableLanguages() {
const rtlLanguages = ['fa', 'ar']; const rtlLanguages = ['fa', 'ar'];
// Update HTML lang attribute and dir for RTL languages // Update HTML lang attribute and dir for RTL languages
/**
*
* @param {string} langCode
*/
function updateHtmlAttributes(langCode) { function updateHtmlAttributes(langCode) {
const htmlElement = document.documentElement; const htmlElement = document.documentElement;
@@ -150,6 +154,7 @@ function createLanguageSelector(containerId = 'language-selector') {
/** /**
* Toggle dropdown open/closed * Toggle dropdown open/closed
* @param {HTMLElement} container
*/ */
function toggleDropdown(container) { function toggleDropdown(container) {
const isOpen = container.classList.contains('open'); const isOpen = container.classList.contains('open');
@@ -162,6 +167,7 @@ function toggleDropdown(container) {
/** /**
* Open dropdown * Open dropdown
* @param {HTMLElement} container
*/ */
function openDropdown(container) { function openDropdown(container) {
container.classList.add('open'); container.classList.add('open');
@@ -173,6 +179,7 @@ function openDropdown(container) {
/** /**
* Close dropdown * Close dropdown
* @param {HTMLElement} container
*/ */
function closeDropdown(container) { function closeDropdown(container) {
container.classList.remove('open'); container.classList.remove('open');
@@ -184,6 +191,8 @@ function closeDropdown(container) {
/** /**
* Select a language * Select a language
* @param {String} langCode
* @param {HTMLElement} container
*/ */
async function selectLanguage(langCode, container) { async function selectLanguage(langCode, container) {
await i18n.setLocale(langCode); await i18n.setLocale(langCode);
@@ -197,6 +206,8 @@ async function selectLanguage(langCode, container) {
/** /**
* Update the UI to reflect selected language * Update the UI to reflect selected language
* @param {String} langCode
* @param {HTMLElement} container
*/ */
function updateSelectedLanguage(langCode, container) { function updateSelectedLanguage(langCode, container) {
const languages = getAvailableLanguages(); const languages = getAvailableLanguages();
@@ -213,7 +224,7 @@ function updateSelectedLanguage(langCode, container) {
options.forEach((option) => { options.forEach((option) => {
const isActive = option.getAttribute('data-lang') === langCode; const isActive = option.getAttribute('data-lang') === langCode;
option.classList.toggle('active', isActive); option.classList.toggle('active', isActive);
option.setAttribute('aria-selected', isActive); option.setAttribute('aria-selected', String(isActive));
}); });
} }
+20 -18
View File
@@ -10,25 +10,25 @@ const Modal = {
// Modal element references // Modal element references
/** @private @type {HTMLElement | null} */ /** @private @type {HTMLElement | null} */
overlay: null, overlay: null,
// FIXME: unused ?
container: null,
/** @private @type {HTMLElement | null} */ /** @private @type {HTMLElement | null} */
icon: null, icon: null,
/** @private @type {HTMLElement | null} */ /** @private @type {HTMLElement | null} */
title: null, title: null,
/** @private @type {HTMLElement | null} */ /** @private @type {HTMLElement | null} */
label: null, label: null,
/** @private @type {HTMLElement | null} */ /** @private @type {HTMLInputElement | null} */
input: null, input: null,
/** @private @type {HTMLElement | null} */ /** @private @type {HTMLButtonElement | null} */
cancelBtn: null, cancelBtn: null,
/** @private @type {HTMLElement | null} */ /** @private @type {HTMLButtonElement | null} */
confirmBtn: null, confirmBtn: null,
/** @private @type {HTMLElement | null} */ /** @private @type {HTMLButtonElement | null} */
closeBtn: null, closeBtn: null,
// Current callback // Current callback
/** @private @type {Function | null} */
onConfirm: null, onConfirm: null,
/** @private @type {Function | null} */
onCancel: null, onCancel: null,
/** @private @type {((value: string) => Promise<void>) | null} */ /** @private @type {((value: string) => Promise<void>) | null} */
@@ -53,10 +53,10 @@ const Modal = {
this.icon = document.getElementById('modal-icon'); this.icon = document.getElementById('modal-icon');
this.title = document.getElementById('modal-title'); this.title = document.getElementById('modal-title');
this.label = document.getElementById('modal-label'); this.label = document.getElementById('modal-label');
this.input = document.getElementById('modal-input'); this.input = /** @type {HTMLInputElement} */ (document.getElementById('modal-input'));
this.cancelBtn = document.getElementById('modal-cancel-btn'); this.cancelBtn = /** @type {HTMLButtonElement} */ (document.getElementById('modal-cancel-btn'));
this.confirmBtn = document.getElementById('modal-confirm-btn'); this.confirmBtn = /** @type {HTMLButtonElement} */ (document.getElementById('modal-confirm-btn'));
this.closeBtn = document.getElementById('modal-close-btn'); this.closeBtn = /** @type {HTMLButtonElement} */ (document.getElementById('modal-close-btn'));
// Event listeners // Event listeners
this.errorEl = document.getElementById('modal-error'); this.errorEl = document.getElementById('modal-error');
@@ -106,13 +106,13 @@ const Modal = {
/** /**
* Show input modal (replacement for prompt()) * Show input modal (replacement for prompt())
* @param {Object} options - Modal configuration * @param {Object} options - Modal configuration
* @param {string} options.title - Modal title * @param {string} [options.title] - Modal title
* @param {string} options.label - Input label * @param {string} [options.label] - Input label
* @param {string} options.placeholder - Input placeholder * @param {string} [options.placeholder] - Input placeholder
* @param {string} options.value - Initial input value * @param {string} [options.value] - Initial input value
* @param {string} options.icon - Font Awesome icon class (e.g., 'fa-folder-plus') * @param {string} [options.icon] - Font Awesome icon class (e.g., 'fa-folder-plus')
* @param {string} options.confirmText - Confirm button text * @param {string} [options.confirmText] - Confirm button text
* @param {string} options.cancelText - Cancel button text * @param {string} [options.cancelText] - Cancel button text
* @param {(value: string) => Promise<void>} [options.action] - Async action called on confirm. * @param {(value: string) => Promise<void>} [options.action] - Async action called on confirm.
* Throw an Error to keep the modal open and display the error message inline. * Throw an Error to keep the modal open and display the error message inline.
* When omitted the modal resolves immediately with the input value (legacy behaviour). * When omitted the modal resolves immediately with the input value (legacy behaviour).
@@ -218,6 +218,8 @@ const Modal = {
open() { open() {
if (!this.overlay) return; if (!this.overlay) return;
this.confirmBtn.disabled = false;
// Show overlay // Show overlay
this.overlay.classList.remove('hidden'); this.overlay.classList.remove('hidden');
@@ -292,7 +294,7 @@ const Modal = {
if (this.onConfirm) this.onConfirm(); if (this.onConfirm) this.onConfirm();
this.close(true); this.close(true);
} catch (e) { } catch (e) {
this.showError(e.message || 'An error occurred'); this.showError(/** @type {Error} */ (e).message || 'An error occurred');
this.confirmBtn.disabled = false; this.confirmBtn.disabled = false;
this.input.focus(); this.input.focus();
} }
+25 -1
View File
@@ -15,13 +15,28 @@ import { i18n } from './i18n.js';
* clear() * clear()
*/ */
/**
* @typedef {Object} BatchNotification
* @property {HTMLElement} el
* @property {Number} totalFiles,
* @property {Number} completed
* @property {Number} successCount
* @property {Number} errorCount
* @property {Number} lastLabelUpdateTs
* @property {String} lastLabelFile
*/
const notifications = (() => { const notifications = (() => {
/* ── state ──────────────────────────────────────────────── */ /* ── state ──────────────────────────────────────────────── */
let _badgeCount = 0; let _badgeCount = 0;
let _batchSeq = 0; let _batchSeq = 0;
const _batches = {}; // batchId → { el, files:{}, totalFiles }
/** @type {Record<String,BatchNotification>} */
const _batches = {};
/* ── DOM refs (resolved lazily) ─────────────────────────── */ /* ── DOM refs (resolved lazily) ─────────────────────────── */
/** @type {(id: string) => HTMLElement | null } */
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
/* ── bell toggle ────────────────────────────────────────── */ /* ── bell toggle ────────────────────────────────────────── */
@@ -237,6 +252,8 @@ const notifications = (() => {
/** /**
* Mark a file as completed within a batch (updates overall bar). * Mark a file as completed within a batch (updates overall bar).
* DOM updates are throttled to every 5 files to avoid reflow starvation. * DOM updates are throttled to every 5 files to avoid reflow starvation.
* @param {string} batchId
* @param {boolean} success
*/ */
function fileCompleted(batchId, success) { function fileCompleted(batchId, success) {
const batch = _batches[batchId]; const batch = _batches[batchId];
@@ -262,6 +279,9 @@ const notifications = (() => {
/** /**
* Finalise a batch – update icon and title. * Finalise a batch – update icon and title.
* @param {string} batchId
* @param {number} successCount
* @param {number} totalFiles
*/ */
function finishBatch(batchId, successCount, totalFiles) { function finishBatch(batchId, successCount, totalFiles) {
const batch = _batches[batchId]; const batch = _batches[batchId];
@@ -320,6 +340,10 @@ const notifications = (() => {
} }
/* ── util ───────────────────────────────────────────────── */ /* ── util ───────────────────────────────────────────────── */
// FIXME move to global library
/**
* @param {string} s
*/
function _esc(s) { function _esc(s) {
const d = document.createElement('div'); const d = document.createElement('div');
d.textContent = s; d.textContent = s;
+205 -5
View File
@@ -1,6 +1,20 @@
/** /**
* @typedef {Object} FolderInfo * @typedef {'file' | 'folder'} ItemTypeEnum
* @property {string} category */
// FIXME to simplify
/**
* @typedef {Object} LightItem
* @property {string} id
* @property {string} name
* @property {ItemTypeEnum} type
* @property {string} parentId
*/
//FIXME: rename into FolderItem
/**
* @typedef {Object} FolderItem
* @property {string} category (folder)
* @property {number} created_at - timestamp * @property {number} created_at - timestamp
* @property {string} icon_class * @property {string} icon_class
* @property {string} icon_special_class * @property {string} icon_special_class
@@ -13,8 +27,9 @@
* @property {string} path the full path * @property {string} path the full path
*/ */
//FIXME: rename into FileItem
/** /**
* @typedef {Object} FileInfo * @typedef {Object} FileItem
* @property {string} category * @property {string} category
* @property {number} created_at - timestamp * @property {number} created_at - timestamp
* @property {string} icon_class * @property {string} icon_class
@@ -39,7 +54,7 @@
*/ */
/** /**
* @typedef {Object} Share * @typedef {Object} ShareItem
* @property {number} access_count * @property {number} access_count
* @property {number} created_at - timestamp * @property {number} created_at - timestamp
* @property {String} created_by * @property {String} created_by
@@ -48,8 +63,193 @@
* @property {string} id * @property {string} id
* @property {string} item_id * @property {string} item_id
* @property {string} item_name * @property {string} item_name
* @property {string} item_type * @property {ItemTypeEnum} item_type
* @property {SharePermissions} permissions * @property {SharePermissions} permissions
* @property {string | null} token * @property {string | null} token
* @property {string} url * @property {string} url
*/ */
/**
* @typedef {Object} CreateShare
* @property {string} item_id
* @property {string|null} [item_name]
* @property {ItemTypeEnum} item_type
* @property {string|null} password
* @property {number|null} expires_at - timestamp
* @property {SharePermissions|null} permissions
*/
/**
* @typedef {Object} UpdateShare
* @property {string|null} password
* @property {number|null} expires_at - timestamp
* @property {SharePermissions|null} permissions
*/
/**
* @typedef {Object} FavoriteItem
* @property {string} id
* @property {string} user_id
* @property {string} item_id /// ID of the favorited item (file or folder)
* @property {ItemTypeEnum} item_type
* @property {number} created_at
* @property {string|null} item_name: null if folder
* @property {number|null} item_size null if folder
* @property {string|null} item_mime_type if file
* @property {string|null} parent_id
* @property {number|null} modified_at: Option<DateTime<Utc>>,
* @property {String} item_path Full human-readable path (e.g. "Documents/Work" for a folder, "Documents/Work/report.pdf" for a file)
* @property {String} icon_class
* @property {String} icon_special_class
* @property {String} category
* @property {String} size_formatted
*/
/**
* @typedef {Object} RecentItem
* @property {string} id
* @property {string} user_id
* @property {string} item_id /// ID of the favorited item (file or folder)
* @property {ItemTypeEnum} item_type
* @property {number} accessed_at
* @property {string|null} item_name: null if folder
* @property {number|null} item_size null if folder
* @property {string|null} item_mime_type if file
* @property {string|null} parent_id
* @property {String} item_path Full human-readable path (e.g. "Documents/Work" for a folder, "Documents/Work/report.pdf" for a file)
* @property {String} icon_class
* @property {String} icon_special_class
* @property {String} category
* @property {String} size_formatted
*/
/**
* @typedef {Object} TrashItem
* @property {string} id
* @property {string} original_id
* @property {ItemTypeEnum} item_type
* @property {string} name
* @property {string} original_path - timestamp
* @property {number} trashed_at
* @property {number} days_until_deletion
* @property {string} category
* @property {string} icon_class
* @property {string} icon_special_class
*/
/**
* @typedef {Object} User
* @property {string} id
* @property {string} username
* @property {string} email
* @property {string} role
* @property {number} storage_quota_bytes
* @property {number} storage_used_bytes
* @property {number} created_at
* @property {number} updated_at
* @property {number} last_login_at
* @property {boolean} active
* @property {string} auth_provider
*/
/**
* @typedef {Object} AuthResponse
* @property {User} user
* @property {String} access_token
* @property {String} refresh_token
* @property {String} token_type
* @property {number} expires_in
*/
/**
* @typedef {'user' | 'admin'} RoleEnum
*/
/**
* @typedef {"relevance" | "name" | "name_desc" | "date" | "date_desc" | "size" | "size_desc"} SortByEnnum
*/
/**
* @typedef {Object} SearchCriteria
* @property {SortByEnnum} sort_by
* @property {boolean} recursive
* @property {number} limit
* @property {number} offset
*
* @property {String} [name_contains]
* @property {String[]} [file_types] pdf, jpg, ...
* @property {String} [folder_id]
*
*
* @property {number} [min_size]
* @property {number} [max_size]
*
* @property {number} [created_before]
* @property {number} [created_after]
*
* @property {number} [modified_before]
* @property {number} [modified_after]
*/
/**
* @typedef {Object} SearchResults
* FIXME: is in fact Vec<SearchFileResultDto>,
* @property {FileItem[]} files
* FIXME: is infact Vec<SearchFolderResultDto>,
* @property {FolderItem[]} folders:
* @property {number | null} total_count
* @property {number} limit
* @property {number} offset
* @property {boolean} has_more
* @property {number} query_time_ms
* @property {string} sort_by
*/
/**
* @typedef {Object} Playlist
* @property {String} id
* @property {String} name
* @property {String | null} description
* @property {String} owner_id
* @property {boolean} is_public
* @property {String | null} cover_file_id
* @property {number} track_count
* @property {number} total_duration_secs
* @property {number} created_at
* @property {number} updated_at
*/
/**
* @typedef {Object} PlaylistItem
* @property {String} id
* @property {String} playlist_id
* @property {String} file_id
* @property {number} position
* @property {number} added_at
* @property {String|null} file_name
* @property {number|null} file_size
* @property {String|null} mime_type
* @property {String|null} title
* @property {String|null} artist
* @property {String|null} album
* @property {number|null} duration_secs
*/
/**
* @typedef {Object} Musicshare
* @property {String} user_id
* @property {boolean|null} can_write
*/
/**
* @typedef {Object} FileMetadata
* @property {String} file_id
* @property {number} captured_at
* @property {number|null} latitude
* @property {number|null} longitude
* @property {String|null} camera_make
* @property {String|null} camera_model
* @property {number|null} orientation
* @property {number|null} width
* @property {number|null} height
*/
+58 -5
View File
@@ -6,6 +6,10 @@
import { getCsrfHeaders } from '../../core/csrf.js'; import { getCsrfHeaders } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
/**
* @import {AuthResponse, RoleEnum, User} from '../../core/types.js'
*/
// API endpoints // API endpoints
const API_URL = '/api/auth'; const API_URL = '/api/auth';
const LOGIN_ENDPOINT = `${API_URL}/login`; const LOGIN_ENDPOINT = `${API_URL}/login`;
@@ -41,6 +45,18 @@ function inputVal(id) {
} }
// Language selector texts (used before i18n is loaded) // Language selector texts (used before i18n is loaded)
/**
* @typedef {Object} PreTranslatedText
* @property {string} title
* @property {string} subtitle
* @property {string} continue
* @property {string} autodetected
* @property {string} moreLanguages
* @property {string} modalTitle
* @property {string} searchPlaceholder
*/
/** @type {Record<String,PreTranslatedText>} */
const LANGUAGE_TEXTS = { const LANGUAGE_TEXTS = {
en: { en: {
title: 'Welcome!', title: 'Welcome!',
@@ -145,6 +161,16 @@ const LANGUAGE_TEXTS = {
// Complete language registry — add new languages here, they'll appear automatically // Complete language registry — add new languages here, they'll appear automatically
// `popular: true` languages show as cards on the main screen, the rest in the modal // `popular: true` languages show as cards on the main screen, the rest in the modal
/**
* @typedef {Object} Lang
* @property {string} code
* @property {string} name
* @property {string} nativeName
* @property {string} flag
* @property {boolean} popular
*/
/** @type {Lang[]} */
export const ALL_LANGUAGES = [ export const ALL_LANGUAGES = [
{ {
code: 'en', code: 'en',
@@ -377,9 +403,18 @@ export const ALL_LANGUAGES = [
// --- Panel visibility helpers --- // --- Panel visibility helpers ---
// The `.hidden` CSS class uses `display: none !important`, so inline // The `.hidden` CSS class uses `display: none !important`, so inline
// `style.display` can never override it. Always toggle the class instead. // `style.display` can never override it. Always toggle the class instead.
/**
*
* @param {HTMLElement} el
*/
function showPanel(el) { function showPanel(el) {
if (el) el.classList.remove('hidden'); if (el) el.classList.remove('hidden');
} }
/**
*
* @param {HTMLElement} el
*/
function hidePanel(el) { function hidePanel(el) {
if (el) el.classList.add('hidden'); if (el) el.classList.add('hidden');
} }
@@ -434,13 +469,18 @@ function detectBrowserLanguage() {
return ALL_LANGUAGES[0]; // fallback to English return ALL_LANGUAGES[0]; // fallback to English
} }
// Build a language option element (card style) /**
* Build a language option element (card style)
* @param {Lang} lang
* @param {boolean} isSelected
* @returns
*/
function buildLanguageCard(lang, isSelected) { function buildLanguageCard(lang, isSelected) {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = `lang-picker-item${isSelected ? ' selected' : ''}`; item.className = `lang-picker-item${isSelected ? ' selected' : ''}`;
item.setAttribute('data-lang', lang.code); item.setAttribute('data-lang', lang.code);
item.setAttribute('role', 'option'); item.setAttribute('role', 'option');
item.setAttribute('aria-selected', isSelected); item.setAttribute('aria-selected', String(isSelected));
item.innerHTML = ` item.innerHTML = `
<span class="lang-picker-item-flag">${lang.flag}</span> <span class="lang-picker-item-flag">${lang.flag}</span>
<span class="lang-picker-item-name">${lang.nativeName}</span> <span class="lang-picker-item-name">${lang.nativeName}</span>
@@ -596,7 +636,10 @@ function initLanguageSelector() {
}); });
} }
// Update language panel texts based on selected language /**
* Update language panel texts based on selected language
* @param {string} lang
*/
function updateLanguagePanelTexts(lang) { function updateLanguagePanelTexts(lang) {
const texts = LANGUAGE_TEXTS[lang] || LANGUAGE_TEXTS.en; const texts = LANGUAGE_TEXTS[lang] || LANGUAGE_TEXTS.en;
const titleEl = document.getElementById('language-title'); const titleEl = document.getElementById('language-title');
@@ -1089,6 +1132,9 @@ if (isLoginPage && adminSetupForm) {
/** /**
* Login with username and password * Login with username and password
* @param {string} username
* @param {string} password
* @returns {Promise<AuthResponse>}
*/ */
async function login(username, password) { async function login(username, password) {
try { try {
@@ -1126,8 +1172,9 @@ async function login(username, password) {
// Parse the JSON response // Parse the JSON response
try { try {
/** @type {AuthResponse} */
const data = await response.json(); const data = await response.json();
console.log('Login successful, received data'); console.log(`Login successful for user id ${data.user.id}, received data`);
return data; return data;
} catch (jsonError) { } catch (jsonError) {
console.error('Error parsing login response:', jsonError); console.error('Error parsing login response:', jsonError);
@@ -1141,6 +1188,11 @@ async function login(username, password) {
/** /**
* Register a new user * Register a new user
* @param {string} username
* @param {string} email
* @param {string} password
* @param {RoleEnum} [role]
* @returns {Promise<User>}
*/ */
async function register(username, email, password, role = 'user') { async function register(username, email, password, role = 'user') {
try { try {
@@ -1170,8 +1222,9 @@ async function register(username, email, password, role = 'user') {
// Parse the JSON response // Parse the JSON response
try { try {
/** @type {User} */
const data = await response.json(); const data = await response.json();
console.log('Registration successful, received data'); console.log(`Registration successful, user created: ${data.id}, received data`);
return data; return data;
} catch (jsonError) { } catch (jsonError) {
console.error('Error parsing registration response:', jsonError); console.error('Error parsing registration response:', jsonError);
+49 -15
View File
@@ -20,10 +20,19 @@ import { inlineViewer } from './inlineViewer.js';
import { multiSelect } from './multiSelect.js'; import { multiSelect } from './multiSelect.js';
import { wopiEditor } from './wopiEditor.js'; import { wopiEditor } from './wopiEditor.js';
/**
* @import {FolderItem, FileItem, ItemTypeEnum, Playlist} from '../../core/types.js'
*/
/** @type {EventListener | null} */
let _moveDialogEscapeHandler = null; let _moveDialogEscapeHandler = null;
// Context Menus Module // Context Menus Module
const contextMenus = { const contextMenus = {
/**
* @param {string} optionId
* @param {boolean} isFavorite
*/
_setFavoriteOptionLabel(optionId, isFavorite) { _setFavoriteOptionLabel(optionId, isFavorite) {
const option = document.getElementById(optionId); const option = document.getElementById(optionId);
if (!option) return; if (!option) return;
@@ -308,11 +317,13 @@ const contextMenus = {
// Note: We don't use stopPropagation because all Escape handlers are on document level // Note: We don't use stopPropagation because all Escape handlers are on document level
// Each handler checks its own state, so multiple dialogs can be closed with multiple Escape presses // Each handler checks its own state, so multiple dialogs can be closed with multiple Escape presses
if (!_moveDialogEscapeHandler) { if (!_moveDialogEscapeHandler) {
_moveDialogEscapeHandler = (e) => { _moveDialogEscapeHandler = /** @type {EventListener} */ (
if (e.key === 'Escape' && !moveFileDialog?.classList.contains('hidden')) { (/** @type {KeyboardEvent} */ e) => {
this.closeMoveDialog(); if (e.key === 'Escape' && !moveFileDialog?.classList.contains('hidden')) {
this.closeMoveDialog();
}
} }
}; );
document.addEventListener('keydown', _moveDialogEscapeHandler); document.addEventListener('keydown', _moveDialogEscapeHandler);
} }
@@ -385,8 +396,8 @@ const contextMenus = {
/** /**
* Show move dialog for a file or folder * Show move dialog for a file or folder
* @param {Object} item - File or folder object * @param {FolderItem | FileItem} item - File or folder object
* @param {string} mode - 'file' or 'folder' * @param {ItemTypeEnum} mode
*/ */
async showMoveDialog(item, mode) { async showMoveDialog(item, mode) {
// Set mode // Set mode
@@ -405,15 +416,15 @@ const contextMenus = {
// Start at the parent of the item being moved (so user sees siblings and can navigate) // Start at the parent of the item being moved (so user sees siblings and can navigate)
let startFolderId = null; let startFolderId = null;
let startFolderName = null; let startFolderName = null;
if (mode === 'file' && item.folder_id) { if (mode === 'file' && /** @type {FileItem} */ (item).folder_id) {
startFolderId = item.folder_id; startFolderId = /** @type {FileItem} */ (item).folder_id;
// We need the folder name for breadcrumb - try to get it from current view // We need the folder name for breadcrumb - try to get it from current view
const folderEl = document.querySelector(`[data-folder-id="${startFolderId}"]`); const folderEl = document.querySelector(`[data-folder-id="${startFolderId}"]`);
if (folderEl) { if (folderEl) {
startFolderName = folderEl.querySelector('.folder-name, .item-name')?.textContent || null; startFolderName = folderEl.querySelector('.folder-name, .item-name')?.textContent || null;
} }
} else if (mode === 'folder' && item.parent_id) { } else if (mode === 'folder' && /** @type {FolderItem} */ (item).parent_id) {
startFolderId = item.parent_id; startFolderId = /** @type {FolderItem} */ (item).parent_id;
} else { } else {
// If item is at root level, start at user's home folder // If item is at root level, start at user's home folder
startFolderId = app.userHomeFolderId || null; startFolderId = app.userHomeFolderId || null;
@@ -492,6 +503,7 @@ const contextMenus = {
// The contents endpoint returns an array of child folders // The contents endpoint returns an array of child folders
// The fallback /api/folders returns root folders (home folder itself) // The fallback /api/folders returns root folders (home folder itself)
/** @type {FolderItem[]} */
const folders = Array.isArray(data) ? data : data.folders || []; const folders = Array.isArray(data) ? data : data.folders || [];
console.log('[Move Dialog] Loaded folders:', folders.length, 'folders:', folders); console.log('[Move Dialog] Loaded folders:', folders.length, 'folders:', folders);
@@ -623,6 +635,9 @@ const contextMenus = {
/** /**
* Render breadcrumb navigation for move dialog * Render breadcrumb navigation for move dialog
* @param {HTMLElement | null} container
* @param {Array<{id: string, name: string}>} breadcrumb
* @param {string | null} _currentFolderId
*/ */
_renderMoveDialogBreadcrumb(container, breadcrumb, _currentFolderId) { _renderMoveDialogBreadcrumb(container, breadcrumb, _currentFolderId) {
if (!container) return; if (!container) return;
@@ -666,7 +681,7 @@ const contextMenus = {
} }
// Breadcrumb path // Breadcrumb path
breadcrumb.forEach((segment, index) => { breadcrumb.forEach((/** @type {{id: string, name: string}} */ segment, /** @type {number} */ index) => {
const separator = document.createElement('span'); const separator = document.createElement('span');
separator.className = 'move-breadcrumb-separator'; separator.className = 'move-breadcrumb-separator';
separator.textContent = '>'; separator.textContent = '>';
@@ -709,8 +724,8 @@ const contextMenus = {
/** /**
* Show share dialog for files or folders * Show share dialog for files or folders
* @param {Object} item - File or folder object * @param {FileItem | FolderItem} item - File or folder object
* @param {string} itemType - 'file' or 'folder' * @param {ItemTypeEnum} itemType
*/ */
async showShareDialog(item, itemType) { async showShareDialog(item, itemType) {
try { try {
@@ -835,7 +850,7 @@ const contextMenus = {
btn.closest('.existing-share-item').remove(); btn.closest('.existing-share-item').remove();
if (existingSharesContainer.children.length === 0) { if (existingSharesContainer.children.length === 0) {
document.getElementById('existing-shares-section').classList.add('hidden'); document.getElementById('existing-shares-section').classList.add('hidden');
ui.setSharedVisualState(item.id, item.type, false); ui.setSharedVisualState(item.id, itemType, false);
} }
} }
}); });
@@ -920,7 +935,7 @@ const contextMenus = {
} }
// Update Item's shared badge // Update Item's shared badge
ui.setSharedVisualState(item.id, item.type, true); ui.setSharedVisualState(item.id, itemType, true);
// Show success message // Show success message
ui.showNotification(i18n.t('notifications.link_created'), i18n.t('notifications.share_success')); ui.showNotification(i18n.t('notifications.link_created'), i18n.t('notifications.share_success'));
@@ -994,8 +1009,14 @@ const contextMenus = {
app.notificationShareUrl = null; app.notificationShareUrl = null;
}, },
/** @type {String | null} */
_selectedPlaylistId: null, _selectedPlaylistId: null,
/**
*
* @param {FileItem} file
* @returns
*/
async showPlaylistDialog(file) { async showPlaylistDialog(file) {
const dialog = document.getElementById('playlist-dialog'); const dialog = document.getElementById('playlist-dialog');
const container = document.getElementById('playlist-select-container'); const container = document.getElementById('playlist-select-container');
@@ -1031,6 +1052,7 @@ const contextMenus = {
const resp = await fetch('/api/playlists', { credentials: 'include' }); const resp = await fetch('/api/playlists', { credentials: 'include' });
if (!resp.ok) throw new Error('Failed to load playlists'); if (!resp.ok) throw new Error('Failed to load playlists');
/** @type {Playlist[]} */
const playlists = await resp.json(); const playlists = await resp.json();
this._renderPlaylistSelect(container, playlists); this._renderPlaylistSelect(container, playlists);
} catch (err) { } catch (err) {
@@ -1039,6 +1061,12 @@ const contextMenus = {
} }
}, },
/**
*
* @param {HTMLElement} container
* @param {Playlist[]} playlists
* @returns
*/
_renderPlaylistSelect(container, playlists) { _renderPlaylistSelect(container, playlists) {
container.innerHTML = ''; container.innerHTML = '';
@@ -1124,6 +1152,12 @@ const contextMenus = {
this._selectedPlaylistId = null; this._selectedPlaylistId = null;
}, },
/**
*
* @param {string} str
* @returns
*/
//FIXME: move to common library
_escapeHtml(str) { _escapeHtml(str) {
if (!str) return ''; if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+74 -31
View File
@@ -11,10 +11,18 @@ import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { notifications } from '../../core/notifications.js'; import { notifications } from '../../core/notifications.js';
/** @import {TrashItem} from '../../core/types.js' */
/**
* @typedef {Object} BatchResult
* @property {number} success number of files|folders sucessfully updated
* @property {number} errors number of files|folders in error
* /
/** /**
* Get authorization headers for API requests. * Get authorization headers for API requests.
* Tokens are now in HttpOnly cookies — no explicit Authorization header needed. * Tokens are now in HttpOnly cookies — no explicit Authorization header needed.
* @returns {Object} Headers object * @returns {Record<String, String>} Headers object
*/ */
function getAuthHeaders() { function getAuthHeaders() {
return { ...getCsrfHeaders() }; return { ...getCsrfHeaders() };
@@ -25,15 +33,26 @@ const fileOps = {
// ======================================================================== // ========================================================================
// Upload progress — notification bell integration // Upload progress — notification bell integration
// ======================================================================== // ========================================================================
/** @type {string | null} */
_currentBatchId: null, _currentBatchId: null,
/** @type {boolean} */
_isUploading: false, // Guard against concurrent upload calls _isUploading: false, // Guard against concurrent upload calls
/** Start a new upload batch in the notification bell */ /**
* Start a new upload batch in the notification bell
* @param {number} totalFiles
* @param {string} [folderName]
*/
_initUploadToast(totalFiles, folderName) { _initUploadToast(totalFiles, folderName) {
this._currentBatchId = notifications.addUploadBatch(totalFiles, folderName); this._currentBatchId = notifications.addUploadBatch(totalFiles, folderName);
}, },
/** Finalise the batch in the notification bell */ /**
* Finalise the batch in the notification bell
* @param {number} successCount
* @param {number} totalFiles
* */
_finishUploadToast(successCount, totalFiles) { _finishUploadToast(successCount, totalFiles) {
if (this._currentBatchId) { if (this._currentBatchId) {
notifications.finishBatch(this._currentBatchId, successCount, totalFiles); notifications.finishBatch(this._currentBatchId, successCount, totalFiles);
@@ -44,6 +63,8 @@ const fileOps = {
* Some drag-and-drop sources can inject directory placeholders into * Some drag-and-drop sources can inject directory placeholders into
* DataTransfer.files. Browsers fail those with net::ERR_ACCESS_DENIED * DataTransfer.files. Browsers fail those with net::ERR_ACCESS_DENIED
* when trying to send them as normal files. * when trying to send them as normal files.
* @param {File} file
* @returns {Promise<boolean>}
*/ */
_canReadFileBlob(file) { _canReadFileBlob(file) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -58,10 +79,24 @@ const fileOps = {
}); });
}, },
// FIXME: prefer exceptions for errors
/**
* @typedef {Object} UploadAnswer
* @property {boolean} ok
* @property {any} [data]
* @property {string} [errorMsg]
* @property {boolean} [isQuotaError]
* @property {boolean} [isTimeout]
*/
/** /**
* Upload a single file via XMLHttpRequest with progress events. * Upload a single file via XMLHttpRequest with progress events.
* Progress is reported to the notification bell via batchId + fileName. * Progress is reported to the notification bell via batchId + fileName.
* Returns a promise that resolves with { ok, data?, errorMsg?, isQuotaError? }. * Returns a promise that resolves with { ok, data?, errorMsg?, isQuotaError? }.
* @param {FormData} formData
* @param {string} batchId
* @param {string} fileName
* @param {number} [timeoutMs=120000]
*/ */
_uploadFileXHR(formData, batchId, fileName, timeoutMs = 120000) { _uploadFileXHR(formData, batchId, fileName, timeoutMs = 120000) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -76,9 +111,16 @@ const fileOps = {
let lastProgressPctSent = -1; let lastProgressPctSent = -1;
let isSettled = false; let isSettled = false;
/** @type {ReturnType<typeof setTimeout>} */
let stallTimer = null; let stallTimer = null;
/** @type {ReturnType<typeof setTimeout>} */
let hardTimer = null; let hardTimer = null;
/**
*
* @param {number} pct
* @param {'uploading' | 'done' | 'error'} status
*/
const safeUpdateFile = (pct, status) => { const safeUpdateFile = (pct, status) => {
if (!notif || !batchId) return; if (!notif || !batchId) return;
try { try {
@@ -88,6 +130,11 @@ const fileOps = {
} }
}; };
/**
*
* @param {UploadAnswer} result
* @returns
*/
const finalize = (result) => { const finalize = (result) => {
if (isSettled) return; if (isSettled) return;
isSettled = true; isSettled = true;
@@ -219,6 +266,12 @@ const fileOps = {
* Used by folder uploads to avoid browser XHR edge-cases with dragged entries. * Used by folder uploads to avoid browser XHR edge-cases with dragged entries.
* Returns { ok, data?, errorMsg?, isQuotaError?, isTimeout? }. * Returns { ok, data?, errorMsg?, isQuotaError?, isTimeout? }.
*/ */
/**
*
* @param {*} formData
* @param {*} timeoutMs
* @returns {Promise<UploadAnswer>}
*/
async _uploadFileFetch(formData, timeoutMs = 60000) { async _uploadFileFetch(formData, timeoutMs = 60000) {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
@@ -254,11 +307,13 @@ const fileOps = {
const isQuotaError = (body && typeof body === 'object' && body.error_type === 'QuotaExceeded') || response.status === 507; const isQuotaError = (body && typeof body === 'object' && body.error_type === 'QuotaExceeded') || response.status === 507;
return { ok: false, errorMsg, isQuotaError }; return { ok: false, errorMsg, isQuotaError };
} catch (e) { } catch (e) {
const isTimeout = e?.name === 'AbortError'; const isTimeout = /** @type {Error} */ (e)?.name === 'AbortError';
return { return {
ok: false, ok: false,
isTimeout, isTimeout,
errorMsg: isTimeout ? `Timeout after ${Math.round(timeoutMs / 1000)}s` : `Fetch upload failed: ${e?.message || 'network error'}` errorMsg: isTimeout
? `Timeout after ${Math.round(timeoutMs / 1000)}s`
: `Fetch upload failed: ${/** @type {Error} */ (e)?.message || 'network error'}`
}; };
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
@@ -458,6 +513,7 @@ const fileOps = {
try { try {
// Filter unreadable entries // Filter unreadable entries
/** @type {Array<{file: File, relativePath: string}>} */
const validEntries = []; const validEntries = [];
for (const e of rawEntries) { for (const e of rawEntries) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
@@ -560,12 +616,18 @@ const fileOps = {
const TIMEOUT_MIN_MS = 10000; // floor for tiny files const TIMEOUT_MIN_MS = 10000; // floor for tiny files
const TIMEOUT_MS_ZERO = 3000; // 3s for 0-byte files const TIMEOUT_MS_ZERO = 3000; // 3s for 0-byte files
/**
*
* @param {number} idx
* @returns
*/
const uploadOneFile = async (idx) => { const uploadOneFile = async (idx) => {
if (quotaStop) return; if (quotaStop) return;
const entry = validEntries[idx]; const entry = validEntries[idx];
const file = entry.file; const file = entry.file;
const rel = entry.relativePath || file.name; const rel = entry.relativePath || file.name;
/** @type {UploadAnswer} */
let result = { ok: false, errorMsg: 'Unknown client error' }; let result = { ok: false, errorMsg: 'Unknown client error' };
try { try {
const parts = rel.split('/'); const parts = rel.split('/');
@@ -577,6 +639,7 @@ const fileOps = {
// but block on open(). Pre-read only 0-byte files into // but block on open(). Pre-read only 0-byte files into
// memory; files with size>0 are always regular files and // memory; files with size>0 are always regular files and
// go straight to FormData (zero extra memory copy). // go straight to FormData (zero extra memory copy).
/** @type {Blob} */
let uploadFile = file; // default: use original File let uploadFile = file; // default: use original File
if (file.size === 0) { if (file.size === 0) {
try { try {
@@ -616,7 +679,7 @@ const fileOps = {
} catch (e) { } catch (e) {
result = { result = {
ok: false, ok: false,
errorMsg: `Client exception: ${e?.message || 'unknown'}` errorMsg: `Client exception: ${/** @type {Error} */ (e)?.message || 'unknown'}`
}; };
console.error(`[UPLOAD EXCEPTION] #${idx} ${rel}:`, e); console.error(`[UPLOAD EXCEPTION] #${idx} ${rel}:`, e);
} }
@@ -823,12 +886,6 @@ const fileOps = {
} }
}, },
/**
* @typedef {Object} BatchResult
* @property {number} success number of files|folders sucessfully updated
* @property {number} errors number of files|folders in error
* /
/** /**
* Move files & folders * Move files & folders
* @param {string[]} fileIds - File IDs * @param {string[]} fileIds - File IDs
@@ -941,18 +998,12 @@ const fileOps = {
return res.ok; return res.ok;
}, },
/**
* @typedef {Object} BatchCopyReturn
* @property {number} success
* @property {number} errors
*/
/** /**
* Copy files & folders * Copy files & folders
* @param {string[]} fileIds - File IDs * @param {string[]} fileIds - File IDs
* @param {string[]} folderIds - Folder IDs * @param {string[]} folderIds - Folder IDs
* @param {string} targetFolderId - Target folder ID * @param {string} targetFolderId - Target folder ID
* @returns {Promise<BatchCopyReturn>} - Success status * @returns {Promise<BatchResult>} - Success status
*/ */
async batchCopy(fileIds, folderIds, targetFolderId) { async batchCopy(fileIds, folderIds, targetFolderId) {
// FIXME ensure not moving a folder into itself // FIXME ensure not moving a folder into itself
@@ -1010,7 +1061,6 @@ const fileOps = {
* Rename a file * Rename a file
* @param {string} fileId - File ID * @param {string} fileId - File ID
* @param {string} newName - New file name * @param {string} newName - New file name
* @returns {Promise<string|null>} - null on success, error message string on failure
*/ */
async renameFile(fileId, newName) { async renameFile(fileId, newName) {
try { try {
@@ -1052,13 +1102,6 @@ const fileOps = {
* Rename a folder * Rename a folder
* @param {string} folderId - Folder ID * @param {string} folderId - Folder ID
* @param {string} newName - New folder name * @param {string} newName - New folder name
* @returns {Promise<boolean>} - Success status
*/
/**
* Rename a folder
* @param {string} folderId - Folder ID
* @param {string} newName - New folder name
* @returns {Promise<string|null>} - null on success, error message string on failure
*/ */
async renameFolder(folderId, newName) { async renameFolder(folderId, newName) {
try { try {
@@ -1170,7 +1213,7 @@ const fileOps = {
// If we're inside the folder we just deleted, go back up // If we're inside the folder we just deleted, go back up
if (app.currentPath === folderId) { if (app.currentPath === folderId) {
app.currentPath = ''; app.currentPath = '';
ui.updateBreadcrumb(''); ui.updateBreadcrumb();
} }
loadFiles(); loadFiles();
ui.showNotification('Folder moved to trash', `"${folderName}" moved to trash`); ui.showNotification('Folder moved to trash', `"${folderName}" moved to trash`);
@@ -1186,7 +1229,7 @@ const fileOps = {
// If we're inside the folder we just deleted, go back up // If we're inside the folder we just deleted, go back up
if (app.currentPath === folderId) { if (app.currentPath === folderId) {
app.currentPath = ''; app.currentPath = '';
ui.updateBreadcrumb(''); ui.updateBreadcrumb();
} }
loadFiles(); loadFiles();
ui.showNotification('Folder deleted', `"${folderName}" deleted successfully`); ui.showNotification('Folder deleted', `"${folderName}" deleted successfully`);
@@ -1205,7 +1248,7 @@ const fileOps = {
/** /**
* Get trash items * Get trash items
* @returns {Promise<Array>} - List of trash items * @returns {Promise<Array<TrashItem>>} - List of trash items
*/ */
async getTrashItems() { async getTrashItems() {
try { try {
@@ -1214,7 +1257,7 @@ const fileOps = {
}); });
if (response.ok) { if (response.ok) {
return await response.json(); return /** @type {TrashItem[]} */ (await response.json());
} else { } else {
console.error('Error fetching trash items:', response.statusText); console.error('Error fetching trash items:', response.statusText);
return []; return [];
+64 -32
View File
@@ -8,6 +8,8 @@ import { app } from '../../app/state.js';
import { isTextViewable } from '../../core/formatters.js'; import { isTextViewable } from '../../core/formatters.js';
import { wopiEditor } from './wopiEditor.js'; import { wopiEditor } from './wopiEditor.js';
/** @import {FileItem} from '../../core/types.js' */
class InlineViewer { class InlineViewer {
constructor() { constructor() {
this.setupViewer(); this.setupViewer();
@@ -93,6 +95,11 @@ class InlineViewer {
console.log('Inline viewer initialized'); console.log('Inline viewer initialized');
} }
/**
*
* @param {FileItem} file
* @returns
*/
async openFile(file) { async openFile(file) {
console.log('Opening file:', file); console.log('Opening file:', file);
@@ -115,7 +122,7 @@ class InlineViewer {
// Get container // Get container
const modal = document.getElementById('inline-viewer-modal'); const modal = document.getElementById('inline-viewer-modal');
const container = modal.querySelector('.inline-viewer-container'); const container = /** @type {HTMLDivElement} */ (modal.querySelector('.inline-viewer-container'));
const title = modal.querySelector('.inline-viewer-title'); const title = modal.querySelector('.inline-viewer-title');
// Clear container // Clear container
@@ -125,12 +132,12 @@ class InlineViewer {
title.textContent = file.name; title.textContent = file.name;
// Set controls visibility // Set controls visibility
const controls = modal.querySelector('.inline-viewer-controls'); const controls = /** @type {HTMLDivElement} */ (modal.querySelector('.inline-viewer-controls'));
// Show viewer based on file type // Show viewer based on file type
if (isImage) { if (isImage) {
// Show zoom controls // Show zoom controls
controls.style.display = 'flex'; controls.classList.remove('hidden');
// Show loading indicator // Show loading indicator
const loader = document.createElement('div'); const loader = document.createElement('div');
@@ -142,7 +149,7 @@ class InlineViewer {
this.createBlobUrlViewer(file, 'image', container, loader); this.createBlobUrlViewer(file, 'image', container, loader);
} else if (file.mime_type && file.mime_type === 'application/pdf') { } else if (file.mime_type && file.mime_type === 'application/pdf') {
// Hide zoom controls for PDFs // Hide zoom controls for PDFs
controls.style.display = 'none'; controls.classList.add('hidden');
// Show loading indicator // Show loading indicator
const loader = document.createElement('div'); const loader = document.createElement('div');
@@ -152,9 +159,9 @@ class InlineViewer {
// Create PDF viewer using object tag with blob URL // Create PDF viewer using object tag with blob URL
this.createBlobUrlViewer(file, 'pdf', container, loader); this.createBlobUrlViewer(file, 'pdf', container, loader);
} else if (file.mime_type && this.isTextViewable(file.mime_type)) { } else if (file.mime_type && isTextViewable(file.mime_type)) {
// Hide zoom controls for text files // Hide zoom controls for text files
controls.style.display = 'none'; controls.classList.add('hidden');
// Show loading indicator // Show loading indicator
const loader = document.createElement('div'); const loader = document.createElement('div');
@@ -166,7 +173,7 @@ class InlineViewer {
this.createTextViewer(file, container, loader); this.createTextViewer(file, container, loader);
} else if (file.mime_type?.startsWith('audio/')) { } else if (file.mime_type?.startsWith('audio/')) {
// Hide zoom controls for audio // Hide zoom controls for audio
controls.style.display = 'none'; controls.classList.add('hidden');
// Show loading indicator // Show loading indicator
const loader = document.createElement('div'); const loader = document.createElement('div');
@@ -178,7 +185,7 @@ class InlineViewer {
this.createMediaViewer(file, 'audio', container, loader); this.createMediaViewer(file, 'audio', container, loader);
} else if (file.mime_type?.startsWith('video/')) { } else if (file.mime_type?.startsWith('video/')) {
// Hide zoom controls for video // Hide zoom controls for video
controls.style.display = 'none'; controls.classList.add('hidden');
// Show loading indicator // Show loading indicator
const loader = document.createElement('div'); const loader = document.createElement('div');
@@ -190,7 +197,7 @@ class InlineViewer {
this.createMediaViewer(file, 'video', container, loader); this.createMediaViewer(file, 'video', container, loader);
} else { } else {
// Hide zoom controls for unsupported files // Hide zoom controls for unsupported files
controls.style.display = 'none'; controls.classList.add('hidden');
// Show unsupported file message // Show unsupported file message
const message = document.createElement('div'); const message = document.createElement('div');
@@ -209,12 +216,13 @@ class InlineViewer {
modal.classList.add('active'); modal.classList.add('active');
} }
// Check if a MIME type is text-viewable
isTextViewable(mimeType) {
return isTextViewable(mimeType);
}
// Creates a text viewer using authenticated fetch // Creates a text viewer using authenticated fetch
/**
*
* @param {FileItem} file
* @param {HTMLDivElement} container
* @param {*} loader
*/
async createTextViewer(file, container, loader) { async createTextViewer(file, container, loader) {
try { try {
console.log('Creating text viewer for:', file.name); console.log('Creating text viewer for:', file.name);
@@ -253,14 +261,20 @@ class InlineViewer {
} }
} }
// Creates a viewer using a Blob URL to avoid content-disposition header /**
async createBlobUrlViewer(file, type, container, loader) { * Creates a viewer using a Blob URL to avoid content-disposition header
* @param {FileItem} file
* @param {string} mediaType
* @param {HTMLDivElement} container
* @param {HTMLDivElement} loader
*/
async createBlobUrlViewer(file, mediaType, container, loader) {
try { try {
console.log('Creating blob URL viewer for:', file.name, 'type:', type); console.log('Creating blob URL viewer for:', file.name, 'type:', mediaType);
// Update loader to show progress bar for large files // Update loader to show progress bar for large files
let progressBar = null; let progressBar = /** @type {HTMLElement|null} */ (null);
let progressText = null; let progressText = /** @type {HTMLElement|null} */ (null);
if (loader && file.size > 10 * 1024 * 1024) { if (loader && file.size > 10 * 1024 * 1024) {
// Show progress for files > 10MB // Show progress for files > 10MB
loader.innerHTML = ` loader.innerHTML = `
@@ -272,8 +286,8 @@ class InlineViewer {
<div class="inline-viewer-progress-text">0%</div> <div class="inline-viewer-progress-text">0%</div>
</div> </div>
`; `;
progressBar = loader.querySelector('.inline-viewer-progress-fill'); progressBar = /** @type {HTMLElement|null} */ (loader.querySelector('.inline-viewer-progress-fill'));
progressText = loader.querySelector('.inline-viewer-progress-text'); progressText = /** @type {HTMLElement|null} */ (loader.querySelector('.inline-viewer-progress-text'));
} }
// Use XMLHttpRequest instead of fetch to get better control over the response // Use XMLHttpRequest instead of fetch to get better control over the response
@@ -322,7 +336,7 @@ class InlineViewer {
loader.parentNode.removeChild(loader); loader.parentNode.removeChild(loader);
} }
if (type === 'image') { if (mediaType === 'image') {
console.log('Creating image viewer'); console.log('Creating image viewer');
// Create image element // Create image element
const img = document.createElement('img'); const img = document.createElement('img');
@@ -332,10 +346,10 @@ class InlineViewer {
container.appendChild(img); container.appendChild(img);
// Add loading indicator until image loads // Add loading indicator until image loads
img.style.opacity = 0; img.style.opacity = String(0);
img.onload = () => { img.onload = () => {
console.log('Image loaded successfully'); console.log('Image loaded successfully');
img.style.opacity = 1; img.style.opacity = String(1);
}; };
img.onerror = () => { img.onerror = () => {
@@ -343,7 +357,7 @@ class InlineViewer {
container.removeChild(img); container.removeChild(img);
this.showErrorMessage(container); this.showErrorMessage(container);
}; };
} else if (type === 'pdf') { } else if (mediaType === 'pdf') {
console.log('Creating PDF viewer'); console.log('Creating PDF viewer');
// Create iframe for PDF (more reliable than object tag) // Create iframe for PDF (more reliable than object tag)
@@ -382,7 +396,13 @@ class InlineViewer {
} }
} }
// Creates an audio or video player using blob URL (authenticated fetch) /**
* Creates an audio or video player using blob URL (authenticated fetch)
* @param {FileItem} file
* @param {string} mediaType
* @param {HTMLDivElement} container
* @param {HTMLDivElement} loader
*/
async createMediaViewer(file, mediaType, container, loader) { async createMediaViewer(file, mediaType, container, loader) {
try { try {
console.log(`Creating ${mediaType} player for:`, file.name); console.log(`Creating ${mediaType} player for:`, file.name);
@@ -485,7 +505,10 @@ class InlineViewer {
} }
} }
// Helper to show error message /**
* Helper to show error message
* @param {HTMLDivElement} container
*/
showErrorMessage(container) { showErrorMessage(container) {
// Show error message // Show error message
const message = document.createElement('div'); const message = document.createElement('div');
@@ -505,7 +528,7 @@ class InlineViewer {
const modal = document.getElementById('inline-viewer-modal'); const modal = document.getElementById('inline-viewer-modal');
// stops audio/video before closing viewver // stops audio/video before closing viewver
const media = modal.querySelector('audio, video'); const media = /** @type {HTMLMediaElement} */ (modal.querySelector('audio, video'));
if (media && !media.paused) media.pause(); if (media && !media.paused) media.pause();
// Hide modal // Hide modal
@@ -525,6 +548,10 @@ class InlineViewer {
this.currentFile = null; this.currentFile = null;
} }
/**
*
* @param {FileItem} file
*/
downloadFile(file) { downloadFile(file) {
fetch(`/api/files/${file.id}`, { credentials: 'same-origin' }) fetch(`/api/files/${file.id}`, { credentials: 'same-origin' })
.then((res) => { .then((res) => {
@@ -544,9 +571,14 @@ class InlineViewer {
.catch((err) => console.error('Download error:', err)); .catch((err) => console.error('Download error:', err));
} }
/**
*
* @param {number} factor
* @returns
*/
zoomImage(factor) { zoomImage(factor) {
const container = document.querySelector('.inline-viewer-container'); const container = document.querySelector('.inline-viewer-container');
const img = container.querySelector('.inline-viewer-image'); const img = /** @type {HTMLDivElement} */ (container.querySelector('.inline-viewer-image'));
if (!img) return; if (!img) return;
@@ -560,7 +592,7 @@ class InlineViewer {
scale = Math.max(0.1, Math.min(5.0, scale)); scale = Math.max(0.1, Math.min(5.0, scale));
// Save scale // Save scale
img.dataset.scale = scale; img.dataset.scale = String(scale);
// Apply scale // Apply scale
img.style.transform = `scale(${scale})`; img.style.transform = `scale(${scale})`;
@@ -568,12 +600,12 @@ class InlineViewer {
resetZoom() { resetZoom() {
const container = document.querySelector('.inline-viewer-container'); const container = document.querySelector('.inline-viewer-container');
const img = container.querySelector('.inline-viewer-image'); const img = /** @type {HTMLDivElement} */ (container.querySelector('.inline-viewer-image'));
if (!img) return; if (!img) return;
// Reset scale // Reset scale
img.dataset.scale = 1.0; img.dataset.scale = String(1);
img.style.transform = 'scale(1.0)'; img.style.transform = 'scale(1.0)';
} }
} }
+69 -14
View File
@@ -9,8 +9,6 @@
// TODO: rename into selection-bar ? // TODO: rename into selection-bar ?
// TODO: merge with photo part // TODO: merge with photo part
// @ts-check
import { loadFiles } from '../../app/filesView.js'; import { loadFiles } from '../../app/filesView.js';
import { app } from '../../app/state.js'; import { app } from '../../app/state.js';
import { showConfirmDialog, ui } from '../../app/ui.js'; import { showConfirmDialog, ui } from '../../app/ui.js';
@@ -19,8 +17,14 @@ import { favorites } from '../library/favorites.js';
import { contextMenus } from './contextMenus.js'; import { contextMenus } from './contextMenus.js';
import { getAuthHeaders } from './fileOperations.js'; import { getAuthHeaders } from './fileOperations.js';
/**
* @import {ItemTypeEnum, LightItem} from '../../core/types.js'
* @import {BatchResult} from './fileOperations.js'
*/
const multiSelect = { const multiSelect = {
/** Currently selected items: Map<id, { id, name, type, parentId }> */ /** @type {Map<String, LightItem>} items: Map<id, { id, name, type, parentId }> */
_selected: new Map(), _selected: new Map(),
/** Last clicked index for Shift-range selection */ /** Last clicked index for Shift-range selection */
@@ -49,6 +53,12 @@ const multiSelect = {
// ── Helpers for i18n ──────────────────────────────────── // ── Helpers for i18n ────────────────────────────────────
/**
*
* @param {string} key
* @param {any} vars
* @returns
*/
_t(key, vars) { _t(key, vars) {
const val = i18n.t(key, vars); const val = i18n.t(key, vars);
return val !== key ? val : null; return val !== key ? val : null;
@@ -56,6 +66,14 @@ const multiSelect = {
// ── Selection state management ────────────────────────── // ── Selection state management ──────────────────────────
/**
*
* @param {string} id
* @param {string} name
* @param {ItemTypeEnum} type
* @param {string} parentId
* @returns
*/
toggle(id, name, type, parentId) { toggle(id, name, type, parentId) {
if (this._selected.has(id)) { if (this._selected.has(id)) {
this._selected.delete(id); this._selected.delete(id);
@@ -65,10 +83,22 @@ const multiSelect = {
return true; return true;
}, },
/**
*
* @param {string} id
* @param {string} name
* @param {ItemTypeEnum} type
* @param {string} parentId
* @returns
*/
select(id, name, type, parentId) { select(id, name, type, parentId) {
this._selected.set(id, { id, name, type, parentId }); this._selected.set(id, { id, name, type, parentId });
}, },
/**
*
* @param {string} id
*/
deselect(id) { deselect(id) {
this._selected.delete(id); this._selected.delete(id);
}, },
@@ -80,7 +110,7 @@ const multiSelect = {
el.classList.remove('selected'); el.classList.remove('selected');
}); });
document.querySelectorAll('.item-checkbox').forEach((cb) => { document.querySelectorAll('.item-checkbox').forEach((cb) => {
cb.checked = false; /** @type {HTMLInputElement} */ (cb).checked = false;
}); });
this._syncUI(); this._syncUI();
}, },
@@ -111,11 +141,13 @@ const multiSelect = {
* @return {ItemSelection} * @return {ItemSelection}
*/ */
getSelection(targtFolderId) { getSelection(targtFolderId) {
/** @type {Array<string>} */
const fileIds = []; const fileIds = [];
/** @type {Array<string>} */
const folderIds = []; const folderIds = [];
// TODO optimize & check if _selected is a better use // TODO optimize & check if _selected is a better use
document.querySelectorAll(`div.file-item.selected`).forEach((item) => { /** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll(`div.file-item.selected`)).forEach((item) => {
if (item.dataset.fileId) { if (item.dataset.fileId) {
fileIds.push(item.dataset.fileId); fileIds.push(item.dataset.fileId);
} else { } else {
@@ -152,6 +184,10 @@ const multiSelect = {
// ── DOM helpers ───────────────────────────────────────── // ── DOM helpers ─────────────────────────────────────────
/**
*
* @param {HTMLDivElement} el
*/
_selectElement(el) { _selectElement(el) {
const info = this._extractInfo(el); const info = this._extractInfo(el);
if (info) { if (info) {
@@ -160,18 +196,32 @@ const multiSelect = {
} }
}, },
/**
*
* @param {string} containerId
* @param {string} selector
* @returns {void}
*/
_selectAllInContainer(containerId, selector) { _selectAllInContainer(containerId, selector) {
const container = document.getElementById(containerId); const container = /** @type {HTMLDivElement} */ (document.getElementById(containerId));
if (!container) return; if (!container) return;
container.querySelectorAll(selector).forEach((el) => { /** @type {NodeListOf<HTMLDivElement>} */ (container.querySelectorAll(selector)).forEach((el) => {
this._selectElement(el); this._selectElement(el);
}); });
}, },
/**
*
* @returns {HTMLDivElement[]}
*/
_getAllVisibleItems() { _getAllVisibleItems() {
return [...document.querySelectorAll('.file-item')]; return /** @type {HTMLDivElement[]} */ ([...document.querySelectorAll('.file-item')]);
}, },
/**
* @param {HTMLDivElement} el
* @returns {LightItem}
*/
_extractInfo(el) { _extractInfo(el) {
if (el.dataset.folderId && el.dataset.folderName !== undefined) { if (el.dataset.folderId && el.dataset.folderName !== undefined) {
return { return {
@@ -194,6 +244,10 @@ const multiSelect = {
// ── Click handler (shared by grid + list) ─────────────── // ── Click handler (shared by grid + list) ───────────────
/**
* @param {HTMLDivElement} el
* @param {MouseEvent} event
*/
handleToggleItem(el, event) { handleToggleItem(el, event) {
const items = this._getAllVisibleItems(); const items = this._getAllVisibleItems();
const index = items.indexOf(el); const index = items.indexOf(el);
@@ -210,7 +264,7 @@ const multiSelect = {
const sel = iInfo.type === 'folder' ? `[data-folder-id="${iInfo.id}"]` : `[data-file-id="${iInfo.id}"]`; const sel = iInfo.type === 'folder' ? `[data-folder-id="${iInfo.id}"]` : `[data-file-id="${iInfo.id}"]`;
document.querySelectorAll(sel).forEach((e) => { document.querySelectorAll(sel).forEach((e) => {
e.classList.add('selected'); e.classList.add('selected');
const checkbox = e.querySelector('input[type="checkbox"]'); const checkbox = /** @type {HTMLInputElement} */ (e.querySelector('input[type="checkbox"]'));
if (checkbox) checkbox.checked = true; if (checkbox) checkbox.checked = true;
}); });
} }
@@ -218,7 +272,7 @@ const multiSelect = {
} else { } else {
const nowSelected = this.toggle(info.id, info.name, info.type, info.parentId); const nowSelected = this.toggle(info.id, info.name, info.type, info.parentId);
el.classList.toggle('selected', nowSelected); el.classList.toggle('selected', nowSelected);
const checkbox = el.querySelector('input[type="checkbox"]'); const checkbox = /** @type {HTMLInputElement} */ (el.querySelector('input[type="checkbox"]'));
if (checkbox) checkbox.checked = nowSelected; if (checkbox) checkbox.checked = nowSelected;
} }
this._lastClickedIndex = index; this._lastClickedIndex = index;
@@ -271,13 +325,13 @@ const multiSelect = {
_syncItemCheckboxes() { _syncItemCheckboxes() {
document.querySelectorAll('.file-item').forEach((el) => { document.querySelectorAll('.file-item').forEach((el) => {
const cb = el.querySelector('.item-checkbox'); const cb = /** @type {HTMLInputElement} */ (el.querySelector('.item-checkbox'));
if (cb) cb.checked = el.classList.contains('selected'); if (cb) cb.checked = el.classList.contains('selected');
}); });
}, },
_syncSelectAllCheckbox() { _syncSelectAllCheckbox() {
const cb = document.getElementById('select-all-checkbox'); const cb = /** @type {HTMLInputElement} */ (document.getElementById('select-all-checkbox'));
if (!cb) return; if (!cb) return;
const all = this._getAllVisibleItems(); const all = this._getAllVisibleItems();
if (all.length === 0) { if (all.length === 0) {
@@ -454,9 +508,10 @@ const multiSelect = {
// Keyboard shortcuts // Keyboard shortcuts
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return; const target = /** @type {Element} */ (e.target);
if (target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return;
const selectAllCheckbox = document.getElementById('select-all-checkbox'); const selectAllCheckbox = /** @type {HTMLInputElement} */ (document.getElementById('select-all-checkbox'));
// ctrl+a cmd+a // ctrl+a cmd+a
if ((e.ctrlKey || e.metaKey) && e.key === 'a') { if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
if (selectAllCheckbox) selectAllCheckbox.checked = true; if (selectAllCheckbox) selectAllCheckbox.checked = true;
+30 -18
View File
@@ -12,6 +12,10 @@ import { app } from '../../app/state.js';
import { ui } from '../../app/ui.js'; import { ui } from '../../app/ui.js';
import { getAuthHeaders } from './fileOperations.js'; import { getAuthHeaders } from './fileOperations.js';
/**
* @import {SearchCriteria, SearchResults} from '../../core/types.js'}
*/
const search = { const search = {
/** /**
* Perform a search using query parameters. * Perform a search using query parameters.
@@ -19,25 +23,29 @@ const search = {
* relevance_score, icon_class, category, size_formatted, etc. * relevance_score, icon_class, category, size_formatted, etc.
* *
* @param {string} query - Search query * @param {string} query - Search query
* @param {Object} options - Additional search options * @param {SearchCriteria} [options] - Additional search options
* @returns {Promise<Object>} - Enriched search results from backend * @returns {Promise<SearchResults>} - Enriched search results from backend
*/ */
async searchFiles(query, options = {}) { async searchFiles(query, options) {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('query', query); params.append('query', query);
if (options.folder_id) params.append('folder_id', options.folder_id); if (options.folder_id) params.append('folder_id', options.folder_id);
if (options.recursive !== undefined) params.append('recursive', options.recursive); if (options.recursive !== undefined) params.append('recursive', String(options.recursive));
if (options.file_types) params.append('type', options.file_types); if (options.file_types) {
if (options.min_size) params.append('min_size', options.min_size); options.file_types.forEach((file_type) => {
if (options.max_size) params.append('max_size', options.max_size); params.append('type', file_type);
if (options.created_after) params.append('created_after', options.created_after); });
if (options.created_before) params.append('created_before', options.created_before); }
if (options.modified_after) params.append('modified_after', options.modified_after); if (options.min_size) params.append('min_size', String(options.min_size));
if (options.modified_before) params.append('modified_before', options.modified_before); if (options.max_size) params.append('max_size', String(options.max_size));
if (options.limit) params.append('limit', options.limit); if (options.created_after) params.append('created_after', String(options.created_after));
if (options.offset) params.append('offset', options.offset); if (options.created_before) params.append('created_before', String(options.created_before));
if (options.modified_after) params.append('modified_after', String(options.modified_after));
if (options.modified_before) params.append('modified_before', String(options.modified_before));
if (options.limit) params.append('limit', String(options.limit));
if (options.offset) params.append('offset', String(options.offset));
if (options.sort_by) params.append('sort_by', options.sort_by); if (options.sort_by) params.append('sort_by', options.sort_by);
const url = `/api/search?${params.toString()}`; const url = `/api/search?${params.toString()}`;
@@ -46,6 +54,7 @@ const search = {
const response = await fetch(url, { headers: getAuthHeaders() }); const response = await fetch(url, { headers: getAuthHeaders() });
if (response.ok) { if (response.ok) {
/** @type {SearchResults} */
return await response.json(); return await response.json();
} else { } else {
let errorText = ''; let errorText = '';
@@ -66,7 +75,10 @@ const search = {
folders: [], folders: [],
total_count: 0, total_count: 0,
query_time_ms: 0, query_time_ms: 0,
sort_by: 'relevance' sort_by: 'relevance',
limit: 0,
offset: 0,
has_more: false
}; };
} }
}, },
@@ -76,15 +88,15 @@ const search = {
* Returns lightweight name suggestions without full search overhead. * Returns lightweight name suggestions without full search overhead.
* *
* @param {string} query - Prefix to search for * @param {string} query - Prefix to search for
* @param {Object} options - { folder_id, limit } * @param {SearchCriteria} [options] - { folder_id, limit }
* @returns {Promise<Object>} - { suggestions: [...], query_time_ms } * @returns {Promise<Object>} - { suggestions: [...], query_time_ms }
*/ */
async getSuggestions(query, options = {}) { async getSuggestions(query, options) {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('query', query); params.append('query', query);
if (options.folder_id) params.append('folder_id', options.folder_id); if (options.folder_id) params.append('folder_id', options.folder_id);
if (options.limit) params.append('limit', options.limit); if (options.limit) params.append('limit', String(options.limit));
const url = `/api/search/suggest?${params.toString()}`; const url = `/api/search/suggest?${params.toString()}`;
const response = await fetch(url, { headers: getAuthHeaders() }); const response = await fetch(url, { headers: getAuthHeaders() });
@@ -111,7 +123,7 @@ const search = {
* - query_time_ms: Server-side query execution time * - query_time_ms: Server-side query execution time
* - sort_by: Active sort order * - sort_by: Active sort order
* *
* @param {Object} results - Enriched search results from backend * @param {SearchResults} results - Enriched search results from backend
*/ */
displaySearchResults(results) { displaySearchResults(results) {
ui.resetFilesList(); // ensure also list visible & error hidden ui.resetFilesList(); // ensure also list visible & error hidden
+23 -4
View File
@@ -19,6 +19,7 @@ class WopiEditor {
/** /**
* Check if a file can be opened in a WOPI editor by extension. * Check if a file can be opened in a WOPI editor by extension.
* Fetches supported extensions from the server (cached after first call). * Fetches supported extensions from the server (cached after first call).
* @param {string} filename
*/ */
async canEdit(filename) { async canEdit(filename) {
const ext = filename.split('.').pop().toLowerCase(); const ext = filename.split('.').pop().toLowerCase();
@@ -28,6 +29,9 @@ class WopiEditor {
/** /**
* Open file in a modal overlay (default mode). * Open file in a modal overlay (default mode).
* @param {string} fileId
* @param {string} fileName
* @param {string} [action]
*/ */
async openInModal(fileId, fileName, action) { async openInModal(fileId, fileName, action) {
action = action || 'edit'; action = action || 'edit';
@@ -38,6 +42,9 @@ class WopiEditor {
/** /**
* Open file in a new browser tab. * Open file in a new browser tab.
* @param {string} fileId
* @param {string} fileName
* @param {string} [action]
*/ */
async openInTab(fileId, fileName, action) { async openInTab(fileId, fileName, action) {
action = action || 'edit'; action = action || 'edit';
@@ -53,6 +60,8 @@ class WopiEditor {
/** /**
* Fetch editor URL and WOPI token from the backend. * Fetch editor URL and WOPI token from the backend.
* @param {string} fileId
* @param {string} action
*/ */
async _getEditorUrl(fileId, action) { async _getEditorUrl(fileId, action) {
const response = await fetch(`/api/wopi/editor-url?file_id=${encodeURIComponent(fileId)}&action=${encodeURIComponent(action)}`, { const response = await fetch(`/api/wopi/editor-url?file_id=${encodeURIComponent(fileId)}&action=${encodeURIComponent(action)}`, {
@@ -68,6 +77,9 @@ class WopiEditor {
/** /**
* Some WOPI file types, such as PDFs, are view-only. * Some WOPI file types, such as PDFs, are view-only.
* If an edit request returns 422, retry once in view mode. * If an edit request returns 422, retry once in view mode.
* @param {string} fileId
* @param {string} fileName
* @param {string} action
*/ */
async _getEditorUrlWithFallback(fileId, fileName, action) { async _getEditorUrlWithFallback(fileId, fileName, action) {
try { try {
@@ -81,6 +93,11 @@ class WopiEditor {
} }
} }
/**
* @param {string} fileName
* @param {string} action
* @param {any} error
*/
_shouldRetryInViewMode(fileName, action, error) { _shouldRetryInViewMode(fileName, action, error) {
if (action !== 'edit' || !error || !error.message) { if (action !== 'edit' || !error || !error.message) {
return false; return false;
@@ -92,6 +109,8 @@ class WopiEditor {
/** /**
* Show the editor in a full-screen modal with iframe. * Show the editor in a full-screen modal with iframe.
* @param {Record<string, any>} editorData
* @param {string} fileName
*/ */
_showModal(editorData, fileName) { _showModal(editorData, fileName) {
this.closeEditor(); this.closeEditor();
@@ -160,13 +179,13 @@ class WopiEditor {
document.body.appendChild(modal); document.body.appendChild(modal);
// ESC key handler // ESC key handler
this._escHandler = function (e) { this._escHandler = (/** @type {KeyboardEvent} */ e) => {
if (e.key === 'Escape') this.closeEditor(); if (e.key === 'Escape') this.closeEditor();
}.bind(this); };
document.addEventListener('keydown', this._escHandler); document.addEventListener('keydown', this._escHandler);
// Fix 7: Listen for postMessage from the editor iframe // Fix 7: Listen for postMessage from the editor iframe
this._messageHandler = function (e) { this._messageHandler = (/** @type {MessageEvent} */ e) => {
var data; var data;
try { try {
data = JSON.parse(e.data); data = JSON.parse(e.data);
@@ -183,7 +202,7 @@ class WopiEditor {
if (sp) sp.remove(); if (sp) sp.remove();
} }
} }
}.bind(this); };
window.addEventListener('message', this._messageHandler); window.addEventListener('message', this._messageHandler);
form.submit(); form.submit();
+57 -21
View File
@@ -12,8 +12,10 @@ import { i18n } from '../../core/i18n.js';
import { multiSelect } from '../files/multiSelect.js'; import { multiSelect } from '../files/multiSelect.js';
import * as pathTooltip from '../pathTooltip.js'; import * as pathTooltip from '../pathTooltip.js';
/** @import {FavoriteItem, FileItem, FolderItem} from '../../core/types.js' */
const favorites = { const favorites = {
/** @type {Map<string, object>} key = "file:<id>" | "folder:<id>" */ /** @type {Map<string, FavoriteItem>} key = "file:<id>" | "folder:<id>" */
_cache: new Map(), _cache: new Map(),
/** Whether the initial fetch from the server has completed */ /** Whether the initial fetch from the server has completed */
@@ -25,6 +27,10 @@ const favorites = {
return { ...getCsrfHeaders() }; return { ...getCsrfHeaders() };
}, },
/**
* @param {string} id
* @param {string} type
*/
_cacheKey(id, type) { _cacheKey(id, type) {
return `${type}:${id}`; return `${type}:${id}`;
}, },
@@ -33,6 +39,7 @@ const favorites = {
* Replace the entire in-memory cache from an array of FavoriteItemDto * Replace the entire in-memory cache from an array of FavoriteItemDto
* objects (as returned by the batch endpoint). Avoids an extra * objects (as returned by the batch endpoint). Avoids an extra
* GET /api/favorites round-trip. * GET /api/favorites round-trip.
* @param {any[]} items
*/ */
_replaceCacheFromResponse(items) { _replaceCacheFromResponse(items) {
this._cache.clear(); this._cache.clear();
@@ -68,6 +75,7 @@ const favorites = {
return; return;
} }
/** @type {FavoriteItem[]} */
const items = await response.json(); const items = await response.json();
this._cache.clear(); this._cache.clear();
for (const item of items) { for (const item of items) {
@@ -85,6 +93,8 @@ const favorites = {
/** /**
* Synchronous check used by ui.js to paint star icons. * Synchronous check used by ui.js to paint star icons.
* @param {string} id
* @param {string} type
*/ */
isFavorite(id, type) { isFavorite(id, type) {
return this._cache.has(this._cacheKey(id, type)); return this._cache.has(this._cacheKey(id, type));
@@ -92,6 +102,10 @@ const favorites = {
/** /**
* Add an item to favourites (server-first). * Add an item to favourites (server-first).
* @param {string} id
* @param {string} name
* @param {string} type
* @param {string} _parentId
*/ */
async addToFavorites(id, name, type, _parentId) { async addToFavorites(id, name, type, _parentId) {
try { try {
@@ -121,6 +135,8 @@ const favorites = {
/** /**
* Remove an item from favourites (server-first). * Remove an item from favourites (server-first).
* @param {string} id
* @param {string} type
*/ */
async removeFromFavorites(id, type) { async removeFromFavorites(id, type) {
try { try {
@@ -178,31 +194,51 @@ const favorites = {
return; return;
} }
/** @type {FolderItem[]} */
const folders = []; const folders = [];
/** @type {FileItem[]} */
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...
if (item.item_type === 'folder') { if (item.item_type === 'folder') {
folders.push({ folders.push(
id: item.item_id, // FIXME: better to grab the real values
name: item.item_name || item.item_id, /** @type {FolderItem} */ {
parent_id: item.parent_id || '', id: item.item_id,
modified_at: item.modified_at || item.created_at, name: item.item_name || item.item_id,
path: item.item_path || '' parent_id: item.parent_id || '',
}); modified_at: item.modified_at || item.created_at,
path: item.item_path || '',
category: 'folder',
created_at: item.created_at,
icon_class: item.icon_class,
icon_special_class: item.icon_special_class,
owner_id: item.user_id,
is_root: false
}
);
} else { } else {
files.push({ files.push(
id: item.item_id, // FIXME: better to grab the real values
name: item.item_name || item.item_id, /** @type {FileItem} */ {
folder_id: item.parent_id || '', id: item.item_id,
mime_type: item.item_mime_type, name: item.item_name || item.item_id,
icon_class: item.icon_class, folder_id: item.parent_id || '',
icon_special_class: item.icon_special_class, mime_type: item.item_mime_type,
category: item.category, icon_class: item.icon_class,
size: item.item_size || 0, icon_special_class: item.icon_special_class,
size_formatted: item.size_formatted, category: item.category,
modified_at: item.modified_at || item.created_at, size: item.item_size || 0,
path: item.item_path || '' size_formatted: item.size_formatted,
}); modified_at: item.modified_at || item.created_at,
path: item.item_path || '',
owner_id: item.user_id,
created_at: item.created_at,
sort_date: item.created_at
}
);
} }
} }
if (folders.length) ui.renderFolders(folders); if (folders.length) ui.renderFolders(folders);
+164 -33
View File
@@ -6,17 +6,27 @@ import { oxiIcon } from '../../core/icons.js';
import { Modal } from '../../core/modal.js'; import { Modal } from '../../core/modal.js';
import { notifications } from '../../core/notifications.js'; import { notifications } from '../../core/notifications.js';
/** @import {FileItem, Musicshare, Playlist, PlaylistItem} from '../../core/types.js' */
/** /**
* OxiCloud - Music Library View * OxiCloud - Music Library View
* Playlist management with track listings and audio player * Playlist management with track listings and audio player
*/ */
const musicView = { const musicView = {
/** @type {Playlist[]} */
playlists: [], playlists: [],
/** @type {Playlist | null} */
currentPlaylist: null, currentPlaylist: null,
/** @type {PlaylistItem[]} */
currentTracks: [], currentTracks: [],
loading: false, loading: false,
/** @type {HTMLDivElement | null} */
_container: null, _container: null,
_initialized: false, _initialized: false,
selected: new Set(), selected: new Set(),
@@ -72,11 +82,11 @@ const musicView = {
if (!resp.ok) throw new Error('Failed to load playlists'); if (!resp.ok) throw new Error('Failed to load playlists');
this.playlists = await resp.json(); this.playlists = /** @type {Playlist[]} */ (await resp.json());
this._renderPlaylists(); this._renderPlaylists();
} catch (err) { } catch (err) {
console.error('Music load error:', err); console.error('Music load error:', err);
this._showError(err.message); this._showError(/** @type {Error} */ (err).message);
} finally { } finally {
this.loading = false; this.loading = false;
this._showLoading(false); this._showLoading(false);
@@ -209,7 +219,7 @@ const musicView = {
) )
.join(''); .join('');
listEl.querySelectorAll('.music-playlist-item').forEach((item) => { /** @type {NodeListOf<HTMLDivElement>} */ (listEl.querySelectorAll('.music-playlist-item')).forEach((item) => {
item.addEventListener('click', () => { item.addEventListener('click', () => {
const id = item.dataset.id; const id = item.dataset.id;
this._selectPlaylist(id); this._selectPlaylist(id);
@@ -269,6 +279,11 @@ const musicView = {
} }
}, },
/**
*
* @param {string} playlistId
* @returns
*/
async _selectPlaylist(playlistId) { async _selectPlaylist(playlistId) {
const playlist = this.playlists.find((p) => p.id === playlistId); const playlist = this.playlists.find((p) => p.id === playlistId);
if (!playlist) return; if (!playlist) return;
@@ -308,13 +323,18 @@ const musicView = {
togglePublicBtn.classList.toggle('active', playlist.is_public); togglePublicBtn.classList.toggle('active', playlist.is_public);
} }
document.querySelectorAll('.music-playlist-item').forEach((item) => { /** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll('.music-playlist-item')).forEach((item) => {
item.classList.toggle('active', item.dataset.id === playlistId); item.classList.toggle('active', item.dataset.id === playlistId);
}); });
await this._loadPlaylistTracks(playlistId); await this._loadPlaylistTracks(playlistId);
}, },
/**
*
* @param {string} playlistId
* @returns
*/
async _loadPlaylistTracks(playlistId) { async _loadPlaylistTracks(playlistId) {
const trackListEl = document.getElementById('music-track-list'); const trackListEl = document.getElementById('music-track-list');
if (!trackListEl) return; if (!trackListEl) return;
@@ -333,7 +353,7 @@ const musicView = {
this._renderTracks(); this._renderTracks();
} catch (err) { } catch (err) {
console.error('Track load error:', err); console.error('Track load error:', err);
trackListEl.innerHTML = `<div class="music-error">${err.message}</div>`; trackListEl.innerHTML = `<div class="music-error">${/** @type {Error} */ (err).message}</div>`;
} }
}, },
@@ -385,7 +405,7 @@ const musicView = {
) )
.join('')} .join('')}
`; `;
trackListEl.querySelectorAll('.music-track').forEach((row) => { /** @type {NodeListOf<HTMLDivElement>} */ (trackListEl.querySelectorAll('.music-track')).forEach((row) => {
row.addEventListener('click', () => { row.addEventListener('click', () => {
const idx = parseInt(row.dataset.idx, 10); const idx = parseInt(row.dataset.idx, 10);
// Toggle selection // Toggle selection
@@ -449,6 +469,11 @@ const musicView = {
}); });
}, },
/**
*
* @param {number} idx
* @returns
*/
_playTrack(idx) { _playTrack(idx) {
if (!this.currentTracks[idx]) return; if (!this.currentTracks[idx]) return;
@@ -487,8 +512,12 @@ const musicView = {
this._createPlaylist(name.trim()); this._createPlaylist(name.trim());
}, },
/**
*
* @param {String} name
*/
async _createPlaylist(name) { async _createPlaylist(name) {
const createBtn = document.getElementById('music-create-playlist-btn'); const createBtn = /** @type {HTMLButtonElement} */ (document.getElementById('music-create-playlist-btn'));
if (createBtn) createBtn.disabled = true; if (createBtn) createBtn.disabled = true;
try { try {
const resp = await fetch('/api/playlists', { const resp = await fetch('/api/playlists', {
@@ -519,7 +548,7 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
} finally { } finally {
@@ -542,7 +571,7 @@ const musicView = {
}); });
if (!confirmed) return; if (!confirmed) return;
const deleteBtn = document.getElementById('music-delete-playlist-btn'); const deleteBtn = /** @type {HTMLButtonElement} */ (document.getElementById('music-delete-playlist-btn'));
if (deleteBtn) deleteBtn.disabled = true; if (deleteBtn) deleteBtn.disabled = true;
try { try {
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}`, { const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}`, {
@@ -568,7 +597,7 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
} finally { } finally {
@@ -576,6 +605,11 @@ const musicView = {
} }
}, },
/**
*
* @param {number|null} secs
* @returns
*/
_formatDuration(secs) { _formatDuration(secs) {
if (!secs) return '-'; if (!secs) return '-';
const mins = Math.floor(secs / 60); const mins = Math.floor(secs / 60);
@@ -583,11 +617,20 @@ const musicView = {
return `${mins}:${s.toString().padStart(2, '0')}`; return `${mins}:${s.toString().padStart(2, '0')}`;
}, },
/**
*
* @param {string|null} str
* @returns
*/
_escapeHtml(str) { _escapeHtml(str) {
if (!str) return ''; if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}, },
/**
*
* @param {boolean} show
*/
_showLoading(show) { _showLoading(show) {
const existing = this._container?.querySelector('.music-loading'); const existing = this._container?.querySelector('.music-loading');
if (show && !existing) { if (show && !existing) {
@@ -600,6 +643,11 @@ const musicView = {
} }
}, },
/**
*
* @param {string} message
* @returns
*/
_showError(message) { _showError(message) {
if (!this._container) return; if (!this._container) return;
this._container.innerHTML = ` this._container.innerHTML = `
@@ -647,7 +695,7 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
} }
@@ -690,7 +738,7 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
} }
@@ -731,8 +779,8 @@ const musicView = {
requestAnimationFrame(() => overlay.classList.add('active')); requestAnimationFrame(() => overlay.classList.add('active'));
const listEl = document.getElementById('music-picker-list'); const listEl = document.getElementById('music-picker-list');
const queryInput = document.getElementById('music-picker-query'); const queryInput = /** @type {HTMLInputElement} */ (document.getElementById('music-picker-query'));
const addBtn = document.getElementById('music-picker-add-btn'); const addBtn = /** @type {HTMLButtonElement} */ (document.getElementById('music-picker-add-btn'));
const countEl = document.getElementById('music-picker-count'); const countEl = document.getElementById('music-picker-count');
const selectedIds = new Set(); const selectedIds = new Set();
@@ -765,6 +813,11 @@ const musicView = {
} }
}; };
/**
*
* @param {FileItem[]} files
* @returns
*/
const renderFiles = (files) => { const renderFiles = (files) => {
if (files.length === 0) { if (files.length === 0) {
listEl.innerHTML = `<div class="music-picker-empty"><i class="fas fa-folder-open"></i> ${i18n.t('music.no_audio_files')}</div>`; listEl.innerHTML = `<div class="music-picker-empty"><i class="fas fa-folder-open"></i> ${i18n.t('music.no_audio_files')}</div>`;
@@ -798,6 +851,7 @@ const musicView = {
}; };
// ── Debounced search ── // ── Debounced search ──
/** @type {ReturnType<typeof setTimeout> | null} */
let searchTimer = null; let searchTimer = null;
queryInput.addEventListener('input', () => { queryInput.addEventListener('input', () => {
clearTimeout(searchTimer); clearTimeout(searchTimer);
@@ -857,6 +911,12 @@ const musicView = {
fetchAudioFiles(); fetchAudioFiles();
}, },
/**
*
* @param {string} _trackId
* @param {string} fileId
* @returns
*/
async _removeTrackFromPlaylist(_trackId, fileId) { async _removeTrackFromPlaylist(_trackId, fileId) {
if (!this.currentPlaylist) return; if (!this.currentPlaylist) return;
@@ -892,12 +952,18 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
} }
}, },
/**
*
* @param {number} fromIdx
* @param {number} toIdx
* @returns
*/
async _reorderTrack(fromIdx, toIdx) { async _reorderTrack(fromIdx, toIdx) {
if (!this.currentPlaylist) return; if (!this.currentPlaylist) return;
@@ -923,7 +989,7 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
await this._loadPlaylistTracks(this.currentPlaylist.id); await this._loadPlaylistTracks(this.currentPlaylist.id);
@@ -967,8 +1033,8 @@ const musicView = {
}); });
dialog.querySelector('#music-share-add-btn').addEventListener('click', async () => { dialog.querySelector('#music-share-add-btn').addEventListener('click', async () => {
const userInput = dialog.querySelector('#music-share-user-input'); const userInput = /** @type {HTMLInputElement} */ (dialog.querySelector('#music-share-user-input'));
const writeInput = dialog.querySelector('#music-share-write-input'); const writeInput = /** @type {HTMLInputElement} */ (dialog.querySelector('#music-share-write-input'));
const userId = userInput.value.trim(); const userId = userInput.value.trim();
if (!userId) return; if (!userId) return;
@@ -997,7 +1063,7 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
} }
@@ -1006,6 +1072,11 @@ const musicView = {
this._loadSharesList(dialog); this._loadSharesList(dialog);
}, },
/**
*
* @param {HTMLDivElement} dialog
* @returns
*/
async _loadSharesList(dialog) { async _loadSharesList(dialog) {
if (!this.currentPlaylist) return; if (!this.currentPlaylist) return;
const body = dialog.querySelector('.music-shares-body'); const body = dialog.querySelector('.music-shares-body');
@@ -1019,6 +1090,7 @@ const musicView = {
headers: this._headers() headers: this._headers()
}); });
if (!resp.ok) throw new Error('Failed to load shares'); if (!resp.ok) throw new Error('Failed to load shares');
/** @type {Musicshare[]} */
const shares = await resp.json(); const shares = await resp.json();
if (shares.length === 0) { if (shares.length === 0) {
@@ -1040,16 +1112,22 @@ const musicView = {
body.querySelectorAll('.music-share-remove-btn').forEach((btn) => { body.querySelectorAll('.music-share-remove-btn').forEach((btn) => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const item = btn.closest('.music-share-item'); const item = /** @type {HTMLDivElement} */ (btn.closest('.music-share-item'));
const userId = item.dataset.userId; const userId = item.dataset.userId;
await this._removeShare(userId, dialog); await this._removeShare(userId, dialog);
}); });
}); });
} catch (err) { } catch (err) {
body.innerHTML = `<p class="music-shares-empty">${this._escapeHtml(err.message)}</p>`; body.innerHTML = `<p class="music-shares-empty">${this._escapeHtml(/** @type {Error} */ (err).message)}</p>`;
} }
}, },
/**
*
* @param {string} userId
* @param {HTMLDivElement} dialog
* @returns
*/
async _removeShare(userId, dialog) { async _removeShare(userId, dialog) {
if (!this.currentPlaylist) return; if (!this.currentPlaylist) return;
@@ -1067,7 +1145,7 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
} }
@@ -1115,7 +1193,7 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
} }
@@ -1183,7 +1261,7 @@ const musicView = {
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
iconClass: 'error', iconClass: 'error',
title: i18n.t('music.error'), title: i18n.t('music.error'),
text: err.message text: /** @type {Error} */ (err).message
}); });
} }
} }
@@ -1198,10 +1276,15 @@ const musicView = {
* Handles audio playback, queue, and controls * Handles audio playback, queue, and controls
*/ */
const musicPlayer = { const musicPlayer = {
/** @type {HTMLAudioElement | null} */
audio: null, audio: null,
/** @type {PlaylistItem[]} */
queue: [], queue: [],
currentIndex: -1, currentIndex: -1,
/** @type {PlaylistItem|null} */
currentTrack: null, currentTrack: null,
isPlaying: false, isPlaying: false,
volume: 0.7, volume: 0.7,
isMuted: false, isMuted: false,
@@ -1306,7 +1389,7 @@ const musicPlayer = {
const shuffleBtn = document.getElementById('player-shuffle-btn'); const shuffleBtn = document.getElementById('player-shuffle-btn');
const repeatBtn = document.getElementById('player-repeat-btn'); const repeatBtn = document.getElementById('player-repeat-btn');
const progressBar = document.getElementById('player-progress-bar'); const progressBar = document.getElementById('player-progress-bar');
const volumeInput = document.getElementById('player-volume-input'); const volumeInput = /** @type {HTMLInputElement} */ (document.getElementById('player-volume-input'));
const volBtn = document.getElementById('player-vol-btn'); const volBtn = document.getElementById('player-vol-btn');
const playlistBtn = document.getElementById('player-playlist-btn'); const playlistBtn = document.getElementById('player-playlist-btn');
const closeQueueBtn = document.getElementById('player-close-queue-btn'); const closeQueueBtn = document.getElementById('player-close-queue-btn');
@@ -1338,7 +1421,8 @@ const musicPlayer = {
if (volumeInput) { if (volumeInput) {
volumeInput.addEventListener('input', (e) => { volumeInput.addEventListener('input', (e) => {
this.setVolume(e.target.value / 100); const target = /** @type {HTMLInputElement} */ (e.target);
this.setVolume(parseFloat(target.value) / 100);
}); });
} }
@@ -1381,12 +1465,22 @@ const musicPlayer = {
document.body.classList.remove('music-player-active'); document.body.classList.remove('music-player-active');
}, },
/**
*
* @param {PlaylistItem[]} tracks
* @param {string} playlistName
*/
setQueue(tracks, playlistName = '') { setQueue(tracks, playlistName = '') {
this.queue = [...tracks]; this.queue = [...tracks];
this.playlistName = playlistName; this.playlistName = playlistName;
this._updateQueueUI(); this._updateQueueUI();
}, },
/**
*
* @param {number} index
* @returns
*/
playTrack(index) { playTrack(index) {
if (index < 0 || index >= this.queue.length) return; if (index < 0 || index >= this.queue.length) return;
@@ -1488,15 +1582,19 @@ const musicPlayer = {
} }
}, },
/**
*
* @param {number} vol
*/
setVolume(vol) { setVolume(vol) {
this.volume = Math.max(0, Math.min(1, vol)); this.volume = Math.max(0, Math.min(1, vol));
this.audio.volume = this.volume; this.audio.volume = this.volume;
this.isMuted = this.volume === 0; this.isMuted = this.volume === 0;
this._updateVolumeIcon(); this._updateVolumeIcon();
const input = document.getElementById('player-volume-input'); const input = /** @type {HTMLInputElement} */ (document.getElementById('player-volume-input'));
if (input) { if (input) {
input.value = this.volume * 100; input.value = String(this.volume * 100);
} }
}, },
@@ -1522,6 +1620,11 @@ const musicPlayer = {
btn.querySelector('i').className = `fas ${icon}`; btn.querySelector('i').className = `fas ${icon}`;
}, },
/**
*
* @param {PointerEvent} e
* @returns
*/
_seek(e) { _seek(e) {
const bar = document.getElementById('player-progress-bar'); const bar = document.getElementById('player-progress-bar');
if (!bar) return; if (!bar) return;
@@ -1596,6 +1699,10 @@ const musicPlayer = {
this._updateUI(); this._updateUI();
}, },
/**
*
* @param {ErrorEvent} e
*/
_onError(e) { _onError(e) {
console.error('Audio error:', e); console.error('Audio error:', e);
this.isPlaying = false; this.isPlaying = false;
@@ -1624,7 +1731,8 @@ const musicPlayer = {
if (oxiIcon) { if (oxiIcon) {
icon.outerHTML = oxiIcon(iconName, extraClass); icon.outerHTML = oxiIcon(iconName, extraClass);
} else { } else {
icon.className = `fas fa-${iconName} ${extraClass}`; icon.classList.remove(...icon.classList);
icon.classList.add('fas', `fa-${iconName}`, `${extraClass}`);
} }
} }
} }
@@ -1640,7 +1748,7 @@ const musicPlayer = {
} }
if (musicView.currentTracks.length > 0) { if (musicView.currentTracks.length > 0) {
document.querySelectorAll('.music-track').forEach((row) => { /** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll('.music-track')).forEach((row) => {
const idx = parseInt(row.dataset.idx, 10); const idx = parseInt(row.dataset.idx, 10);
row.classList.toggle('playing', idx === this.currentIndex && this.isPlaying); row.classList.toggle('playing', idx === this.currentIndex && this.isPlaying);
@@ -1710,15 +1818,16 @@ const musicPlayer = {
) )
.join(''); .join('');
queueList.querySelectorAll('.player-queue-item').forEach((item) => { /** @type {NodeListOf<HTMLDivElement>} */ (queueList.querySelectorAll('.player-queue-item')).forEach((item) => {
item.addEventListener('click', (e) => { item.addEventListener('click', (e) => {
if (e.target.closest('.queue-item-remove')) return; const target = /** @type {Element} */ (e.target);
if (target.closest('.queue-item-remove')) return;
const idx = parseInt(item.dataset.idx, 10); const idx = parseInt(item.dataset.idx, 10);
this.playTrack(idx); this.playTrack(idx);
}); });
}); });
queueList.querySelectorAll('.queue-item-remove').forEach((btn) => { /** @type {NodeListOf<HTMLButtonElement>} */ (queueList.querySelectorAll('.queue-item-remove')).forEach((btn) => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
const idx = parseInt(btn.dataset.idx, 10); const idx = parseInt(btn.dataset.idx, 10);
@@ -1727,6 +1836,10 @@ const musicPlayer = {
}); });
}, },
/**
*
* @param {number} idx
*/
_removeFromQueue(idx) { _removeFromQueue(idx) {
if (idx === this.currentIndex) { if (idx === this.currentIndex) {
if (this.queue.length === 1) { if (this.queue.length === 1) {
@@ -1754,6 +1867,10 @@ const musicPlayer = {
this._updateUI(); this._updateUI();
}, },
/**
*
* @param {boolean|undefined} [show]
*/
_toggleQueue(show) { _toggleQueue(show) {
const queue = document.getElementById('player-queue'); const queue = document.getElementById('player-queue');
if (queue) { if (queue) {
@@ -1765,6 +1882,11 @@ const musicPlayer = {
} }
}, },
/**
*
* @param {number|null} secs
* @returns {String}
*/
_formatTime(secs) { _formatTime(secs) {
if (!secs || Number.isNaN(secs)) return '0:00'; if (!secs || Number.isNaN(secs)) return '0:00';
const mins = Math.floor(secs / 60); const mins = Math.floor(secs / 60);
@@ -1772,10 +1894,19 @@ const musicPlayer = {
return `${mins}:${s.toString().padStart(2, '0')}`; return `${mins}:${s.toString().padStart(2, '0')}`;
}, },
/**
*
* @param {number|null} secs
* @returns {String}
*/
_formatDuration(secs) { _formatDuration(secs) {
return this._formatTime(secs); return this._formatTime(secs);
}, },
/**
* @param {string|null} str
* @returns {String}
*/
_escapeHtml(str) { _escapeHtml(str) {
if (!str) return ''; if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+48 -15
View File
@@ -8,10 +8,14 @@ import { i18n } from '../../core/i18n.js';
import { thumbnail } from '../thumbnail.js'; import { thumbnail } from '../thumbnail.js';
import { photosLightbox } from './photosLightbox.js'; import { photosLightbox } from './photosLightbox.js';
/** @import {FileInfo} from '../../core/types.js' */ /** @import {FileItem} from '../../core/types.js' */
/**
* @typedef {'daily'|'monthly'|'yearly'} PhotoModeEnum
*/
const photosView = { const photosView = {
/** @type {Array} All loaded photo items */ /** @type {Array<FileItem>} All loaded photo items */
items: [], items: [],
/** @type {string|null} Cursor for next page */ /** @type {string|null} Cursor for next page */
nextCursor: null, nextCursor: null,
@@ -27,7 +31,7 @@ const photosView = {
_container: null, _container: null,
/** @type {boolean} */ /** @type {boolean} */
_initialized: false, _initialized: false,
/** @type {'daily'|'monthly'|'yearly'} */ /** @type {PhotoModeEnum} */
groupMode: 'monthly', groupMode: 'monthly',
/** @type {Map<string, string>} fileId → thumbnail URL (persists across re-renders) */ /** @type {Map<string, string>} fileId → thumbnail URL (persists across re-renders) */
_videoThumbCache: new Map(), _videoThumbCache: new Map(),
@@ -84,6 +88,11 @@ const photosView = {
}, },
/** Switch grouping mode */ /** Switch grouping mode */
/**
*
* @param {PhotoModeEnum} mode
* @returns
*/
setGroupMode(mode) { setGroupMode(mode) {
if (this.groupMode === mode) return; if (this.groupMode === mode) return;
this.groupMode = mode; this.groupMode = mode;
@@ -112,6 +121,7 @@ const photosView = {
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
/** @type {FileItem[]} */
const data = await res.json(); const data = await res.json();
if (!data || data.length === 0) { if (!data || data.length === 0) {
@@ -178,7 +188,9 @@ const photosView = {
/** Append-only render for infinite scroll — inserts only the items /** Append-only render for infinite scroll — inserts only the items
* from this.items[startIndex..] without destroying existing DOM. * from this.items[startIndex..] without destroying existing DOM.
* Complexity: O(batch) instead of O(total_items). */ * Complexity: O(batch) instead of O(total_items).
* @param {number} startIndex
*/
_appendBatch(startIndex) { _appendBatch(startIndex) {
if (!this._container) return; if (!this._container) return;
this._destroyObserver(); this._destroyObserver();
@@ -227,7 +239,10 @@ const photosView = {
this._setupVideoThumbnails(startIndex); this._setupVideoThumbnails(startIndex);
}, },
/** Generate HTML for a single photo/video tile */ /**
* Generate HTML for a single photo/video tile
* @param {FileItem} file
*/
_renderTile(file) { _renderTile(file) {
const isVideo = file.mime_type?.startsWith('video/'); const isVideo = file.mime_type?.startsWith('video/');
const selected = this.selected.has(file.id) ? ' selected' : ''; const selected = this.selected.has(file.id) ? ' selected' : '';
@@ -266,7 +281,7 @@ const photosView = {
/** @param {number} [startIndex=0] When > 0, only process video tiles /** @param {number} [startIndex=0] When > 0, only process video tiles
* for items[startIndex..] — avoids re-scanning the entire DOM. */ * for items[startIndex..] — avoids re-scanning the entire DOM. */
_setupVideoThumbnails(startIndex = 0) { _setupVideoThumbnails(startIndex = 0) {
const tiles = /** @type {NodeListOf<HTMLDivElement> */ (this._container?.querySelectorAll('.photo-tile[data-mime^="video/"]')); const tiles = /** @type {NodeListOf<HTMLDivElement>} */ (this._container?.querySelectorAll('.photo-tile[data-mime^="video/"]'));
const newIds = startIndex > 0 ? new Set(this.items.slice(startIndex).map((f) => f.id)) : null; const newIds = startIndex > 0 ? new Set(this.items.slice(startIndex).map((f) => f.id)) : null;
if (!tiles) return; if (!tiles) return;
@@ -290,11 +305,15 @@ const photosView = {
} }
}, },
/** Extract a frame and upload all thumbnail sizes via thumbnail.queueGenerate(). */ /**
* Extract a frame and upload all thumbnail sizes via thumbnail.queueGenerate().
* @param {HTMLDivElement} tile
* @param {HTMLImageElement} img
*/
async _generateVideoThumbnail(tile, img) { async _generateVideoThumbnail(tile, img) {
const fileId = tile.dataset.id; const fileId = tile.dataset.id;
// TODO: remove this HACK, this is not evolutive... // TODO: remove this HACK, this is not evolutive...
const file = /** @type {FileInfo} */ ({ id: fileId, icon_special_class: 'video-icon', name: tile.dataset.name, mime_type: tile.dataset.mime }); const file = /** @type {FileItem} */ ({ id: fileId, icon_special_class: 'video-icon', name: tile.dataset.name, mime_type: tile.dataset.mime });
try { try {
await thumbnail.queueGenerate(file, null, (previewDataUrl) => { await thumbnail.queueGenerate(file, null, (previewDataUrl) => {
@@ -335,7 +354,10 @@ const photosView = {
</div>`; </div>`;
}, },
/** Group items by the current groupMode */ /**
* Group items by the current groupMode
* @param {FileItem[]} items
*/
_groupItems(items) { _groupItems(items) {
const map = new Map(); const map = new Map();
for (const item of items) { for (const item of items) {
@@ -363,20 +385,24 @@ const photosView = {
return map; return map;
}, },
/** Handle click on photo tile or toolbar */ /**
* Handle click on photo tile or toolbar
* @param {MouseEvent} e
*/
_handleClick(e) { _handleClick(e) {
// Handle group mode toggle // Handle group mode toggle
const modeBtn = e.target.closest('[data-group-mode]'); const target = /** @type {Element} */ (e.target);
const modeBtn = /** @type {HTMLButtonElement} */ (target.closest('[data-group-mode]'));
if (modeBtn) { if (modeBtn) {
this.setGroupMode(modeBtn.dataset.groupMode); this.setGroupMode(/** @type {PhotoModeEnum} */ (modeBtn.dataset.groupMode));
return; return;
} }
const tile = e.target.closest('.photo-tile'); const tile = /** @type {HTMLDivElement} */ (target.closest('.photo-tile'));
if (!tile) return; if (!tile) return;
const id = tile.dataset.id; const id = tile.dataset.id;
const check = e.target.closest('.photo-check'); const check = target.closest('.photo-check');
// If clicking checkbox or in selection mode, toggle select // If clicking checkbox or in selection mode, toggle select
if (check || this.selected.size > 0) { if (check || this.selected.size > 0) {
@@ -391,7 +417,11 @@ const photosView = {
} }
}, },
/** Toggle selection of an item */ /**
* Toggle selection of an item
* @param {string} id
* @param {HTMLDivElement} tile
*/
_toggleSelect(id, tile) { _toggleSelect(id, tile) {
if (this.selected.has(id)) { if (this.selected.has(id)) {
this.selected.delete(id); this.selected.delete(id);
@@ -483,6 +513,7 @@ const photosView = {
if (bar) bar.style.display = 'none'; if (bar) bar.style.display = 'none';
}, },
/** @param {boolean} show */
_showLoading(show) { _showLoading(show) {
if (!this._container) return; if (!this._container) return;
let loader = this._container.querySelector('.photos-loading'); let loader = this._container.querySelector('.photos-loading');
@@ -503,12 +534,14 @@ const photosView = {
} }
}, },
/** @param {any} s */
_escHtml(s) { _escHtml(s) {
const d = document.createElement('div'); const d = document.createElement('div');
d.textContent = s; d.textContent = s;
return d.innerHTML; return d.innerHTML;
}, },
/** @param {any} s */
_escAttr(s) { _escAttr(s) {
return String(s || '') return String(s || '')
.replace(/"/g, '&quot;') .replace(/"/g, '&quot;')
+39 -23
View File
@@ -6,8 +6,11 @@
import { getCsrfHeaders } from '../../core/csrf.js'; import { getCsrfHeaders } from '../../core/csrf.js';
import { favorites } from '../library/favorites.js'; import { favorites } from '../library/favorites.js';
/** @import {FileItem, FileMetadata} from '../../core/types.js' */
/** @typedef {typeof import('./photos.js').photosView} PhotosView */
export const photosLightbox = { export const photosLightbox = {
/** @type {Array} Items array reference */ /** @type {Array<FileItem>} Items array reference */
items: [], items: [],
/** @type {number} Current index */ /** @type {number} Current index */
index: -1, index: -1,
@@ -15,14 +18,14 @@ export const photosLightbox = {
_overlay: null, _overlay: null,
/** @type {string|null} Current blob URL to revoke */ /** @type {string|null} Current blob URL to revoke */
_blobUrl: null, _blobUrl: null,
/** @type {Function|null} */ /** @type {(ev: KeyboardEvent) => any|null} */
_keyHandler: null, _keyHandler: null,
/** @type {Object|null} Reference to photosView, set after both modules load */ /** @type {PhotosView|null} Reference to photosView, set after both modules load */
_photosView: null, _photosView: null,
/** /**
* Register the photosView reference (called from photos.js to avoid circular imports). * Register the photosView reference (called from photos.js to avoid circular imports).
* @param {Object} pv * @param {any} pv
*/ */
setPhotosView(pv) { setPhotosView(pv) {
this._photosView = pv; this._photosView = pv;
@@ -33,7 +36,11 @@ export const photosLightbox = {
return getCsrfHeaders(); return getCsrfHeaders();
}, },
/** Open lightbox at given index */ /**
* Open lightbox at given index
* @param {FileItem[]} items
* @param {number} index
*/
open(items, index) { open(items, index) {
this.items = items; this.items = items;
this.index = index; this.index = index;
@@ -99,21 +106,21 @@ export const photosLightbox = {
this._overlay = el; this._overlay = el;
// Event listeners // Event listeners
el.querySelector('.lightbox-close').onclick = () => this.close(); /** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-close')).onclick = () => this.close();
el.querySelector('.lightbox-prev').onclick = () => this.prev(); /** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-prev')).onclick = () => this.prev();
el.querySelector('.lightbox-next').onclick = () => this.next(); /** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-next')).onclick = () => this.next();
// Click backdrop to close // Click backdrop to close
el.addEventListener('click', (e) => { el.addEventListener('click', (e) => {
if (e.target === el || e.target.classList.contains('lightbox-content')) { if (e.target === el || /** @type {HTMLElement} */ (e.target).classList.contains('lightbox-content')) {
this.close(); this.close();
} }
}); });
// Toolbar actions // Toolbar actions
el.querySelector('.lb-download').onclick = () => this._download(); /** @type {HTMLButtonElement} */ (el.querySelector('.lb-download')).onclick = () => this._download();
el.querySelector('.lb-favorite').onclick = () => this._toggleFavorite(); /** @type {HTMLButtonElement} */ (el.querySelector('.lb-favorite')).onclick = () => this._toggleFavorite();
el.querySelector('.lb-delete').onclick = () => this._delete(); /** @type {HTMLButtonElement} */ (el.querySelector('.lb-delete')).onclick = () => this._delete();
// Animate in // Animate in
requestAnimationFrame(() => el.classList.add('active')); requestAnimationFrame(() => el.classList.add('active'));
@@ -144,8 +151,8 @@ export const photosLightbox = {
meta.textContent = `${dateStr} · ${item.size_formatted || ''}`; meta.textContent = `${dateStr} · ${item.size_formatted || ''}`;
// Update nav button visibility // Update nav button visibility
this._overlay.querySelector('.lightbox-prev').style.visibility = this.index > 0 ? 'visible' : 'hidden'; /** @type {HTMLButtonElement} */ (this._overlay.querySelector('.lightbox-prev')).classList.toggle('hidden', !(this.index > 0));
this._overlay.querySelector('.lightbox-next').style.visibility = this.index < this.items.length - 1 ? 'visible' : 'hidden'; /** @type {HTMLButtonElement} */ (this._overlay.querySelector('.lightbox-next')).classList.toggle('hidden', !(this.index < this.items.length - 1));
// Load content // Load content
this._revokeBlob(); this._revokeBlob();
@@ -176,7 +183,13 @@ export const photosLightbox = {
this._loadMetadata(item.id, meta, dateStr, item.size_formatted || ''); this._loadMetadata(item.id, meta, dateStr, item.size_formatted || '');
}, },
/** Load EXIF metadata for info bar */ /**
* Load EXIF metadata for info bar
* @param {string} fileId
* @param {Element} metaEl
* @param {string} dateStr
* @param {string} sizeStr
*/
async _loadMetadata(fileId, metaEl, dateStr, sizeStr) { async _loadMetadata(fileId, metaEl, dateStr, sizeStr) {
try { try {
const res = await fetch(`/api/files/${fileId}/metadata`, { const res = await fetch(`/api/files/${fileId}/metadata`, {
@@ -184,16 +197,18 @@ export const photosLightbox = {
headers: this._headers() headers: this._headers()
}); });
if (res.ok) { if (res.ok) {
const data = await res.json(); const metadata = /** @type {FileMetadata} */ (await res.json());
const parts = [dateStr]; const parts = [dateStr];
if (sizeStr) parts.push(sizeStr); if (sizeStr) parts.push(sizeStr);
if (data.camera_make || data.camera_model) { if (metadata.camera_make || metadata.camera_model) {
parts.push([data.camera_make, data.camera_model].filter(Boolean).join(' ')); parts.push([metadata.camera_make, metadata.camera_model].filter(Boolean).join(' '));
} }
if (data.width && data.height) { if (metadata.width && metadata.height) {
parts.push(`${data.width}×${data.height}`); parts.push(`${metadata.width}×${metadata.height}`);
} }
metaEl.textContent = parts.join(' · '); metaEl.textContent = parts.join(' · ');
//TODO: add geoloc pointer to openstreetmap ?
} }
} catch (_err) { } catch (_err) {
// Non-critical, keep existing meta // Non-critical, keep existing meta
@@ -220,7 +235,7 @@ export const photosLightbox = {
await fetch(`/api/favorites/file/${item.id}`, { await fetch(`/api/favorites/file/${item.id}`, {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: this._headers(true) headers: this._headers()
}); });
const btn = this._overlay.querySelector('.lb-favorite'); const btn = this._overlay.querySelector('.lb-favorite');
if (btn) { if (btn) {
@@ -254,11 +269,11 @@ export const photosLightbox = {
this.items.splice(this.index, 1); this.items.splice(this.index, 1);
if (this.items.length === 0) { if (this.items.length === 0) {
this.close(); this.close();
if (this._photosView) this._photosView._render(); if (this._photosView) this._photosView._renderFull(); // will call renderEmpty() on this case
} else { } else {
if (this.index >= this.items.length) this.index = this.items.length - 1; if (this.index >= this.items.length) this.index = this.items.length - 1;
this._show(); this._show();
if (this._photosView) this._photosView._render(); if (this._photosView) this._photosView._renderFull();
} }
} catch (err) { } catch (err) {
console.error('Delete failed:', err); console.error('Delete failed:', err);
@@ -289,6 +304,7 @@ export const photosLightbox = {
} }
}, },
/** @param {any} s */
_escAttr(s) { _escAttr(s) {
return String(s || '') return String(s || '')
.replace(/"/g, '&quot;') .replace(/"/g, '&quot;')
+28 -5
View File
@@ -12,6 +12,8 @@ import { i18n } from '../../core/i18n.js';
import { multiSelect } from '../files/multiSelect.js'; import { multiSelect } from '../files/multiSelect.js';
import * as pathTooltip from '../pathTooltip.js'; import * as pathTooltip from '../pathTooltip.js';
/** @import {FileItem, FolderItem, ItemTypeEnum, RecentItem} from '../../core/types.js' */
const recent = { const recent = {
/** Maximum items to request from the server */ /** Maximum items to request from the server */
MAX_RECENT_FILES: 20, MAX_RECENT_FILES: 20,
@@ -38,8 +40,9 @@ const recent = {
*/ */
setupEventListeners() { setupEventListeners() {
document.addEventListener('file-accessed', (event) => { document.addEventListener('file-accessed', (event) => {
if (event.detail?.file) { const e = /** @type {CustomEvent} */ (event);
const file = event.detail.file; if (e.detail?.file) {
const file = e.detail.file;
const itemType = file.item_type || 'file'; const itemType = file.item_type || 'file';
this._recordAccess(file.id, itemType); this._recordAccess(file.id, itemType);
} }
@@ -48,6 +51,8 @@ const recent = {
/** /**
* Record an access event on the server. * Record an access event on the server.
* @param {string} itemId
* @param {ItemTypeEnum} itemType
*/ */
async _recordAccess(itemId, itemType) { async _recordAccess(itemId, itemType) {
try { try {
@@ -90,7 +95,7 @@ const recent = {
throw new Error(`Server returned ${response.status}`); throw new Error(`Server returned ${response.status}`);
} }
const recentItems = await response.json(); const recentItems = /** @type {RecentItem[]} */ (await response.json());
ui.resetFilesList(); // ensure also list visible & error hidden ui.resetFilesList(); // ensure also list visible & error hidden
const filesList = document.getElementById('files-list'); const filesList = document.getElementById('files-list');
@@ -120,8 +125,12 @@ const recent = {
`); `);
} }
/** @type {FolderItem[]} */
const folders = []; const folders = [];
/** @type {FileItem[]} */
const files = []; const files = [];
for (const item of recentItems) { for (const item of recentItems) {
const isFolder = item.item_type === 'folder'; const isFolder = item.item_type === 'folder';
if (isFolder) { if (isFolder) {
@@ -130,9 +139,20 @@ const recent = {
name: item.item_name || item.item_id, name: item.item_name || item.item_id,
parent_id: item.parent_id || '', parent_id: item.parent_id || '',
modified_at: item.accessed_at, modified_at: item.accessed_at,
path: item.item_path || '' path: item.item_path || '',
category: 'folder',
created_at: item.accessed_at, //Wrong information
icon_class: item.icon_class,
icon_special_class: item.icon_special_class,
owner_id: '',
is_root: false
}); });
} else { } else {
if (item.item_mime_type === undefined || item.item_mime_type === null) {
// FIXME: this case should not be possible, is it an information badly cleaned up on server ?
console.warn('Broken information for RecentItem: ', item);
//continue;
}
files.push({ files.push({
id: item.item_id, id: item.item_id,
name: item.item_name || item.item_id, name: item.item_name || item.item_id,
@@ -144,7 +164,10 @@ const recent = {
size: item.item_size || 0, size: item.item_size || 0,
size_formatted: item.size_formatted, size_formatted: item.size_formatted,
modified_at: item.accessed_at, modified_at: item.accessed_at,
path: item.item_path || '' path: item.item_path || '',
owner_id: '',
created_at: item.accessed_at, //wrong information
sort_date: item.accessed_at
}); });
} }
} }
+13 -2
View File
@@ -38,7 +38,13 @@ function _onLeave() {
_tooltip?.classList.add('hidden'); _tooltip?.classList.add('hidden');
} }
/** @type {WeakMap<HTMLElement, {enter: Function, leave: Function}>} */ /**
* @typedef {Object} EnterLeaveF
* @property {(e: MouseEvent) => void} enter
* @property {(e: MouseEvent) => void} leave
*
/** @type {WeakMap<HTMLElement, EnterLeaveF>} */
const _listeners = new WeakMap(); const _listeners = new WeakMap();
/** /**
@@ -49,10 +55,15 @@ function init(container) {
const items = container.querySelectorAll('.file-item[data-path]'); const items = container.querySelectorAll('.file-item[data-path]');
items.forEach((item) => { items.forEach((item) => {
const el = /** @type {HTMLElement} */ (item); const el = /** @type {HTMLElement} */ (item);
/** @type {(e: MouseEvent) => void} */
const enter = (e) => _onEnter(e); const enter = (e) => _onEnter(e);
const leave = () => _onLeave();
el.addEventListener('mouseenter', enter); el.addEventListener('mouseenter', enter);
/** @type {(e: MouseEvent) => void} */
const leave = (_e) => _onLeave();
el.addEventListener('mouseleave', leave); el.addEventListener('mouseleave', leave);
_listeners.set(el, { enter, leave }); _listeners.set(el, { enter, leave });
}); });
} }
+15 -8
View File
@@ -9,6 +9,10 @@ import { ui } from '../../app/ui.js';
import { getCsrfHeaders } from '../../core/csrf.js'; import { getCsrfHeaders } from '../../core/csrf.js';
import { formatDateTime } from '../../core/formatters.js'; import { formatDateTime } from '../../core/formatters.js';
/**
* @import {CreateShare, ShareItem, UpdateShare} from '../../core/types.js'
*/
const fileSharing = { const fileSharing = {
/** Auth header helper — tokens are in HttpOnly cookies now */ /** Auth header helper — tokens are in HttpOnly cookies now */
_headers(json = true) { _headers(json = true) {
@@ -21,16 +25,17 @@ const fileSharing = {
* Create a shared link via backend API * Create a shared link via backend API
* @param {string} itemId - ID of the file or folder * @param {string} itemId - ID of the file or folder
* @param {string} itemType - 'file' or 'folder' * @param {string} itemType - 'file' or 'folder'
* @param {Object} options - { name, password, expirationDate, permissions } * @param {CreateShare} [options] -
* @returns {Promise<Object>} ShareDto from backend * @returns {Promise<Object>} ShareDto from backend
*/ */
async createSharedLink(itemId, itemType, options = {}) { // FIXME unused ?? duplicate with createSharedLink() from contextMenu
async createSharedLink(itemId, itemType, options) {
const body = { const body = {
item_id: itemId, item_id: itemId,
item_name: options.name || null, item_name: options.item_name || null,
item_type: itemType, item_type: itemType,
password: options.password || null, password: options.password || null,
expires_at: options.expirationDate ? Math.floor(new Date(options.expirationDate).getTime() / 1000) : null, expires_at: options.expires_at ? Math.floor(new Date(options.expires_at).getTime() / 1000) : null,
permissions: options.permissions || { permissions: options.permissions || {
read: true, read: true,
write: false, write: false,
@@ -54,7 +59,7 @@ const fileSharing = {
/** /**
* Get all shared links for the current user * Get all shared links for the current user
* @returns {Promise<Array>} Array of ShareDto * @returns {Promise<ShareItem[]>} Array of ShareDto
*/ */
async getSharedLinks() { async getSharedLinks() {
try { try {
@@ -62,7 +67,7 @@ const fileSharing = {
headers: this._headers(false) headers: this._headers(false)
}); });
if (!res.ok) return []; if (!res.ok) return [];
const data = await res.json(); const data = /** @type {ShareItem[]} */ await res.json();
return data.items || []; return data.items || [];
} catch (error) { } catch (error) {
console.error('Error fetching shared links:', error); console.error('Error fetching shared links:', error);
@@ -74,7 +79,7 @@ const fileSharing = {
* Get shared links for a specific item (server-side filtered) * Get shared links for a specific item (server-side filtered)
* @param {string} itemId * @param {string} itemId
* @param {string} itemType - 'file' or 'folder' * @param {string} itemType - 'file' or 'folder'
* @returns {Promise<Array>} Shares for this item * @returns {Promise<ShareItem[]>} Shares for this item
*/ */
async getSharedLinksForItem(itemId, itemType) { async getSharedLinksForItem(itemId, itemType) {
try { try {
@@ -96,6 +101,8 @@ const fileSharing = {
/** /**
* Check if an item has any shared links * Check if an item has any shared links
* @param {string} itemId
* @param {string} itemType
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
async hasSharedLinks(itemId, itemType) { async hasSharedLinks(itemId, itemType) {
@@ -106,7 +113,7 @@ const fileSharing = {
/** /**
* Update a shared link * Update a shared link
* @param {string} shareId * @param {string} shareId
* @param {Object} updateData - { permissions, password, expires_at } * @param {UpdateShare} updateData - { permissions, password, expires_at }
* @returns {Promise<Object>} Updated ShareDto * @returns {Promise<Object>} Updated ShareDto
*/ */
async updateSharedLink(shareId, updateData) { async updateSharedLink(shareId, updateData) {
+15 -10
View File
@@ -1,8 +1,11 @@
import { getCsrfHeaders } from '../core/csrf.js'; import { getCsrfHeaders } from '../core/csrf.js';
/** @import {FileInfo} from '../core/types.js' */ /** @import {FileItem} from '../core/types.js' */
/** @type {typeof import('../vendors/pdf.min.d.ts') | null} */ /**
* use any type so tsc will not scan library
* @type {any}
*/
let _pdfjsLib = null; let _pdfjsLib = null;
// TODO: do we need to add a max concurrncy ? // TODO: do we need to add a max concurrncy ?
@@ -10,11 +13,13 @@ let _pdfjsLib = null;
/** /**
* Lazy-loads pdf.min.mjs on first use via dynamic import so it is never * Lazy-loads pdf.min.mjs on first use via dynamic import so it is never
* bundled into the IIFE (it uses top-level await which breaks IIFE wrapping). * bundled into the IIFE (it uses top-level await which breaks IIFE wrapping).
* @returns {Promise<typeof import('../vendors/pdf.min.d.ts')>} * @returns {Promise<any>}
*/ */
async function getPdfjsLib() { async function getPdfjsLib() {
if (_pdfjsLib) return _pdfjsLib; if (_pdfjsLib) return _pdfjsLib;
_pdfjsLib = await import('/js/vendors/pdf.min.mjs'); // IMPORTANT: this hack (const lib=...) so tsc will not load vendors library
const lib = '../vendors/pdf.min.mjs';
_pdfjsLib = /** @type {any} */ (await import(lib));
_pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/vendors/pdf.worker.min.mjs'; _pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/vendors/pdf.worker.min.mjs';
return _pdfjsLib; return _pdfjsLib;
} }
@@ -23,7 +28,7 @@ export const thumbnail = {
SUPPORTED_MIME_TYPE: [/^image\//, /^application\/pdf$/, /^video\//], SUPPORTED_MIME_TYPE: [/^image\//, /^application\/pdf$/, /^video\//],
/** /**
* *
* @param {Object} file * @param {FileItem} file
* @returns {boolean} * @returns {boolean}
*/ */
canHandle(file) { canHandle(file) {
@@ -109,7 +114,7 @@ export const thumbnail = {
/** /**
* *
* @param {FileInfo} file * @param {FileItem} file
* @param {string} source * @param {string} source
* @returns {Promise<ImageBitmap>} * @returns {Promise<ImageBitmap>}
* *
@@ -163,7 +168,7 @@ export const thumbnail = {
/** /**
* generateThumbnail and update image * generateThumbnail and update image
* *
* @param {Object} file the source of the image * @param {FileItem} file the source of the image
* @param {((dataURL: string) => void) | null} [onIconGenerated] the callback once thumbnail is generated * @param {((dataURL: string) => void) | null} [onIconGenerated] the callback once thumbnail is generated
* @param {((dataURL: string) => void) | null} [onPreviewGenerated] the callback once thumbnail is generated * @param {((dataURL: string) => void) | null} [onPreviewGenerated] the callback once thumbnail is generated
* *
@@ -203,7 +208,7 @@ export const thumbnail = {
MAX_CONCURRENT: 3, MAX_CONCURRENT: 3,
_activeGenerates: 0, _activeGenerates: 0,
/** @type {Array<() => void>} */ /** @type {Array<(resolve: any) => void>} */
_generateQueue: [], _generateQueue: [],
/** /**
@@ -211,7 +216,7 @@ export const thumbnail = {
* At most MAX_CONCURRENT generations run simultaneously; excess calls are * At most MAX_CONCURRENT generations run simultaneously; excess calls are
* queued and resume automatically as slots free up. * queued and resume automatically as slots free up.
* *
* @param {FileInfo} file * @param {FileItem} file
* @param {((dataURL: string) => void) | null} [onIconGenerated] * @param {((dataURL: string) => void) | null} [onIconGenerated]
* @param {((dataURL: string) => void) | null} [onPreviewGenerated] * @param {((dataURL: string) => void) | null} [onPreviewGenerated]
* @returns {Promise<void>} * @returns {Promise<void>}
@@ -224,7 +229,7 @@ export const thumbnail = {
try { try {
await this._generate(file, onIconGenerated, onPreviewGenerated); await this._generate(file, onIconGenerated, onPreviewGenerated);
} catch (err) { } catch (err) {
if (err instanceof Event) { if (err instanceof Event && 'error' in err.target) {
console.warn(`generation of thumbnail for ${file.name} failed: `, err.target.error); console.warn(`generation of thumbnail for ${file.name} failed: `, err.target.error);
} else if (err instanceof Error) { } else if (err instanceof Error) {
console.warn(`generation of thumbnail for ${file.name} failed: `, err.message); console.warn(`generation of thumbnail for ${file.name} failed: `, err.message);
+4
View File
@@ -0,0 +1,4 @@
declare module '*pdf.min.mjs' {
const pdfjsLib: any;
export = pdfjsLib;
}
+5
View File
@@ -0,0 +1,5 @@
declare module '*.mjs' {
const value: any;
export default value;
}
+169 -109
View File
@@ -3,13 +3,20 @@ import { escapeHtml } 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 {RoleEnum} from '../../core/types.js'
*/
const API = '/api'; const API = '/api';
let currentAdminId = ''; let currentAdminId = '';
let usersPage = 0; let usersPage = 0;
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
let totalUsers = 0; let totalUsers = 0;
/** Escape a string for safe embedding inside a JS string literal within an HTML attribute. */ /**
* Escape a string for safe embedding inside a JS string literal within an HTML attribute.
* @param {string} s
*/
function _escJs(s) { function _escJs(s) {
if (typeof s !== 'string') return ''; if (typeof s !== 'string') return '';
return s.replace(/[^\w .-]/g, (c) => { return s.replace(/[^\w .-]/g, (c) => {
@@ -17,6 +24,7 @@ function _escJs(s) {
}); });
} }
/** @param {string} id */
function hideElement(id) { function hideElement(id) {
const element = document.getElementById(id); const element = document.getElementById(id);
if (!element) return; if (!element) return;
@@ -24,6 +32,10 @@ function hideElement(id) {
element.classList.add('hidden'); element.classList.add('hidden');
} }
/**
* @param {string} id
* @param {string} [mode]
*/
function showElement(id, mode = 'block') { function showElement(id, mode = 'block') {
const element = document.getElementById(id); const element = document.getElementById(id);
if (!element) return; if (!element) return;
@@ -39,6 +51,7 @@ function headers() {
return { 'Content-Type': 'application/json', ...getCsrfHeaders() }; return { 'Content-Type': 'application/json', ...getCsrfHeaders() };
} }
/** @param {number} bytes */
function formatBytes(bytes) { function formatBytes(bytes) {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
const k = 1024, const k = 1024,
@@ -47,11 +60,12 @@ function formatBytes(bytes) {
return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`; return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
} }
/** @param {string|null} dateStr */
function timeAgo(dateStr) { function timeAgo(dateStr) {
if (!dateStr) return i18n.t('admin.never'); if (!dateStr) return i18n.t('admin.never');
const d = new Date(dateStr); const d = new Date(dateStr);
const now = new Date(); const now = new Date();
const secs = Math.floor((now - d) / 1000); const secs = Math.floor((now.getTime() - d.getTime()) / 1000);
if (secs < 60) return i18n.t('admin.just_now'); if (secs < 60) return i18n.t('admin.just_now');
if (secs < 3600) return i18n.t('admin.minutes_ago', { n: Math.floor(secs / 60) }); if (secs < 3600) return i18n.t('admin.minutes_ago', { n: Math.floor(secs / 60) });
if (secs < 86400) return i18n.t('admin.hours_ago', { n: Math.floor(secs / 3600) }); if (secs < 86400) return i18n.t('admin.hours_ago', { n: Math.floor(secs / 3600) });
@@ -60,6 +74,7 @@ function timeAgo(dateStr) {
} }
/* ── Custom confirm modal ── */ /* ── Custom confirm modal ── */
/** @param {string} message */
function showConfirm(message) { function showConfirm(message) {
return new Promise((resolve) => { return new Promise((resolve) => {
const overlay = document.getElementById('confirm-modal'); const overlay = document.getElementById('confirm-modal');
@@ -70,6 +85,7 @@ function showConfirm(message) {
overlay.classList.remove('hidden'); overlay.classList.remove('hidden');
overlay.classList.add('show-flex'); overlay.classList.add('show-flex');
/** @param {any} result */
function cleanup(result) { function cleanup(result) {
overlay.classList.remove('show-flex'); overlay.classList.remove('show-flex');
overlay.classList.add('hidden'); overlay.classList.add('hidden');
@@ -84,6 +100,7 @@ function showConfirm(message) {
function onNo() { function onNo() {
cleanup(false); cleanup(false);
} }
/** @param {Event} e */
function onOverlay(e) { function onOverlay(e) {
if (e.target === overlay) cleanup(false); if (e.target === overlay) cleanup(false);
} }
@@ -96,6 +113,10 @@ function showConfirm(message) {
/* ── Tab switching with fade animation ── */ /* ── Tab switching with fade animation ── */
let activeTabName = 'dashboard'; let activeTabName = 'dashboard';
/**
* @param {string} name
* @param {Element|undefined} el
*/
function switchTab(name, el) { function switchTab(name, el) {
if (name === activeTabName) return; if (name === activeTabName) return;
var oldTab = document.getElementById(`tab-${activeTabName}`); var oldTab = document.getElementById(`tab-${activeTabName}`);
@@ -158,7 +179,7 @@ async function loadDashboard() {
document.getElementById('ds-quotas-flag').textContent = d.quotas_enabled ? i18n.t('admin.enabled') : i18n.t('admin.disabled'); document.getElementById('ds-quotas-flag').textContent = d.quotas_enabled ? i18n.t('admin.enabled') : i18n.t('admin.disabled');
if (typeof d.registration_enabled !== 'undefined') { if (typeof d.registration_enabled !== 'undefined') {
document.getElementById('ds-registration').checked = d.registration_enabled; /** @type {HTMLInputElement} */ (document.getElementById('ds-registration')).checked = d.registration_enabled;
if (d.registration_enabled) hideElement('registration-warning'); if (d.registration_enabled) hideElement('registration-warning');
else showElement('registration-warning', 'flex'); else showElement('registration-warning', 'flex');
} }
@@ -200,7 +221,7 @@ async function loadUsers() {
} }
tbody.innerHTML = users tbody.innerHTML = users
.map((u) => { .map((/** @type {any} */ u) => {
const quotaPct = u.storage_quota_bytes > 0 ? (u.storage_used_bytes / u.storage_quota_bytes) * 100 : 0; const quotaPct = u.storage_quota_bytes > 0 ? (u.storage_used_bytes / u.storage_quota_bytes) * 100 : 0;
const quotaColor = quotaPct > 90 ? 'red' : quotaPct > 70 ? 'orange' : 'green'; const quotaColor = quotaPct > 90 ? 'red' : quotaPct > 70 ? 'orange' : 'green';
const quotaText = const quotaText =
@@ -306,18 +327,18 @@ async function loadUsers() {
.join(''); .join('');
// Set dynamic progress bar widths (CSP-safe via JS property) // Set dynamic progress bar widths (CSP-safe via JS property)
document.querySelectorAll('.progress-fill[data-width]').forEach((el) => { /** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll('.progress-fill[data-width]')).forEach((el) => {
el.style.width = `${el.dataset.width}%`; el.style.width = `${el.dataset.width}%`;
el.removeAttribute('data-width'); el.removeAttribute('data-width');
}); });
// Wire up admin action buttons (replaces inline onclick handlers) // Wire up admin action buttons (replaces inline onclick handlers)
document.querySelectorAll('.admin-action-btn').forEach((btn) => { /** @type {NodeListOf<HTMLButtonElement>} */ (document.querySelectorAll('.admin-action-btn')).forEach((btn) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const action = btn.dataset.action; const action = btn.dataset.action;
if (action === 'quota') openQuotaModal(btn.dataset.uid, btn.dataset.uname, Number(btn.dataset.quota)); if (action === 'quota') openQuotaModal(btn.dataset.uid, btn.dataset.uname, Number(btn.dataset.quota));
else if (action === 'reset-pw') openResetPasswordModal(btn.dataset.uid, btn.dataset.uname); else if (action === 'reset-pw') openResetPasswordModal(btn.dataset.uid, btn.dataset.uname);
else if (action === 'toggle-role') toggleRole(btn.dataset.uid, btn.dataset.role); else if (action === 'toggle-role') toggleRole(btn.dataset.uid, /** @type {RoleEnum} */ (btn.dataset.role));
else if (action === 'toggle-active') toggleActive(btn.dataset.uid, btn.dataset.active === 'true'); else if (action === 'toggle-active') toggleActive(btn.dataset.uid, btn.dataset.active === 'true');
else if (action === 'delete') deleteUser(btn.dataset.uid, btn.dataset.uname); else if (action === 'delete') deleteUser(btn.dataset.uid, btn.dataset.uname);
}); });
@@ -326,12 +347,12 @@ async function loadUsers() {
const from = usersPage * PAGE_SIZE + 1; const from = usersPage * PAGE_SIZE + 1;
const to = Math.min((usersPage + 1) * PAGE_SIZE, totalUsers); const to = Math.min((usersPage + 1) * PAGE_SIZE, totalUsers);
document.getElementById('users-info').textContent = i18n.t('admin.showing_users', { from: from, to: to, total: totalUsers }); document.getElementById('users-info').textContent = i18n.t('admin.showing_users', { from: from, to: to, total: totalUsers });
document.getElementById('prev-btn').disabled = usersPage === 0; /** @type {HTMLButtonElement} */ (document.getElementById('prev-btn')).disabled = usersPage === 0;
document.getElementById('next-btn').disabled = (usersPage + 1) * PAGE_SIZE >= totalUsers; /** @type {HTMLButtonElement} */ (document.getElementById('next-btn')).disabled = (usersPage + 1) * PAGE_SIZE >= totalUsers;
} catch (e) { } catch (e) {
tbody.innerHTML = tbody.innerHTML =
'<tr><td colspan="7" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ' + '<tr><td colspan="7" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ' +
escapeHtml(i18n.t('admin.error_network', { message: e.message })) + escapeHtml(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message })) +
'</td></tr>'; '</td></tr>';
} }
} }
@@ -349,6 +370,10 @@ function nextPage() {
} }
} }
/**
* @param {string} userId
* @param {RoleEnum} currentRole
*/
async function toggleRole(userId, currentRole) { async function toggleRole(userId, currentRole) {
const newRole = currentRole === 'admin' ? 'user' : 'admin'; const newRole = currentRole === 'admin' ? 'user' : 'admin';
const ok = await showConfirm(i18n.t('admin.confirm_role_change', { role: newRole })); const ok = await showConfirm(i18n.t('admin.confirm_role_change', { role: newRole }));
@@ -366,10 +391,14 @@ async function toggleRole(userId, currentRole) {
alert(e.message || i18n.t('admin.error_generic')); alert(e.message || i18n.t('admin.error_generic'));
} }
} catch (e) { } catch (e) {
alert(i18n.t('admin.error_network', { message: e.message })); alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
} }
} }
/**
* @param {string} userId
* @param {boolean} currentActive
*/
async function toggleActive(userId, currentActive) { async function toggleActive(userId, currentActive) {
const msg = currentActive ? i18n.t('admin.confirm_deactivate') : i18n.t('admin.confirm_activate'); const msg = currentActive ? i18n.t('admin.confirm_deactivate') : i18n.t('admin.confirm_activate');
const ok = await showConfirm(msg); const ok = await showConfirm(msg);
@@ -387,10 +416,14 @@ async function toggleActive(userId, currentActive) {
alert(e.message || i18n.t('admin.error_generic')); alert(e.message || i18n.t('admin.error_generic'));
} }
} catch (e) { } catch (e) {
alert(i18n.t('admin.error_network', { message: e.message })); alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
} }
} }
/**
* @param {string} userId
* @param {string} username
*/
async function deleteUser(userId, username) { async function deleteUser(userId, username) {
const ok = await showConfirm(i18n.t('admin.confirm_delete_user', { name: username })); const ok = await showConfirm(i18n.t('admin.confirm_delete_user', { name: username }));
if (!ok) return; if (!ok) return;
@@ -408,17 +441,22 @@ async function deleteUser(userId, username) {
alert(e.message || i18n.t('admin.error_generic')); alert(e.message || i18n.t('admin.error_generic'));
} }
} catch (e) { } catch (e) {
alert(i18n.t('admin.error_network', { message: e.message })); alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
} }
} }
let quotaUserId = ''; let quotaUserId = '';
/**
* @param {string} userId
* @param {string} username
* @param {number} currentQuota
*/
function openQuotaModal(userId, username, currentQuota) { function openQuotaModal(userId, username, currentQuota) {
quotaUserId = userId; quotaUserId = userId;
document.getElementById('qm-username').textContent = username; document.getElementById('qm-username').textContent = username;
const gb = currentQuota / 1073741824; const gb = currentQuota / 1073741824;
document.getElementById('qm-unit').value = '1073741824'; /** @type {HTMLInputElement} */ (document.getElementById('qm-unit')).value = '1073741824';
document.getElementById('qm-value').value = gb > 0 ? Math.round(gb * 10) / 10 : 0; /** @type {HTMLInputElement} */ (document.getElementById('qm-value')).value = String(gb > 0 ? Math.round(gb * 10) / 10 : 0);
showElement('quota-modal', 'flex'); showElement('quota-modal', 'flex');
} }
function closeQuotaModal() { function closeQuotaModal() {
@@ -426,8 +464,8 @@ function closeQuotaModal() {
} }
async function saveQuota() { async function saveQuota() {
const val = parseFloat(document.getElementById('qm-value').value) || 0; const val = parseFloat(/** @type {HTMLInputElement} */ (document.getElementById('qm-value')).value) || 0;
const unit = parseInt(document.getElementById('qm-unit').value, 10); const unit = parseInt(/** @type {HTMLInputElement} */ (document.getElementById('qm-unit')).value, 10);
const bytes = Math.round(val * unit); const bytes = Math.round(val * unit);
try { try {
const resp = await fetch(`${API}/admin/users/${quotaUserId}/quota`, { const resp = await fetch(`${API}/admin/users/${quotaUserId}/quota`, {
@@ -445,33 +483,33 @@ async function saveQuota() {
alert(e.message || i18n.t('admin.error_generic')); alert(e.message || i18n.t('admin.error_generic'));
} }
} catch (e) { } catch (e) {
alert(i18n.t('admin.error_network', { message: e.message })); alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
} }
} }
function openCreateUserModal() { function openCreateUserModal() {
document.getElementById('cu-username').value = ''; /** @type {HTMLInputElement} */ (document.getElementById('cu-username')).value = '';
document.getElementById('cu-password').value = ''; /** @type {HTMLInputElement} */ (document.getElementById('cu-password')).value = '';
document.getElementById('cu-email').value = ''; /** @type {HTMLInputElement} */ (document.getElementById('cu-email')).value = '';
document.getElementById('cu-role').value = 'user'; /** @type {HTMLInputElement} */ (document.getElementById('cu-role')).value = 'user';
document.getElementById('cu-quota-value').value = '1'; /** @type {HTMLInputElement} */ (document.getElementById('cu-quota-value')).value = '1';
document.getElementById('cu-quota-unit').value = '1073741824'; /** @type {HTMLInputElement} */ (document.getElementById('cu-quota-unit')).value = '1073741824';
document.getElementById('cu-error').className = 'alert'; document.getElementById('cu-error').className = 'alert';
document.getElementById('cu-error').textContent = ''; document.getElementById('cu-error').textContent = '';
showElement('create-user-modal', 'flex'); showElement('create-user-modal', 'flex');
setTimeout(() => document.getElementById('cu-username').focus(), 100); setTimeout(() => /** @type {HTMLInputElement} */ (document.getElementById('cu-username')).focus(), 100);
} }
function closeCreateUserModal() { function closeCreateUserModal() {
hideElement('create-user-modal'); hideElement('create-user-modal');
} }
async function submitCreateUser() { async function submitCreateUser() {
const username = document.getElementById('cu-username').value.trim(); const username = /** @type {HTMLInputElement} */ (document.getElementById('cu-username')).value.trim();
const password = document.getElementById('cu-password').value; const password = /** @type {HTMLInputElement} */ (document.getElementById('cu-password')).value;
const email = document.getElementById('cu-email').value.trim() || null; const email = /** @type {HTMLInputElement} */ (document.getElementById('cu-email')).value.trim() || null;
const role = document.getElementById('cu-role').value; const role = /** @type {HTMLInputElement} */ (document.getElementById('cu-role')).value;
const quotaVal = parseFloat(document.getElementById('cu-quota-value').value) || 0; const quotaVal = parseFloat(/** @type {HTMLInputElement} */ (document.getElementById('cu-quota-value')).value) || 0;
const quotaUnit = parseInt(document.getElementById('cu-quota-unit').value, 10); const quotaUnit = parseInt(/** @type {HTMLInputElement} */ (document.getElementById('cu-quota-unit')).value, 10);
const quotaBytes = Math.round(quotaVal * quotaUnit); const quotaBytes = Math.round(quotaVal * quotaUnit);
const errorEl = document.getElementById('cu-error'); const errorEl = document.getElementById('cu-error');
@@ -486,7 +524,7 @@ async function submitCreateUser() {
return; return;
} }
const btn = document.getElementById('cu-submit'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('cu-submit'));
btn.disabled = true; btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.creating'))}`; btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.creating'))}`;
try { try {
@@ -512,7 +550,7 @@ async function submitCreateUser() {
errorEl.className = 'alert alert-error'; errorEl.className = 'alert alert-error';
} }
} catch (e) { } catch (e) {
errorEl.textContent = i18n.t('admin.error_network', { message: e.message }); errorEl.textContent = i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message });
errorEl.className = 'alert alert-error'; errorEl.className = 'alert alert-error';
} }
btn.disabled = false; btn.disabled = false;
@@ -520,21 +558,25 @@ async function submitCreateUser() {
} }
let resetPwUserId = ''; let resetPwUserId = '';
/**
* @param {string} userId
* @param {string} username
*/
function openResetPasswordModal(userId, username) { function openResetPasswordModal(userId, username) {
resetPwUserId = userId; resetPwUserId = userId;
document.getElementById('rp-username').textContent = username; document.getElementById('rp-username').textContent = username;
document.getElementById('rp-password').value = ''; /** @type {HTMLInputElement} */ (document.getElementById('rp-password')).value = '';
document.getElementById('rp-error').className = 'alert'; document.getElementById('rp-error').className = 'alert';
document.getElementById('rp-error').textContent = ''; document.getElementById('rp-error').textContent = '';
showElement('reset-pw-modal', 'flex'); showElement('reset-pw-modal', 'flex');
setTimeout(() => document.getElementById('rp-password').focus(), 100); setTimeout(() => /** @type {HTMLInputElement} */ (document.getElementById('rp-password')).focus(), 100);
} }
function closeResetPasswordModal() { function closeResetPasswordModal() {
hideElement('reset-pw-modal'); hideElement('reset-pw-modal');
} }
async function submitResetPassword() { async function submitResetPassword() {
const password = document.getElementById('rp-password').value; const password = /** @type {HTMLInputElement} */ (document.getElementById('rp-password')).value;
const errorEl = document.getElementById('rp-error'); const errorEl = document.getElementById('rp-error');
if (password.length < 8) { if (password.length < 8) {
errorEl.textContent = i18n.t('admin.error_password_short'); errorEl.textContent = i18n.t('admin.error_password_short');
@@ -542,7 +584,7 @@ async function submitResetPassword() {
return; return;
} }
const btn = document.getElementById('rp-submit'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('rp-submit'));
btn.disabled = true; btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.resetting'))}`; btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.resetting'))}`;
try { try {
@@ -560,13 +602,14 @@ async function submitResetPassword() {
errorEl.className = 'alert alert-error'; errorEl.className = 'alert alert-error';
} }
} catch (e) { } catch (e) {
errorEl.textContent = i18n.t('admin.error_network', { message: e.message }); errorEl.textContent = i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message });
errorEl.className = 'alert alert-error'; errorEl.className = 'alert alert-error';
} }
btn.disabled = false; btn.disabled = false;
btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.reset_btn'))}`; btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.reset_btn'))}`;
} }
/** @param {boolean} enabled */
async function toggleRegistration(enabled) { async function toggleRegistration(enabled) {
if (enabled) hideElement('registration-warning'); if (enabled) hideElement('registration-warning');
else showElement('registration-warning', 'flex'); else showElement('registration-warning', 'flex');
@@ -578,29 +621,33 @@ async function toggleRegistration(enabled) {
body: JSON.stringify({ registration_enabled: enabled }) body: JSON.stringify({ registration_enabled: enabled })
}); });
if (!resp.ok) { if (!resp.ok) {
document.getElementById('ds-registration').checked = !enabled; /** @type {HTMLInputElement} */ (document.getElementById('ds-registration')).checked = !enabled;
if (!enabled) showElement('registration-warning', 'flex'); if (!enabled) showElement('registration-warning', 'flex');
else hideElement('registration-warning'); else hideElement('registration-warning');
const e = await resp.json().catch(() => ({})); const e = await resp.json().catch(() => ({}));
alert(e.message || i18n.t('admin.error_generic')); alert(e.message || i18n.t('admin.error_generic'));
} }
} catch (e) { } catch (e) {
document.getElementById('ds-registration').checked = !enabled; /** @type {HTMLInputElement} */ (document.getElementById('ds-registration')).checked = !enabled;
if (!enabled) showElement('registration-warning', 'flex'); if (!enabled) showElement('registration-warning', 'flex');
else hideElement('registration-warning'); else hideElement('registration-warning');
alert(i18n.t('admin.error_network', { message: e.message })); alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
} }
} }
document.getElementById('oidc-enabled').addEventListener('change', function () { document.getElementById('oidc-enabled').addEventListener('change', function () {
if (this.checked) showElement('oidc-form'); if (/** @type {HTMLInputElement} */ (this).checked) showElement('oidc-form');
else hideElement('oidc-form'); else hideElement('oidc-form');
}); });
document.getElementById('disable-password').addEventListener('change', function () { document.getElementById('disable-password').addEventListener('change', function () {
if (this.checked) showElement('password-warning', 'flex'); if (/** @type {HTMLInputElement} */ (this).checked) showElement('password-warning', 'flex');
else hideElement('password-warning'); else hideElement('password-warning');
}); });
/**
* @param {string} msg
* @param {string} type
*/
function showOidcStatus(msg, type) { function showOidcStatus(msg, type) {
const el = document.getElementById('oidc-status'); const el = document.getElementById('oidc-status');
el.textContent = msg; el.textContent = msg;
@@ -613,12 +660,12 @@ function copyCallback() {
} }
async function testConnection() { async function testConnection() {
const url = document.getElementById('issuer-url').value.trim(); const url = /** @type {HTMLInputElement} */ (document.getElementById('issuer-url')).value.trim();
if (!url) { if (!url) {
showOidcStatus('Enter an Issuer URL first', 'error'); showOidcStatus('Enter an Issuer URL first', 'error');
return; return;
} }
const btn = document.getElementById('discover-btn'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('discover-btn'));
btn.disabled = true; btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.discovering'))}`; btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.discovering'))}`;
const resultDiv = document.getElementById('discovery-result'); const resultDiv = document.getElementById('discovery-result');
@@ -639,32 +686,32 @@ async function testConnection() {
'</dd><dt>Auth Endpoint</dt><dd>' + '</dd><dt>Auth Endpoint</dt><dd>' +
escapeHtml(r.authorization_endpoint || '—') + escapeHtml(r.authorization_endpoint || '—') +
'</dd></dl></div>'; '</dd></dl></div>';
if (!document.getElementById('provider-name').value && r.provider_name_suggestion) if (!(/** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value) && r.provider_name_suggestion)
document.getElementById('provider-name').value = r.provider_name_suggestion; /** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value = r.provider_name_suggestion;
} else { } else {
resultDiv.innerHTML = `<div class="discovery-result fail"><strong><i class="fas fa-times-circle"></i> ${escapeHtml(r.message)}</strong></div>`; resultDiv.innerHTML = `<div class="discovery-result fail"><strong><i class="fas fa-times-circle"></i> ${escapeHtml(r.message)}</strong></div>`;
} }
} catch (e) { } catch (e) {
resultDiv.innerHTML = `<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ${escapeHtml(e.message)}</div>`; resultDiv.innerHTML = `<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ${escapeHtml(/** @type {Error} */ (e).message)}</div>`;
} }
btn.disabled = false; btn.disabled = false;
btn.innerHTML = `<i class="fas fa-search"></i> ${escapeHtml(i18n.t('admin.auto_discover'))}`; btn.innerHTML = `<i class="fas fa-search"></i> ${escapeHtml(i18n.t('admin.auto_discover'))}`;
} }
async function saveOidcSettings() { async function saveOidcSettings() {
const btn = document.getElementById('save-btn'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('save-btn'));
btn.disabled = true; btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.saving'))}`; btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.saving'))}`;
const body = { const body = {
enabled: document.getElementById('oidc-enabled').checked, enabled: /** @type {HTMLInputElement} */ (document.getElementById('oidc-enabled')).checked,
issuer_url: document.getElementById('issuer-url').value.trim(), issuer_url: /** @type {HTMLInputElement} */ (document.getElementById('issuer-url')).value.trim(),
client_id: document.getElementById('client-id').value.trim(), client_id: /** @type {HTMLInputElement} */ (document.getElementById('client-id')).value.trim(),
client_secret: document.getElementById('client-secret').value || null, client_secret: /** @type {HTMLInputElement} */ (document.getElementById('client-secret')).value || null,
scopes: document.getElementById('scopes').value.trim() || null, scopes: /** @type {HTMLInputElement} */ (document.getElementById('scopes')).value.trim() || null,
auto_provision: document.getElementById('auto-provision').checked, auto_provision: /** @type {HTMLInputElement} */ (document.getElementById('auto-provision')).checked,
admin_groups: document.getElementById('admin-groups').value.trim() || null, admin_groups: /** @type {HTMLInputElement} */ (document.getElementById('admin-groups')).value.trim() || null,
disable_password_login: document.getElementById('disable-password').checked, disable_password_login: /** @type {HTMLInputElement} */ (document.getElementById('disable-password')).checked,
provider_name: document.getElementById('provider-name').value.trim() || null provider_name: /** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value.trim() || null
}; };
try { try {
const resp = await fetch(`${API}/admin/settings/oidc`, { const resp = await fetch(`${API}/admin/settings/oidc`, {
@@ -682,7 +729,7 @@ async function saveOidcSettings() {
showOidcStatus(`Error: ${e.message || resp.statusText}`, 'error'); showOidcStatus(`Error: ${e.message || resp.statusText}`, 'error');
} }
} catch (e) { } catch (e) {
showOidcStatus(i18n.t('admin.error_network', { message: e.message }), 'error'); showOidcStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
} }
btn.disabled = false; btn.disabled = false;
btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.save_btn'))}`; btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.save_btn'))}`;
@@ -701,20 +748,25 @@ const STORAGE_PRESETS = {
'wasabi': { endpoint: 'https://s3.{region}.wasabisys.com', region: 'us-east-1', pathStyle: false }, 'wasabi': { endpoint: 'https://s3.{region}.wasabisys.com', region: 'us-east-1', pathStyle: false },
}; };
/** @param {boolean} visible */
function toggleS3Form(visible) { function toggleS3Form(visible) {
if (visible) showElement('storage-s3-form'); if (visible) showElement('storage-s3-form');
else hideElement('storage-s3-form'); else hideElement('storage-s3-form');
} }
function onStoragePresetChange() { function onStoragePresetChange() {
const preset = document.getElementById('storage-preset').value; const preset = /** @type {HTMLInputElement} */ (document.getElementById('storage-preset')).value;
const p = STORAGE_PRESETS[preset]; const p = STORAGE_PRESETS[/** @type {keyof typeof STORAGE_PRESETS} */ (preset)];
if (!p) return; if (!p) return;
if (p.endpoint) document.getElementById('storage-endpoint-url').value = p.endpoint; if (p.endpoint) /** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value = p.endpoint;
if (p.region) document.getElementById('storage-region').value = p.region; if (p.region) /** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value = p.region;
document.getElementById('storage-path-style').checked = p.pathStyle; /** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked = p.pathStyle;
} }
/**
* @param {string} msg
* @param {string} type
*/
function showStorageStatus(msg, type) { function showStorageStatus(msg, type) {
const el = document.getElementById('storage-status'); const el = document.getElementById('storage-status');
el.textContent = msg; el.textContent = msg;
@@ -732,21 +784,23 @@ async function loadStorage() {
// Backend selector // Backend selector
document.querySelectorAll('input[name="storage-backend"]').forEach((r) => { document.querySelectorAll('input[name="storage-backend"]').forEach((r) => {
r.checked = r.value === s.backend; const input = /** @type {HTMLInputElement} */ (r);
input.checked = input.value === s.backend;
}); });
toggleS3Form(s.backend === 's3'); toggleS3Form(s.backend === 's3');
// S3 fields // S3 fields
document.getElementById('storage-endpoint-url').value = s.s3_endpoint_url || ''; /** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value = s.s3_endpoint_url || '';
document.getElementById('storage-bucket').value = s.s3_bucket || ''; /** @type {HTMLInputElement} */ (document.getElementById('storage-bucket')).value = s.s3_bucket || '';
document.getElementById('storage-region').value = s.s3_region || ''; /** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value = s.s3_region || '';
document.getElementById('storage-access-key').value = ''; /** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).value = '';
document.getElementById('storage-secret-key').value = ''; /** @type {HTMLInputElement} */ (document.getElementById('storage-secret-key')).value = '';
document.getElementById('storage-path-style').checked = s.s3_force_path_style; /** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked = s.s3_force_path_style;
// Secret hints // Secret hints
if (s.s3_access_key_set) { if (s.s3_access_key_set) {
document.getElementById('storage-access-key').placeholder = i18n.t('admin.storage_key_placeholder') || 'Leave empty to keep current value'; /** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).placeholder =
i18n.t('admin.storage_key_placeholder') || 'Leave empty to keep current value';
} }
if (s.s3_secret_key_set) { if (s.s3_secret_key_set) {
showElement('storage-secret-hint'); showElement('storage-secret-hint');
@@ -755,7 +809,7 @@ async function loadStorage() {
} }
// ENV badges // ENV badges
(s.env_overrides || []).forEach((field) => { /** @type {string[]} */ (s.env_overrides || []).forEach((field) => {
const badge = document.getElementById(`badge-${field}`); const badge = document.getElementById(`badge-${field}`);
if (badge) badge.innerHTML = '<span class="badge badge-env">ENV</span>'; if (badge) badge.innerHTML = '<span class="badge badge-env">ENV</span>';
}); });
@@ -766,7 +820,7 @@ async function loadStorage() {
document.getElementById('storage-total-size').textContent = s.total_bytes_stored != null ? formatBytes(s.total_bytes_stored) : '—'; document.getElementById('storage-total-size').textContent = s.total_bytes_stored != null ? formatBytes(s.total_bytes_stored) : '—';
document.getElementById('storage-dedup-ratio').textContent = s.dedup_ratio != null ? `${s.dedup_ratio.toFixed(2)}x` : '—'; document.getElementById('storage-dedup-ratio').textContent = s.dedup_ratio != null ? `${s.dedup_ratio.toFixed(2)}x` : '—';
} catch (e) { } catch (e) {
showStorageStatus(i18n.t('admin.error_network', { message: e.message }), 'error'); showStorageStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
} }
// Also load migration status // Also load migration status
@@ -774,19 +828,19 @@ async function loadStorage() {
} }
async function saveStorageSettings() { async function saveStorageSettings() {
const btn = document.getElementById('btn-save-storage'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-save-storage'));
btn.disabled = true; btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.saving'))}`; btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.saving'))}`;
const backend = document.querySelector('input[name="storage-backend"]:checked').value; const backend = /** @type {HTMLInputElement} */ (document.querySelector('input[name="storage-backend"]:checked')).value;
const body = { const body = {
backend, backend,
s3_endpoint_url: document.getElementById('storage-endpoint-url').value.trim() || null, s3_endpoint_url: /** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value.trim() || null,
s3_bucket: document.getElementById('storage-bucket').value.trim() || null, s3_bucket: /** @type {HTMLInputElement} */ (document.getElementById('storage-bucket')).value.trim() || null,
s3_region: document.getElementById('storage-region').value.trim() || null, s3_region: /** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value.trim() || null,
s3_access_key: document.getElementById('storage-access-key').value || null, s3_access_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).value || null,
s3_secret_key: document.getElementById('storage-secret-key').value || null, s3_secret_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-secret-key')).value || null,
s3_force_path_style: document.getElementById('storage-path-style').checked s3_force_path_style: /** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked
}; };
try { try {
@@ -804,26 +858,26 @@ async function saveStorageSettings() {
showStorageStatus(`Error: ${e.message || resp.statusText}`, 'error'); showStorageStatus(`Error: ${e.message || resp.statusText}`, 'error');
} }
} catch (e) { } catch (e) {
showStorageStatus(i18n.t('admin.error_network', { message: e.message }), 'error'); showStorageStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
} }
btn.disabled = false; btn.disabled = false;
btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.storage_save') || 'Save')}`; btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.storage_save') || 'Save')}`;
} }
async function testStorageConnection() { async function testStorageConnection() {
const btn = document.getElementById('btn-test-storage'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-test-storage'));
btn.disabled = true; btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.testing') || 'Testing...')}`; btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.testing') || 'Testing...')}`;
const backend = document.querySelector('input[name="storage-backend"]:checked').value; const backend = /** @type {HTMLInputElement} */ (document.querySelector('input[name="storage-backend"]:checked')).value;
const body = { const body = {
backend, backend,
s3_endpoint_url: document.getElementById('storage-endpoint-url').value.trim() || null, s3_endpoint_url: /** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value.trim() || null,
s3_bucket: document.getElementById('storage-bucket').value.trim() || null, s3_bucket: /** @type {HTMLInputElement} */ (document.getElementById('storage-bucket')).value.trim() || null,
s3_region: document.getElementById('storage-region').value.trim() || null, s3_region: /** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value.trim() || null,
s3_access_key: document.getElementById('storage-access-key').value || null, s3_access_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).value || null,
s3_secret_key: document.getElementById('storage-secret-key').value || null, s3_secret_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-secret-key')).value || null,
s3_force_path_style: document.getElementById('storage-path-style').checked s3_force_path_style: /** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked
}; };
try { try {
@@ -842,7 +896,7 @@ async function testStorageConnection() {
showStorageStatus(`${i18n.t('admin.storage_test_failure') || 'Connection failed'}: ${escapeHtml(r.message)}`, 'error'); showStorageStatus(`${i18n.t('admin.storage_test_failure') || 'Connection failed'}: ${escapeHtml(r.message)}`, 'error');
} }
} catch (e) { } catch (e) {
showStorageStatus(i18n.t('admin.error_network', { message: e.message }), 'error'); showStorageStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
} }
btn.disabled = false; btn.disabled = false;
btn.innerHTML = `<i class="fas fa-vial"></i> ${escapeHtml(i18n.t('admin.storage_test_connection') || 'Test Connection')}`; btn.innerHTML = `<i class="fas fa-vial"></i> ${escapeHtml(i18n.t('admin.storage_test_connection') || 'Test Connection')}`;
@@ -850,8 +904,13 @@ async function testStorageConnection() {
/* ── Migration ── */ /* ── Migration ── */
/** @type {ReturnType<typeof setInterval> | null} */
let migrationPollTimer = null; let migrationPollTimer = null;
/**
* @param {string} msg
* @param {string} type
*/
function showMigrationMsg(msg, type) { function showMigrationMsg(msg, type) {
const el = document.getElementById('migration-status-msg'); const el = document.getElementById('migration-status-msg');
el.textContent = msg; el.textContent = msg;
@@ -859,6 +918,7 @@ function showMigrationMsg(msg, type) {
el.style.display = ''; el.style.display = '';
} }
/** @param {any} m */
function updateMigrationUI(m) { function updateMigrationUI(m) {
// Status badge // Status badge
const badge = document.getElementById('migration-status-badge'); const badge = document.getElementById('migration-status-badge');
@@ -937,7 +997,7 @@ async function loadMigrationStatus() {
} }
async function startMigration() { async function startMigration() {
const btn = document.getElementById('btn-start-migration'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-start-migration'));
btn.disabled = true; btn.disabled = true;
try { try {
const resp = await fetch(`${API}/admin/storage/migration/start`, { const resp = await fetch(`${API}/admin/storage/migration/start`, {
@@ -954,7 +1014,7 @@ async function startMigration() {
showMigrationMsg(`Error: ${e.message || resp.statusText}`, 'error'); showMigrationMsg(`Error: ${e.message || resp.statusText}`, 'error');
} }
} catch (e) { } catch (e) {
showMigrationMsg(i18n.t('admin.error_network', { message: e.message }), 'error'); showMigrationMsg(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
} }
btn.disabled = false; btn.disabled = false;
} }
@@ -992,7 +1052,7 @@ async function resumeMigration() {
} }
async function verifyMigration() { async function verifyMigration() {
const btn = document.getElementById('btn-verify-migration'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-verify-migration'));
btn.disabled = true; btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.migration_verifying') || 'Verifying...')}`; btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.migration_verifying') || 'Verifying...')}`;
const resultDiv = document.getElementById('migration-verify-result'); const resultDiv = document.getElementById('migration-verify-result');
@@ -1015,7 +1075,7 @@ async function verifyMigration() {
} }
} catch (e) { } catch (e) {
resultDiv.style.display = ''; resultDiv.style.display = '';
resultDiv.innerHTML = `<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ${escapeHtml(e.message)}</div>`; resultDiv.innerHTML = `<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ${escapeHtml(/** @type {Error} */ (e).message)}</div>`;
} }
btn.disabled = false; btn.disabled = false;
btn.innerHTML = `<i class="fas fa-check-double"></i> ${escapeHtml(i18n.t('admin.migration_verify') || 'Verify Integrity')}`; btn.innerHTML = `<i class="fas fa-check-double"></i> ${escapeHtml(i18n.t('admin.migration_verify') || 'Verify Integrity')}`;
@@ -1036,7 +1096,7 @@ async function completeMigration() {
showMigrationMsg(`Error: ${e.message || resp.statusText}`, 'error'); showMigrationMsg(`Error: ${e.message || resp.statusText}`, 'error');
} }
} catch (e) { } catch (e) {
showMigrationMsg(i18n.t('admin.error_network', { message: e.message }), 'error'); showMigrationMsg(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
} }
} }
@@ -1064,21 +1124,21 @@ async function init() {
}); });
if (oidcResp.ok) { if (oidcResp.ok) {
const s = await oidcResp.json(); const s = await oidcResp.json();
document.getElementById('oidc-enabled').checked = s.enabled; /** @type {HTMLInputElement} */ (document.getElementById('oidc-enabled')).checked = s.enabled;
if (s.enabled) showElement('oidc-form'); if (s.enabled) showElement('oidc-form');
else hideElement('oidc-form'); else hideElement('oidc-form');
document.getElementById('provider-name').value = s.provider_name || ''; /** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value = s.provider_name || '';
document.getElementById('issuer-url').value = s.issuer_url || ''; /** @type {HTMLInputElement} */ (document.getElementById('issuer-url')).value = s.issuer_url || '';
document.getElementById('client-id').value = s.client_id || ''; /** @type {HTMLInputElement} */ (document.getElementById('client-id')).value = s.client_id || '';
document.getElementById('scopes').value = s.scopes || 'openid profile email'; /** @type {HTMLInputElement} */ (document.getElementById('scopes')).value = s.scopes || 'openid profile email';
document.getElementById('auto-provision').checked = s.auto_provision; /** @type {HTMLInputElement} */ (document.getElementById('auto-provision')).checked = s.auto_provision;
document.getElementById('admin-groups').value = s.admin_groups || ''; /** @type {HTMLInputElement} */ (document.getElementById('admin-groups')).value = s.admin_groups || '';
document.getElementById('disable-password').checked = s.disable_password_login; /** @type {HTMLInputElement} */ (document.getElementById('disable-password')).checked = s.disable_password_login;
if (s.disable_password_login) showElement('password-warning', 'flex'); if (s.disable_password_login) showElement('password-warning', 'flex');
else hideElement('password-warning'); else hideElement('password-warning');
document.getElementById('callback-url').textContent = s.callback_url; document.getElementById('callback-url').textContent = s.callback_url;
if (s.client_secret_set) showElement('secret-hint'); if (s.client_secret_set) showElement('secret-hint');
(s.env_overrides || []).forEach((field) => { /** @type {string[]} */ (s.env_overrides || []).forEach((field) => {
const badge = document.getElementById(`badge-${field}`); const badge = document.getElementById(`badge-${field}`);
if (badge) badge.innerHTML = '<span class="badge badge-env">ENV</span>'; if (badge) badge.innerHTML = '<span class="badge badge-env">ENV</span>';
}); });
@@ -1128,7 +1188,7 @@ document.getElementById('tab-btn-storage').addEventListener('click', function ()
}); });
document.getElementById('ds-registration').addEventListener('change', function () { document.getElementById('ds-registration').addEventListener('change', function () {
toggleRegistration(this.checked); toggleRegistration(/** @type {HTMLInputElement} */ (this).checked);
}); });
document.getElementById('btn-create-user').addEventListener('click', openCreateUserModal); document.getElementById('btn-create-user').addEventListener('click', openCreateUserModal);
@@ -1151,8 +1211,8 @@ document.getElementById('rp-submit').addEventListener('click', submitResetPasswo
/* ── Storage event listeners ── */ /* ── Storage event listeners ── */
document.querySelectorAll('input[name="storage-backend"]').forEach((r) => { document.querySelectorAll('input[name="storage-backend"]').forEach((r) => {
r.addEventListener('change', function () { r.addEventListener('change', () => {
toggleS3Form(this.value === 's3'); toggleS3Form(/** @type {HTMLInputElement} */ (r).value === 's3');
}); });
}); });
document.getElementById('storage-preset').addEventListener('change', onStoragePresetChange); document.getElementById('storage-preset').addEventListener('change', onStoragePresetChange);
+21 -5
View File
@@ -8,8 +8,10 @@ import { oxiIconsInit } from '../../core/icons.js';
var deviceInfo = document.getElementById('device-info'); var deviceInfo = document.getElementById('device-info');
var actionButtons = document.getElementById('action-buttons'); var actionButtons = document.getElementById('action-buttons');
var errorText = document.getElementById('error-text'); var errorText = document.getElementById('error-text');
var btnApprove = document.getElementById('btn-approve'); var btnApprove = /** @type {HTMLButtonElement} */ (document.getElementById('btn-approve'));
var btnDeny = document.getElementById('btn-deny'); var btnDeny = /** @type {HTMLButtonElement} */ (document.getElementById('btn-deny'));
/** @type {ReturnType<typeof setTimeout>} */
var debounceTimer = null; var debounceTimer = null;
var currentCode = ''; var currentCode = '';
@@ -24,12 +26,13 @@ import { oxiIconsInit } from '../../core/icons.js';
// Auto-insert hyphen and lookup on input // Auto-insert hyphen and lookup on input
codeInput.addEventListener('input', (e) => { codeInput.addEventListener('input', (e) => {
var val = e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, ''); const target = /** @type {HTMLInputElement} */ (e.target);
var val = target.value.toUpperCase().replace(/[^A-Z0-9-]/g, '');
// Auto-insert hyphen after 4 chars // Auto-insert hyphen after 4 chars
if (val.length === 4 && val.indexOf('-') === -1) { if (val.length === 4 && val.indexOf('-') === -1) {
val = `${val}-`; val = `${val}-`;
} }
e.target.value = val; target.value = val;
errorText.classList.add('hidden'); errorText.classList.add('hidden');
// Debounce lookup // Debounce lookup
@@ -52,6 +55,11 @@ import { oxiIconsInit } from '../../core/icons.js';
handleAction('deny'); handleAction('deny');
}); });
/**
*
* @param {string} code
* @returns
*/
async function lookupCode(code) { async function lookupCode(code) {
try { try {
const resp = await fetch(`${API_BASE}/api/auth/device/verify?code=${encodeURIComponent(code)}`, { const resp = await fetch(`${API_BASE}/api/auth/device/verify?code=${encodeURIComponent(code)}`, {
@@ -81,6 +89,10 @@ import { oxiIconsInit } from '../../core/icons.js';
} }
} }
/**
*
* @param {'approve' | 'deny'} action
*/
async function handleAction(action) { async function handleAction(action) {
btnApprove.disabled = true; btnApprove.disabled = true;
btnDeny.disabled = true; btnDeny.disabled = true;
@@ -109,10 +121,14 @@ import { oxiIconsInit } from '../../core/icons.js';
} catch (err) { } catch (err) {
btnApprove.disabled = false; btnApprove.disabled = false;
btnDeny.disabled = false; btnDeny.disabled = false;
showError(err.message || 'Failed to process action.'); showError(/** @type {Error} */ (err).message || 'Failed to process action.');
} }
} }
/**
*
* @param {string} msg
*/
function showError(msg) { function showError(msg) {
errorText.textContent = msg; errorText.textContent = msg;
errorText.classList.remove('hidden'); errorText.classList.remove('hidden');
+1 -1
View File
@@ -6,7 +6,7 @@ if (!/^[0-9a-fA-F]+$/.test(token)) {
document.body.innerHTML = '<p>Invalid session token.</p>'; document.body.innerHTML = '<p>Invalid session token.</p>';
throw new Error('Invalid token format'); throw new Error('Invalid token format');
} }
document.getElementById('login-flow-form').action = `/login/v2/flow/${token}`; /** @type {HTMLFormElement} */ (document.getElementById('login-flow-form')).action = `/login/v2/flow/${token}`;
// Check if OIDC is available and configure SSO button // Check if OIDC is available and configure SSO button
(async () => { (async () => {
+32 -14
View File
@@ -4,10 +4,16 @@ import { oxiIconsInit } from '../../core/icons.js';
const API = '/api'; const API = '/api';
// TOOD: reuse common library
/**
* @returns {Record<string, string>}
*/
function headers() { function headers() {
return { 'Content-Type': 'application/json', ...getCsrfHeaders() }; return { 'Content-Type': 'application/json', ...getCsrfHeaders() };
} }
// TOOD: move to common library
/** @param {number} bytes */
function formatBytes(bytes) { function formatBytes(bytes) {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
const k = 1024, const k = 1024,
@@ -16,11 +22,13 @@ function formatBytes(bytes) {
return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`; return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
} }
// TOOD: move to common library
/** @param {string | null | undefined} dateStr */
function timeAgo(dateStr) { function timeAgo(dateStr) {
if (!dateStr) return i18n.t('profile.never'); if (!dateStr) return i18n.t('profile.never');
const d = new Date(dateStr); const d = new Date(dateStr);
const now = new Date(); const now = Date.now();
const secs = Math.floor((now - d) / 1000); const secs = Math.floor((now - d.valueOf()) / 1000);
if (secs < 60) return i18n.t('profile.just_now'); if (secs < 60) return i18n.t('profile.just_now');
if (secs < 3600) return i18n.t('profile.minutes_ago', { n: Math.floor(secs / 60) }); if (secs < 3600) return i18n.t('profile.minutes_ago', { n: Math.floor(secs / 60) });
if (secs < 86400) return i18n.t('profile.hours_ago', { n: Math.floor(secs / 3600) }); if (secs < 86400) return i18n.t('profile.hours_ago', { n: Math.floor(secs / 3600) });
@@ -104,11 +112,12 @@ function showError() {
document.getElementById('auth-error').classList.remove('hidden'); document.getElementById('auth-error').classList.remove('hidden');
} }
/** @param {Event} e */
async function changePassword(e) { async function changePassword(e) {
e.preventDefault(); e.preventDefault();
const currentPw = document.getElementById('current-password').value; const currentPw = /** @type {HTMLInputElement} */ (document.getElementById('current-password')).value;
const newPw = document.getElementById('new-password').value; const newPw = /** @type {HTMLInputElement} */ (document.getElementById('new-password')).value;
const confirmPw = document.getElementById('confirm-password').value; const confirmPw = /** @type {HTMLInputElement} */ (document.getElementById('confirm-password')).value;
const statusEl = document.getElementById('pw-status'); const statusEl = document.getElementById('pw-status');
if (newPw !== confirmPw) { if (newPw !== confirmPw) {
@@ -121,7 +130,7 @@ async function changePassword(e) {
return false; return false;
} }
const btn = document.getElementById('pw-submit'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('pw-submit'));
btn.disabled = true; btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('profile.updating'))}`; btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('profile.updating'))}`;
@@ -138,7 +147,7 @@ async function changePassword(e) {
if (resp.ok) { if (resp.ok) {
statusEl.innerHTML = `<div class="alert alert-success"><i class="fas fa-check-circle"></i> ${escapeHtml(i18n.t('profile.password_updated'))}</div>`; statusEl.innerHTML = `<div class="alert alert-success"><i class="fas fa-check-circle"></i> ${escapeHtml(i18n.t('profile.password_updated'))}</div>`;
document.getElementById('password-form').reset(); /** @type {HTMLFormElement} */ (document.getElementById('password-form')).reset();
} else { } else {
const err = await resp.json().catch(() => ({})); const err = await resp.json().catch(() => ({}));
statusEl.innerHTML = statusEl.innerHTML =
@@ -149,7 +158,7 @@ async function changePassword(e) {
} catch (err) { } catch (err) {
statusEl.innerHTML = statusEl.innerHTML =
'<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' +
escapeHtml(i18n.t('profile.error_network', { message: err.message })) + escapeHtml(i18n.t('profile.error_network', { message: /** @type {Error} */ (err).message })) +
'</div>'; '</div>';
} }
@@ -162,10 +171,12 @@ async function changePassword(e) {
const AUTO_LABELS = ['Nextcloud', 'Nextcloud (OIDC)']; const AUTO_LABELS = ['Nextcloud', 'Nextcloud (OIDC)'];
/** @param {{label: string, active?: boolean, id: string}} pw */
function isAutoPassword(pw) { function isAutoPassword(pw) {
return AUTO_LABELS.includes(pw.label); return AUTO_LABELS.includes(pw.label);
} }
/** @param {{label: string, active?: boolean, id: string, created_at: string, last_used_at?: string}} pw */
function renderPwRow(pw) { function renderPwRow(pw) {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
const label = document.createElement('td'); const label = document.createElement('td');
@@ -210,7 +221,9 @@ async function loadAppPasswords() {
return; return;
} }
const data = await resp.json(); const data = await resp.json();
const passwords = data.app_passwords || data; const passwords = /** @type {Array<{label: string, active?: boolean, id: string, created_at: string, last_used_at?: string}>} */ (
data.app_passwords || data
);
const userPws = passwords.filter((pw) => { const userPws = passwords.filter((pw) => {
return !isAutoPassword(pw); return !isAutoPassword(pw);
}); });
@@ -236,7 +249,7 @@ async function loadAppPasswords() {
autoSection.classList.add('hidden'); autoSection.classList.add('hidden');
} else { } else {
autoSection.classList.remove('hidden'); autoSection.classList.remove('hidden');
document.getElementById('app-pw-auto-count').textContent = autoPws.length; document.getElementById('app-pw-auto-count').textContent = String(autoPws.length);
const autoTbody = document.getElementById('app-pw-auto-tbody'); const autoTbody = document.getElementById('app-pw-auto-tbody');
autoTbody.innerHTML = ''; autoTbody.innerHTML = '';
for (const pw of autoPws) autoTbody.appendChild(renderPwRow(pw)); for (const pw of autoPws) autoTbody.appendChild(renderPwRow(pw));
@@ -255,10 +268,10 @@ function toggleAutoPasswords() {
} }
async function createAppPassword() { async function createAppPassword() {
const labelInput = document.getElementById('app-pw-label'); const labelInput = /** @type {HTMLInputElement} */ (document.getElementById('app-pw-label'));
const label = labelInput.value.trim(); const label = labelInput.value.trim();
const statusEl = document.getElementById('app-pw-status'); const statusEl = document.getElementById('app-pw-status');
const btn = document.getElementById('app-pw-generate'); const btn = /** @type {HTMLButtonElement} */ (document.getElementById('app-pw-generate'));
if (!label) { if (!label) {
statusEl.innerHTML = `<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(i18n.t('profile.error_label_required'))}</div>`; statusEl.innerHTML = `<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(i18n.t('profile.error_label_required'))}</div>`;
@@ -291,7 +304,7 @@ async function createAppPassword() {
labelInput.value = ''; labelInput.value = '';
loadAppPasswords(); loadAppPasswords();
} catch (err) { } catch (err) {
statusEl.innerHTML = `<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ${err.message}</div>`; statusEl.innerHTML = `<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ${/** @type {Error} */ (err).message}</div>`;
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.innerHTML = `<i class="fas fa-plus"></i> ${escapeHtml(i18n.t('profile.generate'))}`; btn.innerHTML = `<i class="fas fa-plus"></i> ${escapeHtml(i18n.t('profile.generate'))}`;
@@ -309,6 +322,10 @@ function copyAppPassword() {
}); });
} }
/**
* @param {string} id
* @param {string} label
*/
async function revokeAppPassword(id, label) { async function revokeAppPassword(id, label) {
if (!confirm(i18n.t('profile.confirm_revoke', { label: label }))) return; if (!confirm(i18n.t('profile.confirm_revoke', { label: label }))) return;
try { try {
@@ -325,10 +342,11 @@ async function revokeAppPassword(id, label) {
alert(err.message || i18n.t('profile.error_revoke')); alert(err.message || i18n.t('profile.error_revoke'));
} }
} catch (err) { } catch (err) {
alert(i18n.t('profile.error_network', { message: err.message })); alert(i18n.t('profile.error_network', { message: /** @type {Error} */ (err).message }));
} }
} }
/** @param {string} str */
function escapeHtml(str) { function escapeHtml(str) {
var div = document.createElement('div'); var div = document.createElement('div');
div.textContent = str || ''; div.textContent = str || '';
+114 -23
View File
@@ -1,4 +1,4 @@
import { oxiIconsInit } from '../../core/icons'; import { oxiIconsInit } from '../../core/icons.js';
/** /**
* publicShare.js — client-side logic for /s/{token}. * publicShare.js — client-side logic for /s/{token}.
@@ -17,12 +17,12 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
const $folder = document.getElementById('share-folder'); const $folder = document.getElementById('share-folder');
const $pwForm = document.getElementById('password-form'); const $pwForm = document.getElementById('password-form');
const $pwInput = document.getElementById('password-input'); const $pwInput = /** @type {HTMLInputElement} */ (document.getElementById('password-input'));
const $pwError = document.getElementById('password-error'); const $pwError = document.getElementById('password-error');
const $fileName = document.getElementById('file-name'); const $fileName = document.getElementById('file-name');
const $fileMeta = document.getElementById('file-meta'); const $fileMeta = document.getElementById('file-meta');
const $fileDl = document.getElementById('file-download'); const $fileDl = /** @type {HTMLAnchorElement} */ (document.getElementById('file-download'));
const $expiredMsg = document.getElementById('expired-message'); const $expiredMsg = document.getElementById('expired-message');
// ── Token from URL path (/s/{token}) ────────────────────────── // ── Token from URL path (/s/{token}) ──────────────────────────
@@ -49,35 +49,49 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
} }
let rootDisplayName = 'Shared folder'; let rootDisplayName = 'Shared folder';
/**
* @param {'loading'|'password'|'expired'|'file'|'folder'} name
*/
function showState(name) { function showState(name) {
for (const el of [$loading, $password, $expired, $file, $folder]) { for (const el of [$loading, $password, $expired, $file, $folder]) {
if (el) el.classList.add('hidden'); if (el) el.classList.add('hidden');
} }
const target = { /** @type {{ loading: HTMLElement|null, password: HTMLElement|null, expired: HTMLElement|null, file: HTMLElement|null, folder: HTMLElement|null }} */
const map = {
loading: $loading, loading: $loading,
password: $password, password: $password,
expired: $expired, expired: $expired,
file: $file, file: $file,
folder: $folder folder: $folder
}[name]; };
const target = map[name];
if (target) target.classList.remove('hidden'); if (target) target.classList.remove('hidden');
document.body.classList.toggle('gallery-mode', name === 'folder'); document.body.classList.toggle('gallery-mode', name === 'folder');
} }
// ── Utilities ───────────────────────────────────────────────── // ── Utilities ─────────────────────────────────────────────────
/**
* @param {string|null|undefined} s
* @returns {string}
*/
function escapeHtml(s) { function escapeHtml(s) {
return String(s == null ? '' : s).replace( return String(s == null ? '' : s).replace(
/[&<>"']/g, /[&<>"']/g,
(c) => (c) =>
/** @type {Record<string, string>} */
({ ({
'&': '&amp;', '&': '&amp;',
'<': '&lt;', '<': '&lt;',
'>': '&gt;', '>': '&gt;',
'"': '&quot;', '"': '&quot;',
"'": '&#39;' "'": '&#39;'
})[c] })[c] ?? c
); );
} }
/**
* @param {number} bytes
* @returns {string}
*/
function formatSize(bytes) { function formatSize(bytes) {
if (bytes == null || Number.isNaN(bytes)) return ''; if (bytes == null || Number.isNaN(bytes)) return '';
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
@@ -90,6 +104,10 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
} }
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`; return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
} }
/**
* @param {string|null|undefined} mime
* @returns {'image'|'video'|null}
*/
function mediaKind(mime) { function mediaKind(mime) {
const m = (mime || '').toLowerCase(); const m = (mime || '').toLowerCase();
if (m.startsWith('image/')) return 'image'; if (m.startsWith('image/')) return 'image';
@@ -97,6 +115,9 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
return null; return null;
} }
// ── Render share data ───────────────────────────────────────── // ── Render share data ─────────────────────────────────────────
/**
* @param {any} data
*/
function renderShare(data) { function renderShare(data) {
if (data.item_type === 'folder') { if (data.item_type === 'folder') {
rootDisplayName = data.item_name || 'Shared folder'; rootDisplayName = data.item_name || 'Shared folder';
@@ -114,13 +135,15 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
.then((res) => { .then((res) => {
if (res.ok) return res.json(); if (res.ok) return res.json();
if (res.status === 401) { if (res.status === 401) {
return res.json().then((body) => { return res.json().then(
if (body?.requiresPassword) { /** @type {(body:any) => null} */ (body) => {
showState('password'); if (body?.requiresPassword) {
return null; showState('password');
return null;
}
throw new Error('Unauthorized');
} }
throw new Error('Unauthorized'); );
});
} }
if (res.status === 410) { if (res.status === 410) {
showState('expired'); showState('expired');
@@ -171,7 +194,9 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
// ── Folder gallery ──────────────────────────────────────────── // ── Folder gallery ────────────────────────────────────────────
/** @type {string | null} */
let currentFolderId = null; let currentFolderId = null;
/** @type {string | null} */
let currentFolderName = null; let currentFolderName = null;
function initFolderGallery() { function initFolderGallery() {
@@ -195,17 +220,33 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
return m ? m[1] : null; return m ? m[1] : null;
} }
/**
* @param {string | null} folderId
* @returns {string}
*/
function listingUrl(folderId) { function listingUrl(folderId) {
return folderId ? `/api/s/${TOKEN_ENC}/contents/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/contents`; return folderId ? `/api/s/${TOKEN_ENC}/contents/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/contents`;
} }
/**
* @param {string} fileId
* @returns {string}
*/
function fileUrl(fileId) { function fileUrl(fileId) {
return `/api/s/${TOKEN_ENC}/file/${encodeURIComponent(fileId)}`; return `/api/s/${TOKEN_ENC}/file/${encodeURIComponent(fileId)}`;
} }
/**
* @param {string | null} folderId
* @returns {string}
*/
function zipUrl(folderId) { function zipUrl(folderId) {
return folderId ? `/api/s/${TOKEN_ENC}/zip/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/zip`; return folderId ? `/api/s/${TOKEN_ENC}/zip/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/zip`;
} }
/** @type {AbortController | null} */
let currentLoadController = null; let currentLoadController = null;
/**
* @param {string | null} folderId
*/
function loadAndRender(folderId) { function loadAndRender(folderId) {
if (currentLoadController) currentLoadController.abort(); if (currentLoadController) currentLoadController.abort();
const controller = new AbortController(); const controller = new AbortController();
@@ -236,6 +277,10 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
}); });
} }
/**
* @param {any} listing
* @param {string | null} folderId
*/
function renderGallery(listing, folderId) { function renderGallery(listing, folderId) {
const isSubfolder = folderId !== null; const isSubfolder = folderId !== null;
const title = isSubfolder ? currentFolderName || 'Subfolder' : rootDisplayName; const title = isSubfolder ? currentFolderName || 'Subfolder' : rootDisplayName;
@@ -243,7 +288,21 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
const backHtml = isSubfolder ? '<a class="gallery-back" href="#" data-action="back"><i class="fas fa-arrow-left"></i> Back to share root</a>' : ''; const backHtml = isSubfolder ? '<a class="gallery-back" href="#" data-action="back"><i class="fas fa-arrow-left"></i> Back to share root</a>' : '';
const headerHtml = `<header class="gallery-header"><h2 class="gallery-title">${escapeHtml(title)}</h2><div class="gallery-actions">${backHtml}<div class="gallery-view-toggle" role="group"><button type="button" data-view="grid" aria-pressed="${viewMode === 'grid'}"><i class="fas fa-th-large"></i></button><button type="button" data-view="list" aria-pressed="${viewMode === 'list'}"><i class="fas fa-bars"></i></button></div><a class="gallery-zip btn-primary" href="${escapeHtml(zipUrl(folderId))}" download><i class="fas fa-file-archive"></i> Download ZIP</a></div></header>`; const headerHtml = `
<header class="gallery-header">
<h2 class="gallery-title">${escapeHtml(title)}</h2>
<div class="gallery-actions">
${backHtml}
<div class="gallery-view-toggle" role="group">
<button type="button" data-view="grid" aria-pressed="${viewMode === 'grid'}"><i class="fas fa-th"></i></button>
<button type="button" data-view="list" aria-pressed="${viewMode === 'list'}"><i class="fas fa-bars"></i></button>
</div>
<a class="gallery-zip btn-primary" href="${escapeHtml(zipUrl(folderId))}" download>
<i class="fas fa-file-archive"></i>
Download ZIP
</a>
</div>
</header>`;
const foldersHtml = const foldersHtml =
listing.folders && listing.folders.length > 0 listing.folders && listing.folders.length > 0
@@ -252,7 +311,7 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
const filesHtml = const filesHtml =
listing.files && listing.files.length > 0 listing.files && listing.files.length > 0
? `<h3 class="gallery-section-title">Files</h3><div class="gallery-files">${listing.files.map((f) => fileCardHtml(f)).join('')}</div>` ? `<h3 class="gallery-section-title">Files</h3><div class="gallery-files">${listing.files.map((/** @type {any} */ f) => fileCardHtml(f)).join('')}</div>`
: ''; : '';
const emptyHtml = empty ? '<div class="gallery-empty">This folder is empty.</div>' : ''; const emptyHtml = empty ? '<div class="gallery-empty">This folder is empty.</div>' : '';
@@ -265,10 +324,18 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
wireImageRetry(); wireImageRetry();
} }
/**
* @param {any} folder
* @returns {string}
*/
function folderCardHtml(folder) { function folderCardHtml(folder) {
return `<a class="folder-card" href="#" data-action="open-folder" data-id="${escapeHtml(folder.id)}" data-name="${escapeHtml(folder.name)}"><i class="fas fa-folder folder-icon"></i><div class="card-body"><div class="card-name">${escapeHtml(folder.name)}</div><div class="card-meta">Subfolder</div></div></a>`; return `<a class="folder-card" href="#" data-action="open-folder" data-id="${escapeHtml(folder.id)}" data-name="${escapeHtml(folder.name)}"><i class="fas fa-folder folder-icon"></i><div class="card-body"><div class="card-name">${escapeHtml(folder.name)}</div><div class="card-meta">Subfolder</div></div></a>`;
} }
/**
* @param {any} file
* @returns {string}
*/
function fileCardHtml(file) { function fileCardHtml(file) {
const url = fileUrl(file.id); const url = fileUrl(file.id);
const kind = mediaKind(file.mime_type); const kind = mediaKind(file.mime_type);
@@ -286,7 +353,7 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
} }
function wireGallery() { function wireGallery() {
for (const btn of $folder.querySelectorAll('.gallery-view-toggle button')) { for (const btn of /** @type {NodeListOf<HTMLButtonElement>} */ ($folder.querySelectorAll('.gallery-view-toggle button'))) {
btn.addEventListener('click', () => setViewMode(btn.dataset.view)); btn.addEventListener('click', () => setViewMode(btn.dataset.view));
} }
const backBtn = $folder.querySelector('[data-action="back"]'); const backBtn = $folder.querySelector('[data-action="back"]');
@@ -296,14 +363,15 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
navigate(null, rootDisplayName); navigate(null, rootDisplayName);
}); });
} }
for (const card of $folder.querySelectorAll('[data-action="open-folder"]')) { for (const card of /** @type {NodeListOf<HTMLDivElement>} */ ($folder.querySelectorAll('[data-action="open-folder"]'))) {
card.addEventListener('click', (e) => { card.addEventListener('click', (e) => {
if (!(e instanceof MouseEvent)) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault(); e.preventDefault();
navigate(card.dataset.id, card.dataset.name); navigate(card.dataset.id, card.dataset.name);
}); });
} }
const mediaCards = Array.from($folder.querySelectorAll('.file-card[data-mediakind]')); const mediaCards = Array.from(/** @type {NodeListOf<HTMLDivElement>} */ ($folder.querySelectorAll('.file-card[data-mediakind]')));
const items = mediaCards.map((el) => ({ const items = mediaCards.map((el) => ({
kind: el.dataset.mediakind, kind: el.dataset.mediakind,
src: el.dataset.src, src: el.dataset.src,
@@ -318,6 +386,9 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
}); });
} }
/**
* @param {string | null | undefined} v
*/
function setViewMode(v) { function setViewMode(v) {
const mode = v === 'list' ? 'list' : 'grid'; const mode = v === 'list' ? 'list' : 'grid';
viewMode = mode; viewMode = mode;
@@ -327,11 +398,15 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
// ignore // ignore
} }
document.body.dataset.shareView = mode; document.body.dataset.shareView = mode;
for (const b of $folder.querySelectorAll('.gallery-view-toggle button')) { for (const b of /** @type {NodeListOf<HTMLButtonElement>} */ ($folder.querySelectorAll('.gallery-view-toggle button'))) {
b.setAttribute('aria-pressed', String(b.dataset.view === mode)); b.setAttribute('aria-pressed', String(b.dataset.view === mode));
} }
} }
/**
* @param {string | null} folderId
* @param {string | null | undefined} folderName
*/
function navigate(folderId, folderName) { function navigate(folderId, folderName) {
currentFolderId = folderId; currentFolderId = folderId;
currentFolderName = folderName; currentFolderName = folderName;
@@ -352,7 +427,7 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
function wireLazyVideos() { function wireLazyVideos() {
const lazy = $folder.querySelectorAll('.file-thumb video[data-lazy-src]'); const lazy = $folder.querySelectorAll('.file-thumb video[data-lazy-src]');
if (!lazy.length) return; if (!lazy.length) return;
const start = (v) => { const start = (/** @type {HTMLVideoElement} */ v) => {
v.addEventListener( v.addEventListener(
'loadedmetadata', 'loadedmetadata',
() => { () => {
@@ -385,20 +460,21 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
(entries) => { (entries) => {
for (const e of entries) { for (const e of entries) {
if (!e.isIntersecting) continue; if (!e.isIntersecting) continue;
if (e.target.dataset.lazySrc && !e.target.src) start(e.target); const target = /** @type {HTMLVideoElement} */ (e.target);
obs.unobserve(e.target); if (target.dataset.lazySrc && !target.src) start(target);
obs.unobserve(target);
} }
}, },
{ rootMargin: '300px' } { rootMargin: '300px' }
); );
for (const v of lazy) obs.observe(v); for (const v of lazy) obs.observe(v);
} else { } else {
for (const v of lazy) start(v); for (const v of lazy) start(/** @type {HTMLVideoElement} */ (v));
} }
} }
function wireImageRetry() { function wireImageRetry() {
for (const img of $folder.querySelectorAll('.file-thumb img')) { for (const img of /** @type {NodeListOf<HTMLImageElement>} */ ($folder.querySelectorAll('.file-thumb img'))) {
img.addEventListener('error', () => { img.addEventListener('error', () => {
if (img.dataset.retried === '1') return; if (img.dataset.retried === '1') return;
img.dataset.retried = '1'; img.dataset.retried = '1';
@@ -412,7 +488,12 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
} }
// ── Lightbox ────────────────────────────────────────────────── // ── Lightbox ──────────────────────────────────────────────────
/**
* @typedef {{ root: HTMLElement, title: Element|null, download: HTMLAnchorElement|null, close: HTMLButtonElement|null, stage: Element|null, content: Element|null, prev: HTMLButtonElement|null, next: HTMLButtonElement|null }} LightboxRefs
*/
/** @type {LightboxRefs | null} */
let lb = null; let lb = null;
/** @type {Array<{kind: string|undefined, src: string|undefined, name: string|undefined}>} */
let lbItems = []; let lbItems = [];
let lbIndex = -1; let lbIndex = -1;
@@ -466,11 +547,18 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
return lb; return lb;
} }
/**
* @param {Array<{kind: string|undefined, src: string|undefined, name: string|undefined}>} items
* @param {number} index
*/
function openLightbox(items, index) { function openLightbox(items, index) {
ensureLightbox(); ensureLightbox();
lbItems = items; lbItems = items;
showLightboxItem(index); showLightboxItem(index);
} }
/**
* @param {number} i
*/
function showLightboxItem(i) { function showLightboxItem(i) {
if (i < 0 || i >= lbItems.length) return; if (i < 0 || i >= lbItems.length) return;
lbIndex = i; lbIndex = i;
@@ -497,6 +585,9 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
lb.root.classList.remove('hidden'); lb.root.classList.remove('hidden');
lb.root.setAttribute('aria-hidden', 'false'); lb.root.setAttribute('aria-hidden', 'false');
} }
/**
* @param {number} delta
*/
function stepLightbox(delta) { function stepLightbox(delta) {
const next = lbIndex + delta; const next = lbIndex + delta;
if (next >= 0 && next < lbItems.length) showLightboxItem(next); if (next >= 0 && next < lbItems.length) showLightboxItem(next);
+76 -76
View File
@@ -6,18 +6,18 @@
import { switchToFilesSection } from '../../app/navigation.js'; import { switchToFilesSection } from '../../app/navigation.js';
import { ui } from '../../app/ui.js'; import { ui } from '../../app/ui.js';
import { getCsrfHeaders } from '../../core/csrf.js'; import { getCsrfHeaders } from '../../core/csrf.js';
import { formatDateShort } from '../../core/formatters.js'; import { formatDateShort, isEmailValid } from '../../core/formatters.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { fileSharing } from '../../features/sharing/fileSharing.js'; import { fileSharing } from '../../features/sharing/fileSharing.js';
/** @import {Share} from '../../core/types.js' */ /** @import {ShareItem} from '../../core/types.js' */
const TTL = 5 * 60 * 1000; // 5 min const TTL = 5 * 60 * 1000; // 5 min
const sharedView = { const sharedView = {
// State // State
/** @type {Array<Share>} */ /** @type {Array<ShareItem>} */
items: [], items: [],
_expires: 0, _expires: 0,
@@ -25,8 +25,10 @@ const sharedView = {
/** @type {Map<string, boolean>} key = "file:<id>" | "folder:<id>" */ /** @type {Map<string, boolean>} key = "file:<id>" | "folder:<id>" */
_knownItemsId: new Map(), _knownItemsId: new Map(),
/** @type {Array<Share>} */ /** @type {Array<ShareItem>} */
filteredItems: [], filteredItems: [],
/** @type {ShareItem | null} */
currentItem: null, currentItem: null,
/** Auth header helper — tokens are in HttpOnly cookies now */ /** Auth header helper — tokens are in HttpOnly cookies now */
@@ -238,6 +240,7 @@ const sharedView = {
// Close dropdowns when clicking outside // Close dropdowns when clicking outside
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
document.querySelectorAll('.shared-custom-select.open').forEach((sel) => { document.querySelectorAll('.shared-custom-select.open').forEach((sel) => {
if (!(e.target instanceof Node)) return;
if (!sel.contains(e.target)) sel.classList.remove('open'); if (!sel.contains(e.target)) sel.classList.remove('open');
}); });
}); });
@@ -249,8 +252,8 @@ const sharedView = {
if (closeBtn) closeBtn.addEventListener('click', () => this.closeShareDialog()); if (closeBtn) closeBtn.addEventListener('click', () => this.closeShareDialog());
const copyLinkBtn = document.getElementById('sv-copy-link-btn'); const copyLinkBtn = document.getElementById('sv-copy-link-btn');
if (copyLinkBtn) copyLinkBtn.addEventListener('click', () => this.copyShareLink()); if (copyLinkBtn) copyLinkBtn.addEventListener('click', () => this.copyShareLink());
const enablePw = document.getElementById('sv-enable-password'); const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
const pwField = document.getElementById('sv-share-password'); const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
if (enablePw) if (enablePw)
enablePw.addEventListener('change', () => { enablePw.addEventListener('change', () => {
if (pwField) { if (pwField) {
@@ -260,8 +263,8 @@ const sharedView = {
}); });
const genPwBtn = document.getElementById('sv-generate-password'); const genPwBtn = document.getElementById('sv-generate-password');
if (genPwBtn) genPwBtn.addEventListener('click', () => this.generatePassword()); if (genPwBtn) genPwBtn.addEventListener('click', () => this.generatePassword());
const enableExp = document.getElementById('sv-enable-expiration'); const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration'));
const expField = document.getElementById('sv-share-expiration'); const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration'));
if (enableExp) if (enableExp)
enableExp.addEventListener('change', () => { enableExp.addEventListener('change', () => {
if (expField) { if (expField) {
@@ -294,6 +297,13 @@ const sharedView = {
}, },
// Initialize a custom select dropdown // Initialize a custom select dropdown
/**
*
* @param {string} wrapperId
* @param {string} toggleId
* @param {string} dropdownId
* @returns
*/
_initCustomSelect(wrapperId, toggleId, dropdownId) { _initCustomSelect(wrapperId, toggleId, dropdownId) {
const wrapper = document.getElementById(wrapperId); const wrapper = document.getElementById(wrapperId);
const toggle = document.getElementById(toggleId); const toggle = document.getElementById(toggleId);
@@ -330,14 +340,14 @@ const sharedView = {
// Filter and sort items // Filter and sort items
filterAndSortItems() { filterAndSortItems() {
const filterTypeActive = document.querySelector('#filter-type-dropdown .shared-select-option.active'); const filterTypeActive = /** @type {HTMLDivElement} */ (document.querySelector('#filter-type-dropdown .shared-select-option.active'));
const sortByActive = document.querySelector('#sort-by-dropdown .shared-select-option.active'); const sortByActive = /** @type {HTMLDivElement} */ (document.querySelector('#sort-by-dropdown .shared-select-option.active'));
const type = filterTypeActive ? filterTypeActive.dataset.value : 'all'; const type = filterTypeActive ? filterTypeActive.dataset.value : 'all';
const sort = sortByActive ? sortByActive.dataset.value : 'date'; const sort = sortByActive ? sortByActive.dataset.value : 'date';
// Use the main top-bar search input // Use the main top-bar search input
const searchInput = document.getElementById('search-input'); const searchInput = /** @type {HTMLInputElement} */ (document.getElementById('search-input'));
const searchTerm = searchInput ? searchInput.value.toLowerCase() : ''; const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
this.filteredItems = this.items.filter((item) => { this.filteredItems = this.items.filter((item) => {
@@ -396,23 +406,23 @@ const sharedView = {
nameCell.appendChild(nameSpan); nameCell.appendChild(nameSpan);
const typeCell = document.createElement('td'); const typeCell = document.createElement('td');
typeCell.textContent = item.item_type === 'file' ? this.translate('shared_typeFile', 'File') : this.translate('shared_typeFolder', 'Folder'); typeCell.textContent = item.item_type === 'file' ? i18n.t('shared_typeFile', 'File') : i18n.t('shared_typeFolder', 'Folder');
const dateCell = document.createElement('td'); const dateCell = document.createElement('td');
dateCell.textContent = this.formatDate(item.created_at); dateCell.textContent = formatDateShort(item.created_at);
const expCell = document.createElement('td'); const expCell = document.createElement('td');
expCell.textContent = item.expires_at ? this.formatDate(item.expires_at) : this.translate('shared_noExpiration', 'No expiration'); expCell.textContent = item.expires_at ? formatDateShort(item.expires_at) : i18n.t('shared_noExpiration', 'No expiration');
const permCell = document.createElement('td'); const permCell = document.createElement('td');
const perms = []; const perms = [];
if (item.permissions?.read) perms.push(this.translate('share_permissionRead', 'Read')); if (item.permissions?.read) perms.push(i18n.t('share_permissionRead', 'Read'));
if (item.permissions?.write) perms.push(this.translate('share_permissionWrite', 'Write')); if (item.permissions?.write) perms.push(i18n.t('share_permissionWrite', 'Write'));
if (item.permissions?.reshare) perms.push(this.translate('share_permissionReshare', 'Reshare')); if (item.permissions?.reshare) perms.push(i18n.t('share_permissionReshare', 'Reshare'));
permCell.textContent = perms.join(', ') || 'Read'; permCell.textContent = perms.join(', ') || 'Read';
const pwCell = document.createElement('td'); const pwCell = document.createElement('td');
pwCell.textContent = item.has_password ? this.translate('shared_hasPassword', 'Yes') : this.translate('shared_noPassword', 'No'); pwCell.textContent = item.has_password ? i18n.t('shared_hasPassword', 'Yes') : i18n.t('shared_noPassword', 'No');
const actionsCell = document.createElement('td'); const actionsCell = document.createElement('td');
actionsCell.className = 'shared-item-actions'; actionsCell.className = 'shared-item-actions';
@@ -420,30 +430,30 @@ const sharedView = {
const editBtn = document.createElement('button'); const editBtn = document.createElement('button');
editBtn.className = 'action-btn edit-btn'; editBtn.className = 'action-btn edit-btn';
editBtn.innerHTML = '<span class="action-icon">✏️</span>'; editBtn.innerHTML = '<span class="action-icon">✏️</span>';
editBtn.title = this.translate('shared_editShare', 'Edit Share'); editBtn.title = i18n.t('shared_editShare', 'Edit Share');
editBtn.addEventListener('click', () => this.openShareDialog(item)); editBtn.addEventListener('click', () => this.openShareDialog(item));
const notifyBtn = document.createElement('button'); const notifyBtn = document.createElement('button');
notifyBtn.className = 'action-btn notify-btn'; notifyBtn.className = 'action-btn notify-btn';
notifyBtn.innerHTML = '<span class="action-icon">📧</span>'; notifyBtn.innerHTML = '<span class="action-icon">📧</span>';
notifyBtn.title = this.translate('shared_notifyShare', 'Notify Someone'); notifyBtn.title = i18n.t('shared_notifyShare', 'Notify Someone');
notifyBtn.addEventListener('click', () => this.openNotificationDialog(item)); notifyBtn.addEventListener('click', () => this.openNotificationDialog(item));
const copyBtn = document.createElement('button'); const copyBtn = document.createElement('button');
copyBtn.className = 'action-btn copy-btn'; copyBtn.className = 'action-btn copy-btn';
copyBtn.innerHTML = '<span class="action-icon">📋</span>'; copyBtn.innerHTML = '<span class="action-icon">📋</span>';
copyBtn.title = this.translate('shared_copyLink', 'Copy Link'); copyBtn.title = i18n.t('shared_copyLink', 'Copy Link');
copyBtn.addEventListener('click', () => { copyBtn.addEventListener('click', () => {
navigator.clipboard navigator.clipboard
.writeText(item.url) .writeText(item.url)
.then(() => this.showNotification(this.translate('shared_linkCopied', 'Link copied!'))) .then(() => ui.showNotification(i18n.t('shared_linkCopied', 'Link copied!'), 'success'))
.catch(() => this.showNotification(this.translate('shared_linkCopyFailed', 'Failed to copy link'), 'error')); .catch(() => ui.showNotification(i18n.t('shared_linkCopyFailed', 'Failed to copy link'), 'error'));
}); });
const rmBtn = document.createElement('button'); const rmBtn = document.createElement('button');
rmBtn.className = 'action-btn remove-btn'; rmBtn.className = 'action-btn remove-btn';
rmBtn.innerHTML = '<span class="action-icon">🗑️</span>'; rmBtn.innerHTML = '<span class="action-icon">🗑️</span>';
rmBtn.title = this.translate('shared_removeShare', 'Remove Share'); rmBtn.title = i18n.t('shared_removeShare', 'Remove Share');
rmBtn.addEventListener('click', () => { rmBtn.addEventListener('click', () => {
this.currentItem = item; this.currentItem = item;
this.removeSharedItem(); this.removeSharedItem();
@@ -456,6 +466,11 @@ const sharedView = {
}, },
// Open share dialog // Open share dialog
/**
*
* @param {ShareItem} item
* @returns {void}
*/
openShareDialog(item) { openShareDialog(item) {
this.currentItem = item; this.currentItem = item;
const shareDialog = document.getElementById('shared-view-edit-dialog'); const shareDialog = document.getElementById('shared-view-edit-dialog');
@@ -463,14 +478,14 @@ const sharedView = {
const iconEl = document.getElementById('sv-dialog-icon'); const iconEl = document.getElementById('sv-dialog-icon');
const nameEl = document.getElementById('sv-dialog-name'); const nameEl = document.getElementById('sv-dialog-name');
const urlEl = document.getElementById('sv-share-link-url'); const urlEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-link-url'));
const enablePw = document.getElementById('sv-enable-password'); const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
const pwField = document.getElementById('sv-share-password'); const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
const enableExp = document.getElementById('sv-enable-expiration'); const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration'));
const expField = document.getElementById('sv-share-expiration'); const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration'));
const permRead = document.getElementById('sv-permission-read'); const permRead = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-read'));
const permWrite = document.getElementById('sv-permission-write'); const permWrite = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-write'));
const permReshare = document.getElementById('sv-permission-reshare'); const permReshare = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-reshare'));
if (!shareDialog) return; if (!shareDialog) return;
if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁'; if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁';
@@ -505,14 +520,19 @@ const sharedView = {
this.currentItem = null; this.currentItem = null;
}, },
/**
*
* @param {ShareItem} item
* @returns {void}
*/
openNotificationDialog(item) { openNotificationDialog(item) {
this.currentItem = item; this.currentItem = item;
const dn = item.item_name || item.item_id || 'Unknown'; const dn = item.item_name || item.item_id || 'Unknown';
const d = document.getElementById('sv-notification-dialog'); const d = document.getElementById('sv-notification-dialog');
const iconEl = document.getElementById('sv-notify-dialog-icon'); const iconEl = document.getElementById('sv-notify-dialog-icon');
const nameEl = document.getElementById('sv-notify-dialog-name'); const nameEl = document.getElementById('sv-notify-dialog-name');
const emailEl = document.getElementById('sv-notification-email'); const emailEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-email'));
const msgEl = document.getElementById('sv-notification-message'); const msgEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-message'));
if (!d) return; if (!d) return;
if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁'; if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁';
@@ -529,18 +549,18 @@ const sharedView = {
}, },
copyShareLink() { copyShareLink() {
const el = document.getElementById('sv-share-link-url'); const el = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-link-url'));
if (!el) return; if (!el) return;
navigator.clipboard navigator.clipboard
.writeText(el.value) .writeText(el.value)
.then(() => this.showNotification(this.translate('shared_linkCopied', 'Link copied!'))) .then(() => ui.showNotification(i18n.t('shared_linkCopied', 'Link copied!'), 'success'))
.catch(() => this.showNotification(this.translate('shared_linkCopyFailed', 'Failed to copy link'), 'error')); .catch(() => ui.showNotification(i18n.t('shared_linkCopyFailed', 'Failed to copy link'), 'error'));
}, },
// Generate secure password with crypto API // Generate secure password with crypto API
generatePassword() { generatePassword() {
const pwField = document.getElementById('sv-share-password'); const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
const enablePw = document.getElementById('sv-enable-password'); const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
if (!pwField || !enablePw) return; if (!pwField || !enablePw) return;
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
@@ -559,13 +579,13 @@ const sharedView = {
async updateSharedItem() { async updateSharedItem() {
if (!this.currentItem) return; if (!this.currentItem) return;
const permRead = document.getElementById('sv-permission-read'); const permRead = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-read'));
const permWrite = document.getElementById('sv-permission-write'); const permWrite = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-write'));
const permReshare = document.getElementById('sv-permission-reshare'); const permReshare = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-reshare'));
const enablePw = document.getElementById('sv-enable-password'); const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
const pwField = document.getElementById('sv-share-password'); const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
const enableExp = document.getElementById('sv-enable-expiration'); const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration'));
const expField = document.getElementById('sv-share-expiration'); const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration'));
const body = { const body = {
permissions: { permissions: {
@@ -588,10 +608,10 @@ const sharedView = {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Server error ${res.status}`); throw new Error(err.error || `Server error ${res.status}`);
} }
this.showNotification(this.translate('shared_itemUpdated', 'Share settings updated')); ui.showNotification(i18n.t('shared_itemUpdated', 'Share settings updated'), 'success');
} catch (err) { } catch (err) {
console.error('Error updating share:', err); console.error('Error updating share:', err);
this.showNotification(err.message || 'Error updating share', 'error'); ui.showNotification(/** @type {Error} */ (err).message || 'Error updating share', 'error');
} }
// update UI // update UI
ui.setSharedVisualState(this.currentItem.item_id, this.currentItem.item_type, true); ui.setSharedVisualState(this.currentItem.item_id, this.currentItem.item_type, true);
@@ -611,10 +631,10 @@ const sharedView = {
headers: this._headers() headers: this._headers()
}); });
if (!res.ok && res.status !== 204) throw new Error(`Server error ${res.status}`); if (!res.ok && res.status !== 204) throw new Error(`Server error ${res.status}`);
this.showNotification(this.translate('shared_itemRemoved', 'Share removed')); ui.showNotification(i18n.t('shared_itemRemoved', 'Share removed'), 'success');
} catch (err) { } catch (err) {
console.error('Error removing share:', err); console.error('Error removing share:', err);
this.showNotification('Error removing share', 'error'); ui.showNotification('Error removing share', 'error');
} }
this.closeShareDialog(); this.closeShareDialog();
@@ -627,13 +647,13 @@ const sharedView = {
// Send notification (stub) // Send notification (stub)
sendNotification() { sendNotification() {
if (!this.currentItem) return; if (!this.currentItem) return;
const emailEl = document.getElementById('sv-notification-email'); const emailEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-email'));
const msgEl = document.getElementById('sv-notification-message'); const msgEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-message'));
const email = emailEl ? emailEl.value.trim() : ''; const email = emailEl ? emailEl.value.trim() : '';
const message = msgEl ? msgEl.value.trim() : ''; const message = msgEl ? msgEl.value.trim() : '';
if (!email || !this.validateEmail(email)) { if (!email || !isEmailValid(email)) {
this.showNotification(this.translate('shared_invalidEmail', 'Please enter a valid email address'), 'error'); ui.showNotification(i18n.t('shared_invalidEmail', 'Please enter a valid email address'), 'error');
return; return;
} }
@@ -642,30 +662,10 @@ const sharedView = {
.sendShareNotification(this.currentItem.url, email, message) .sendShareNotification(this.currentItem.url, email, message)
.then(() => { .then(() => {
this.closeNotificationDialog(); this.closeNotificationDialog();
this.showNotification(this.translate('shared_notificationSent', 'Notification sent')); ui.showNotification(i18n.t('shared_notificationSent', 'Notification sent'), 'success');
}) })
.catch(() => this.showNotification(this.translate('shared_notificationFailed', 'Failed to send notification'), 'error')); .catch(() => ui.showNotification(i18n.t('shared_notificationFailed', 'Failed to send notification'), 'error'));
} }
},
showNotification(message, type = 'success') {
if (ui?.showNotification) {
ui.showNotification(message, type);
} else {
alert(message);
}
},
validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
},
formatDate(value) {
return formatDateShort(value);
},
translate(key, defaultText) {
return i18n.t(key, defaultText);
} }
}; };
+4
View File
@@ -17,3 +17,7 @@ OXICLOUD_WOPI_ENABLED=false
OXICLOUD_OIDC_ENABLED=false OXICLOUD_OIDC_ENABLED=false
RUST_LOG=warn RUST_LOG=warn
#RUST_LOG=debug #RUST_LOG=debug
# grow up limits for tests
OXICLOUD_RATE_LIMIT_REFRESH_MAX=120
OXICLOUD_RATE_LIMIT_LOGIN_MAX=120
+6
View File
@@ -0,0 +1,6 @@
import { execSync } from 'child_process';
import * as path from 'path';
export default async function globalTeardown() {
execSync(`bash ${path.join(__dirname, '../common/stop-db.sh')}`, { stdio: 'inherit' });
}
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"pretest": "bash ../common/spawn-db.sh", "pretest": "bash ../common/spawn-db.sh",
"test": "npx playwright test", "test": "npx playwright test --reporter=line,github --trace=on-first-retry scenarios/01-home-and-login.spec.ts scenarios/02-folder-management.spec.ts",
"posttest": "bash ../common/stop-db.sh" "posttest": "bash ../common/stop-db.sh"
}, },
"keywords": [], "keywords": [],
+9 -10
View File
@@ -2,6 +2,8 @@ import { defineConfig, devices } from '@playwright/test';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
const startScript = path.join(__dirname, 'start-server.sh');
/** Parse a KEY=VALUE env file, skipping blank lines and comments. */ /** Parse a KEY=VALUE env file, skipping blank lines and comments. */
function loadEnv(filePath: string): Record<string, string> { function loadEnv(filePath: string): Record<string, string> {
const env: Record<string, string> = {}; const env: Record<string, string> = {};
@@ -23,13 +25,14 @@ const workspace=process.env.GITHUB_WORKSPACE ?? path.join(__dirname, '../..');
export default defineConfig({ export default defineConfig({
testDir: './scenarios', testDir: './scenarios',
fullyParallel: true, fullyParallel: false,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined, workers: 1,
reporter: process.env.CI ? [['github'], ['html']] : 'html', reporter: process.env.CI ? [['github'], ['html']] : 'html',
globalSetup: require.resolve('./global-setup'), globalSetup: require.resolve('./global-setup'),
globalTeardown: require.resolve('./global-teardown'),
use: { use: {
baseURL: 'http://localhost:8087', baseURL: 'http://localhost:8087',
@@ -45,22 +48,18 @@ export default defineConfig({
name: 'chromium', name: 'chromium',
use: { ...devices['Desktop Chrome'] }, use: { ...devices['Desktop Chrome'] },
}, },
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
], ],
webServer: { webServer: {
command: process.env.BUILD_TARGET command: process.env.BUILD_TARGET
? `${workspace}/target/${process.env.BUILD_TARGET}/oxicloud` ? `bash "${startScript}" "${workspace}/target/${process.env.BUILD_TARGET}/oxicloud"`
: 'cargo run', : `bash "${startScript}" cargo run`,
url: 'http://localhost:8087', url: 'http://localhost:8087',
timeout: 600_000, timeout: 600_000,
reuseExistingServer: false, reuseExistingServer: false,
cwd: '../..', cwd: '../..',
stdout: 'pipe', stdout: 'inherit',
stderr: 'pipe', stderr: 'inherit',
env: { env: {
...commonEnv, ...commonEnv,
OXICLOUD_SERVER_PORT: '8087', OXICLOUD_SERVER_PORT: '8087',
@@ -43,7 +43,7 @@ test.describe('authenticated as admin', () => {
await loginAsAdmin(page); await loginAsAdmin(page);
}); });
test('home page shows files list', async ({ page }) => { test('home page shows empty files list', async ({ page }) => {
await expect(page.locator('.files-container')).toBeVisible(); await expect(page.locator('.files-container')).toBeVisible();
await expect(page.locator('#user-menu-wrapper')).toBeVisible(); await expect(page.locator('#user-menu-wrapper')).toBeVisible();
await expect(page).toHaveScreenshot('home-files.png', { await expect(page).toHaveScreenshot('home-files.png', {
@@ -52,6 +52,8 @@ test.describe('authenticated as admin', () => {
animations: 'disabled', animations: 'disabled',
mask: [page.locator('.storage-bar'), page.locator('.storage-info') ] mask: [page.locator('.storage-bar'), page.locator('.storage-info') ]
}); });
await expect(page.locator('#files-container-error')).toBeVisible();
await expect(page.locator('#files-container-error')).toContainText("No files in this folder");
}); });
test('theme can be changed to dark', async ({ page }) => { test('theme can be changed to dark', async ({ page }) => {
@@ -0,0 +1,232 @@
import * as path from 'path';
import * as fs from 'fs/promises';
import { test, expect, Page } from '@playwright/test';
import { loginAsAdmin } from './helpers';
const FIXTURES = path.join(__dirname, '../../fixtures');
/**
* Opens the new-folder modal, fills the name, and clicks Confirm.
* Does NOT wait for the modal to close — the caller decides what to assert next.
*/
async function submitNewFolder(page: Page, name: string) {
await page.locator('#new-folder-btn').click();
await expect(page.locator('#input-modal')).toBeVisible();
await page.locator('#modal-input').fill(name);
await page.locator('#modal-confirm-btn').click();
}
function mimeFromPath(fp: string): string {
const map: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.mp4': 'video/mp4',
'.pdf': 'application/pdf',
};
return map[path.extname(fp).toLowerCase()] ?? 'application/octet-stream';
}
/**
* Simulates a file drag-and-drop onto the OxiCloud dropzone overlay.
*
* How it works:
* 1. File buffers are read in Node and transferred into the page as File
* objects inside a DataTransfer — no real OS drag needed.
* 2. A document-level 'dragover' is dispatched so the app reveals the
* dropzone (same code path as a real drag from the OS).
* 3. A 'drop' is dispatched on #dropzone. The handler falls back to
* dataTransfer.files when webkitGetAsEntry() returns null (which it
* does for programmatic File objects), hitting the same upload path.
*/
async function dragFilesToDropzone(page: Page, filePaths: string[]) {
const fileData = await Promise.all(
filePaths.map(async (fp) => ({
name: path.basename(fp),
buffer: Array.from(await fs.readFile(fp)),
type: mimeFromPath(fp),
}))
);
const dataTransfer = await page.evaluateHandle((files) => {
const dt = new DataTransfer();
for (const { name, buffer, type } of files) {
dt.items.add(new File([new Uint8Array(buffer)], name, { type }));
}
return dt;
}, fileData);
// Reveal the dropzone overlay (same logic as a real OS drag).
await dataTransfer.evaluate((dt) => {
document.dispatchEvent(new DragEvent('dragover', {
bubbles: true, cancelable: true, dataTransfer: dt,
}));
});
await expect(page.locator('#dropzone')).toBeVisible();
await page.locator('#dropzone').dispatchEvent('drop', { dataTransfer });
}
test.describe('Folder management', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
});
test('folder creation', async ({ page }) => {
const name = `Test folder ${Date.now()}`;
await submitNewFolder(page, name);
await expect(page.locator('#input-modal')).toBeHidden();
await expect(page.locator(`.file-item[data-folder-name="${name}"]`)).toBeVisible();
// Screenshot: mask dynamic text so layout is what's tested, not the name.
await expect(page).toHaveScreenshot('folder-created.png', {
animations: 'disabled',
mask: [
page.locator('.storage-bar'),
page.locator('.storage-info'),
page.locator('.date-cell'),
],
});
});
test('folder reject if already exists', async ({ page }) => {
const name = `Test folder ${Date.now()}`;
// Prerequisite: create the folder once successfully.
await submitNewFolder(page, name);
await expect(page.locator('#input-modal')).toBeHidden();
// Second creation with the same name must keep the modal open with an error.
await submitNewFolder(page, name);
await expect(page.locator('#modal-error')).toBeVisible();
await expect(page.locator('#modal-error')).toContainText('already exists');
// Cancel leaves the list unchanged.
await page.locator('#modal-cancel-btn').click();
await expect(page.locator('#input-modal')).toBeHidden();
});
test('folder creation rejected if bad name', async ({ page }) => {
await page.locator('#new-folder-btn').click();
await expect(page.locator('#input-modal')).toBeVisible();
await page.locator('#modal-input').fill('/');
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#modal-error')).toBeVisible();
await expect(page.locator('#modal-error')).toContainText('Invalid folder name');
await page.locator('#modal-cancel-btn').click();
await expect(page.locator('#input-modal')).toBeHidden();
});
test('folder rename', async ({ page }) => {
const ts = Date.now();
const original = `Test folder ${ts}`;
const renamed = `Renamed folder ${ts}`;
// Prerequisite: create the folder to rename.
await submitNewFolder(page, original);
await expect(page.locator('#input-modal')).toBeHidden();
await expect(page.locator(`.file-item[data-folder-name="${original}"]`)).toBeVisible();
// Open context menu and trigger rename.
await page.locator(`.file-item[data-folder-name="${original}"] .file-actions`).click();
await expect(page.locator('#folder-context-menu')).toBeVisible();
await page.locator('#rename-folder-option').click();
await expect(page.locator('#input-modal')).toBeVisible();
await page.locator('#modal-input').fill(renamed);
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#input-modal')).toBeHidden();
await expect(page.locator(`.file-item[data-folder-name="${renamed}"]`)).toBeVisible();
await expect(page.locator(`.file-item[data-folder-name="${original}"]`)).not.toBeVisible();
await expect(page).toHaveScreenshot('folder-renamed.png', {
animations: 'disabled',
mask: [
page.locator('.storage-bar'),
page.locator('.storage-info'),
page.locator('.date-cell'),
],
});
});
// ── File upload ────────────────────────────────────────────────────────────
// These two tests are intentionally sequential: test 1 uploads the files,
// test 2 reads the state left by test 1. Both run inside the same describe
// block so Playwright executes them in order within a single worker.
test('upload of multiple files', async ({ page }) => {
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('#upload-btn').click();
await expect(page.locator('#upload-dropdown-menu')).toBeVisible();
await page.locator('#upload-files-btn').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles([
path.join(FIXTURES, 'oxicloud-logo.jpg'),
path.join(FIXTURES, 'free_video_over_1MB.mp4'),
]);
// Each file appears in the list as soon as its individual upload completes.
await expect(page.locator('.file-item[data-file-name="oxicloud-logo.jpg"]')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.file-item[data-file-name="free_video_over_1MB.mp4"]')).toBeVisible({ timeout: 15_000 });
});
test('uploaded files rendered with thumbnails', async ({ page }) => {
// The beforeEach login re-loads the home folder; files uploaded in the
// previous test are already in the DB and visible immediately.
await expect(page.locator('.file-item[data-file-name="oxicloud-logo.jpg"]')).toBeVisible();
await expect(page.locator('.file-item[data-file-name="free_video_over_1MB.mp4"]')).toBeVisible();
// Image thumbnail is generated server-side: wait for the <img> to be
// visible (the error handler hides it while the server is still processing,
// then un-hides it once the src resolves successfully).
await expect(
page.locator('.file-item[data-file-name="oxicloud-logo.jpg"] .file-thumb')
).toBeVisible({ timeout: 10_000 });
// Video thumbnail is generated client-side from a canvas frame extraction.
// Give the browser 1 s to finish before taking the screenshot.
await page.waitForTimeout(1_000);
await expect(page).toHaveScreenshot('files-with-thumbnails.png', {
animations: 'disabled',
mask: [
page.locator('.storage-bar'),
page.locator('.storage-info'),
page.locator('.date-cell'), // upload timestamps differ every run
],
});
});
test('drag and drop images onto dropzone', async ({ page }) => {
await dragFilesToDropzone(page, [
path.join(FIXTURES, 'blue-image.png'),
path.join(FIXTURES, 'green-image.png'),
path.join(FIXTURES, 'red-image.png'),
]);
// Each card appears as its upload completes.
await expect(page.locator('.file-item[data-file-name="blue-image.png"]')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.file-item[data-file-name="green-image.png"]')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.file-item[data-file-name="red-image.png"]')).toBeVisible({ timeout: 15_000 });
// PNGs get server-side thumbnails — wait for at least the first to resolve.
await expect(
page.locator('.file-item[data-file-name="blue-image.png"] .file-thumb')
).toBeVisible({ timeout: 10_000 });
await expect(page).toHaveScreenshot('dropped-files.png', {
animations: 'disabled',
mask: [
page.locator('.storage-bar'),
page.locator('.storage-info'),
page.locator('.date-cell'),
],
});
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Boot a clean test DB then start OxiCloud (passed as arguments).
# Used by playwright.config.ts as the webServer command so that both
# `npm test` and `npx playwright test` always start from an empty database.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/e2e/storage"
# ensure storage is empty before starting
echo "Wipe $OXICLOUD_STORAGE_PATH to ensure clean startup"
rm -rf "$OXICLOUD_STORAGE_PATH"
mkdir -p "$OXICLOUD_STORAGE_PATH"
# Spawn database
bash "$REPO_ROOT/tests/common/spawn-db.sh"
# Replace the shell with the server process so Playwright's PID tracking works.
exec "$@"
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
# **Lorem ipsum**
Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.
Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.
Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.
Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.
Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.
Binary file not shown.
Binary file not shown.
+327
View File
@@ -0,0 +1,327 @@
{\rtf1\ansi\ansicpg1252\uc0\stshfdbch0\stshfloch0\stshfhich0\stshfbi0\deff0\adeff0{\fonttbl{\f0\froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;}{\f1\froman\fcharset2\fprq2{\*\panose 05050102010706020507}Symbol;}{\f2\fswiss\fcharset0\fprq2{\*\panose 020b0604020202020204}Arial;}}{\colortbl;\red0\green0\blue0;\red100\green107\blue107;
\red67\green67\blue67;\red102\green102\blue102;}{\stylesheet{\s0\snext0\sqformat\spriority0\fi0\sb0\sa0\aspalpha\aspnum\adjustright\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl276\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1 Normal;}{\s1\sbasedon0\snext0\styrsid15694742
\sqformat\spriority0\keep\keepn\fi0\sb400\sa120\aspalpha\aspnum\adjustright\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl240\slmult1\rtlch\ab0\ai0\af2\afs40\ltrch\b0\i0\fs40\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1 heading 1;}{\s2\sbasedon0\snext0\styrsid15694742
\sqformat\spriority0\keep\keepn\fi0\sb360\sa120\aspalpha\aspnum\adjustright\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl240\slmult1\rtlch\ab0\ai0\af2\afs32\ltrch\b0\i0\fs32\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1 heading 2;}{\s3\sbasedon0\snext0\styrsid15694742
\sqformat\spriority0\keep\keepn\fi0\sb320\sa80\aspalpha\aspnum\adjustright\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl240\slmult1\rtlch\ab0\ai0\af2\afs28\ltrch\b0\i0\fs28\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf3 heading 3;}{\s4\sbasedon0\snext0\styrsid15694742
\sqformat\spriority0\keep\keepn\fi0\sb280\sa80\aspalpha\aspnum\adjustright\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl240\slmult1\rtlch\ab0\ai0\af2\afs24\ltrch\b0\i0\fs24\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf4 heading 4;}{\s5\sbasedon0\snext0\styrsid15694742
\sqformat\spriority0\keep\keepn\fi0\sb240\sa80\aspalpha\aspnum\adjustright\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl240\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf4 heading 5;}{\s6\sbasedon0\snext0\styrsid15694742
\sqformat\spriority0\keep\keepn\fi0\sb240\sa80\aspalpha\aspnum\adjustright\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl240\slmult1\rtlch\ab0\ai\af2\afs22\ltrch\b0\i\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf4 heading 6;}{\*\cs10\additive\ssemihidden\spriority0 Default Paragraph Font;
}{\*\ts11\tsrowd\snext11\ssemihidden\spriority0\aspalpha\aspnum\adjustright\ltrpar\li0\lin0\ri0\rin0\ql\faauto\tsvertalt\tsbrdrl\tsbrdrr\tsbrdrt\tsbrdrb\tsbrdrdgr\tsbrdrdgl\tsbrdrh\tsbrdrv\trpaddl108\trpaddfl3\trwWidthB0\trftsWidthB3\trpaddt0\trpaddft3\trpaddb0
\trpaddfb3\trpaddr108\trpaddfr3 Normal Table;}{\s15\sbasedon0\snext15\styrsid15694742\sqformat\spriority0\keep\keepn\fi0\sb0\sa60\aspalpha\aspnum\adjustright\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl240\slmult1\rtlch\ab0\ai0\af2\afs52\ltrch\b0\i0\fs52\loch\af2
\dbch\af2\hich\f2\strike0\ulnone\cf1 Title;}{\s16\sbasedon0\snext16\styrsid15694742\sqformat\spriority0\keep\keepn\fi0\sb0\sa320\aspalpha\aspnum\adjustright\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl240\slmult1\rtlch\ab0\ai0\af2\afs30\ltrch\b0\i0\fs30
\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf4 Subtitle;}}{\*\rsidtbl\rsid10976062\rsid13249109}{\*\generator Aspose.Words for Java 23.4.0;}{\info\version1\edmins0\nofpages1\nofwords0\nofchars0\nofcharsws0}\paperw11909\paperh16834\margl1440\margr1440\margt1440\margb1440\gutter0
{\mmathPr\mbrkBin0\mbrkBinSub0\mdefJc1\mdispDef1\minterSp0\mintLim0\mintraSp0\mlMargin0\mmathFont0\mnaryLim1\mpostSp0\mpreSp0\mrMargin0\msmallFrac0\mwrapIndent1440\mwrapRight0}\deflang1033\deflangfe2052\adeflang1025\jexpand\showxmlerrors1\validatexml1{
\*\wgrffmtfilter 013f}\viewkind1\viewscale100\fet0\ftnbj\aenddoc\ftnrstcont\aftnrstcont\ftnnar\aftnnrlc\widowctrl\nospaceforul\nolnhtadjtbl\alntblind\lyttblrtgr\dntblnsbdb\noxlattoyen\wrppunct\nobrkwrptbl\expshrtn\snaptogridincell\asianbrkrule\htmautsp\noultrlspc
\useltbaln\splytwnine\ftnlytwnine\lytcalctblwd\allowfieldendsel\lnbrkrule\nouicompat\nofeaturethrottle1\utinl\formshade\nojkernpunct\dghspace180\dgvspace180\dghorigin1800\dgvorigin1440\dghshow1\dgvshow1\dgmargin\pgbrdrhead\pgbrdrfoot\rsidroot10976062\sectd\sectlinegrid360\pgwsxn11909\pghsxn16834\marglsxn1440\margrsxn1440\margtsxn1440\margbsxn1440\guttersxn0\headery720\footery720\colsx720\ltrsect\pard\plain\itap0\s15\keep\keepn\ilvl0\fi0\sb0\sa360\aspalpha\aspnum\adjustright\brdrt\brdrl\brdrb\brdrr
\brdrbtw\brdrbar\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl276\slmult1\rtlch\ab0\ai0\af2\afs52\ltrch\b0\i0\fs52\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1{\*\bkmkstart h.mjnd912kz2gu}{\*\bkmkend h.mjnd912kz2gu}{\rtlch\ab\ai0\af2\alang1025\afs52\ltrch\b\i0\fs52\lang1033\langnp1033\langfe1033\langfenp1033
\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1 Lorem}{\rtlch\ab\ai0\af2\alang1025\afs52\ltrch\b\i0\fs52\lang1033\langnp1033\langfe1033\langfenp1033\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1 }{\rtlch\ab\ai0\af2\alang1025\afs52\ltrch\b\i0\fs52\lang1033\langnp1033\langfe1033\langfenp1033
\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1 ipsum}{\rtlch\ab\ai0\af2\afs52\ltrch\b\i0\fs52\loch\af2\dbch\af2\hich\f2\insrsid10976062\strike0\ulnone\cf1\par}\pard\plain\itap0\s0\ilvl0\fi0\sb0\sa360\aspalpha\aspnum\adjustright\brdrt\brdrl\brdrb\brdrr\brdrbtw
\brdrbar\widctlpar\ltrpar\li0\lin0\ri0\rin0\qj\faauto\sl276\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1{\rtlch\ab0\ai0\af0\afs42\ltrch\b0\i0\fs42\f0\insrsid10976062\strike0\ulnone\cf2\par}\pard\plain\itap0\s0\ilvl0\fi0\sb0\sa360
\aspalpha\aspnum\adjustright\brdrt\brdrl\brdrb\brdrr\brdrbtw\brdrbar\widctlpar\ltrpar\li0\lin0\ri0\rin0\qj\faauto\sl276\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 Lorem}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ipsum}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 dolor}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 amet}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 consectetur}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 adipiscing}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 elit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Quisque}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 faucibus}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ex}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sapien}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 vitae}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pellentesque}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sem}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 placerat}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 In}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 id}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 cursus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 mi}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pretium}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tellus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 duis}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 convallis}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Tempus}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 leo}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 eu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 aenean}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sed}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 diam}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 urna}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tempor}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Pulvinar}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 vivamus}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 fringilla}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 lacus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nec}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 metus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 bibendum}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 egestas}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Iaculis}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 massa}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nisl}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 malesuada}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 lacinia}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 integer}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nunc}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 posuere}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ut}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 hendrerit}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 semper}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 vel}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 class}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 aptent}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 taciti}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 sociosqu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ad}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 litora}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 torquent}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 per}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 conubia}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nostra}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 inceptos}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 himenaeos}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 .}{\rtlch\ab0\ai0\af0\afs42\ltrch\b0\i0\fs42\f0\insrsid10976062\strike0
\ulnone\cf2\par}\pard\plain\itap0\s0\ilvl0\fi0\sb0\sa360\aspalpha\aspnum\adjustright\brdrt\brdrl\brdrb\brdrr\brdrbtw\brdrbar\widctlpar\ltrpar\li0\lin0\ri0\rin0\qj\faauto\sl276\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0
\ulnone\cf1{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Lorem}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ipsum}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 dolor}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 sit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 amet}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 consectetur}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 adipiscing}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 elit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Quisque}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 faucibus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ex}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sapien}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 vitae}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pellentesque}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sem}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 placerat}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 In}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 id}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 cursus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 mi}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pretium}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tellus}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 duis}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 convallis}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Tempus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 leo}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 eu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 aenean}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sed}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 diam}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 urna}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tempor}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Pulvinar}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 vivamus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 fringilla}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 lacus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nec}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 metus}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 bibendum}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 egestas}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Iaculis}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 massa}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nisl}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 malesuada}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 lacinia}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 integer}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nunc}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 posuere}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ut}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 hendrerit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 semper}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 vel}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 class}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 aptent}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 taciti}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sociosqu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ad}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 litora}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 torquent}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 per}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 conubia}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nostra}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 inceptos}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 himenaeos}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 .}{\rtlch\ab0\ai0\af0\afs42\ltrch\b0\i0\fs42\f0\insrsid10976062\strike0\ulnone\cf2\par}\pard\plain\itap0\s0\ilvl0\fi0\sb0\sa360\aspalpha\aspnum\adjustright\brdrt\brdrl\brdrb\brdrr\brdrbtw\brdrbar\widctlpar\ltrpar\li0\lin0\ri0\rin0\qj\faauto\sl276
\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Lorem}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ipsum}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 dolor}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 amet}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 consectetur}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 adipiscing}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 elit}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Quisque}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 faucibus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ex}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sapien}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 vitae}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pellentesque}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sem}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 placerat}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 In}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 id}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 cursus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 mi}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pretium}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tellus}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 duis}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 convallis}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Tempus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 leo}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 eu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 aenean}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sed}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 diam}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 urna}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tempor}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Pulvinar}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 vivamus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 fringilla}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 lacus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nec}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 metus}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 bibendum}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 egestas}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Iaculis}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 massa}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nisl}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 malesuada}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 lacinia}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 integer}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nunc}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 posuere}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ut}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 hendrerit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 semper}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 vel}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 class}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 aptent}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 taciti}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sociosqu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ad}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 litora}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 torquent}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 per}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 conubia}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nostra}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 inceptos}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 himenaeos}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 .}{\rtlch\ab0\ai0\af0\afs42\ltrch\b0\i0\fs42\f0\insrsid10976062\strike0\ulnone\cf2\par}\pard\plain\itap0\s0\ilvl0\fi0\sb0\sa360\aspalpha\aspnum\adjustright\brdrt\brdrl\brdrb\brdrr\brdrbtw\brdrbar\widctlpar\ltrpar\li0\lin0\ri0\rin0\qj\faauto\sl276
\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Lorem}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ipsum}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 dolor}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 amet}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 consectetur}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 adipiscing}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 elit}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Quisque}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 faucibus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ex}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sapien}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 vitae}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pellentesque}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sem}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 placerat}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 In}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 id}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 cursus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 mi}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pretium}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tellus}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 duis}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 convallis}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Tempus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 leo}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 eu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 aenean}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sed}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 diam}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 urna}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tempor}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Pulvinar}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 vivamus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 fringilla}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 lacus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nec}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 metus}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 bibendum}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 egestas}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Iaculis}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 massa}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nisl}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 malesuada}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 lacinia}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 integer}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nunc}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 posuere}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ut}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 hendrerit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 semper}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 vel}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 class}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 aptent}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 taciti}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sociosqu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ad}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 litora}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 torquent}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 per}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 conubia}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nostra}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 inceptos}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 himenaeos}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 .}{\rtlch\ab0\ai0\af0\afs42\ltrch\b0\i0\fs42\f0\insrsid10976062\strike0\ulnone\cf2\par}\pard\plain\itap0\s0\ilvl0\fi0\sb0\sa360\aspalpha\aspnum\adjustright\brdrt\brdrl\brdrb\brdrr\brdrbtw\brdrbar\widctlpar\ltrpar\li0\lin0\ri0\rin0\qj\faauto\sl276
\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Lorem}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ipsum}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 dolor}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 amet}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 consectetur}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 adipiscing}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 elit}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Quisque}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 faucibus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ex}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sapien}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 vitae}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pellentesque}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sem}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 placerat}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 In}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 id}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 cursus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 mi}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pretium}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tellus}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 duis}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 convallis}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Tempus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 leo}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 eu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 aenean}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sed}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 diam}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 urna}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tempor}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Pulvinar}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 vivamus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 fringilla}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 lacus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nec}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 metus}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 bibendum}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 egestas}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Iaculis}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 massa}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nisl}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 malesuada}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 lacinia}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 integer}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nunc}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 posuere}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ut}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 hendrerit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 semper}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 vel}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 class}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 aptent}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 taciti}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sociosqu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ad}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 litora}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 torquent}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 per}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 conubia}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nostra}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 inceptos}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 himenaeos}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 .}{\rtlch\ab0\ai0\af0\afs42\ltrch\b0\i0\fs42\f0\insrsid10976062\strike0\ulnone\cf2\par}\pard\plain\itap0\s0\ilvl0\fi0\sb0\sa0\aspalpha\aspnum\adjustright\brdrt\brdrl\brdrb\brdrr\brdrbtw\brdrbar\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl276
\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1{\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\insrsid10976062\strike0\ulnone\cf1\par}\pard\plain\itap0\s0\ilvl0\fi0\sb0\sa0\aspalpha\aspnum
\adjustright\brdrt\brdrl\brdrb\brdrr\brdrbtw\brdrbar\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl276\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1{\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\insrsid10976062\strike0
\ulnone\cf1\par}{
\*\latentstyles\lsdstimax267\lsdlockeddef0\lsdsemihiddendef0\lsdunhideuseddef0\lsdqformatdef0\lsdprioritydef0{\lsdlockedexcept\lsdqformat1 Normal;\lsdqformat1 heading 1;\lsdsemihidden1\lsdunhideused1\lsdqformat1 heading 2;\lsdsemihidden1\lsdunhideused1\lsdqformat1 heading 3;
\lsdsemihidden1\lsdunhideused1\lsdqformat1 heading 4;\lsdsemihidden1\lsdunhideused1\lsdqformat1 heading 5;\lsdsemihidden1\lsdunhideused1\lsdqformat1 heading 6;\lsdsemihidden1\lsdunhideused1\lsdqformat1 heading 7;\lsdsemihidden1\lsdunhideused1\lsdqformat1 heading 8;
\lsdsemihidden1\lsdunhideused1\lsdqformat1 heading 9;\lsdsemihidden1\lsdunhideused1\lsdqformat1 caption;\lsdqformat1 Title;\lsdqformat1 Subtitle;\lsdqformat1 Strong;\lsdqformat1 Emphasis;\lsdsemihidden1\lsdpriority99 Placeholder Text;\lsdqformat1\lsdpriority1 No Spacing;
\lsdpriority60 Light Shading;\lsdpriority61 Light List;\lsdpriority62 Light Grid;\lsdpriority63 Medium Shading 1;\lsdpriority64 Medium Shading 2;\lsdpriority65 Medium List 1;\lsdpriority66 Medium List 2;\lsdpriority67 Medium Grid 1;\lsdpriority68 Medium Grid 2;
\lsdpriority69 Medium Grid 3;\lsdpriority70 Dark List;\lsdpriority71 Colorful Shading;\lsdpriority72 Colorful List;\lsdpriority73 Colorful Grid;\lsdpriority60 Light Shading Accent 1;\lsdpriority61 Light List Accent 1;\lsdpriority62 Light Grid Accent 1;\lsdpriority63 Medium Shading 1 Accent 1;
\lsdpriority64 Medium Shading 2 Accent 1;\lsdpriority65 Medium List 1 Accent 1;\lsdsemihidden1\lsdpriority99 Revision;\lsdqformat1\lsdpriority34 List Paragraph;\lsdqformat1\lsdpriority29 Quote;\lsdqformat1\lsdpriority30 Intense Quote;\lsdpriority66 Medium List 2 Accent 1;
\lsdpriority67 Medium Grid 1 Accent 1;\lsdpriority68 Medium Grid 2 Accent 1;\lsdpriority69 Medium Grid 3 Accent 1;\lsdpriority70 Dark List Accent 1;\lsdpriority71 Colorful Shading Accent 1;\lsdpriority72 Colorful List Accent 1;\lsdpriority73 Colorful Grid Accent 1;
\lsdpriority60 Light Shading Accent 2;\lsdpriority61 Light List Accent 2;\lsdpriority62 Light Grid Accent 2;\lsdpriority63 Medium Shading 1 Accent 2;\lsdpriority64 Medium Shading 2 Accent 2;\lsdpriority65 Medium List 1 Accent 2;\lsdpriority66 Medium List 2 Accent 2;
\lsdpriority67 Medium Grid 1 Accent 2;\lsdpriority68 Medium Grid 2 Accent 2;\lsdpriority69 Medium Grid 3 Accent 2;\lsdpriority70 Dark List Accent 2;\lsdpriority71 Colorful Shading Accent 2;\lsdpriority72 Colorful List Accent 2;\lsdpriority73 Colorful Grid Accent 2;
\lsdpriority60 Light Shading Accent 3;\lsdpriority61 Light List Accent 3;\lsdpriority62 Light Grid Accent 3;\lsdpriority63 Medium Shading 1 Accent 3;\lsdpriority64 Medium Shading 2 Accent 3;\lsdpriority65 Medium List 1 Accent 3;\lsdpriority66 Medium List 2 Accent 3;
\lsdpriority67 Medium Grid 1 Accent 3;\lsdpriority68 Medium Grid 2 Accent 3;\lsdpriority69 Medium Grid 3 Accent 3;\lsdpriority70 Dark List Accent 3;\lsdpriority71 Colorful Shading Accent 3;\lsdpriority72 Colorful List Accent 3;\lsdpriority73 Colorful Grid Accent 3;
\lsdpriority60 Light Shading Accent 4;\lsdpriority61 Light List Accent 4;\lsdpriority62 Light Grid Accent 4;\lsdpriority63 Medium Shading 1 Accent 4;\lsdpriority64 Medium Shading 2 Accent 4;\lsdpriority65 Medium List 1 Accent 4;\lsdpriority66 Medium List 2 Accent 4;
\lsdpriority67 Medium Grid 1 Accent 4;\lsdpriority68 Medium Grid 2 Accent 4;\lsdpriority69 Medium Grid 3 Accent 4;\lsdpriority70 Dark List Accent 4;\lsdpriority71 Colorful Shading Accent 4;\lsdpriority72 Colorful List Accent 4;\lsdpriority73 Colorful Grid Accent 4;
\lsdpriority60 Light Shading Accent 5;\lsdpriority61 Light List Accent 5;\lsdpriority62 Light Grid Accent 5;\lsdpriority63 Medium Shading 1 Accent 5;\lsdpriority64 Medium Shading 2 Accent 5;\lsdpriority65 Medium List 1 Accent 5;\lsdpriority66 Medium List 2 Accent 5;
\lsdpriority67 Medium Grid 1 Accent 5;\lsdpriority68 Medium Grid 2 Accent 5;\lsdpriority69 Medium Grid 3 Accent 5;\lsdpriority70 Dark List Accent 5;\lsdpriority71 Colorful Shading Accent 5;\lsdpriority72 Colorful List Accent 5;\lsdpriority73 Colorful Grid Accent 5;
\lsdpriority60 Light Shading Accent 6;\lsdpriority61 Light List Accent 6;\lsdpriority62 Light Grid Accent 6;\lsdpriority63 Medium Shading 1 Accent 6;\lsdpriority64 Medium Shading 2 Accent 6;\lsdpriority65 Medium List 1 Accent 6;\lsdpriority66 Medium List 2 Accent 6;
\lsdpriority67 Medium Grid 1 Accent 6;\lsdpriority68 Medium Grid 2 Accent 6;\lsdpriority69 Medium Grid 3 Accent 6;\lsdpriority70 Dark List Accent 6;\lsdpriority71 Colorful Shading Accent 6;\lsdpriority72 Colorful List Accent 6;\lsdpriority73 Colorful Grid Accent 6;
\lsdqformat1\lsdpriority19 Subtle Emphasis;\lsdqformat1\lsdpriority21 Intense Emphasis;\lsdqformat1\lsdpriority31 Subtle Reference;\lsdqformat1\lsdpriority32 Intense Reference;\lsdqformat1\lsdpriority33 Book Title;\lsdsemihidden1\lsdunhideused1\lsdpriority37 Bibliography;
\lsdsemihidden1\lsdunhideused1\lsdqformat1\lsdpriority39 TOC Heading;}}}
Binary file not shown.