Merge pull request #374 from EdouardVanbelle/style/type
@@ -42,7 +42,7 @@ jobs:
|
||||
- 'tests/**'
|
||||
|
||||
frontend-check:
|
||||
name: Frontend — CSS and JS checks (format, lint, rules)
|
||||
name: Frontend — CSS and JS checks (format, lint, css-rules, types)
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -61,16 +61,20 @@ jobs:
|
||||
node-version: 25
|
||||
|
||||
# because we are not using package.json
|
||||
- name: Install Stylelint and plugins
|
||||
- name: Install Stylelint, TypeScript and plugins
|
||||
run: |
|
||||
npm install --global \
|
||||
stylelint@17 \
|
||||
postcss@8 \
|
||||
stylelint-value-no-unknown-custom-properties@6
|
||||
stylelint-value-no-unknown-custom-properties@6 \
|
||||
typescript
|
||||
|
||||
- name: Run Stylelint
|
||||
run: npx stylelint "static/css/**/*.{css,scss}"
|
||||
|
||||
- name: Run TypeScript check
|
||||
run: tsc -p jsconfig.json --noEmit
|
||||
|
||||
rust-fmt:
|
||||
name: Rustfmt
|
||||
needs: changes
|
||||
|
||||
@@ -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
|
||||
- No `var` — use `const`/`let` only
|
||||
- **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`:
|
||||
|
||||
```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/`
|
||||
- [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
|
||||
- Edit `Cargo.lock` directly
|
||||
- Use npm dependencies not listed in this file
|
||||
|
||||
@@ -173,6 +173,7 @@ fn resolve_css_imports(entry: &Path, css_dir: &Path) -> String {
|
||||
if let Some(rel) = extract_import_path(t) {
|
||||
let resolved = css_dir.join(rel.trim_start_matches("./"));
|
||||
if resolved.exists() {
|
||||
println!("cargo:warning=CSS importing: {}", resolved.display());
|
||||
out.push_str(&fs::read_to_string(&resolved).unwrap_or_default());
|
||||
out.push('\n');
|
||||
} else {
|
||||
@@ -238,6 +239,7 @@ fn minify_tree_css(dir: &Path) {
|
||||
if fname.starts_with("app.") || fname == "main.css" {
|
||||
continue;
|
||||
}
|
||||
println!("cargo:warning=CSS importing: {}", p.display());
|
||||
if let Ok(src) = fs::read_to_string(&p) {
|
||||
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);
|
||||
}
|
||||
|
||||
println!(
|
||||
"cargo:warning=bundle: {} files in dependency order:",
|
||||
order.len()
|
||||
);
|
||||
let mut bundle = String::with_capacity(2 * 1024 * 1024);
|
||||
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) {
|
||||
Ok(src) => {
|
||||
bundle.push_str(&strip_esm_syntax(&src));
|
||||
bundle.push_str(&strip_esm_syntax(&src, file, &mut declared_namespaces));
|
||||
bundle.push('\n');
|
||||
}
|
||||
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
|
||||
// patterns that are incompatible with IIFE wrapping. They must be
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -381,20 +398,82 @@ fn extract_from_clause(s: &str) -> Option<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.
|
||||
///
|
||||
/// | Input | Output |
|
||||
/// |------------------------------------------|------------------------------|
|
||||
/// | `import { X } from './y.js';` | *(empty line)* |
|
||||
/// | `import { X as Y } from './y.js';` | `const Y = X;` |
|
||||
/// | `export { X, Y };` | *(empty line)* |
|
||||
/// | `export { X } from './y.js';` | *(empty line)* |
|
||||
/// | `export const X = …` | `const X = …` |
|
||||
/// | `export function f() {…}` | `function f() {…}` |
|
||||
/// | `export async function f() {…}` | `async function f() {…}` |
|
||||
/// | `export class C {…}` | `class C {…}` |
|
||||
/// | `export default expr;` | `const _default = expr;` |
|
||||
fn strip_esm_syntax(source: &str) -> String {
|
||||
/// | Input | Output |
|
||||
/// |------------------------------------------|---------------------------------------------|
|
||||
/// | `import { X } from './y.js';` | *(empty line)* |
|
||||
/// | `import { X as Y } from './y.js';` | `const Y = X;` |
|
||||
/// | `import * as ns from './y.js';` | `const ns = { export1, export2, … };` |
|
||||
/// | `export { X, Y };` | *(empty line)* |
|
||||
/// | `export { X } from './y.js';` | *(empty line)* |
|
||||
/// | `export const X = …` | `const X = …` |
|
||||
/// | `export function f() {…}` | `function f() {…}` |
|
||||
/// | `export async function f() {…}` | `async function f() {…}` |
|
||||
/// | `export class C {…}` | `class C {…}` |
|
||||
/// | `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());
|
||||
// True while we are inside a multi-line import/export-list that has not yet
|
||||
// seen its terminating `;`.
|
||||
@@ -412,6 +491,66 @@ fn strip_esm_syntax(source: &str) -> String {
|
||||
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 … ──────────────────────────────────────────────────────────
|
||||
// Emit `const Y = X;` for any `import { X as Y }` aliases so that code
|
||||
// using the aliased name still resolves inside the IIFE scope.
|
||||
@@ -600,6 +739,7 @@ fn minify_tree_js(dir: &Path) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(src) = fs::read_to_string(&p) {
|
||||
println!("cargo:warning=minify-js: {}", p.display());
|
||||
let _ = fs::write(&p, js_minify_safe(&src));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"noImplicitAny": false,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"strictFunctionTypes": true,
|
||||
|
||||
"lib": ["ES2022", "DOM"],
|
||||
// Treat all JS files as modules
|
||||
"moduleDetection": "force",
|
||||
@@ -17,6 +20,6 @@
|
||||
"skipLibCheck": true,
|
||||
"target": "ESNext"
|
||||
},
|
||||
"include": ["static/js/**/*.js"],
|
||||
"exclude": ["static/js/vendors/**", "static/js/vendors/**/*.mjs"]
|
||||
"include": ["static/js/**/*.js" ],
|
||||
"exclude": ["static/js/vendors/**", "static/js/vendors/**/*.mjs", "static/js/vendors/**/*.js" ]
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ front-fmt:
|
||||
front-lint:
|
||||
biome lint static/
|
||||
|
||||
# test types (JSDOC), using typescript
|
||||
front-type:
|
||||
tsc -p jsconfig.json --noEmit
|
||||
|
||||
# check CSS rules
|
||||
front-rules:
|
||||
stylelint static/css/
|
||||
|
||||
@@ -155,13 +155,7 @@ impl FolderUseCase for FolderService {
|
||||
let folder = self
|
||||
.folder_storage
|
||||
.create_folder(dto.name, dto.parent_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to create folder: {}", e),
|
||||
)
|
||||
})?;
|
||||
.await?;
|
||||
|
||||
// Convert to DTO
|
||||
Ok(FolderDto::from(folder))
|
||||
|
||||
@@ -841,8 +841,8 @@ impl AppServiceFactory {
|
||||
tracing::warn!("╔══════════════════════════════════════════════════════════╗");
|
||||
tracing::warn!("║ SYSTEM NOT INITIALIZED — first admin setup required ║");
|
||||
tracing::warn!("║ ║");
|
||||
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!("║ Open the web UI to create the first admin account. ║");
|
||||
tracing::warn!("║ The setup page is available until an admin is created. ║");
|
||||
tracing::warn!("╚══════════════════════════════════════════════════════════╝");
|
||||
} else {
|
||||
tracing::info!("System already initialized — setup endpoint disabled");
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
@import url("./components/search.css");
|
||||
@import url("./components/icons.css");
|
||||
@import url("./components/csp-utilities.css");
|
||||
@import url("./components/pathTooltip.css");
|
||||
|
||||
/* Theme */
|
||||
@import url("./themes/dark.css");
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
<link rel="stylesheet" href="/css/views/photos.css">
|
||||
<link rel="stylesheet" href="/css/views/photosLightbox.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) -->
|
||||
<script defer type="module" src="/js/core/i18n.js"></script>
|
||||
|
||||
@@ -8,6 +8,14 @@ import { updateStorageUsageDisplay } from './main.js';
|
||||
import { app } from './state.js';
|
||||
import { ui } from './ui.js';
|
||||
|
||||
/**
|
||||
* @import {User} from '../core/types.js'
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<User | null>}
|
||||
*/
|
||||
async function refreshUserData() {
|
||||
const USER_DATA_KEY = 'oxicloud_user';
|
||||
|
||||
@@ -25,6 +33,7 @@ async function refreshUserData() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @type {User} */
|
||||
const userData = await response.json();
|
||||
console.log('Refreshed user data from server:', userData);
|
||||
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)
|
||||
console.log('Checking session via /api/auth/me...');
|
||||
|
||||
/** @type {User} */
|
||||
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
|
||||
if (userData.username) {
|
||||
// We have cached user data — render immediately, refresh in background
|
||||
|
||||
@@ -9,14 +9,14 @@ import { app } from './state.js';
|
||||
import { ui } from './ui.js';
|
||||
import { uiNotifications } from './uiNotifications.js';
|
||||
|
||||
/** @import {FileInfo, FolderInfo} from '../core/types.js' */
|
||||
/** @import {FileItem, FolderItem} from '../core/types.js' */
|
||||
|
||||
let isLoadingFiles = false;
|
||||
|
||||
/**
|
||||
* getFolder information
|
||||
* @param {string} id the id of the folder
|
||||
* @returns {Promise<FolderInfo>}
|
||||
* @returns {Promise<FolderItem>}
|
||||
*/
|
||||
async function getFolder(id) {
|
||||
/** @type {HeadersInit} */
|
||||
@@ -47,7 +47,7 @@ async function getFolder(id) {
|
||||
async function rebuildBreadCrumb() {
|
||||
/**
|
||||
* Store the leaf (this is the current displayed folder)
|
||||
* @type {FolderInfo | null}
|
||||
* @type {FolderItem | null}
|
||||
*/
|
||||
let currentFolderInfo = null;
|
||||
|
||||
@@ -172,7 +172,11 @@ async function loadFiles(options = { insertHistory: true }) {
|
||||
|
||||
if (forceRefresh) {
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -202,10 +206,10 @@ async function loadFiles(options = { insertHistory: true }) {
|
||||
multiSelect.init(); // this will wire buttons & select-all-checkbox
|
||||
}
|
||||
|
||||
/** @type {FolderInfo[]} */
|
||||
/** @type {FolderItem[]} */
|
||||
const folderList = Array.isArray(listing.folders) ? listing.folders : [];
|
||||
|
||||
/** @type {FileInfo[]} */
|
||||
/** @type {FileItem[]} */
|
||||
const fileList = Array.isArray(listing.files) ? listing.files : [];
|
||||
|
||||
if (folderList.length === 0 && fileList.length === 0) {
|
||||
|
||||
@@ -35,6 +35,10 @@ import { loadTrashItems } from './trashView.js';
|
||||
import { ui } from './ui.js';
|
||||
import { setupUserMenu } from './userMenu.js';
|
||||
|
||||
/**
|
||||
* @import {User} from '../core/types.js'
|
||||
*/
|
||||
|
||||
// Upload dropdown listener state (prevents accumulated listeners)
|
||||
/** @type {((e: MouseEvent) => void) | 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]
|
||||
* @returns
|
||||
*/
|
||||
@@ -494,6 +498,7 @@ function setupEventListeners() {
|
||||
ui.setupDragAndDrop();
|
||||
|
||||
// Debounce timer for live search
|
||||
/** @type {ReturnType<typeof setTimeout>} */
|
||||
let searchDebounceTimer = null;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
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
|
||||
* @param {Object} userData - The user data object
|
||||
* @param {User} userData - The user data object
|
||||
*/
|
||||
function updateStorageUsageDisplay(userData) {
|
||||
// Default values
|
||||
|
||||
@@ -136,12 +136,6 @@ export const SECTIONS_MAPPER = {
|
||||
*/
|
||||
function setCurrentSection(section) {
|
||||
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;
|
||||
|
||||
// Update nav item active classes by finding matching item from DOM
|
||||
|
||||
@@ -8,9 +8,14 @@ import { app } from './state.js';
|
||||
import { ui } from './ui.js';
|
||||
|
||||
/**
|
||||
* @param {string} query
|
||||
* @param {string} [sortBy]
|
||||
* @import {SearchCriteria, SortByEnnum} from '../core/types.js'
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} query
|
||||
* @param {SortByEnnum} [sortBy]
|
||||
*/
|
||||
// FIXME: refactor with search.js ?
|
||||
async function performSearch(query, sortBy) {
|
||||
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>`);
|
||||
|
||||
/** @type {SearchCriteria} */
|
||||
const options = {
|
||||
recursive: true,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
sort_by: sortBy || 'relevance'
|
||||
};
|
||||
|
||||
@@ -51,7 +58,8 @@ document.addEventListener('search-resort', (e) => {
|
||||
const event = /** @type {CustomEvent<{sort_by: string}>} */ (e);
|
||||
const searchInput = /** @type {HTMLInputElement} */ (document.querySelector('.search-container input'));
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -3,39 +3,70 @@
|
||||
* 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 = {
|
||||
currentView: 'grid',
|
||||
|
||||
/** @type {string | null} */
|
||||
currentPath: '',
|
||||
|
||||
/** @type {string | null} */
|
||||
currentFolder: null,
|
||||
|
||||
/** @type {FolderInfo | null} */
|
||||
/** @type {FolderItem | null} */
|
||||
currentFolderInfo: null,
|
||||
|
||||
/** @type {Object | null} */
|
||||
/** @type {FolderItem | null} */
|
||||
contextMenuTargetFolder: null,
|
||||
|
||||
/** @type {Object | null} */
|
||||
/** @type {FileItem | null} */
|
||||
contextMenuTargetFile: null,
|
||||
selectedTargetFolderId: '',
|
||||
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} */
|
||||
currentSection: null, // will be defined on first call
|
||||
isSearchMode: false,
|
||||
|
||||
/** @type {FileItem | FolderItem | null} */
|
||||
shareDialogItem: null,
|
||||
|
||||
/** @type {'file' | 'folder' | null} */
|
||||
shareDialogItemType: null,
|
||||
|
||||
/** @type {String | null} */
|
||||
notificationShareUrl: null,
|
||||
|
||||
/** @type {string | null} */
|
||||
userHomeFolderId: null,
|
||||
|
||||
/** @type {string | null} */
|
||||
userHomeFolderName: null,
|
||||
/** @type {Object[]} */
|
||||
|
||||
/** @type {Array<{id: string, name: string}>} */
|
||||
breadcrumbPath: [], // Array of {id, name} tracking folder navigation hierarchy
|
||||
|
||||
/** @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 = {
|
||||
|
||||
@@ -9,6 +9,11 @@ import { multiSelect } from '../features/files/multiSelect.js';
|
||||
import { appElements } from './state.js';
|
||||
import { ui } from './ui.js';
|
||||
|
||||
/**
|
||||
*
|
||||
* @import {TrashItem} from '../core/types.js'
|
||||
*/
|
||||
|
||||
async function loadTrashItems() {
|
||||
const elements = appElements;
|
||||
|
||||
@@ -25,7 +30,7 @@ async function loadTrashItems() {
|
||||
</div>
|
||||
`;
|
||||
|
||||
ui.updateBreadcrumb('');
|
||||
ui.updateBreadcrumb();
|
||||
|
||||
const trashItems = await fileOps.getTrashItems();
|
||||
|
||||
@@ -46,6 +51,10 @@ async function loadTrashItems() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {TrashItem} item
|
||||
*/
|
||||
function addTrashItemToView(item) {
|
||||
const elements = appElements;
|
||||
const isFile = item.item_type === 'file';
|
||||
|
||||
@@ -25,10 +25,17 @@ import { app } from './state.js';
|
||||
import { uiFileTypes } from './uiFileTypes.js';
|
||||
import { uiNotifications } from './uiNotifications.js';
|
||||
|
||||
/**
|
||||
* @import {FileItem, FolderItem} from '../core/types.js'
|
||||
* @import {BatchResult} from '../features/files/fileOperations.js'
|
||||
*/
|
||||
|
||||
// UI Module
|
||||
const ui = {
|
||||
/** @type {HTMLDListElement | null} */
|
||||
//dragPreview,
|
||||
/** @type {HTMLDivElement | null} */
|
||||
dragPreview: null,
|
||||
/** @type {HTMLDivElement | null} */
|
||||
draggedItems: null,
|
||||
|
||||
/**
|
||||
* Initialize context menus and dialogs
|
||||
@@ -338,21 +345,31 @@ const ui = {
|
||||
|
||||
const dropzone = document.getElementById('dropzone');
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {DataTransfer} dataTransfer
|
||||
* @returns {Promise<any[]|null>}
|
||||
*/
|
||||
const collectDroppedEntries = async (dataTransfer) => {
|
||||
const items = Array.from(dataTransfer?.items || []);
|
||||
const rootEntries = items.map((it) => (typeof it.webkitGetAsEntry === 'function' ? it.webkitGetAsEntry() : null)).filter(Boolean);
|
||||
|
||||
if (rootEntries.length === 0) return null;
|
||||
|
||||
/** @type {Array<{file: File, relativePath: string}>} */
|
||||
const out = [];
|
||||
|
||||
/**
|
||||
* @param {FileSystemEntry} entry
|
||||
* @param {string} prefix
|
||||
*/
|
||||
const walkEntry = async (entry, prefix = '') => {
|
||||
if (!entry) return;
|
||||
|
||||
if (entry.isFile) {
|
||||
await new Promise((resolve) => {
|
||||
entry.file(
|
||||
(file) => {
|
||||
/** @type {FileSystemFileEntry} */ (entry).file(
|
||||
(/** @type {File} */ file) => {
|
||||
out.push({ file, relativePath: `${prefix}${file.name}` });
|
||||
resolve(undefined);
|
||||
},
|
||||
@@ -364,7 +381,7 @@ const ui = {
|
||||
|
||||
if (entry.isDirectory) {
|
||||
const dirPrefix = `${prefix}${entry.name}/`;
|
||||
const reader = entry.createReader();
|
||||
const reader = /** @type {FileSystemDirectoryEntry} */ (entry).createReader();
|
||||
|
||||
while (true) {
|
||||
const children = await new Promise((resolve) => {
|
||||
@@ -636,7 +653,7 @@ const ui = {
|
||||
|
||||
/**
|
||||
* Check if a file can be previewed in the viewer
|
||||
* @param {Object} file - File object with mime_type property
|
||||
* @param {FileItem} file
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isViewableFile(file) {
|
||||
@@ -647,6 +664,7 @@ const ui = {
|
||||
* Get FontAwesome icon class for a filename based on its extension.
|
||||
* Used as fallback when the backend DTO doesn't include icon_class
|
||||
* (e.g. trash items).
|
||||
* @param {string} fileName
|
||||
*/
|
||||
getIconClass(fileName) {
|
||||
return uiFileTypes.getIconClass(fileName);
|
||||
@@ -655,6 +673,7 @@ const ui = {
|
||||
/**
|
||||
* Get CSS special class for icon styling based on filename extension.
|
||||
* Used as fallback when the backend DTO doesn't include icon_special_class.
|
||||
* @param {string} fileName
|
||||
*/
|
||||
getIconSpecialClass(fileName) {
|
||||
return uiFileTypes.getIconSpecialClass(fileName);
|
||||
@@ -695,13 +714,13 @@ const ui = {
|
||||
* 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(),
|
||||
|
||||
/** @type {Array<Object>} last rendered folder dataset */
|
||||
/** @type {FolderItem[]} last rendered folder dataset */
|
||||
_lastFolders: [],
|
||||
|
||||
/** @type {Array<Object>} last rendered file dataset */
|
||||
/** @type {FileItem[]} last rendered file dataset */
|
||||
_lastFiles: [],
|
||||
|
||||
/** @type {boolean} */
|
||||
@@ -716,9 +735,7 @@ const ui = {
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object[]} folders
|
||||
* @returns
|
||||
* @param {FolderItem[]} folders
|
||||
*/
|
||||
_renderFoldersToView(folders) {
|
||||
if (!Array.isArray(folders) || folders.length === 0) return;
|
||||
@@ -727,15 +744,17 @@ const ui = {
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
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);
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object[]} files
|
||||
* @returns
|
||||
* @param {FileItem[]} files
|
||||
*/
|
||||
_renderFilesToView(files) {
|
||||
if (!Array.isArray(files) || files.length === 0) return;
|
||||
@@ -744,11 +763,19 @@ const ui = {
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
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);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {any[]} arr
|
||||
* @param {any} item
|
||||
*/
|
||||
_upsertById(arr, item) {
|
||||
if (!Array.isArray(arr) || !item?.id) return;
|
||||
const idx = arr.findIndex((x) => x && x.id === item.id);
|
||||
@@ -796,6 +823,7 @@ const ui = {
|
||||
await fileOps.moveFile(sourceId, targetFolderId);
|
||||
*/
|
||||
|
||||
/** @type {BatchResult} */
|
||||
let result;
|
||||
switch (action) {
|
||||
case 'copy':
|
||||
@@ -843,6 +871,7 @@ const ui = {
|
||||
this._delegationReady = true;
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────
|
||||
/** @param {HTMLDivElement} card */
|
||||
const itemInfo = (card) => {
|
||||
if (!card) return null;
|
||||
const fileId = card.dataset.fileId;
|
||||
@@ -864,6 +893,7 @@ const ui = {
|
||||
return null;
|
||||
};
|
||||
|
||||
/** @param {FileItem} file */
|
||||
const openFile = async (file) => {
|
||||
if (!file) return;
|
||||
if (recent) {
|
||||
@@ -897,6 +927,7 @@ const ui = {
|
||||
}
|
||||
};
|
||||
|
||||
/** @param {HTMLElement} card */
|
||||
const navigateFolder = (card) => {
|
||||
const folderId = card.dataset.folderId;
|
||||
const folderName = card.dataset.folderName;
|
||||
@@ -912,27 +943,31 @@ const ui = {
|
||||
loadFiles();
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} card
|
||||
* @param {{ type: string, id: string, name: string | undefined, data: FolderItem | FileItem | undefined }} info
|
||||
*/
|
||||
const setContextTarget = (card, info) => {
|
||||
if (info.type === 'folder') {
|
||||
app.contextMenuTargetFolder = {
|
||||
app.contextMenuTargetFolder = /** @type {FolderItem} */ ({
|
||||
id: info.id,
|
||||
name: card.dataset.folderName,
|
||||
parent_id: card.dataset.parentId || ''
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const fileData = info.data || this._items.get(info.id);
|
||||
app.contextMenuTargetFile = {
|
||||
const fileData = /** @type {FileItem | undefined} */ (info.data || this._items.get(info.id));
|
||||
app.contextMenuTargetFile = /** @type {FileItem} */ ({
|
||||
id: info.id,
|
||||
name: card.dataset.fileName,
|
||||
folder_id: card.dataset.folderId || '',
|
||||
mime_type: fileData?.mime_type || null
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ── click (open / navigate; select only via checkbox) ──
|
||||
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 (/** @type {HTMLElement} */ (e.target).closest('.file-actions')) {
|
||||
@@ -975,7 +1010,7 @@ const ui = {
|
||||
if (info.type === 'folder') {
|
||||
navigateFolder(card);
|
||||
} else {
|
||||
openFile(info.data);
|
||||
openFile(/** @type {FileItem} */ (info.data));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -989,7 +1024,7 @@ const ui = {
|
||||
// ── shared events ──────────────────────
|
||||
|
||||
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;
|
||||
e.preventDefault();
|
||||
const info = itemInfo(card);
|
||||
@@ -1008,7 +1043,7 @@ const ui = {
|
||||
|
||||
// dragstart
|
||||
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) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
@@ -1088,9 +1123,11 @@ const ui = {
|
||||
// TODO better naming like ("selection in ${parent.name}") modulo i18n ? ...
|
||||
const now = new Date().toISOString().replace(/T/, ' ').replace(/\.*/, '').replaceAll(/:/g, '-');
|
||||
nameEncoded = `oxicloud ${now}.zip`;
|
||||
/** @type {string[]} */
|
||||
const folders = [];
|
||||
/** @type {string[]} */
|
||||
const files = [];
|
||||
filesList.querySelectorAll(`div.selected`).forEach((e) => {
|
||||
/** @type {NodeListOf<HTMLDivElement>} */ (filesList.querySelectorAll(`div.selected`)).forEach((e) => {
|
||||
const item = itemInfo(e);
|
||||
if (item.type === 'file') {
|
||||
files.push(item.id);
|
||||
@@ -1164,6 +1201,7 @@ const ui = {
|
||||
* Favorite star helper – attaches a direct click handler to a
|
||||
* star <button> so the event never bubbles to the card.
|
||||
* ================================================================ */
|
||||
/** @param {HTMLElement} el */
|
||||
_bindStarClick(el) {
|
||||
const star = el.querySelector('.favorite-star');
|
||||
star?.addEventListener('click', (e) => {
|
||||
@@ -1174,7 +1212,8 @@ const ui = {
|
||||
if (!favorites) return;
|
||||
|
||||
// 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 itemType = itemElement.dataset.fileId ? 'file' : 'folder';
|
||||
@@ -1187,7 +1226,7 @@ const ui = {
|
||||
favorites.removeFromFavorites(itemId, itemType);
|
||||
} else {
|
||||
this.setFavoriteVisualState(itemId, itemType, true);
|
||||
favorites.addToFavorites(itemId, itemName, itemType);
|
||||
favorites.addToFavorites(itemId, itemName, itemType, null);
|
||||
}
|
||||
|
||||
// Keep context-menu label in sync if available
|
||||
@@ -1201,26 +1240,30 @@ const ui = {
|
||||
e.preventDefault();
|
||||
|
||||
// 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 itemType = itemElement.dataset.fileId ? 'file' : 'folder';
|
||||
const itemName = itemElement.dataset.fileId ? itemElement.dataset.fileName : itemElement.dataset.folderName;
|
||||
|
||||
// TODO corrently dirty
|
||||
const item = {
|
||||
const item = /** @type {unknown} */ ({
|
||||
id: itemId,
|
||||
item_id: itemId,
|
||||
item_type: itemType,
|
||||
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.
|
||||
* @param {string} itemId
|
||||
* @param {string} itemType
|
||||
* @param {boolean} 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}"]`;
|
||||
@@ -1258,6 +1301,11 @@ const ui = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} itemId
|
||||
* @param {string} itemType
|
||||
* @param {boolean} isShared
|
||||
*/
|
||||
setSharedVisualState(itemId, itemType, 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}"]`;
|
||||
@@ -1273,7 +1321,10 @@ const ui = {
|
||||
* Element-creation helpers
|
||||
* ================================================================ */
|
||||
|
||||
/** Create a list row for a folder */
|
||||
/**
|
||||
* Create a list row for a folder
|
||||
* @param {FolderItem} folder
|
||||
*/
|
||||
_createFolderItem(folder) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'file-item';
|
||||
@@ -1314,7 +1365,10 @@ const ui = {
|
||||
return el;
|
||||
},
|
||||
|
||||
/** Create a grid card for a file */
|
||||
/**
|
||||
* Create a grid card for a file
|
||||
* @param {FileItem} file
|
||||
*/
|
||||
_createFileItem(file) {
|
||||
const iconClass = file.icon_class || this.getIconClass(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
|
||||
* using DocumentFragment for minimal reflows.
|
||||
*
|
||||
* @param {FolderInfo[]} folders
|
||||
* @param {FolderItem[]} folders
|
||||
*/
|
||||
renderFolders(folders) {
|
||||
if (!this._delegationReady) this.initDelegation();
|
||||
@@ -1442,6 +1496,7 @@ const ui = {
|
||||
/**
|
||||
* Render an array of files into both grid and list views
|
||||
* using DocumentFragment for minimal reflows.
|
||||
* @param {FileItem[]} files
|
||||
*/
|
||||
renderFiles(files) {
|
||||
if (!this._delegationReady) this.initDelegation();
|
||||
@@ -1461,7 +1516,7 @@ const ui = {
|
||||
|
||||
/**
|
||||
* Add a single folder to the active view.
|
||||
* @param {Object} folder - Folder object
|
||||
* @param {FolderItem} folder
|
||||
*/
|
||||
addFolderToView(folder) {
|
||||
if (!this._delegationReady) this.initDelegation();
|
||||
@@ -1472,6 +1527,7 @@ const ui = {
|
||||
return;
|
||||
}
|
||||
|
||||
this._clearEmptyState();
|
||||
this._items.set(folder.id, folder);
|
||||
this._upsertById(this._lastFolders, folder);
|
||||
this._renderFoldersToView([folder]);
|
||||
@@ -1479,7 +1535,7 @@ const ui = {
|
||||
|
||||
/**
|
||||
* Add a single file to the active view.
|
||||
* @param {Object} file - File object
|
||||
* @param {FileItem} file
|
||||
*/
|
||||
addFileToView(file) {
|
||||
if (!this._delegationReady) this.initDelegation();
|
||||
@@ -1490,9 +1546,23 @@ const ui = {
|
||||
return;
|
||||
}
|
||||
|
||||
this._clearEmptyState();
|
||||
this._items.set(file.id, file);
|
||||
this._upsertById(this._lastFiles, 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.
|
||||
* Routes through the multiSelect module so batch actions know about selected items.
|
||||
* @param {HTMLDivElement} card
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
function toggleCardSelection(card, event) {
|
||||
if (multiSelect) {
|
||||
@@ -1512,6 +1584,8 @@ function toggleCardSelection(card, event) {
|
||||
|
||||
/**
|
||||
* Show the context menu anchored next to a trigger element (the 3-dot button).
|
||||
* @param {HTMLElement} triggerElement
|
||||
* @param {string} menuId
|
||||
*/
|
||||
function showContextMenuAtElement(triggerElement, menuId) {
|
||||
// Hide any open menus first
|
||||
@@ -1542,8 +1616,6 @@ function showContextMenuAtElement(triggerElement, menuId) {
|
||||
menu.classList.remove('hidden');
|
||||
}
|
||||
|
||||
let __rubberBandJustFinished = false;
|
||||
|
||||
/**
|
||||
* Rubber band (lasso) selection — click + drag on empty grid area
|
||||
* to draw a rectangle and select all cards it touches.
|
||||
@@ -1567,16 +1639,18 @@ function initRubberBandSelection() {
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener('mousedown', (e) => {
|
||||
if (!(e instanceof MouseEvent)) return;
|
||||
// Only start if clicking empty area (not on a card, button, menu, input…)
|
||||
if (e.button !== 0) return; // left click only
|
||||
const target = /** @type {Element} */ (e.target);
|
||||
if (
|
||||
e.target.closest('.file-item') ||
|
||||
e.target.closest('.context-menu') ||
|
||||
e.target.closest('.upload-dropdown') ||
|
||||
e.target.closest('button') ||
|
||||
e.target.closest('input') ||
|
||||
e.target.closest('.breadcrumb') ||
|
||||
e.target.closest('.list-header')
|
||||
target.closest('.file-item') ||
|
||||
target.closest('.context-menu') ||
|
||||
target.closest('.upload-dropdown') ||
|
||||
target.closest('button') ||
|
||||
target.closest('input') ||
|
||||
target.closest('.breadcrumb') ||
|
||||
target.closest('.list-header')
|
||||
)
|
||||
return;
|
||||
|
||||
@@ -1627,14 +1701,14 @@ function initRubberBandSelection() {
|
||||
|
||||
// Sync with multiSelect module
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
card.classList.remove('selected');
|
||||
// Deselect from multiSelect module
|
||||
if (multiSelect) {
|
||||
const info = multiSelect._extractInfo(card);
|
||||
const info = multiSelect._extractInfo(/** @type {HTMLDivElement} */ (card));
|
||||
if (info) multiSelect.deselect(info.id);
|
||||
}
|
||||
}
|
||||
@@ -1651,10 +1725,7 @@ function initRubberBandSelection() {
|
||||
// Suppress the click event that follows mouseup so the global
|
||||
// deselect handler doesn't immediately clear the selection.
|
||||
if (hadSelection) {
|
||||
__rubberBandJustFinished = true;
|
||||
requestAnimationFrame(() => {
|
||||
__rubberBandJustFinished = false;
|
||||
});
|
||||
requestAnimationFrame(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1709,6 +1780,7 @@ function showConfirmDialog({ title, message, confirmText, cancelText, danger = t
|
||||
overlay.classList.add('active');
|
||||
});
|
||||
|
||||
/** @param {boolean} result */
|
||||
const cleanup = (result) => {
|
||||
overlay.classList.remove('active');
|
||||
setTimeout(() => overlay.remove(), 200);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { isTextViewable } from '../core/formatters.js';
|
||||
|
||||
/** @import {FileInfo} from '../core/types.js' */
|
||||
/** @import {FileItem} from '../core/types.js' */
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const ICON_CLASS_MAP = {
|
||||
@@ -158,7 +158,7 @@ const uiFileTypes = {
|
||||
// TODO: 'd better to use a canViw() method in inlineViewer
|
||||
/**
|
||||
*
|
||||
* @param {FileInfo} file
|
||||
* @param {FileItem} file
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isViewableFile(file) {
|
||||
|
||||
@@ -18,6 +18,10 @@ function getCsrfToken() {
|
||||
return match ? match.split('=')[1] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* returns headers to add, this includes the X-CSRF-Token
|
||||
* @returns {Record<String, String>}
|
||||
*/
|
||||
function getCsrfHeaders() {
|
||||
const token = getCsrfToken();
|
||||
return token ? { 'X-CSRF-Token': token } : {};
|
||||
|
||||
@@ -37,6 +37,7 @@ const WRAPPER_USER_DATA_KEY = 'oxicloud_user';
|
||||
let _originalFetch = window.fetch.bind(window);
|
||||
|
||||
/** Deduplicates concurrent refresh attempts into a single in-flight promise. */
|
||||
/** @type {Promise<boolean> | null} */
|
||||
let _refreshInFlight = null;
|
||||
|
||||
async function _refresh() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* OxiCloud - Shared format and escaping utilities
|
||||
* 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);
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
@@ -37,7 +37,7 @@ function resolveBrowserLocale() {
|
||||
let currentLocale = resolveBrowserLocale();
|
||||
|
||||
// Cache for translations
|
||||
/** @type {Record<string, Object>} */
|
||||
/** @type {Record<string, any>} */
|
||||
const translations = {};
|
||||
|
||||
/**
|
||||
@@ -71,7 +71,7 @@ async function loadTranslations(locale) {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @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
|
||||
* @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
|
||||
*/
|
||||
function interpolate(text, params) {
|
||||
@@ -244,11 +244,19 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
// 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.
|
||||
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];
|
||||
if (!localeData) {
|
||||
// Translations not loaded yet — return humanised key suffix
|
||||
return key.split('.').pop() || key;
|
||||
// Translations not loaded yet — return fallback or humanised key suffix
|
||||
return fallback ?? key.split('.').pop() ?? key;
|
||||
}
|
||||
|
||||
let value = getNestedValue(localeData, key);
|
||||
@@ -258,7 +266,7 @@ function safeT(key, params = {}) {
|
||||
value = getNestedValue(translations.en, key);
|
||||
}
|
||||
|
||||
if (!value) return key;
|
||||
if (!value) return fallback ?? key;
|
||||
return interpolate(value, params);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
// All icons use viewBox="0 0 {width} 512" and fill="currentColor".
|
||||
// Keys use FA5 class names (without "fa-" prefix) for backward compatibility.
|
||||
|
||||
/** @type {Record<String, Array<number | String>>} */
|
||||
const OxiIcons = {
|
||||
'arrow-left': [
|
||||
448,
|
||||
@@ -503,7 +504,7 @@ function replaceIconsInElement(container) {
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('fill', 'currentColor');
|
||||
path.setAttribute('d', d);
|
||||
path.setAttribute('d', /** @type {string} */ (d));
|
||||
svg.appendChild(path);
|
||||
|
||||
el.replaceWith(svg);
|
||||
|
||||
@@ -34,6 +34,10 @@ function getAvailableLanguages() {
|
||||
const rtlLanguages = ['fa', 'ar'];
|
||||
|
||||
// Update HTML lang attribute and dir for RTL languages
|
||||
/**
|
||||
*
|
||||
* @param {string} langCode
|
||||
*/
|
||||
function updateHtmlAttributes(langCode) {
|
||||
const htmlElement = document.documentElement;
|
||||
|
||||
@@ -150,6 +154,7 @@ function createLanguageSelector(containerId = 'language-selector') {
|
||||
|
||||
/**
|
||||
* Toggle dropdown open/closed
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
function toggleDropdown(container) {
|
||||
const isOpen = container.classList.contains('open');
|
||||
@@ -162,6 +167,7 @@ function toggleDropdown(container) {
|
||||
|
||||
/**
|
||||
* Open dropdown
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
function openDropdown(container) {
|
||||
container.classList.add('open');
|
||||
@@ -173,6 +179,7 @@ function openDropdown(container) {
|
||||
|
||||
/**
|
||||
* Close dropdown
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
function closeDropdown(container) {
|
||||
container.classList.remove('open');
|
||||
@@ -184,6 +191,8 @@ function closeDropdown(container) {
|
||||
|
||||
/**
|
||||
* Select a language
|
||||
* @param {String} langCode
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
async function selectLanguage(langCode, container) {
|
||||
await i18n.setLocale(langCode);
|
||||
@@ -197,6 +206,8 @@ async function selectLanguage(langCode, container) {
|
||||
|
||||
/**
|
||||
* Update the UI to reflect selected language
|
||||
* @param {String} langCode
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
function updateSelectedLanguage(langCode, container) {
|
||||
const languages = getAvailableLanguages();
|
||||
@@ -213,7 +224,7 @@ function updateSelectedLanguage(langCode, container) {
|
||||
options.forEach((option) => {
|
||||
const isActive = option.getAttribute('data-lang') === langCode;
|
||||
option.classList.toggle('active', isActive);
|
||||
option.setAttribute('aria-selected', isActive);
|
||||
option.setAttribute('aria-selected', String(isActive));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,25 +10,25 @@ const Modal = {
|
||||
// Modal element references
|
||||
/** @private @type {HTMLElement | null} */
|
||||
overlay: null,
|
||||
// FIXME: unused ?
|
||||
container: null,
|
||||
/** @private @type {HTMLElement | null} */
|
||||
icon: null,
|
||||
/** @private @type {HTMLElement | null} */
|
||||
title: null,
|
||||
/** @private @type {HTMLElement | null} */
|
||||
label: null,
|
||||
/** @private @type {HTMLElement | null} */
|
||||
/** @private @type {HTMLInputElement | null} */
|
||||
input: null,
|
||||
/** @private @type {HTMLElement | null} */
|
||||
/** @private @type {HTMLButtonElement | null} */
|
||||
cancelBtn: null,
|
||||
/** @private @type {HTMLElement | null} */
|
||||
/** @private @type {HTMLButtonElement | null} */
|
||||
confirmBtn: null,
|
||||
/** @private @type {HTMLElement | null} */
|
||||
/** @private @type {HTMLButtonElement | null} */
|
||||
closeBtn: null,
|
||||
|
||||
// Current callback
|
||||
/** @private @type {Function | null} */
|
||||
onConfirm: null,
|
||||
/** @private @type {Function | null} */
|
||||
onCancel: null,
|
||||
|
||||
/** @private @type {((value: string) => Promise<void>) | null} */
|
||||
@@ -53,10 +53,10 @@ const Modal = {
|
||||
this.icon = document.getElementById('modal-icon');
|
||||
this.title = document.getElementById('modal-title');
|
||||
this.label = document.getElementById('modal-label');
|
||||
this.input = document.getElementById('modal-input');
|
||||
this.cancelBtn = document.getElementById('modal-cancel-btn');
|
||||
this.confirmBtn = document.getElementById('modal-confirm-btn');
|
||||
this.closeBtn = document.getElementById('modal-close-btn');
|
||||
this.input = /** @type {HTMLInputElement} */ (document.getElementById('modal-input'));
|
||||
this.cancelBtn = /** @type {HTMLButtonElement} */ (document.getElementById('modal-cancel-btn'));
|
||||
this.confirmBtn = /** @type {HTMLButtonElement} */ (document.getElementById('modal-confirm-btn'));
|
||||
this.closeBtn = /** @type {HTMLButtonElement} */ (document.getElementById('modal-close-btn'));
|
||||
|
||||
// Event listeners
|
||||
this.errorEl = document.getElementById('modal-error');
|
||||
@@ -106,13 +106,13 @@ const Modal = {
|
||||
/**
|
||||
* Show input modal (replacement for prompt())
|
||||
* @param {Object} options - Modal configuration
|
||||
* @param {string} options.title - Modal title
|
||||
* @param {string} options.label - Input label
|
||||
* @param {string} options.placeholder - Input placeholder
|
||||
* @param {string} options.value - Initial input value
|
||||
* @param {string} options.icon - Font Awesome icon class (e.g., 'fa-folder-plus')
|
||||
* @param {string} options.confirmText - Confirm button text
|
||||
* @param {string} options.cancelText - Cancel button text
|
||||
* @param {string} [options.title] - Modal title
|
||||
* @param {string} [options.label] - Input label
|
||||
* @param {string} [options.placeholder] - Input placeholder
|
||||
* @param {string} [options.value] - Initial input value
|
||||
* @param {string} [options.icon] - Font Awesome icon class (e.g., 'fa-folder-plus')
|
||||
* @param {string} [options.confirmText] - Confirm button text
|
||||
* @param {string} [options.cancelText] - Cancel button text
|
||||
* @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.
|
||||
* When omitted the modal resolves immediately with the input value (legacy behaviour).
|
||||
@@ -218,6 +218,8 @@ const Modal = {
|
||||
open() {
|
||||
if (!this.overlay) return;
|
||||
|
||||
this.confirmBtn.disabled = false;
|
||||
|
||||
// Show overlay
|
||||
this.overlay.classList.remove('hidden');
|
||||
|
||||
@@ -292,7 +294,7 @@ const Modal = {
|
||||
if (this.onConfirm) this.onConfirm();
|
||||
this.close(true);
|
||||
} catch (e) {
|
||||
this.showError(e.message || 'An error occurred');
|
||||
this.showError(/** @type {Error} */ (e).message || 'An error occurred');
|
||||
this.confirmBtn.disabled = false;
|
||||
this.input.focus();
|
||||
}
|
||||
|
||||
@@ -15,13 +15,28 @@ import { i18n } from './i18n.js';
|
||||
* 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 = (() => {
|
||||
/* ── state ──────────────────────────────────────────────── */
|
||||
let _badgeCount = 0;
|
||||
let _batchSeq = 0;
|
||||
const _batches = {}; // batchId → { el, files:{}, totalFiles }
|
||||
|
||||
/** @type {Record<String,BatchNotification>} */
|
||||
const _batches = {};
|
||||
|
||||
/* ── DOM refs (resolved lazily) ─────────────────────────── */
|
||||
|
||||
/** @type {(id: string) => HTMLElement | null } */
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
/* ── bell toggle ────────────────────────────────────────── */
|
||||
@@ -237,6 +252,8 @@ const notifications = (() => {
|
||||
/**
|
||||
* Mark a file as completed within a batch (updates overall bar).
|
||||
* DOM updates are throttled to every 5 files to avoid reflow starvation.
|
||||
* @param {string} batchId
|
||||
* @param {boolean} success
|
||||
*/
|
||||
function fileCompleted(batchId, success) {
|
||||
const batch = _batches[batchId];
|
||||
@@ -262,6 +279,9 @@ const notifications = (() => {
|
||||
|
||||
/**
|
||||
* Finalise a batch – update icon and title.
|
||||
* @param {string} batchId
|
||||
* @param {number} successCount
|
||||
* @param {number} totalFiles
|
||||
*/
|
||||
function finishBatch(batchId, successCount, totalFiles) {
|
||||
const batch = _batches[batchId];
|
||||
@@ -320,6 +340,10 @@ const notifications = (() => {
|
||||
}
|
||||
|
||||
/* ── util ───────────────────────────────────────────────── */
|
||||
// FIXME move to global library
|
||||
/**
|
||||
* @param {string} s
|
||||
*/
|
||||
function _esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
/**
|
||||
* @typedef {Object} FolderInfo
|
||||
* @property {string} category
|
||||
* @typedef {'file' | 'folder'} ItemTypeEnum
|
||||
*/
|
||||
|
||||
// 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 {string} icon_class
|
||||
* @property {string} icon_special_class
|
||||
@@ -13,8 +27,9 @@
|
||||
* @property {string} path the full path
|
||||
*/
|
||||
|
||||
//FIXME: rename into FileItem
|
||||
/**
|
||||
* @typedef {Object} FileInfo
|
||||
* @typedef {Object} FileItem
|
||||
* @property {string} category
|
||||
* @property {number} created_at - timestamp
|
||||
* @property {string} icon_class
|
||||
@@ -39,7 +54,7 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Share
|
||||
* @typedef {Object} ShareItem
|
||||
* @property {number} access_count
|
||||
* @property {number} created_at - timestamp
|
||||
* @property {String} created_by
|
||||
@@ -48,8 +63,193 @@
|
||||
* @property {string} id
|
||||
* @property {string} item_id
|
||||
* @property {string} item_name
|
||||
* @property {string} item_type
|
||||
* @property {ItemTypeEnum} item_type
|
||||
* @property {SharePermissions} permissions
|
||||
* @property {string | null} token
|
||||
* @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
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
|
||||
/**
|
||||
* @import {AuthResponse, RoleEnum, User} from '../../core/types.js'
|
||||
*/
|
||||
|
||||
// API endpoints
|
||||
const API_URL = '/api/auth';
|
||||
const LOGIN_ENDPOINT = `${API_URL}/login`;
|
||||
@@ -41,6 +45,18 @@ function inputVal(id) {
|
||||
}
|
||||
|
||||
// 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 = {
|
||||
en: {
|
||||
title: 'Welcome!',
|
||||
@@ -145,6 +161,16 @@ const LANGUAGE_TEXTS = {
|
||||
|
||||
// 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
|
||||
/**
|
||||
* @typedef {Object} Lang
|
||||
* @property {string} code
|
||||
* @property {string} name
|
||||
* @property {string} nativeName
|
||||
* @property {string} flag
|
||||
* @property {boolean} popular
|
||||
*/
|
||||
|
||||
/** @type {Lang[]} */
|
||||
export const ALL_LANGUAGES = [
|
||||
{
|
||||
code: 'en',
|
||||
@@ -377,9 +403,18 @@ export const ALL_LANGUAGES = [
|
||||
// --- Panel visibility helpers ---
|
||||
// The `.hidden` CSS class uses `display: none !important`, so inline
|
||||
// `style.display` can never override it. Always toggle the class instead.
|
||||
/**
|
||||
*
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
function showPanel(el) {
|
||||
if (el) el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
function hidePanel(el) {
|
||||
if (el) el.classList.add('hidden');
|
||||
}
|
||||
@@ -434,13 +469,18 @@ function detectBrowserLanguage() {
|
||||
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) {
|
||||
const item = document.createElement('div');
|
||||
item.className = `lang-picker-item${isSelected ? ' selected' : ''}`;
|
||||
item.setAttribute('data-lang', lang.code);
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('aria-selected', isSelected);
|
||||
item.setAttribute('aria-selected', String(isSelected));
|
||||
item.innerHTML = `
|
||||
<span class="lang-picker-item-flag">${lang.flag}</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) {
|
||||
const texts = LANGUAGE_TEXTS[lang] || LANGUAGE_TEXTS.en;
|
||||
const titleEl = document.getElementById('language-title');
|
||||
@@ -1089,6 +1132,9 @@ if (isLoginPage && adminSetupForm) {
|
||||
|
||||
/**
|
||||
* Login with username and password
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
* @returns {Promise<AuthResponse>}
|
||||
*/
|
||||
async function login(username, password) {
|
||||
try {
|
||||
@@ -1126,8 +1172,9 @@ async function login(username, password) {
|
||||
|
||||
// Parse the JSON response
|
||||
try {
|
||||
/** @type {AuthResponse} */
|
||||
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;
|
||||
} catch (jsonError) {
|
||||
console.error('Error parsing login response:', jsonError);
|
||||
@@ -1141,6 +1188,11 @@ async function login(username, password) {
|
||||
|
||||
/**
|
||||
* 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') {
|
||||
try {
|
||||
@@ -1170,8 +1222,9 @@ async function register(username, email, password, role = 'user') {
|
||||
|
||||
// Parse the JSON response
|
||||
try {
|
||||
/** @type {User} */
|
||||
const data = await response.json();
|
||||
console.log('Registration successful, received data');
|
||||
console.log(`Registration successful, user created: ${data.id}, received data`);
|
||||
return data;
|
||||
} catch (jsonError) {
|
||||
console.error('Error parsing registration response:', jsonError);
|
||||
|
||||
@@ -20,10 +20,19 @@ import { inlineViewer } from './inlineViewer.js';
|
||||
import { multiSelect } from './multiSelect.js';
|
||||
import { wopiEditor } from './wopiEditor.js';
|
||||
|
||||
/**
|
||||
* @import {FolderItem, FileItem, ItemTypeEnum, Playlist} from '../../core/types.js'
|
||||
*/
|
||||
|
||||
/** @type {EventListener | null} */
|
||||
let _moveDialogEscapeHandler = null;
|
||||
|
||||
// Context Menus Module
|
||||
const contextMenus = {
|
||||
/**
|
||||
* @param {string} optionId
|
||||
* @param {boolean} isFavorite
|
||||
*/
|
||||
_setFavoriteOptionLabel(optionId, isFavorite) {
|
||||
const option = document.getElementById(optionId);
|
||||
if (!option) return;
|
||||
@@ -308,11 +317,13 @@ const contextMenus = {
|
||||
// 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
|
||||
if (!_moveDialogEscapeHandler) {
|
||||
_moveDialogEscapeHandler = (e) => {
|
||||
if (e.key === 'Escape' && !moveFileDialog?.classList.contains('hidden')) {
|
||||
this.closeMoveDialog();
|
||||
_moveDialogEscapeHandler = /** @type {EventListener} */ (
|
||||
(/** @type {KeyboardEvent} */ e) => {
|
||||
if (e.key === 'Escape' && !moveFileDialog?.classList.contains('hidden')) {
|
||||
this.closeMoveDialog();
|
||||
}
|
||||
}
|
||||
};
|
||||
);
|
||||
document.addEventListener('keydown', _moveDialogEscapeHandler);
|
||||
}
|
||||
|
||||
@@ -385,8 +396,8 @@ const contextMenus = {
|
||||
|
||||
/**
|
||||
* Show move dialog for a file or folder
|
||||
* @param {Object} item - File or folder object
|
||||
* @param {string} mode - 'file' or 'folder'
|
||||
* @param {FolderItem | FileItem} item - File or folder object
|
||||
* @param {ItemTypeEnum} mode
|
||||
*/
|
||||
async showMoveDialog(item, 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)
|
||||
let startFolderId = null;
|
||||
let startFolderName = null;
|
||||
if (mode === 'file' && item.folder_id) {
|
||||
startFolderId = item.folder_id;
|
||||
if (mode === 'file' && /** @type {FileItem} */ (item).folder_id) {
|
||||
startFolderId = /** @type {FileItem} */ (item).folder_id;
|
||||
// We need the folder name for breadcrumb - try to get it from current view
|
||||
const folderEl = document.querySelector(`[data-folder-id="${startFolderId}"]`);
|
||||
if (folderEl) {
|
||||
startFolderName = folderEl.querySelector('.folder-name, .item-name')?.textContent || null;
|
||||
}
|
||||
} else if (mode === 'folder' && item.parent_id) {
|
||||
startFolderId = item.parent_id;
|
||||
} else if (mode === 'folder' && /** @type {FolderItem} */ (item).parent_id) {
|
||||
startFolderId = /** @type {FolderItem} */ (item).parent_id;
|
||||
} else {
|
||||
// If item is at root level, start at user's home folder
|
||||
startFolderId = app.userHomeFolderId || null;
|
||||
@@ -492,6 +503,7 @@ const contextMenus = {
|
||||
|
||||
// The contents endpoint returns an array of child folders
|
||||
// The fallback /api/folders returns root folders (home folder itself)
|
||||
/** @type {FolderItem[]} */
|
||||
const folders = Array.isArray(data) ? data : data.folders || [];
|
||||
console.log('[Move Dialog] Loaded folders:', folders.length, 'folders:', folders);
|
||||
|
||||
@@ -623,6 +635,9 @@ const contextMenus = {
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (!container) return;
|
||||
@@ -666,7 +681,7 @@ const contextMenus = {
|
||||
}
|
||||
|
||||
// Breadcrumb path
|
||||
breadcrumb.forEach((segment, index) => {
|
||||
breadcrumb.forEach((/** @type {{id: string, name: string}} */ segment, /** @type {number} */ index) => {
|
||||
const separator = document.createElement('span');
|
||||
separator.className = 'move-breadcrumb-separator';
|
||||
separator.textContent = '>';
|
||||
@@ -709,8 +724,8 @@ const contextMenus = {
|
||||
|
||||
/**
|
||||
* Show share dialog for files or folders
|
||||
* @param {Object} item - File or folder object
|
||||
* @param {string} itemType - 'file' or 'folder'
|
||||
* @param {FileItem | FolderItem} item - File or folder object
|
||||
* @param {ItemTypeEnum} itemType
|
||||
*/
|
||||
async showShareDialog(item, itemType) {
|
||||
try {
|
||||
@@ -835,7 +850,7 @@ const contextMenus = {
|
||||
btn.closest('.existing-share-item').remove();
|
||||
if (existingSharesContainer.children.length === 0) {
|
||||
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
|
||||
ui.setSharedVisualState(item.id, item.type, true);
|
||||
ui.setSharedVisualState(item.id, itemType, true);
|
||||
|
||||
// Show success message
|
||||
ui.showNotification(i18n.t('notifications.link_created'), i18n.t('notifications.share_success'));
|
||||
@@ -994,8 +1009,14 @@ const contextMenus = {
|
||||
app.notificationShareUrl = null;
|
||||
},
|
||||
|
||||
/** @type {String | null} */
|
||||
_selectedPlaylistId: null,
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {FileItem} file
|
||||
* @returns
|
||||
*/
|
||||
async showPlaylistDialog(file) {
|
||||
const dialog = document.getElementById('playlist-dialog');
|
||||
const container = document.getElementById('playlist-select-container');
|
||||
@@ -1031,6 +1052,7 @@ const contextMenus = {
|
||||
const resp = await fetch('/api/playlists', { credentials: 'include' });
|
||||
if (!resp.ok) throw new Error('Failed to load playlists');
|
||||
|
||||
/** @type {Playlist[]} */
|
||||
const playlists = await resp.json();
|
||||
this._renderPlaylistSelect(container, playlists);
|
||||
} catch (err) {
|
||||
@@ -1039,6 +1061,12 @@ const contextMenus = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLElement} container
|
||||
* @param {Playlist[]} playlists
|
||||
* @returns
|
||||
*/
|
||||
_renderPlaylistSelect(container, playlists) {
|
||||
container.innerHTML = '';
|
||||
|
||||
@@ -1124,6 +1152,12 @@ const contextMenus = {
|
||||
this._selectedPlaylistId = null;
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} str
|
||||
* @returns
|
||||
*/
|
||||
//FIXME: move to common library
|
||||
_escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
@@ -11,10 +11,18 @@ import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js';
|
||||
import { i18n } from '../../core/i18n.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.
|
||||
* Tokens are now in HttpOnly cookies — no explicit Authorization header needed.
|
||||
* @returns {Object} Headers object
|
||||
* @returns {Record<String, String>} Headers object
|
||||
*/
|
||||
function getAuthHeaders() {
|
||||
return { ...getCsrfHeaders() };
|
||||
@@ -25,15 +33,26 @@ const fileOps = {
|
||||
// ========================================================================
|
||||
// Upload progress — notification bell integration
|
||||
// ========================================================================
|
||||
/** @type {string | null} */
|
||||
_currentBatchId: null,
|
||||
|
||||
/** @type {boolean} */
|
||||
_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) {
|
||||
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) {
|
||||
if (this._currentBatchId) {
|
||||
notifications.finishBatch(this._currentBatchId, successCount, totalFiles);
|
||||
@@ -44,6 +63,8 @@ const fileOps = {
|
||||
* Some drag-and-drop sources can inject directory placeholders into
|
||||
* DataTransfer.files. Browsers fail those with net::ERR_ACCESS_DENIED
|
||||
* when trying to send them as normal files.
|
||||
* @param {File} file
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
_canReadFileBlob(file) {
|
||||
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.
|
||||
* Progress is reported to the notification bell via batchId + fileName.
|
||||
* 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) {
|
||||
return new Promise((resolve) => {
|
||||
@@ -76,9 +111,16 @@ const fileOps = {
|
||||
let lastProgressPctSent = -1;
|
||||
|
||||
let isSettled = false;
|
||||
/** @type {ReturnType<typeof setTimeout>} */
|
||||
let stallTimer = null;
|
||||
/** @type {ReturnType<typeof setTimeout>} */
|
||||
let hardTimer = null;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} pct
|
||||
* @param {'uploading' | 'done' | 'error'} status
|
||||
*/
|
||||
const safeUpdateFile = (pct, status) => {
|
||||
if (!notif || !batchId) return;
|
||||
try {
|
||||
@@ -88,6 +130,11 @@ const fileOps = {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {UploadAnswer} result
|
||||
* @returns
|
||||
*/
|
||||
const finalize = (result) => {
|
||||
if (isSettled) return;
|
||||
isSettled = true;
|
||||
@@ -219,6 +266,12 @@ const fileOps = {
|
||||
* Used by folder uploads to avoid browser XHR edge-cases with dragged entries.
|
||||
* Returns { ok, data?, errorMsg?, isQuotaError?, isTimeout? }.
|
||||
*/
|
||||
/**
|
||||
*
|
||||
* @param {*} formData
|
||||
* @param {*} timeoutMs
|
||||
* @returns {Promise<UploadAnswer>}
|
||||
*/
|
||||
async _uploadFileFetch(formData, timeoutMs = 60000) {
|
||||
const controller = new AbortController();
|
||||
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;
|
||||
return { ok: false, errorMsg, isQuotaError };
|
||||
} catch (e) {
|
||||
const isTimeout = e?.name === 'AbortError';
|
||||
const isTimeout = /** @type {Error} */ (e)?.name === 'AbortError';
|
||||
return {
|
||||
ok: false,
|
||||
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 {
|
||||
clearTimeout(timeoutId);
|
||||
@@ -458,6 +513,7 @@ const fileOps = {
|
||||
|
||||
try {
|
||||
// Filter unreadable entries
|
||||
/** @type {Array<{file: File, relativePath: string}>} */
|
||||
const validEntries = [];
|
||||
for (const e of rawEntries) {
|
||||
// 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_MS_ZERO = 3000; // 3s for 0-byte files
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} idx
|
||||
* @returns
|
||||
*/
|
||||
const uploadOneFile = async (idx) => {
|
||||
if (quotaStop) return;
|
||||
const entry = validEntries[idx];
|
||||
const file = entry.file;
|
||||
const rel = entry.relativePath || file.name;
|
||||
|
||||
/** @type {UploadAnswer} */
|
||||
let result = { ok: false, errorMsg: 'Unknown client error' };
|
||||
try {
|
||||
const parts = rel.split('/');
|
||||
@@ -577,6 +639,7 @@ const fileOps = {
|
||||
// but block on open(). Pre-read only 0-byte files into
|
||||
// memory; files with size>0 are always regular files and
|
||||
// go straight to FormData (zero extra memory copy).
|
||||
/** @type {Blob} */
|
||||
let uploadFile = file; // default: use original File
|
||||
if (file.size === 0) {
|
||||
try {
|
||||
@@ -616,7 +679,7 @@ const fileOps = {
|
||||
} catch (e) {
|
||||
result = {
|
||||
ok: false,
|
||||
errorMsg: `Client exception: ${e?.message || 'unknown'}`
|
||||
errorMsg: `Client exception: ${/** @type {Error} */ (e)?.message || 'unknown'}`
|
||||
};
|
||||
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
|
||||
* @param {string[]} fileIds - File IDs
|
||||
@@ -941,18 +998,12 @@ const fileOps = {
|
||||
return res.ok;
|
||||
},
|
||||
|
||||
/**
|
||||
* @typedef {Object} BatchCopyReturn
|
||||
* @property {number} success
|
||||
* @property {number} errors
|
||||
*/
|
||||
|
||||
/**
|
||||
* Copy files & folders
|
||||
* @param {string[]} fileIds - File IDs
|
||||
* @param {string[]} folderIds - Folder IDs
|
||||
* @param {string} targetFolderId - Target folder ID
|
||||
* @returns {Promise<BatchCopyReturn>} - Success status
|
||||
* @returns {Promise<BatchResult>} - Success status
|
||||
*/
|
||||
async batchCopy(fileIds, folderIds, targetFolderId) {
|
||||
// FIXME ensure not moving a folder into itself
|
||||
@@ -1010,7 +1061,6 @@ const fileOps = {
|
||||
* Rename a file
|
||||
* @param {string} fileId - File ID
|
||||
* @param {string} newName - New file name
|
||||
* @returns {Promise<string|null>} - null on success, error message string on failure
|
||||
*/
|
||||
async renameFile(fileId, newName) {
|
||||
try {
|
||||
@@ -1052,13 +1102,6 @@ const fileOps = {
|
||||
* Rename a folder
|
||||
* @param {string} folderId - Folder ID
|
||||
* @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) {
|
||||
try {
|
||||
@@ -1170,7 +1213,7 @@ const fileOps = {
|
||||
// If we're inside the folder we just deleted, go back up
|
||||
if (app.currentPath === folderId) {
|
||||
app.currentPath = '';
|
||||
ui.updateBreadcrumb('');
|
||||
ui.updateBreadcrumb();
|
||||
}
|
||||
loadFiles();
|
||||
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 (app.currentPath === folderId) {
|
||||
app.currentPath = '';
|
||||
ui.updateBreadcrumb('');
|
||||
ui.updateBreadcrumb();
|
||||
}
|
||||
loadFiles();
|
||||
ui.showNotification('Folder deleted', `"${folderName}" deleted successfully`);
|
||||
@@ -1205,7 +1248,7 @@ const fileOps = {
|
||||
|
||||
/**
|
||||
* Get trash items
|
||||
* @returns {Promise<Array>} - List of trash items
|
||||
* @returns {Promise<Array<TrashItem>>} - List of trash items
|
||||
*/
|
||||
async getTrashItems() {
|
||||
try {
|
||||
@@ -1214,7 +1257,7 @@ const fileOps = {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
return /** @type {TrashItem[]} */ (await response.json());
|
||||
} else {
|
||||
console.error('Error fetching trash items:', response.statusText);
|
||||
return [];
|
||||
|
||||
@@ -8,6 +8,8 @@ import { app } from '../../app/state.js';
|
||||
import { isTextViewable } from '../../core/formatters.js';
|
||||
import { wopiEditor } from './wopiEditor.js';
|
||||
|
||||
/** @import {FileItem} from '../../core/types.js' */
|
||||
|
||||
class InlineViewer {
|
||||
constructor() {
|
||||
this.setupViewer();
|
||||
@@ -93,6 +95,11 @@ class InlineViewer {
|
||||
console.log('Inline viewer initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {FileItem} file
|
||||
* @returns
|
||||
*/
|
||||
async openFile(file) {
|
||||
console.log('Opening file:', file);
|
||||
|
||||
@@ -115,7 +122,7 @@ class InlineViewer {
|
||||
|
||||
// Get container
|
||||
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');
|
||||
|
||||
// Clear container
|
||||
@@ -125,12 +132,12 @@ class InlineViewer {
|
||||
title.textContent = file.name;
|
||||
|
||||
// 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
|
||||
if (isImage) {
|
||||
// Show zoom controls
|
||||
controls.style.display = 'flex';
|
||||
controls.classList.remove('hidden');
|
||||
|
||||
// Show loading indicator
|
||||
const loader = document.createElement('div');
|
||||
@@ -142,7 +149,7 @@ class InlineViewer {
|
||||
this.createBlobUrlViewer(file, 'image', container, loader);
|
||||
} else if (file.mime_type && file.mime_type === 'application/pdf') {
|
||||
// Hide zoom controls for PDFs
|
||||
controls.style.display = 'none';
|
||||
controls.classList.add('hidden');
|
||||
|
||||
// Show loading indicator
|
||||
const loader = document.createElement('div');
|
||||
@@ -152,9 +159,9 @@ class InlineViewer {
|
||||
|
||||
// Create PDF viewer using object tag with blob URL
|
||||
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
|
||||
controls.style.display = 'none';
|
||||
controls.classList.add('hidden');
|
||||
|
||||
// Show loading indicator
|
||||
const loader = document.createElement('div');
|
||||
@@ -166,7 +173,7 @@ class InlineViewer {
|
||||
this.createTextViewer(file, container, loader);
|
||||
} else if (file.mime_type?.startsWith('audio/')) {
|
||||
// Hide zoom controls for audio
|
||||
controls.style.display = 'none';
|
||||
controls.classList.add('hidden');
|
||||
|
||||
// Show loading indicator
|
||||
const loader = document.createElement('div');
|
||||
@@ -178,7 +185,7 @@ class InlineViewer {
|
||||
this.createMediaViewer(file, 'audio', container, loader);
|
||||
} else if (file.mime_type?.startsWith('video/')) {
|
||||
// Hide zoom controls for video
|
||||
controls.style.display = 'none';
|
||||
controls.classList.add('hidden');
|
||||
|
||||
// Show loading indicator
|
||||
const loader = document.createElement('div');
|
||||
@@ -190,7 +197,7 @@ class InlineViewer {
|
||||
this.createMediaViewer(file, 'video', container, loader);
|
||||
} else {
|
||||
// Hide zoom controls for unsupported files
|
||||
controls.style.display = 'none';
|
||||
controls.classList.add('hidden');
|
||||
|
||||
// Show unsupported file message
|
||||
const message = document.createElement('div');
|
||||
@@ -209,12 +216,13 @@ class InlineViewer {
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
// Check if a MIME type is text-viewable
|
||||
isTextViewable(mimeType) {
|
||||
return isTextViewable(mimeType);
|
||||
}
|
||||
|
||||
// Creates a text viewer using authenticated fetch
|
||||
/**
|
||||
*
|
||||
* @param {FileItem} file
|
||||
* @param {HTMLDivElement} container
|
||||
* @param {*} loader
|
||||
*/
|
||||
async createTextViewer(file, container, loader) {
|
||||
try {
|
||||
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 {
|
||||
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
|
||||
let progressBar = null;
|
||||
let progressText = null;
|
||||
let progressBar = /** @type {HTMLElement|null} */ (null);
|
||||
let progressText = /** @type {HTMLElement|null} */ (null);
|
||||
if (loader && file.size > 10 * 1024 * 1024) {
|
||||
// Show progress for files > 10MB
|
||||
loader.innerHTML = `
|
||||
@@ -272,8 +286,8 @@ class InlineViewer {
|
||||
<div class="inline-viewer-progress-text">0%</div>
|
||||
</div>
|
||||
`;
|
||||
progressBar = loader.querySelector('.inline-viewer-progress-fill');
|
||||
progressText = loader.querySelector('.inline-viewer-progress-text');
|
||||
progressBar = /** @type {HTMLElement|null} */ (loader.querySelector('.inline-viewer-progress-fill'));
|
||||
progressText = /** @type {HTMLElement|null} */ (loader.querySelector('.inline-viewer-progress-text'));
|
||||
}
|
||||
|
||||
// Use XMLHttpRequest instead of fetch to get better control over the response
|
||||
@@ -322,7 +336,7 @@ class InlineViewer {
|
||||
loader.parentNode.removeChild(loader);
|
||||
}
|
||||
|
||||
if (type === 'image') {
|
||||
if (mediaType === 'image') {
|
||||
console.log('Creating image viewer');
|
||||
// Create image element
|
||||
const img = document.createElement('img');
|
||||
@@ -332,10 +346,10 @@ class InlineViewer {
|
||||
container.appendChild(img);
|
||||
|
||||
// Add loading indicator until image loads
|
||||
img.style.opacity = 0;
|
||||
img.style.opacity = String(0);
|
||||
img.onload = () => {
|
||||
console.log('Image loaded successfully');
|
||||
img.style.opacity = 1;
|
||||
img.style.opacity = String(1);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
@@ -343,7 +357,7 @@ class InlineViewer {
|
||||
container.removeChild(img);
|
||||
this.showErrorMessage(container);
|
||||
};
|
||||
} else if (type === 'pdf') {
|
||||
} else if (mediaType === 'pdf') {
|
||||
console.log('Creating PDF viewer');
|
||||
|
||||
// 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) {
|
||||
try {
|
||||
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) {
|
||||
// Show error message
|
||||
const message = document.createElement('div');
|
||||
@@ -505,7 +528,7 @@ class InlineViewer {
|
||||
const modal = document.getElementById('inline-viewer-modal');
|
||||
|
||||
// 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();
|
||||
|
||||
// Hide modal
|
||||
@@ -525,6 +548,10 @@ class InlineViewer {
|
||||
this.currentFile = null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {FileItem} file
|
||||
*/
|
||||
downloadFile(file) {
|
||||
fetch(`/api/files/${file.id}`, { credentials: 'same-origin' })
|
||||
.then((res) => {
|
||||
@@ -544,9 +571,14 @@ class InlineViewer {
|
||||
.catch((err) => console.error('Download error:', err));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} factor
|
||||
* @returns
|
||||
*/
|
||||
zoomImage(factor) {
|
||||
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;
|
||||
|
||||
@@ -560,7 +592,7 @@ class InlineViewer {
|
||||
scale = Math.max(0.1, Math.min(5.0, scale));
|
||||
|
||||
// Save scale
|
||||
img.dataset.scale = scale;
|
||||
img.dataset.scale = String(scale);
|
||||
|
||||
// Apply scale
|
||||
img.style.transform = `scale(${scale})`;
|
||||
@@ -568,12 +600,12 @@ class InlineViewer {
|
||||
|
||||
resetZoom() {
|
||||
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;
|
||||
|
||||
// Reset scale
|
||||
img.dataset.scale = 1.0;
|
||||
img.dataset.scale = String(1);
|
||||
img.style.transform = 'scale(1.0)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
// TODO: rename into selection-bar ?
|
||||
// TODO: merge with photo part
|
||||
|
||||
// @ts-check
|
||||
|
||||
import { loadFiles } from '../../app/filesView.js';
|
||||
import { app } from '../../app/state.js';
|
||||
import { showConfirmDialog, ui } from '../../app/ui.js';
|
||||
@@ -19,8 +17,14 @@ import { favorites } from '../library/favorites.js';
|
||||
import { contextMenus } from './contextMenus.js';
|
||||
import { getAuthHeaders } from './fileOperations.js';
|
||||
|
||||
/**
|
||||
* @import {ItemTypeEnum, LightItem} from '../../core/types.js'
|
||||
* @import {BatchResult} from './fileOperations.js'
|
||||
*/
|
||||
|
||||
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(),
|
||||
|
||||
/** Last clicked index for Shift-range selection */
|
||||
@@ -49,6 +53,12 @@ const multiSelect = {
|
||||
|
||||
// ── Helpers for i18n ────────────────────────────────────
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {any} vars
|
||||
* @returns
|
||||
*/
|
||||
_t(key, vars) {
|
||||
const val = i18n.t(key, vars);
|
||||
return val !== key ? val : null;
|
||||
@@ -56,6 +66,14 @@ const multiSelect = {
|
||||
|
||||
// ── Selection state management ──────────────────────────
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {string} name
|
||||
* @param {ItemTypeEnum} type
|
||||
* @param {string} parentId
|
||||
* @returns
|
||||
*/
|
||||
toggle(id, name, type, parentId) {
|
||||
if (this._selected.has(id)) {
|
||||
this._selected.delete(id);
|
||||
@@ -65,10 +83,22 @@ const multiSelect = {
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {string} name
|
||||
* @param {ItemTypeEnum} type
|
||||
* @param {string} parentId
|
||||
* @returns
|
||||
*/
|
||||
select(id, name, type, parentId) {
|
||||
this._selected.set(id, { id, name, type, parentId });
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
*/
|
||||
deselect(id) {
|
||||
this._selected.delete(id);
|
||||
},
|
||||
@@ -80,7 +110,7 @@ const multiSelect = {
|
||||
el.classList.remove('selected');
|
||||
});
|
||||
document.querySelectorAll('.item-checkbox').forEach((cb) => {
|
||||
cb.checked = false;
|
||||
/** @type {HTMLInputElement} */ (cb).checked = false;
|
||||
});
|
||||
this._syncUI();
|
||||
},
|
||||
@@ -111,11 +141,13 @@ const multiSelect = {
|
||||
* @return {ItemSelection}
|
||||
*/
|
||||
getSelection(targtFolderId) {
|
||||
/** @type {Array<string>} */
|
||||
const fileIds = [];
|
||||
/** @type {Array<string>} */
|
||||
const folderIds = [];
|
||||
|
||||
// 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) {
|
||||
fileIds.push(item.dataset.fileId);
|
||||
} else {
|
||||
@@ -152,6 +184,10 @@ const multiSelect = {
|
||||
|
||||
// ── DOM helpers ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLDivElement} el
|
||||
*/
|
||||
_selectElement(el) {
|
||||
const info = this._extractInfo(el);
|
||||
if (info) {
|
||||
@@ -160,18 +196,32 @@ const multiSelect = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} containerId
|
||||
* @param {string} selector
|
||||
* @returns {void}
|
||||
*/
|
||||
_selectAllInContainer(containerId, selector) {
|
||||
const container = document.getElementById(containerId);
|
||||
const container = /** @type {HTMLDivElement} */ (document.getElementById(containerId));
|
||||
if (!container) return;
|
||||
container.querySelectorAll(selector).forEach((el) => {
|
||||
/** @type {NodeListOf<HTMLDivElement>} */ (container.querySelectorAll(selector)).forEach((el) => {
|
||||
this._selectElement(el);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {HTMLDivElement[]}
|
||||
*/
|
||||
_getAllVisibleItems() {
|
||||
return [...document.querySelectorAll('.file-item')];
|
||||
return /** @type {HTMLDivElement[]} */ ([...document.querySelectorAll('.file-item')]);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {HTMLDivElement} el
|
||||
* @returns {LightItem}
|
||||
*/
|
||||
_extractInfo(el) {
|
||||
if (el.dataset.folderId && el.dataset.folderName !== undefined) {
|
||||
return {
|
||||
@@ -194,6 +244,10 @@ const multiSelect = {
|
||||
|
||||
// ── Click handler (shared by grid + list) ───────────────
|
||||
|
||||
/**
|
||||
* @param {HTMLDivElement} el
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
handleToggleItem(el, event) {
|
||||
const items = this._getAllVisibleItems();
|
||||
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}"]`;
|
||||
document.querySelectorAll(sel).forEach((e) => {
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -218,7 +272,7 @@ const multiSelect = {
|
||||
} else {
|
||||
const nowSelected = this.toggle(info.id, info.name, info.type, info.parentId);
|
||||
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;
|
||||
}
|
||||
this._lastClickedIndex = index;
|
||||
@@ -271,13 +325,13 @@ const multiSelect = {
|
||||
|
||||
_syncItemCheckboxes() {
|
||||
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');
|
||||
});
|
||||
},
|
||||
|
||||
_syncSelectAllCheckbox() {
|
||||
const cb = document.getElementById('select-all-checkbox');
|
||||
const cb = /** @type {HTMLInputElement} */ (document.getElementById('select-all-checkbox'));
|
||||
if (!cb) return;
|
||||
const all = this._getAllVisibleItems();
|
||||
if (all.length === 0) {
|
||||
@@ -454,9 +508,10 @@ const multiSelect = {
|
||||
|
||||
// Keyboard shortcuts
|
||||
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
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||
if (selectAllCheckbox) selectAllCheckbox.checked = true;
|
||||
|
||||
@@ -12,6 +12,10 @@ import { app } from '../../app/state.js';
|
||||
import { ui } from '../../app/ui.js';
|
||||
import { getAuthHeaders } from './fileOperations.js';
|
||||
|
||||
/**
|
||||
* @import {SearchCriteria, SearchResults} from '../../core/types.js'}
|
||||
*/
|
||||
|
||||
const search = {
|
||||
/**
|
||||
* Perform a search using query parameters.
|
||||
@@ -19,25 +23,29 @@ const search = {
|
||||
* relevance_score, icon_class, category, size_formatted, etc.
|
||||
*
|
||||
* @param {string} query - Search query
|
||||
* @param {Object} options - Additional search options
|
||||
* @returns {Promise<Object>} - Enriched search results from backend
|
||||
* @param {SearchCriteria} [options] - Additional search options
|
||||
* @returns {Promise<SearchResults>} - Enriched search results from backend
|
||||
*/
|
||||
async searchFiles(query, options = {}) {
|
||||
async searchFiles(query, options) {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('query', query);
|
||||
|
||||
if (options.folder_id) params.append('folder_id', options.folder_id);
|
||||
if (options.recursive !== undefined) params.append('recursive', options.recursive);
|
||||
if (options.file_types) params.append('type', options.file_types);
|
||||
if (options.min_size) params.append('min_size', options.min_size);
|
||||
if (options.max_size) params.append('max_size', options.max_size);
|
||||
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.modified_before) params.append('modified_before', options.modified_before);
|
||||
if (options.limit) params.append('limit', options.limit);
|
||||
if (options.offset) params.append('offset', options.offset);
|
||||
if (options.recursive !== undefined) params.append('recursive', String(options.recursive));
|
||||
if (options.file_types) {
|
||||
options.file_types.forEach((file_type) => {
|
||||
params.append('type', file_type);
|
||||
});
|
||||
}
|
||||
if (options.min_size) params.append('min_size', String(options.min_size));
|
||||
if (options.max_size) params.append('max_size', String(options.max_size));
|
||||
if (options.created_after) params.append('created_after', String(options.created_after));
|
||||
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);
|
||||
|
||||
const url = `/api/search?${params.toString()}`;
|
||||
@@ -46,6 +54,7 @@ const search = {
|
||||
const response = await fetch(url, { headers: getAuthHeaders() });
|
||||
|
||||
if (response.ok) {
|
||||
/** @type {SearchResults} */
|
||||
return await response.json();
|
||||
} else {
|
||||
let errorText = '';
|
||||
@@ -66,7 +75,10 @@ const search = {
|
||||
folders: [],
|
||||
total_count: 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.
|
||||
*
|
||||
* @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 }
|
||||
*/
|
||||
async getSuggestions(query, options = {}) {
|
||||
async getSuggestions(query, options) {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('query', query);
|
||||
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 response = await fetch(url, { headers: getAuthHeaders() });
|
||||
@@ -111,7 +123,7 @@ const search = {
|
||||
* - query_time_ms: Server-side query execution time
|
||||
* - sort_by: Active sort order
|
||||
*
|
||||
* @param {Object} results - Enriched search results from backend
|
||||
* @param {SearchResults} results - Enriched search results from backend
|
||||
*/
|
||||
displaySearchResults(results) {
|
||||
ui.resetFilesList(); // ensure also list visible & error hidden
|
||||
|
||||
@@ -19,6 +19,7 @@ class WopiEditor {
|
||||
/**
|
||||
* Check if a file can be opened in a WOPI editor by extension.
|
||||
* Fetches supported extensions from the server (cached after first call).
|
||||
* @param {string} filename
|
||||
*/
|
||||
async canEdit(filename) {
|
||||
const ext = filename.split('.').pop().toLowerCase();
|
||||
@@ -28,6 +29,9 @@ class WopiEditor {
|
||||
|
||||
/**
|
||||
* Open file in a modal overlay (default mode).
|
||||
* @param {string} fileId
|
||||
* @param {string} fileName
|
||||
* @param {string} [action]
|
||||
*/
|
||||
async openInModal(fileId, fileName, action) {
|
||||
action = action || 'edit';
|
||||
@@ -38,6 +42,9 @@ class WopiEditor {
|
||||
|
||||
/**
|
||||
* Open file in a new browser tab.
|
||||
* @param {string} fileId
|
||||
* @param {string} fileName
|
||||
* @param {string} [action]
|
||||
*/
|
||||
async openInTab(fileId, fileName, action) {
|
||||
action = action || 'edit';
|
||||
@@ -53,6 +60,8 @@ class WopiEditor {
|
||||
|
||||
/**
|
||||
* Fetch editor URL and WOPI token from the backend.
|
||||
* @param {string} fileId
|
||||
* @param {string} action
|
||||
*/
|
||||
async _getEditorUrl(fileId, 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.
|
||||
* 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) {
|
||||
try {
|
||||
@@ -81,6 +93,11 @@ class WopiEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fileName
|
||||
* @param {string} action
|
||||
* @param {any} error
|
||||
*/
|
||||
_shouldRetryInViewMode(fileName, action, error) {
|
||||
if (action !== 'edit' || !error || !error.message) {
|
||||
return false;
|
||||
@@ -92,6 +109,8 @@ class WopiEditor {
|
||||
|
||||
/**
|
||||
* Show the editor in a full-screen modal with iframe.
|
||||
* @param {Record<string, any>} editorData
|
||||
* @param {string} fileName
|
||||
*/
|
||||
_showModal(editorData, fileName) {
|
||||
this.closeEditor();
|
||||
@@ -160,13 +179,13 @@ class WopiEditor {
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// ESC key handler
|
||||
this._escHandler = function (e) {
|
||||
this._escHandler = (/** @type {KeyboardEvent} */ e) => {
|
||||
if (e.key === 'Escape') this.closeEditor();
|
||||
}.bind(this);
|
||||
};
|
||||
document.addEventListener('keydown', this._escHandler);
|
||||
|
||||
// Fix 7: Listen for postMessage from the editor iframe
|
||||
this._messageHandler = function (e) {
|
||||
this._messageHandler = (/** @type {MessageEvent} */ e) => {
|
||||
var data;
|
||||
try {
|
||||
data = JSON.parse(e.data);
|
||||
@@ -183,7 +202,7 @@ class WopiEditor {
|
||||
if (sp) sp.remove();
|
||||
}
|
||||
}
|
||||
}.bind(this);
|
||||
};
|
||||
window.addEventListener('message', this._messageHandler);
|
||||
|
||||
form.submit();
|
||||
|
||||
@@ -12,8 +12,10 @@ import { i18n } from '../../core/i18n.js';
|
||||
import { multiSelect } from '../files/multiSelect.js';
|
||||
import * as pathTooltip from '../pathTooltip.js';
|
||||
|
||||
/** @import {FavoriteItem, FileItem, FolderItem} from '../../core/types.js' */
|
||||
|
||||
const favorites = {
|
||||
/** @type {Map<string, object>} key = "file:<id>" | "folder:<id>" */
|
||||
/** @type {Map<string, FavoriteItem>} key = "file:<id>" | "folder:<id>" */
|
||||
_cache: new Map(),
|
||||
|
||||
/** Whether the initial fetch from the server has completed */
|
||||
@@ -25,6 +27,10 @@ const favorites = {
|
||||
return { ...getCsrfHeaders() };
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {string} type
|
||||
*/
|
||||
_cacheKey(id, type) {
|
||||
return `${type}:${id}`;
|
||||
},
|
||||
@@ -33,6 +39,7 @@ const favorites = {
|
||||
* Replace the entire in-memory cache from an array of FavoriteItemDto
|
||||
* objects (as returned by the batch endpoint). Avoids an extra
|
||||
* GET /api/favorites round-trip.
|
||||
* @param {any[]} items
|
||||
*/
|
||||
_replaceCacheFromResponse(items) {
|
||||
this._cache.clear();
|
||||
@@ -68,6 +75,7 @@ const favorites = {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {FavoriteItem[]} */
|
||||
const items = await response.json();
|
||||
this._cache.clear();
|
||||
for (const item of items) {
|
||||
@@ -85,6 +93,8 @@ const favorites = {
|
||||
|
||||
/**
|
||||
* Synchronous check used by ui.js to paint star icons.
|
||||
* @param {string} id
|
||||
* @param {string} type
|
||||
*/
|
||||
isFavorite(id, type) {
|
||||
return this._cache.has(this._cacheKey(id, type));
|
||||
@@ -92,6 +102,10 @@ const favorites = {
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
try {
|
||||
@@ -121,6 +135,8 @@ const favorites = {
|
||||
|
||||
/**
|
||||
* Remove an item from favourites (server-first).
|
||||
* @param {string} id
|
||||
* @param {string} type
|
||||
*/
|
||||
async removeFromFavorites(id, type) {
|
||||
try {
|
||||
@@ -178,31 +194,51 @@ const favorites = {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {FolderItem[]} */
|
||||
const folders = [];
|
||||
|
||||
/** @type {FileItem[]} */
|
||||
const files = [];
|
||||
|
||||
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') {
|
||||
folders.push({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
parent_id: item.parent_id || '',
|
||||
modified_at: item.modified_at || item.created_at,
|
||||
path: item.item_path || ''
|
||||
});
|
||||
folders.push(
|
||||
// FIXME: better to grab the real values
|
||||
/** @type {FolderItem} */ {
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
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 {
|
||||
files.push({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
folder_id: item.parent_id || '',
|
||||
mime_type: item.item_mime_type,
|
||||
icon_class: item.icon_class,
|
||||
icon_special_class: item.icon_special_class,
|
||||
category: item.category,
|
||||
size: item.item_size || 0,
|
||||
size_formatted: item.size_formatted,
|
||||
modified_at: item.modified_at || item.created_at,
|
||||
path: item.item_path || ''
|
||||
});
|
||||
files.push(
|
||||
// FIXME: better to grab the real values
|
||||
/** @type {FileItem} */ {
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
folder_id: item.parent_id || '',
|
||||
mime_type: item.item_mime_type,
|
||||
icon_class: item.icon_class,
|
||||
icon_special_class: item.icon_special_class,
|
||||
category: item.category,
|
||||
size: item.item_size || 0,
|
||||
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);
|
||||
|
||||
@@ -6,17 +6,27 @@ import { oxiIcon } from '../../core/icons.js';
|
||||
import { Modal } from '../../core/modal.js';
|
||||
import { notifications } from '../../core/notifications.js';
|
||||
|
||||
/** @import {FileItem, Musicshare, Playlist, PlaylistItem} from '../../core/types.js' */
|
||||
|
||||
/**
|
||||
* OxiCloud - Music Library View
|
||||
* Playlist management with track listings and audio player
|
||||
*/
|
||||
|
||||
const musicView = {
|
||||
/** @type {Playlist[]} */
|
||||
playlists: [],
|
||||
|
||||
/** @type {Playlist | null} */
|
||||
currentPlaylist: null,
|
||||
|
||||
/** @type {PlaylistItem[]} */
|
||||
currentTracks: [],
|
||||
loading: false,
|
||||
|
||||
/** @type {HTMLDivElement | null} */
|
||||
_container: null,
|
||||
|
||||
_initialized: false,
|
||||
selected: new Set(),
|
||||
|
||||
@@ -72,11 +82,11 @@ const musicView = {
|
||||
|
||||
if (!resp.ok) throw new Error('Failed to load playlists');
|
||||
|
||||
this.playlists = await resp.json();
|
||||
this.playlists = /** @type {Playlist[]} */ (await resp.json());
|
||||
this._renderPlaylists();
|
||||
} catch (err) {
|
||||
console.error('Music load error:', err);
|
||||
this._showError(err.message);
|
||||
this._showError(/** @type {Error} */ (err).message);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this._showLoading(false);
|
||||
@@ -209,7 +219,7 @@ const musicView = {
|
||||
)
|
||||
.join('');
|
||||
|
||||
listEl.querySelectorAll('.music-playlist-item').forEach((item) => {
|
||||
/** @type {NodeListOf<HTMLDivElement>} */ (listEl.querySelectorAll('.music-playlist-item')).forEach((item) => {
|
||||
item.addEventListener('click', () => {
|
||||
const id = item.dataset.id;
|
||||
this._selectPlaylist(id);
|
||||
@@ -269,6 +279,11 @@ const musicView = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} playlistId
|
||||
* @returns
|
||||
*/
|
||||
async _selectPlaylist(playlistId) {
|
||||
const playlist = this.playlists.find((p) => p.id === playlistId);
|
||||
if (!playlist) return;
|
||||
@@ -308,13 +323,18 @@ const musicView = {
|
||||
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);
|
||||
});
|
||||
|
||||
await this._loadPlaylistTracks(playlistId);
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} playlistId
|
||||
* @returns
|
||||
*/
|
||||
async _loadPlaylistTracks(playlistId) {
|
||||
const trackListEl = document.getElementById('music-track-list');
|
||||
if (!trackListEl) return;
|
||||
@@ -333,7 +353,7 @@ const musicView = {
|
||||
this._renderTracks();
|
||||
} catch (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('')}
|
||||
`;
|
||||
trackListEl.querySelectorAll('.music-track').forEach((row) => {
|
||||
/** @type {NodeListOf<HTMLDivElement>} */ (trackListEl.querySelectorAll('.music-track')).forEach((row) => {
|
||||
row.addEventListener('click', () => {
|
||||
const idx = parseInt(row.dataset.idx, 10);
|
||||
// Toggle selection
|
||||
@@ -449,6 +469,11 @@ const musicView = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} idx
|
||||
* @returns
|
||||
*/
|
||||
_playTrack(idx) {
|
||||
if (!this.currentTracks[idx]) return;
|
||||
|
||||
@@ -487,8 +512,12 @@ const musicView = {
|
||||
this._createPlaylist(name.trim());
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {String} 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;
|
||||
try {
|
||||
const resp = await fetch('/api/playlists', {
|
||||
@@ -519,7 +548,7 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: 'error',
|
||||
title: i18n.t('music.error'),
|
||||
text: err.message
|
||||
text: /** @type {Error} */ (err).message
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
@@ -542,7 +571,7 @@ const musicView = {
|
||||
});
|
||||
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;
|
||||
try {
|
||||
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}`, {
|
||||
@@ -568,7 +597,7 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: 'error',
|
||||
title: i18n.t('music.error'),
|
||||
text: err.message
|
||||
text: /** @type {Error} */ (err).message
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
@@ -576,6 +605,11 @@ const musicView = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number|null} secs
|
||||
* @returns
|
||||
*/
|
||||
_formatDuration(secs) {
|
||||
if (!secs) return '-';
|
||||
const mins = Math.floor(secs / 60);
|
||||
@@ -583,11 +617,20 @@ const musicView = {
|
||||
return `${mins}:${s.toString().padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string|null} str
|
||||
* @returns
|
||||
*/
|
||||
_escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {boolean} show
|
||||
*/
|
||||
_showLoading(show) {
|
||||
const existing = this._container?.querySelector('.music-loading');
|
||||
if (show && !existing) {
|
||||
@@ -600,6 +643,11 @@ const musicView = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} message
|
||||
* @returns
|
||||
*/
|
||||
_showError(message) {
|
||||
if (!this._container) return;
|
||||
this._container.innerHTML = `
|
||||
@@ -647,7 +695,7 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: 'error',
|
||||
title: i18n.t('music.error'),
|
||||
text: err.message
|
||||
text: /** @type {Error} */ (err).message
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -690,7 +738,7 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: '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'));
|
||||
|
||||
const listEl = document.getElementById('music-picker-list');
|
||||
const queryInput = document.getElementById('music-picker-query');
|
||||
const addBtn = document.getElementById('music-picker-add-btn');
|
||||
const queryInput = /** @type {HTMLInputElement} */ (document.getElementById('music-picker-query'));
|
||||
const addBtn = /** @type {HTMLButtonElement} */ (document.getElementById('music-picker-add-btn'));
|
||||
const countEl = document.getElementById('music-picker-count');
|
||||
const selectedIds = new Set();
|
||||
|
||||
@@ -765,6 +813,11 @@ const musicView = {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {FileItem[]} files
|
||||
* @returns
|
||||
*/
|
||||
const renderFiles = (files) => {
|
||||
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>`;
|
||||
@@ -798,6 +851,7 @@ const musicView = {
|
||||
};
|
||||
|
||||
// ── Debounced search ──
|
||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||
let searchTimer = null;
|
||||
queryInput.addEventListener('input', () => {
|
||||
clearTimeout(searchTimer);
|
||||
@@ -857,6 +911,12 @@ const musicView = {
|
||||
fetchAudioFiles();
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} _trackId
|
||||
* @param {string} fileId
|
||||
* @returns
|
||||
*/
|
||||
async _removeTrackFromPlaylist(_trackId, fileId) {
|
||||
if (!this.currentPlaylist) return;
|
||||
|
||||
@@ -892,12 +952,18 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: '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) {
|
||||
if (!this.currentPlaylist) return;
|
||||
|
||||
@@ -923,7 +989,7 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: 'error',
|
||||
title: i18n.t('music.error'),
|
||||
text: err.message
|
||||
text: /** @type {Error} */ (err).message
|
||||
});
|
||||
}
|
||||
await this._loadPlaylistTracks(this.currentPlaylist.id);
|
||||
@@ -967,8 +1033,8 @@ const musicView = {
|
||||
});
|
||||
|
||||
dialog.querySelector('#music-share-add-btn').addEventListener('click', async () => {
|
||||
const userInput = dialog.querySelector('#music-share-user-input');
|
||||
const writeInput = dialog.querySelector('#music-share-write-input');
|
||||
const userInput = /** @type {HTMLInputElement} */ (dialog.querySelector('#music-share-user-input'));
|
||||
const writeInput = /** @type {HTMLInputElement} */ (dialog.querySelector('#music-share-write-input'));
|
||||
const userId = userInput.value.trim();
|
||||
if (!userId) return;
|
||||
|
||||
@@ -997,7 +1063,7 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: 'error',
|
||||
title: i18n.t('music.error'),
|
||||
text: err.message
|
||||
text: /** @type {Error} */ (err).message
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1006,6 +1072,11 @@ const musicView = {
|
||||
this._loadSharesList(dialog);
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLDivElement} dialog
|
||||
* @returns
|
||||
*/
|
||||
async _loadSharesList(dialog) {
|
||||
if (!this.currentPlaylist) return;
|
||||
const body = dialog.querySelector('.music-shares-body');
|
||||
@@ -1019,6 +1090,7 @@ const musicView = {
|
||||
headers: this._headers()
|
||||
});
|
||||
if (!resp.ok) throw new Error('Failed to load shares');
|
||||
/** @type {Musicshare[]} */
|
||||
const shares = await resp.json();
|
||||
|
||||
if (shares.length === 0) {
|
||||
@@ -1040,16 +1112,22 @@ const musicView = {
|
||||
|
||||
body.querySelectorAll('.music-share-remove-btn').forEach((btn) => {
|
||||
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;
|
||||
await this._removeShare(userId, dialog);
|
||||
});
|
||||
});
|
||||
} 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) {
|
||||
if (!this.currentPlaylist) return;
|
||||
|
||||
@@ -1067,7 +1145,7 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: 'error',
|
||||
title: i18n.t('music.error'),
|
||||
text: err.message
|
||||
text: /** @type {Error} */ (err).message
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1115,7 +1193,7 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: 'error',
|
||||
title: i18n.t('music.error'),
|
||||
text: err.message
|
||||
text: /** @type {Error} */ (err).message
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1183,7 +1261,7 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: '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
|
||||
*/
|
||||
const musicPlayer = {
|
||||
/** @type {HTMLAudioElement | null} */
|
||||
audio: null,
|
||||
|
||||
/** @type {PlaylistItem[]} */
|
||||
queue: [],
|
||||
currentIndex: -1,
|
||||
/** @type {PlaylistItem|null} */
|
||||
currentTrack: null,
|
||||
|
||||
isPlaying: false,
|
||||
volume: 0.7,
|
||||
isMuted: false,
|
||||
@@ -1306,7 +1389,7 @@ const musicPlayer = {
|
||||
const shuffleBtn = document.getElementById('player-shuffle-btn');
|
||||
const repeatBtn = document.getElementById('player-repeat-btn');
|
||||
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 playlistBtn = document.getElementById('player-playlist-btn');
|
||||
const closeQueueBtn = document.getElementById('player-close-queue-btn');
|
||||
@@ -1338,7 +1421,8 @@ const musicPlayer = {
|
||||
|
||||
if (volumeInput) {
|
||||
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');
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {PlaylistItem[]} tracks
|
||||
* @param {string} playlistName
|
||||
*/
|
||||
setQueue(tracks, playlistName = '') {
|
||||
this.queue = [...tracks];
|
||||
this.playlistName = playlistName;
|
||||
this._updateQueueUI();
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} index
|
||||
* @returns
|
||||
*/
|
||||
playTrack(index) {
|
||||
if (index < 0 || index >= this.queue.length) return;
|
||||
|
||||
@@ -1488,15 +1582,19 @@ const musicPlayer = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} vol
|
||||
*/
|
||||
setVolume(vol) {
|
||||
this.volume = Math.max(0, Math.min(1, vol));
|
||||
this.audio.volume = this.volume;
|
||||
this.isMuted = this.volume === 0;
|
||||
this._updateVolumeIcon();
|
||||
|
||||
const input = document.getElementById('player-volume-input');
|
||||
const input = /** @type {HTMLInputElement} */ (document.getElementById('player-volume-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}`;
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {PointerEvent} e
|
||||
* @returns
|
||||
*/
|
||||
_seek(e) {
|
||||
const bar = document.getElementById('player-progress-bar');
|
||||
if (!bar) return;
|
||||
@@ -1596,6 +1699,10 @@ const musicPlayer = {
|
||||
this._updateUI();
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {ErrorEvent} e
|
||||
*/
|
||||
_onError(e) {
|
||||
console.error('Audio error:', e);
|
||||
this.isPlaying = false;
|
||||
@@ -1624,7 +1731,8 @@ const musicPlayer = {
|
||||
if (oxiIcon) {
|
||||
icon.outerHTML = oxiIcon(iconName, extraClass);
|
||||
} 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) {
|
||||
document.querySelectorAll('.music-track').forEach((row) => {
|
||||
/** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll('.music-track')).forEach((row) => {
|
||||
const idx = parseInt(row.dataset.idx, 10);
|
||||
row.classList.toggle('playing', idx === this.currentIndex && this.isPlaying);
|
||||
|
||||
@@ -1710,15 +1818,16 @@ const musicPlayer = {
|
||||
)
|
||||
.join('');
|
||||
|
||||
queueList.querySelectorAll('.player-queue-item').forEach((item) => {
|
||||
/** @type {NodeListOf<HTMLDivElement>} */ (queueList.querySelectorAll('.player-queue-item')).forEach((item) => {
|
||||
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);
|
||||
this.playTrack(idx);
|
||||
});
|
||||
});
|
||||
|
||||
queueList.querySelectorAll('.queue-item-remove').forEach((btn) => {
|
||||
/** @type {NodeListOf<HTMLButtonElement>} */ (queueList.querySelectorAll('.queue-item-remove')).forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const idx = parseInt(btn.dataset.idx, 10);
|
||||
@@ -1727,6 +1836,10 @@ const musicPlayer = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} idx
|
||||
*/
|
||||
_removeFromQueue(idx) {
|
||||
if (idx === this.currentIndex) {
|
||||
if (this.queue.length === 1) {
|
||||
@@ -1754,6 +1867,10 @@ const musicPlayer = {
|
||||
this._updateUI();
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {boolean|undefined} [show]
|
||||
*/
|
||||
_toggleQueue(show) {
|
||||
const queue = document.getElementById('player-queue');
|
||||
if (queue) {
|
||||
@@ -1765,6 +1882,11 @@ const musicPlayer = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number|null} secs
|
||||
* @returns {String}
|
||||
*/
|
||||
_formatTime(secs) {
|
||||
if (!secs || Number.isNaN(secs)) return '0:00';
|
||||
const mins = Math.floor(secs / 60);
|
||||
@@ -1772,10 +1894,19 @@ const musicPlayer = {
|
||||
return `${mins}:${s.toString().padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number|null} secs
|
||||
* @returns {String}
|
||||
*/
|
||||
_formatDuration(secs) {
|
||||
return this._formatTime(secs);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string|null} str
|
||||
* @returns {String}
|
||||
*/
|
||||
_escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
@@ -8,10 +8,14 @@ import { i18n } from '../../core/i18n.js';
|
||||
import { thumbnail } from '../thumbnail.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 = {
|
||||
/** @type {Array} All loaded photo items */
|
||||
/** @type {Array<FileItem>} All loaded photo items */
|
||||
items: [],
|
||||
/** @type {string|null} Cursor for next page */
|
||||
nextCursor: null,
|
||||
@@ -27,7 +31,7 @@ const photosView = {
|
||||
_container: null,
|
||||
/** @type {boolean} */
|
||||
_initialized: false,
|
||||
/** @type {'daily'|'monthly'|'yearly'} */
|
||||
/** @type {PhotoModeEnum} */
|
||||
groupMode: 'monthly',
|
||||
/** @type {Map<string, string>} fileId → thumbnail URL (persists across re-renders) */
|
||||
_videoThumbCache: new Map(),
|
||||
@@ -84,6 +88,11 @@ const photosView = {
|
||||
},
|
||||
|
||||
/** Switch grouping mode */
|
||||
/**
|
||||
*
|
||||
* @param {PhotoModeEnum} mode
|
||||
* @returns
|
||||
*/
|
||||
setGroupMode(mode) {
|
||||
if (this.groupMode === mode) return;
|
||||
this.groupMode = mode;
|
||||
@@ -112,6 +121,7 @@ const photosView = {
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
/** @type {FileItem[]} */
|
||||
const data = await res.json();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
@@ -178,7 +188,9 @@ const photosView = {
|
||||
|
||||
/** Append-only render for infinite scroll — inserts only the items
|
||||
* 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) {
|
||||
if (!this._container) return;
|
||||
this._destroyObserver();
|
||||
@@ -227,7 +239,10 @@ const photosView = {
|
||||
this._setupVideoThumbnails(startIndex);
|
||||
},
|
||||
|
||||
/** Generate HTML for a single photo/video tile */
|
||||
/**
|
||||
* Generate HTML for a single photo/video tile
|
||||
* @param {FileItem} file
|
||||
*/
|
||||
_renderTile(file) {
|
||||
const isVideo = file.mime_type?.startsWith('video/');
|
||||
const selected = this.selected.has(file.id) ? ' selected' : '';
|
||||
@@ -266,7 +281,7 @@ const photosView = {
|
||||
/** @param {number} [startIndex=0] When > 0, only process video tiles
|
||||
* for items[startIndex..] — avoids re-scanning the entire DOM. */
|
||||
_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;
|
||||
|
||||
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) {
|
||||
const fileId = tile.dataset.id;
|
||||
// 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 {
|
||||
await thumbnail.queueGenerate(file, null, (previewDataUrl) => {
|
||||
@@ -335,7 +354,10 @@ const photosView = {
|
||||
</div>`;
|
||||
},
|
||||
|
||||
/** Group items by the current groupMode */
|
||||
/**
|
||||
* Group items by the current groupMode
|
||||
* @param {FileItem[]} items
|
||||
*/
|
||||
_groupItems(items) {
|
||||
const map = new Map();
|
||||
for (const item of items) {
|
||||
@@ -363,20 +385,24 @@ const photosView = {
|
||||
return map;
|
||||
},
|
||||
|
||||
/** Handle click on photo tile or toolbar */
|
||||
/**
|
||||
* Handle click on photo tile or toolbar
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_handleClick(e) {
|
||||
// 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) {
|
||||
this.setGroupMode(modeBtn.dataset.groupMode);
|
||||
this.setGroupMode(/** @type {PhotoModeEnum} */ (modeBtn.dataset.groupMode));
|
||||
return;
|
||||
}
|
||||
|
||||
const tile = e.target.closest('.photo-tile');
|
||||
const tile = /** @type {HTMLDivElement} */ (target.closest('.photo-tile'));
|
||||
if (!tile) return;
|
||||
|
||||
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 (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) {
|
||||
if (this.selected.has(id)) {
|
||||
this.selected.delete(id);
|
||||
@@ -483,6 +513,7 @@ const photosView = {
|
||||
if (bar) bar.style.display = 'none';
|
||||
},
|
||||
|
||||
/** @param {boolean} show */
|
||||
_showLoading(show) {
|
||||
if (!this._container) return;
|
||||
let loader = this._container.querySelector('.photos-loading');
|
||||
@@ -503,12 +534,14 @@ const photosView = {
|
||||
}
|
||||
},
|
||||
|
||||
/** @param {any} s */
|
||||
_escHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
},
|
||||
|
||||
/** @param {any} s */
|
||||
_escAttr(s) {
|
||||
return String(s || '')
|
||||
.replace(/"/g, '"')
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { favorites } from '../library/favorites.js';
|
||||
|
||||
/** @import {FileItem, FileMetadata} from '../../core/types.js' */
|
||||
/** @typedef {typeof import('./photos.js').photosView} PhotosView */
|
||||
|
||||
export const photosLightbox = {
|
||||
/** @type {Array} Items array reference */
|
||||
/** @type {Array<FileItem>} Items array reference */
|
||||
items: [],
|
||||
/** @type {number} Current index */
|
||||
index: -1,
|
||||
@@ -15,14 +18,14 @@ export const photosLightbox = {
|
||||
_overlay: null,
|
||||
/** @type {string|null} Current blob URL to revoke */
|
||||
_blobUrl: null,
|
||||
/** @type {Function|null} */
|
||||
/** @type {(ev: KeyboardEvent) => any|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,
|
||||
|
||||
/**
|
||||
* Register the photosView reference (called from photos.js to avoid circular imports).
|
||||
* @param {Object} pv
|
||||
* @param {any} pv
|
||||
*/
|
||||
setPhotosView(pv) {
|
||||
this._photosView = pv;
|
||||
@@ -33,7 +36,11 @@ export const photosLightbox = {
|
||||
return getCsrfHeaders();
|
||||
},
|
||||
|
||||
/** Open lightbox at given index */
|
||||
/**
|
||||
* Open lightbox at given index
|
||||
* @param {FileItem[]} items
|
||||
* @param {number} index
|
||||
*/
|
||||
open(items, index) {
|
||||
this.items = items;
|
||||
this.index = index;
|
||||
@@ -99,21 +106,21 @@ export const photosLightbox = {
|
||||
this._overlay = el;
|
||||
|
||||
// Event listeners
|
||||
el.querySelector('.lightbox-close').onclick = () => this.close();
|
||||
el.querySelector('.lightbox-prev').onclick = () => this.prev();
|
||||
el.querySelector('.lightbox-next').onclick = () => this.next();
|
||||
/** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-close')).onclick = () => this.close();
|
||||
/** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-prev')).onclick = () => this.prev();
|
||||
/** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-next')).onclick = () => this.next();
|
||||
|
||||
// Click backdrop to close
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
// Toolbar actions
|
||||
el.querySelector('.lb-download').onclick = () => this._download();
|
||||
el.querySelector('.lb-favorite').onclick = () => this._toggleFavorite();
|
||||
el.querySelector('.lb-delete').onclick = () => this._delete();
|
||||
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-download')).onclick = () => this._download();
|
||||
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-favorite')).onclick = () => this._toggleFavorite();
|
||||
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-delete')).onclick = () => this._delete();
|
||||
|
||||
// Animate in
|
||||
requestAnimationFrame(() => el.classList.add('active'));
|
||||
@@ -144,8 +151,8 @@ export const photosLightbox = {
|
||||
meta.textContent = `${dateStr} · ${item.size_formatted || ''}`;
|
||||
|
||||
// Update nav button visibility
|
||||
this._overlay.querySelector('.lightbox-prev').style.visibility = this.index > 0 ? 'visible' : 'hidden';
|
||||
this._overlay.querySelector('.lightbox-next').style.visibility = this.index < this.items.length - 1 ? 'visible' : 'hidden';
|
||||
/** @type {HTMLButtonElement} */ (this._overlay.querySelector('.lightbox-prev')).classList.toggle('hidden', !(this.index > 0));
|
||||
/** @type {HTMLButtonElement} */ (this._overlay.querySelector('.lightbox-next')).classList.toggle('hidden', !(this.index < this.items.length - 1));
|
||||
|
||||
// Load content
|
||||
this._revokeBlob();
|
||||
@@ -176,7 +183,13 @@ export const photosLightbox = {
|
||||
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) {
|
||||
try {
|
||||
const res = await fetch(`/api/files/${fileId}/metadata`, {
|
||||
@@ -184,16 +197,18 @@ export const photosLightbox = {
|
||||
headers: this._headers()
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const metadata = /** @type {FileMetadata} */ (await res.json());
|
||||
const parts = [dateStr];
|
||||
if (sizeStr) parts.push(sizeStr);
|
||||
if (data.camera_make || data.camera_model) {
|
||||
parts.push([data.camera_make, data.camera_model].filter(Boolean).join(' '));
|
||||
if (metadata.camera_make || metadata.camera_model) {
|
||||
parts.push([metadata.camera_make, metadata.camera_model].filter(Boolean).join(' '));
|
||||
}
|
||||
if (data.width && data.height) {
|
||||
parts.push(`${data.width}×${data.height}`);
|
||||
if (metadata.width && metadata.height) {
|
||||
parts.push(`${metadata.width}×${metadata.height}`);
|
||||
}
|
||||
metaEl.textContent = parts.join(' · ');
|
||||
|
||||
//TODO: add geoloc pointer to openstreetmap ?
|
||||
}
|
||||
} catch (_err) {
|
||||
// Non-critical, keep existing meta
|
||||
@@ -220,7 +235,7 @@ export const photosLightbox = {
|
||||
await fetch(`/api/favorites/file/${item.id}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: this._headers(true)
|
||||
headers: this._headers()
|
||||
});
|
||||
const btn = this._overlay.querySelector('.lb-favorite');
|
||||
if (btn) {
|
||||
@@ -254,11 +269,11 @@ export const photosLightbox = {
|
||||
this.items.splice(this.index, 1);
|
||||
if (this.items.length === 0) {
|
||||
this.close();
|
||||
if (this._photosView) this._photosView._render();
|
||||
if (this._photosView) this._photosView._renderFull(); // will call renderEmpty() on this case
|
||||
} else {
|
||||
if (this.index >= this.items.length) this.index = this.items.length - 1;
|
||||
this._show();
|
||||
if (this._photosView) this._photosView._render();
|
||||
if (this._photosView) this._photosView._renderFull();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
@@ -289,6 +304,7 @@ export const photosLightbox = {
|
||||
}
|
||||
},
|
||||
|
||||
/** @param {any} s */
|
||||
_escAttr(s) {
|
||||
return String(s || '')
|
||||
.replace(/"/g, '"')
|
||||
|
||||
@@ -12,6 +12,8 @@ import { i18n } from '../../core/i18n.js';
|
||||
import { multiSelect } from '../files/multiSelect.js';
|
||||
import * as pathTooltip from '../pathTooltip.js';
|
||||
|
||||
/** @import {FileItem, FolderItem, ItemTypeEnum, RecentItem} from '../../core/types.js' */
|
||||
|
||||
const recent = {
|
||||
/** Maximum items to request from the server */
|
||||
MAX_RECENT_FILES: 20,
|
||||
@@ -38,8 +40,9 @@ const recent = {
|
||||
*/
|
||||
setupEventListeners() {
|
||||
document.addEventListener('file-accessed', (event) => {
|
||||
if (event.detail?.file) {
|
||||
const file = event.detail.file;
|
||||
const e = /** @type {CustomEvent} */ (event);
|
||||
if (e.detail?.file) {
|
||||
const file = e.detail.file;
|
||||
const itemType = file.item_type || 'file';
|
||||
this._recordAccess(file.id, itemType);
|
||||
}
|
||||
@@ -48,6 +51,8 @@ const recent = {
|
||||
|
||||
/**
|
||||
* Record an access event on the server.
|
||||
* @param {string} itemId
|
||||
* @param {ItemTypeEnum} itemType
|
||||
*/
|
||||
async _recordAccess(itemId, itemType) {
|
||||
try {
|
||||
@@ -90,7 +95,7 @@ const recent = {
|
||||
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
|
||||
const filesList = document.getElementById('files-list');
|
||||
@@ -120,8 +125,12 @@ const recent = {
|
||||
`);
|
||||
}
|
||||
|
||||
/** @type {FolderItem[]} */
|
||||
const folders = [];
|
||||
|
||||
/** @type {FileItem[]} */
|
||||
const files = [];
|
||||
|
||||
for (const item of recentItems) {
|
||||
const isFolder = item.item_type === 'folder';
|
||||
if (isFolder) {
|
||||
@@ -130,9 +139,20 @@ const recent = {
|
||||
name: item.item_name || item.item_id,
|
||||
parent_id: item.parent_id || '',
|
||||
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 {
|
||||
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({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
@@ -144,7 +164,10 @@ const recent = {
|
||||
size: item.item_size || 0,
|
||||
size_formatted: item.size_formatted,
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,13 @@ function _onLeave() {
|
||||
_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();
|
||||
|
||||
/**
|
||||
@@ -49,10 +55,15 @@ function init(container) {
|
||||
const items = container.querySelectorAll('.file-item[data-path]');
|
||||
items.forEach((item) => {
|
||||
const el = /** @type {HTMLElement} */ (item);
|
||||
|
||||
/** @type {(e: MouseEvent) => void} */
|
||||
const enter = (e) => _onEnter(e);
|
||||
const leave = () => _onLeave();
|
||||
el.addEventListener('mouseenter', enter);
|
||||
|
||||
/** @type {(e: MouseEvent) => void} */
|
||||
const leave = (_e) => _onLeave();
|
||||
el.addEventListener('mouseleave', leave);
|
||||
|
||||
_listeners.set(el, { enter, leave });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import { ui } from '../../app/ui.js';
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { formatDateTime } from '../../core/formatters.js';
|
||||
|
||||
/**
|
||||
* @import {CreateShare, ShareItem, UpdateShare} from '../../core/types.js'
|
||||
*/
|
||||
|
||||
const fileSharing = {
|
||||
/** Auth header helper — tokens are in HttpOnly cookies now */
|
||||
_headers(json = true) {
|
||||
@@ -21,16 +25,17 @@ const fileSharing = {
|
||||
* Create a shared link via backend API
|
||||
* @param {string} itemId - ID of the 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
|
||||
*/
|
||||
async createSharedLink(itemId, itemType, options = {}) {
|
||||
// FIXME unused ?? duplicate with createSharedLink() from contextMenu
|
||||
async createSharedLink(itemId, itemType, options) {
|
||||
const body = {
|
||||
item_id: itemId,
|
||||
item_name: options.name || null,
|
||||
item_name: options.item_name || null,
|
||||
item_type: itemType,
|
||||
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 || {
|
||||
read: true,
|
||||
write: false,
|
||||
@@ -54,7 +59,7 @@ const fileSharing = {
|
||||
|
||||
/**
|
||||
* Get all shared links for the current user
|
||||
* @returns {Promise<Array>} Array of ShareDto
|
||||
* @returns {Promise<ShareItem[]>} Array of ShareDto
|
||||
*/
|
||||
async getSharedLinks() {
|
||||
try {
|
||||
@@ -62,7 +67,7 @@ const fileSharing = {
|
||||
headers: this._headers(false)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const data = /** @type {ShareItem[]} */ await res.json();
|
||||
return data.items || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching shared links:', error);
|
||||
@@ -74,7 +79,7 @@ const fileSharing = {
|
||||
* Get shared links for a specific item (server-side filtered)
|
||||
* @param {string} itemId
|
||||
* @param {string} itemType - 'file' or 'folder'
|
||||
* @returns {Promise<Array>} Shares for this item
|
||||
* @returns {Promise<ShareItem[]>} Shares for this item
|
||||
*/
|
||||
async getSharedLinksForItem(itemId, itemType) {
|
||||
try {
|
||||
@@ -96,6 +101,8 @@ const fileSharing = {
|
||||
|
||||
/**
|
||||
* Check if an item has any shared links
|
||||
* @param {string} itemId
|
||||
* @param {string} itemType
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async hasSharedLinks(itemId, itemType) {
|
||||
@@ -106,7 +113,7 @@ const fileSharing = {
|
||||
/**
|
||||
* Update a shared link
|
||||
* @param {string} shareId
|
||||
* @param {Object} updateData - { permissions, password, expires_at }
|
||||
* @param {UpdateShare} updateData - { permissions, password, expires_at }
|
||||
* @returns {Promise<Object>} Updated ShareDto
|
||||
*/
|
||||
async updateSharedLink(shareId, updateData) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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;
|
||||
|
||||
// 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
|
||||
* 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() {
|
||||
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';
|
||||
return _pdfjsLib;
|
||||
}
|
||||
@@ -23,7 +28,7 @@ export const thumbnail = {
|
||||
SUPPORTED_MIME_TYPE: [/^image\//, /^application\/pdf$/, /^video\//],
|
||||
/**
|
||||
*
|
||||
* @param {Object} file
|
||||
* @param {FileItem} file
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canHandle(file) {
|
||||
@@ -109,7 +114,7 @@ export const thumbnail = {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {FileInfo} file
|
||||
* @param {FileItem} file
|
||||
* @param {string} source
|
||||
* @returns {Promise<ImageBitmap>}
|
||||
*
|
||||
@@ -163,7 +168,7 @@ export const thumbnail = {
|
||||
/**
|
||||
* 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} [onPreviewGenerated] the callback once thumbnail is generated
|
||||
*
|
||||
@@ -203,7 +208,7 @@ export const thumbnail = {
|
||||
|
||||
MAX_CONCURRENT: 3,
|
||||
_activeGenerates: 0,
|
||||
/** @type {Array<() => void>} */
|
||||
/** @type {Array<(resolve: any) => void>} */
|
||||
_generateQueue: [],
|
||||
|
||||
/**
|
||||
@@ -211,7 +216,7 @@ export const thumbnail = {
|
||||
* At most MAX_CONCURRENT generations run simultaneously; excess calls are
|
||||
* 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} [onPreviewGenerated]
|
||||
* @returns {Promise<void>}
|
||||
@@ -224,7 +229,7 @@ export const thumbnail = {
|
||||
try {
|
||||
await this._generate(file, onIconGenerated, onPreviewGenerated);
|
||||
} 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);
|
||||
} else if (err instanceof Error) {
|
||||
console.warn(`generation of thumbnail for ${file.name} failed: `, err.message);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
declare module '*pdf.min.mjs' {
|
||||
const pdfjsLib: any;
|
||||
export = pdfjsLib;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
declare module '*.mjs' {
|
||||
const value: any;
|
||||
export default value;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,20 @@ import { escapeHtml } from '../../core/formatters.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { oxiIconsInit } from '../../core/icons.js';
|
||||
|
||||
/**
|
||||
* @import {RoleEnum} from '../../core/types.js'
|
||||
*/
|
||||
|
||||
const API = '/api';
|
||||
let currentAdminId = '';
|
||||
let usersPage = 0;
|
||||
const PAGE_SIZE = 50;
|
||||
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) {
|
||||
if (typeof s !== 'string') return '';
|
||||
return s.replace(/[^\w .-]/g, (c) => {
|
||||
@@ -17,6 +24,7 @@ function _escJs(s) {
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {string} id */
|
||||
function hideElement(id) {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) return;
|
||||
@@ -24,6 +32,10 @@ function hideElement(id) {
|
||||
element.classList.add('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {string} [mode]
|
||||
*/
|
||||
function showElement(id, mode = 'block') {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) return;
|
||||
@@ -39,6 +51,7 @@ function headers() {
|
||||
return { 'Content-Type': 'application/json', ...getCsrfHeaders() };
|
||||
}
|
||||
|
||||
/** @param {number} bytes */
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024,
|
||||
@@ -47,11 +60,12 @@ function formatBytes(bytes) {
|
||||
return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/** @param {string|null} dateStr */
|
||||
function timeAgo(dateStr) {
|
||||
if (!dateStr) return i18n.t('admin.never');
|
||||
const d = new Date(dateStr);
|
||||
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 < 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) });
|
||||
@@ -60,6 +74,7 @@ function timeAgo(dateStr) {
|
||||
}
|
||||
|
||||
/* ── Custom confirm modal ── */
|
||||
/** @param {string} message */
|
||||
function showConfirm(message) {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.getElementById('confirm-modal');
|
||||
@@ -70,6 +85,7 @@ function showConfirm(message) {
|
||||
overlay.classList.remove('hidden');
|
||||
overlay.classList.add('show-flex');
|
||||
|
||||
/** @param {any} result */
|
||||
function cleanup(result) {
|
||||
overlay.classList.remove('show-flex');
|
||||
overlay.classList.add('hidden');
|
||||
@@ -84,6 +100,7 @@ function showConfirm(message) {
|
||||
function onNo() {
|
||||
cleanup(false);
|
||||
}
|
||||
/** @param {Event} e */
|
||||
function onOverlay(e) {
|
||||
if (e.target === overlay) cleanup(false);
|
||||
}
|
||||
@@ -96,6 +113,10 @@ function showConfirm(message) {
|
||||
/* ── Tab switching with fade animation ── */
|
||||
let activeTabName = 'dashboard';
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {Element|undefined} el
|
||||
*/
|
||||
function switchTab(name, el) {
|
||||
if (name === activeTabName) return;
|
||||
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');
|
||||
|
||||
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');
|
||||
else showElement('registration-warning', 'flex');
|
||||
}
|
||||
@@ -200,7 +221,7 @@ async function loadUsers() {
|
||||
}
|
||||
|
||||
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 quotaColor = quotaPct > 90 ? 'red' : quotaPct > 70 ? 'orange' : 'green';
|
||||
const quotaText =
|
||||
@@ -306,18 +327,18 @@ async function loadUsers() {
|
||||
.join('');
|
||||
|
||||
// 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.removeAttribute('data-width');
|
||||
});
|
||||
|
||||
// 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', () => {
|
||||
const action = btn.dataset.action;
|
||||
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 === '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 === 'delete') deleteUser(btn.dataset.uid, btn.dataset.uname);
|
||||
});
|
||||
@@ -326,12 +347,12 @@ async function loadUsers() {
|
||||
const from = usersPage * PAGE_SIZE + 1;
|
||||
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('prev-btn').disabled = usersPage === 0;
|
||||
document.getElementById('next-btn').disabled = (usersPage + 1) * PAGE_SIZE >= totalUsers;
|
||||
/** @type {HTMLButtonElement} */ (document.getElementById('prev-btn')).disabled = usersPage === 0;
|
||||
/** @type {HTMLButtonElement} */ (document.getElementById('next-btn')).disabled = (usersPage + 1) * PAGE_SIZE >= totalUsers;
|
||||
} catch (e) {
|
||||
tbody.innerHTML =
|
||||
'<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>';
|
||||
}
|
||||
}
|
||||
@@ -349,6 +370,10 @@ function nextPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} userId
|
||||
* @param {RoleEnum} currentRole
|
||||
*/
|
||||
async function toggleRole(userId, currentRole) {
|
||||
const newRole = currentRole === 'admin' ? 'user' : 'admin';
|
||||
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'));
|
||||
}
|
||||
} 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) {
|
||||
const msg = currentActive ? i18n.t('admin.confirm_deactivate') : i18n.t('admin.confirm_activate');
|
||||
const ok = await showConfirm(msg);
|
||||
@@ -387,10 +416,14 @@ async function toggleActive(userId, currentActive) {
|
||||
alert(e.message || i18n.t('admin.error_generic'));
|
||||
}
|
||||
} 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) {
|
||||
const ok = await showConfirm(i18n.t('admin.confirm_delete_user', { name: username }));
|
||||
if (!ok) return;
|
||||
@@ -408,17 +441,22 @@ async function deleteUser(userId, username) {
|
||||
alert(e.message || i18n.t('admin.error_generic'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert(i18n.t('admin.error_network', { message: e.message }));
|
||||
alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
|
||||
}
|
||||
}
|
||||
|
||||
let quotaUserId = '';
|
||||
/**
|
||||
* @param {string} userId
|
||||
* @param {string} username
|
||||
* @param {number} currentQuota
|
||||
*/
|
||||
function openQuotaModal(userId, username, currentQuota) {
|
||||
quotaUserId = userId;
|
||||
document.getElementById('qm-username').textContent = username;
|
||||
const gb = currentQuota / 1073741824;
|
||||
document.getElementById('qm-unit').value = '1073741824';
|
||||
document.getElementById('qm-value').value = gb > 0 ? Math.round(gb * 10) / 10 : 0;
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('qm-unit')).value = '1073741824';
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('qm-value')).value = String(gb > 0 ? Math.round(gb * 10) / 10 : 0);
|
||||
showElement('quota-modal', 'flex');
|
||||
}
|
||||
function closeQuotaModal() {
|
||||
@@ -426,8 +464,8 @@ function closeQuotaModal() {
|
||||
}
|
||||
|
||||
async function saveQuota() {
|
||||
const val = parseFloat(document.getElementById('qm-value').value) || 0;
|
||||
const unit = parseInt(document.getElementById('qm-unit').value, 10);
|
||||
const val = parseFloat(/** @type {HTMLInputElement} */ (document.getElementById('qm-value')).value) || 0;
|
||||
const unit = parseInt(/** @type {HTMLInputElement} */ (document.getElementById('qm-unit')).value, 10);
|
||||
const bytes = Math.round(val * unit);
|
||||
try {
|
||||
const resp = await fetch(`${API}/admin/users/${quotaUserId}/quota`, {
|
||||
@@ -445,33 +483,33 @@ async function saveQuota() {
|
||||
alert(e.message || i18n.t('admin.error_generic'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert(i18n.t('admin.error_network', { message: e.message }));
|
||||
alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateUserModal() {
|
||||
document.getElementById('cu-username').value = '';
|
||||
document.getElementById('cu-password').value = '';
|
||||
document.getElementById('cu-email').value = '';
|
||||
document.getElementById('cu-role').value = 'user';
|
||||
document.getElementById('cu-quota-value').value = '1';
|
||||
document.getElementById('cu-quota-unit').value = '1073741824';
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('cu-username')).value = '';
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('cu-password')).value = '';
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('cu-email')).value = '';
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('cu-role')).value = 'user';
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('cu-quota-value')).value = '1';
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('cu-quota-unit')).value = '1073741824';
|
||||
document.getElementById('cu-error').className = 'alert';
|
||||
document.getElementById('cu-error').textContent = '';
|
||||
showElement('create-user-modal', 'flex');
|
||||
setTimeout(() => document.getElementById('cu-username').focus(), 100);
|
||||
setTimeout(() => /** @type {HTMLInputElement} */ (document.getElementById('cu-username')).focus(), 100);
|
||||
}
|
||||
function closeCreateUserModal() {
|
||||
hideElement('create-user-modal');
|
||||
}
|
||||
|
||||
async function submitCreateUser() {
|
||||
const username = document.getElementById('cu-username').value.trim();
|
||||
const password = document.getElementById('cu-password').value;
|
||||
const email = document.getElementById('cu-email').value.trim() || null;
|
||||
const role = document.getElementById('cu-role').value;
|
||||
const quotaVal = parseFloat(document.getElementById('cu-quota-value').value) || 0;
|
||||
const quotaUnit = parseInt(document.getElementById('cu-quota-unit').value, 10);
|
||||
const username = /** @type {HTMLInputElement} */ (document.getElementById('cu-username')).value.trim();
|
||||
const password = /** @type {HTMLInputElement} */ (document.getElementById('cu-password')).value;
|
||||
const email = /** @type {HTMLInputElement} */ (document.getElementById('cu-email')).value.trim() || null;
|
||||
const role = /** @type {HTMLInputElement} */ (document.getElementById('cu-role')).value;
|
||||
const quotaVal = parseFloat(/** @type {HTMLInputElement} */ (document.getElementById('cu-quota-value')).value) || 0;
|
||||
const quotaUnit = parseInt(/** @type {HTMLInputElement} */ (document.getElementById('cu-quota-unit')).value, 10);
|
||||
const quotaBytes = Math.round(quotaVal * quotaUnit);
|
||||
|
||||
const errorEl = document.getElementById('cu-error');
|
||||
@@ -486,7 +524,7 @@ async function submitCreateUser() {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('cu-submit');
|
||||
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('cu-submit'));
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.creating'))}`;
|
||||
try {
|
||||
@@ -512,7 +550,7 @@ async function submitCreateUser() {
|
||||
errorEl.className = 'alert alert-error';
|
||||
}
|
||||
} 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';
|
||||
}
|
||||
btn.disabled = false;
|
||||
@@ -520,21 +558,25 @@ async function submitCreateUser() {
|
||||
}
|
||||
|
||||
let resetPwUserId = '';
|
||||
/**
|
||||
* @param {string} userId
|
||||
* @param {string} username
|
||||
*/
|
||||
function openResetPasswordModal(userId, username) {
|
||||
resetPwUserId = userId;
|
||||
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').textContent = '';
|
||||
showElement('reset-pw-modal', 'flex');
|
||||
setTimeout(() => document.getElementById('rp-password').focus(), 100);
|
||||
setTimeout(() => /** @type {HTMLInputElement} */ (document.getElementById('rp-password')).focus(), 100);
|
||||
}
|
||||
function closeResetPasswordModal() {
|
||||
hideElement('reset-pw-modal');
|
||||
}
|
||||
|
||||
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');
|
||||
if (password.length < 8) {
|
||||
errorEl.textContent = i18n.t('admin.error_password_short');
|
||||
@@ -542,7 +584,7 @@ async function submitResetPassword() {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('rp-submit');
|
||||
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('rp-submit'));
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.resetting'))}`;
|
||||
try {
|
||||
@@ -560,13 +602,14 @@ async function submitResetPassword() {
|
||||
errorEl.className = 'alert alert-error';
|
||||
}
|
||||
} 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';
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.reset_btn'))}`;
|
||||
}
|
||||
|
||||
/** @param {boolean} enabled */
|
||||
async function toggleRegistration(enabled) {
|
||||
if (enabled) hideElement('registration-warning');
|
||||
else showElement('registration-warning', 'flex');
|
||||
@@ -578,29 +621,33 @@ async function toggleRegistration(enabled) {
|
||||
body: JSON.stringify({ registration_enabled: enabled })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
document.getElementById('ds-registration').checked = !enabled;
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('ds-registration')).checked = !enabled;
|
||||
if (!enabled) showElement('registration-warning', 'flex');
|
||||
else hideElement('registration-warning');
|
||||
const e = await resp.json().catch(() => ({}));
|
||||
alert(e.message || i18n.t('admin.error_generic'));
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('ds-registration').checked = !enabled;
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('ds-registration')).checked = !enabled;
|
||||
if (!enabled) showElement('registration-warning', 'flex');
|
||||
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 () {
|
||||
if (this.checked) showElement('oidc-form');
|
||||
if (/** @type {HTMLInputElement} */ (this).checked) showElement('oidc-form');
|
||||
else hideElement('oidc-form');
|
||||
});
|
||||
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');
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} msg
|
||||
* @param {string} type
|
||||
*/
|
||||
function showOidcStatus(msg, type) {
|
||||
const el = document.getElementById('oidc-status');
|
||||
el.textContent = msg;
|
||||
@@ -613,12 +660,12 @@ function copyCallback() {
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
const url = document.getElementById('issuer-url').value.trim();
|
||||
const url = /** @type {HTMLInputElement} */ (document.getElementById('issuer-url')).value.trim();
|
||||
if (!url) {
|
||||
showOidcStatus('Enter an Issuer URL first', 'error');
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById('discover-btn');
|
||||
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('discover-btn'));
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.discovering'))}`;
|
||||
const resultDiv = document.getElementById('discovery-result');
|
||||
@@ -639,32 +686,32 @@ async function testConnection() {
|
||||
'</dd><dt>Auth Endpoint</dt><dd>' +
|
||||
escapeHtml(r.authorization_endpoint || '—') +
|
||||
'</dd></dl></div>';
|
||||
if (!document.getElementById('provider-name').value && r.provider_name_suggestion)
|
||||
document.getElementById('provider-name').value = r.provider_name_suggestion;
|
||||
if (!(/** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value) && r.provider_name_suggestion)
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value = r.provider_name_suggestion;
|
||||
} else {
|
||||
resultDiv.innerHTML = `<div class="discovery-result fail"><strong><i class="fas fa-times-circle"></i> ${escapeHtml(r.message)}</strong></div>`;
|
||||
}
|
||||
} 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.innerHTML = `<i class="fas fa-search"></i> ${escapeHtml(i18n.t('admin.auto_discover'))}`;
|
||||
}
|
||||
|
||||
async function saveOidcSettings() {
|
||||
const btn = document.getElementById('save-btn');
|
||||
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('save-btn'));
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.saving'))}`;
|
||||
const body = {
|
||||
enabled: document.getElementById('oidc-enabled').checked,
|
||||
issuer_url: document.getElementById('issuer-url').value.trim(),
|
||||
client_id: document.getElementById('client-id').value.trim(),
|
||||
client_secret: document.getElementById('client-secret').value || null,
|
||||
scopes: document.getElementById('scopes').value.trim() || null,
|
||||
auto_provision: document.getElementById('auto-provision').checked,
|
||||
admin_groups: document.getElementById('admin-groups').value.trim() || null,
|
||||
disable_password_login: document.getElementById('disable-password').checked,
|
||||
provider_name: document.getElementById('provider-name').value.trim() || null
|
||||
enabled: /** @type {HTMLInputElement} */ (document.getElementById('oidc-enabled')).checked,
|
||||
issuer_url: /** @type {HTMLInputElement} */ (document.getElementById('issuer-url')).value.trim(),
|
||||
client_id: /** @type {HTMLInputElement} */ (document.getElementById('client-id')).value.trim(),
|
||||
client_secret: /** @type {HTMLInputElement} */ (document.getElementById('client-secret')).value || null,
|
||||
scopes: /** @type {HTMLInputElement} */ (document.getElementById('scopes')).value.trim() || null,
|
||||
auto_provision: /** @type {HTMLInputElement} */ (document.getElementById('auto-provision')).checked,
|
||||
admin_groups: /** @type {HTMLInputElement} */ (document.getElementById('admin-groups')).value.trim() || null,
|
||||
disable_password_login: /** @type {HTMLInputElement} */ (document.getElementById('disable-password')).checked,
|
||||
provider_name: /** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value.trim() || null
|
||||
};
|
||||
try {
|
||||
const resp = await fetch(`${API}/admin/settings/oidc`, {
|
||||
@@ -682,7 +729,7 @@ async function saveOidcSettings() {
|
||||
showOidcStatus(`Error: ${e.message || resp.statusText}`, 'error');
|
||||
}
|
||||
} 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.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 },
|
||||
};
|
||||
|
||||
/** @param {boolean} visible */
|
||||
function toggleS3Form(visible) {
|
||||
if (visible) showElement('storage-s3-form');
|
||||
else hideElement('storage-s3-form');
|
||||
}
|
||||
|
||||
function onStoragePresetChange() {
|
||||
const preset = document.getElementById('storage-preset').value;
|
||||
const p = STORAGE_PRESETS[preset];
|
||||
const preset = /** @type {HTMLInputElement} */ (document.getElementById('storage-preset')).value;
|
||||
const p = STORAGE_PRESETS[/** @type {keyof typeof STORAGE_PRESETS} */ (preset)];
|
||||
if (!p) return;
|
||||
if (p.endpoint) document.getElementById('storage-endpoint-url').value = p.endpoint;
|
||||
if (p.region) document.getElementById('storage-region').value = p.region;
|
||||
document.getElementById('storage-path-style').checked = p.pathStyle;
|
||||
if (p.endpoint) /** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value = p.endpoint;
|
||||
if (p.region) /** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value = p.region;
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked = p.pathStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} msg
|
||||
* @param {string} type
|
||||
*/
|
||||
function showStorageStatus(msg, type) {
|
||||
const el = document.getElementById('storage-status');
|
||||
el.textContent = msg;
|
||||
@@ -732,21 +784,23 @@ async function loadStorage() {
|
||||
|
||||
// Backend selector
|
||||
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');
|
||||
|
||||
// S3 fields
|
||||
document.getElementById('storage-endpoint-url').value = s.s3_endpoint_url || '';
|
||||
document.getElementById('storage-bucket').value = s.s3_bucket || '';
|
||||
document.getElementById('storage-region').value = s.s3_region || '';
|
||||
document.getElementById('storage-access-key').value = '';
|
||||
document.getElementById('storage-secret-key').value = '';
|
||||
document.getElementById('storage-path-style').checked = s.s3_force_path_style;
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value = s.s3_endpoint_url || '';
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('storage-bucket')).value = s.s3_bucket || '';
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value = s.s3_region || '';
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).value = '';
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('storage-secret-key')).value = '';
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked = s.s3_force_path_style;
|
||||
|
||||
// Secret hints
|
||||
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) {
|
||||
showElement('storage-secret-hint');
|
||||
@@ -755,7 +809,7 @@ async function loadStorage() {
|
||||
}
|
||||
|
||||
// ENV badges
|
||||
(s.env_overrides || []).forEach((field) => {
|
||||
/** @type {string[]} */ (s.env_overrides || []).forEach((field) => {
|
||||
const badge = document.getElementById(`badge-${field}`);
|
||||
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-dedup-ratio').textContent = s.dedup_ratio != null ? `${s.dedup_ratio.toFixed(2)}x` : '—';
|
||||
} 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
|
||||
@@ -774,19 +828,19 @@ async function loadStorage() {
|
||||
}
|
||||
|
||||
async function saveStorageSettings() {
|
||||
const btn = document.getElementById('btn-save-storage');
|
||||
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-save-storage'));
|
||||
btn.disabled = true;
|
||||
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 = {
|
||||
backend,
|
||||
s3_endpoint_url: document.getElementById('storage-endpoint-url').value.trim() || null,
|
||||
s3_bucket: document.getElementById('storage-bucket').value.trim() || null,
|
||||
s3_region: document.getElementById('storage-region').value.trim() || null,
|
||||
s3_access_key: document.getElementById('storage-access-key').value || null,
|
||||
s3_secret_key: document.getElementById('storage-secret-key').value || null,
|
||||
s3_force_path_style: document.getElementById('storage-path-style').checked
|
||||
s3_endpoint_url: /** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value.trim() || null,
|
||||
s3_bucket: /** @type {HTMLInputElement} */ (document.getElementById('storage-bucket')).value.trim() || null,
|
||||
s3_region: /** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value.trim() || null,
|
||||
s3_access_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).value || null,
|
||||
s3_secret_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-secret-key')).value || null,
|
||||
s3_force_path_style: /** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -804,26 +858,26 @@ async function saveStorageSettings() {
|
||||
showStorageStatus(`Error: ${e.message || resp.statusText}`, 'error');
|
||||
}
|
||||
} 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.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.storage_save') || 'Save')}`;
|
||||
}
|
||||
|
||||
async function testStorageConnection() {
|
||||
const btn = document.getElementById('btn-test-storage');
|
||||
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-test-storage'));
|
||||
btn.disabled = true;
|
||||
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 = {
|
||||
backend,
|
||||
s3_endpoint_url: document.getElementById('storage-endpoint-url').value.trim() || null,
|
||||
s3_bucket: document.getElementById('storage-bucket').value.trim() || null,
|
||||
s3_region: document.getElementById('storage-region').value.trim() || null,
|
||||
s3_access_key: document.getElementById('storage-access-key').value || null,
|
||||
s3_secret_key: document.getElementById('storage-secret-key').value || null,
|
||||
s3_force_path_style: document.getElementById('storage-path-style').checked
|
||||
s3_endpoint_url: /** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value.trim() || null,
|
||||
s3_bucket: /** @type {HTMLInputElement} */ (document.getElementById('storage-bucket')).value.trim() || null,
|
||||
s3_region: /** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value.trim() || null,
|
||||
s3_access_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).value || null,
|
||||
s3_secret_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-secret-key')).value || null,
|
||||
s3_force_path_style: /** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -842,7 +896,7 @@ async function testStorageConnection() {
|
||||
showStorageStatus(`${i18n.t('admin.storage_test_failure') || 'Connection failed'}: ${escapeHtml(r.message)}`, 'error');
|
||||
}
|
||||
} 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.innerHTML = `<i class="fas fa-vial"></i> ${escapeHtml(i18n.t('admin.storage_test_connection') || 'Test Connection')}`;
|
||||
@@ -850,8 +904,13 @@ async function testStorageConnection() {
|
||||
|
||||
/* ── Migration ── */
|
||||
|
||||
/** @type {ReturnType<typeof setInterval> | null} */
|
||||
let migrationPollTimer = null;
|
||||
|
||||
/**
|
||||
* @param {string} msg
|
||||
* @param {string} type
|
||||
*/
|
||||
function showMigrationMsg(msg, type) {
|
||||
const el = document.getElementById('migration-status-msg');
|
||||
el.textContent = msg;
|
||||
@@ -859,6 +918,7 @@ function showMigrationMsg(msg, type) {
|
||||
el.style.display = '';
|
||||
}
|
||||
|
||||
/** @param {any} m */
|
||||
function updateMigrationUI(m) {
|
||||
// Status badge
|
||||
const badge = document.getElementById('migration-status-badge');
|
||||
@@ -937,7 +997,7 @@ async function loadMigrationStatus() {
|
||||
}
|
||||
|
||||
async function startMigration() {
|
||||
const btn = document.getElementById('btn-start-migration');
|
||||
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-start-migration'));
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`${API}/admin/storage/migration/start`, {
|
||||
@@ -954,7 +1014,7 @@ async function startMigration() {
|
||||
showMigrationMsg(`Error: ${e.message || resp.statusText}`, 'error');
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
@@ -992,7 +1052,7 @@ async function resumeMigration() {
|
||||
}
|
||||
|
||||
async function verifyMigration() {
|
||||
const btn = document.getElementById('btn-verify-migration');
|
||||
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-verify-migration'));
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.migration_verifying') || 'Verifying...')}`;
|
||||
const resultDiv = document.getElementById('migration-verify-result');
|
||||
@@ -1015,7 +1075,7 @@ async function verifyMigration() {
|
||||
}
|
||||
} catch (e) {
|
||||
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.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');
|
||||
}
|
||||
} 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) {
|
||||
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');
|
||||
else hideElement('oidc-form');
|
||||
document.getElementById('provider-name').value = s.provider_name || '';
|
||||
document.getElementById('issuer-url').value = s.issuer_url || '';
|
||||
document.getElementById('client-id').value = s.client_id || '';
|
||||
document.getElementById('scopes').value = s.scopes || 'openid profile email';
|
||||
document.getElementById('auto-provision').checked = s.auto_provision;
|
||||
document.getElementById('admin-groups').value = s.admin_groups || '';
|
||||
document.getElementById('disable-password').checked = s.disable_password_login;
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value = s.provider_name || '';
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('issuer-url')).value = s.issuer_url || '';
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('client-id')).value = s.client_id || '';
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('scopes')).value = s.scopes || 'openid profile email';
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('auto-provision')).checked = s.auto_provision;
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('admin-groups')).value = s.admin_groups || '';
|
||||
/** @type {HTMLInputElement} */ (document.getElementById('disable-password')).checked = s.disable_password_login;
|
||||
if (s.disable_password_login) showElement('password-warning', 'flex');
|
||||
else hideElement('password-warning');
|
||||
document.getElementById('callback-url').textContent = s.callback_url;
|
||||
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}`);
|
||||
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 () {
|
||||
toggleRegistration(this.checked);
|
||||
toggleRegistration(/** @type {HTMLInputElement} */ (this).checked);
|
||||
});
|
||||
|
||||
document.getElementById('btn-create-user').addEventListener('click', openCreateUserModal);
|
||||
@@ -1151,8 +1211,8 @@ document.getElementById('rp-submit').addEventListener('click', submitResetPasswo
|
||||
|
||||
/* ── Storage event listeners ── */
|
||||
document.querySelectorAll('input[name="storage-backend"]').forEach((r) => {
|
||||
r.addEventListener('change', function () {
|
||||
toggleS3Form(this.value === 's3');
|
||||
r.addEventListener('change', () => {
|
||||
toggleS3Form(/** @type {HTMLInputElement} */ (r).value === 's3');
|
||||
});
|
||||
});
|
||||
document.getElementById('storage-preset').addEventListener('change', onStoragePresetChange);
|
||||
|
||||
@@ -8,8 +8,10 @@ import { oxiIconsInit } from '../../core/icons.js';
|
||||
var deviceInfo = document.getElementById('device-info');
|
||||
var actionButtons = document.getElementById('action-buttons');
|
||||
var errorText = document.getElementById('error-text');
|
||||
var btnApprove = document.getElementById('btn-approve');
|
||||
var btnDeny = document.getElementById('btn-deny');
|
||||
var btnApprove = /** @type {HTMLButtonElement} */ (document.getElementById('btn-approve'));
|
||||
var btnDeny = /** @type {HTMLButtonElement} */ (document.getElementById('btn-deny'));
|
||||
|
||||
/** @type {ReturnType<typeof setTimeout>} */
|
||||
var debounceTimer = null;
|
||||
var currentCode = '';
|
||||
|
||||
@@ -24,12 +26,13 @@ import { oxiIconsInit } from '../../core/icons.js';
|
||||
|
||||
// Auto-insert hyphen and lookup on input
|
||||
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
|
||||
if (val.length === 4 && val.indexOf('-') === -1) {
|
||||
val = `${val}-`;
|
||||
}
|
||||
e.target.value = val;
|
||||
target.value = val;
|
||||
errorText.classList.add('hidden');
|
||||
|
||||
// Debounce lookup
|
||||
@@ -52,6 +55,11 @@ import { oxiIconsInit } from '../../core/icons.js';
|
||||
handleAction('deny');
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} code
|
||||
* @returns
|
||||
*/
|
||||
async function lookupCode(code) {
|
||||
try {
|
||||
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) {
|
||||
btnApprove.disabled = true;
|
||||
btnDeny.disabled = true;
|
||||
@@ -109,10 +121,14 @@ import { oxiIconsInit } from '../../core/icons.js';
|
||||
} catch (err) {
|
||||
btnApprove.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) {
|
||||
errorText.textContent = msg;
|
||||
errorText.classList.remove('hidden');
|
||||
|
||||
@@ -6,7 +6,7 @@ if (!/^[0-9a-fA-F]+$/.test(token)) {
|
||||
document.body.innerHTML = '<p>Invalid session token.</p>';
|
||||
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
|
||||
(async () => {
|
||||
|
||||
@@ -4,10 +4,16 @@ import { oxiIconsInit } from '../../core/icons.js';
|
||||
|
||||
const API = '/api';
|
||||
|
||||
// TOOD: reuse common library
|
||||
/**
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
function headers() {
|
||||
return { 'Content-Type': 'application/json', ...getCsrfHeaders() };
|
||||
}
|
||||
|
||||
// TOOD: move to common library
|
||||
/** @param {number} bytes */
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024,
|
||||
@@ -16,11 +22,13 @@ function formatBytes(bytes) {
|
||||
return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
// TOOD: move to common library
|
||||
/** @param {string | null | undefined} dateStr */
|
||||
function timeAgo(dateStr) {
|
||||
if (!dateStr) return i18n.t('profile.never');
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const secs = Math.floor((now - d) / 1000);
|
||||
const now = Date.now();
|
||||
const secs = Math.floor((now - d.valueOf()) / 1000);
|
||||
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 < 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');
|
||||
}
|
||||
|
||||
/** @param {Event} e */
|
||||
async function changePassword(e) {
|
||||
e.preventDefault();
|
||||
const currentPw = document.getElementById('current-password').value;
|
||||
const newPw = document.getElementById('new-password').value;
|
||||
const confirmPw = document.getElementById('confirm-password').value;
|
||||
const currentPw = /** @type {HTMLInputElement} */ (document.getElementById('current-password')).value;
|
||||
const newPw = /** @type {HTMLInputElement} */ (document.getElementById('new-password')).value;
|
||||
const confirmPw = /** @type {HTMLInputElement} */ (document.getElementById('confirm-password')).value;
|
||||
const statusEl = document.getElementById('pw-status');
|
||||
|
||||
if (newPw !== confirmPw) {
|
||||
@@ -121,7 +130,7 @@ async function changePassword(e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('pw-submit');
|
||||
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('pw-submit'));
|
||||
btn.disabled = true;
|
||||
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) {
|
||||
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 {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
statusEl.innerHTML =
|
||||
@@ -149,7 +158,7 @@ async function changePassword(e) {
|
||||
} catch (err) {
|
||||
statusEl.innerHTML =
|
||||
'<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>';
|
||||
}
|
||||
|
||||
@@ -162,10 +171,12 @@ async function changePassword(e) {
|
||||
|
||||
const AUTO_LABELS = ['Nextcloud', 'Nextcloud (OIDC)'];
|
||||
|
||||
/** @param {{label: string, active?: boolean, id: string}} pw */
|
||||
function isAutoPassword(pw) {
|
||||
return AUTO_LABELS.includes(pw.label);
|
||||
}
|
||||
|
||||
/** @param {{label: string, active?: boolean, id: string, created_at: string, last_used_at?: string}} pw */
|
||||
function renderPwRow(pw) {
|
||||
const tr = document.createElement('tr');
|
||||
const label = document.createElement('td');
|
||||
@@ -210,7 +221,9 @@ async function loadAppPasswords() {
|
||||
return;
|
||||
}
|
||||
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) => {
|
||||
return !isAutoPassword(pw);
|
||||
});
|
||||
@@ -236,7 +249,7 @@ async function loadAppPasswords() {
|
||||
autoSection.classList.add('hidden');
|
||||
} else {
|
||||
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');
|
||||
autoTbody.innerHTML = '';
|
||||
for (const pw of autoPws) autoTbody.appendChild(renderPwRow(pw));
|
||||
@@ -255,10 +268,10 @@ function toggleAutoPasswords() {
|
||||
}
|
||||
|
||||
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 statusEl = document.getElementById('app-pw-status');
|
||||
const btn = document.getElementById('app-pw-generate');
|
||||
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('app-pw-generate'));
|
||||
|
||||
if (!label) {
|
||||
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 = '';
|
||||
loadAppPasswords();
|
||||
} 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 {
|
||||
btn.disabled = false;
|
||||
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) {
|
||||
if (!confirm(i18n.t('profile.confirm_revoke', { label: label }))) return;
|
||||
try {
|
||||
@@ -325,10 +342,11 @@ async function revokeAppPassword(id, label) {
|
||||
alert(err.message || i18n.t('profile.error_revoke'));
|
||||
}
|
||||
} 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) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = str || '';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { oxiIconsInit } from '../../core/icons';
|
||||
import { oxiIconsInit } from '../../core/icons.js';
|
||||
|
||||
/**
|
||||
* 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 $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 $fileName = document.getElementById('file-name');
|
||||
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');
|
||||
|
||||
// ── Token from URL path (/s/{token}) ──────────────────────────
|
||||
@@ -49,35 +49,49 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
|
||||
}
|
||||
let rootDisplayName = 'Shared folder';
|
||||
|
||||
/**
|
||||
* @param {'loading'|'password'|'expired'|'file'|'folder'} name
|
||||
*/
|
||||
function showState(name) {
|
||||
for (const el of [$loading, $password, $expired, $file, $folder]) {
|
||||
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,
|
||||
password: $password,
|
||||
expired: $expired,
|
||||
file: $file,
|
||||
folder: $folder
|
||||
}[name];
|
||||
};
|
||||
const target = map[name];
|
||||
if (target) target.classList.remove('hidden');
|
||||
document.body.classList.toggle('gallery-mode', name === 'folder');
|
||||
}
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────
|
||||
/**
|
||||
* @param {string|null|undefined} s
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? '' : s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) =>
|
||||
/** @type {Record<string, string>} */
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
})[c]
|
||||
})[c] ?? c
|
||||
);
|
||||
}
|
||||
/**
|
||||
* @param {number} bytes
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatSize(bytes) {
|
||||
if (bytes == null || Number.isNaN(bytes)) return '';
|
||||
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]}`;
|
||||
}
|
||||
/**
|
||||
* @param {string|null|undefined} mime
|
||||
* @returns {'image'|'video'|null}
|
||||
*/
|
||||
function mediaKind(mime) {
|
||||
const m = (mime || '').toLowerCase();
|
||||
if (m.startsWith('image/')) return 'image';
|
||||
@@ -97,6 +115,9 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
|
||||
return null;
|
||||
}
|
||||
// ── Render share data ─────────────────────────────────────────
|
||||
/**
|
||||
* @param {any} data
|
||||
*/
|
||||
function renderShare(data) {
|
||||
if (data.item_type === 'folder') {
|
||||
rootDisplayName = data.item_name || 'Shared folder';
|
||||
@@ -114,13 +135,15 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
|
||||
.then((res) => {
|
||||
if (res.ok) return res.json();
|
||||
if (res.status === 401) {
|
||||
return res.json().then((body) => {
|
||||
if (body?.requiresPassword) {
|
||||
showState('password');
|
||||
return null;
|
||||
return res.json().then(
|
||||
/** @type {(body:any) => null} */ (body) => {
|
||||
if (body?.requiresPassword) {
|
||||
showState('password');
|
||||
return null;
|
||||
}
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
throw new Error('Unauthorized');
|
||||
});
|
||||
);
|
||||
}
|
||||
if (res.status === 410) {
|
||||
showState('expired');
|
||||
@@ -171,7 +194,9 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
|
||||
|
||||
// ── Folder gallery ────────────────────────────────────────────
|
||||
|
||||
/** @type {string | null} */
|
||||
let currentFolderId = null;
|
||||
/** @type {string | null} */
|
||||
let currentFolderName = null;
|
||||
|
||||
function initFolderGallery() {
|
||||
@@ -195,17 +220,33 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | null} folderId
|
||||
* @returns {string}
|
||||
*/
|
||||
function listingUrl(folderId) {
|
||||
return folderId ? `/api/s/${TOKEN_ENC}/contents/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/contents`;
|
||||
}
|
||||
/**
|
||||
* @param {string} fileId
|
||||
* @returns {string}
|
||||
*/
|
||||
function fileUrl(fileId) {
|
||||
return `/api/s/${TOKEN_ENC}/file/${encodeURIComponent(fileId)}`;
|
||||
}
|
||||
/**
|
||||
* @param {string | null} folderId
|
||||
* @returns {string}
|
||||
*/
|
||||
function zipUrl(folderId) {
|
||||
return folderId ? `/api/s/${TOKEN_ENC}/zip/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/zip`;
|
||||
}
|
||||
|
||||
/** @type {AbortController | null} */
|
||||
let currentLoadController = null;
|
||||
/**
|
||||
* @param {string | null} folderId
|
||||
*/
|
||||
function loadAndRender(folderId) {
|
||||
if (currentLoadController) currentLoadController.abort();
|
||||
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) {
|
||||
const isSubfolder = folderId !== null;
|
||||
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 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 =
|
||||
listing.folders && listing.folders.length > 0
|
||||
@@ -252,7 +311,7 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
|
||||
|
||||
const filesHtml =
|
||||
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>' : '';
|
||||
@@ -265,10 +324,18 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
|
||||
wireImageRetry();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} folder
|
||||
* @returns {string}
|
||||
*/
|
||||
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>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} file
|
||||
* @returns {string}
|
||||
*/
|
||||
function fileCardHtml(file) {
|
||||
const url = fileUrl(file.id);
|
||||
const kind = mediaKind(file.mime_type);
|
||||
@@ -286,7 +353,7 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
const backBtn = $folder.querySelector('[data-action="back"]');
|
||||
@@ -296,14 +363,15 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
|
||||
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) => {
|
||||
if (!(e instanceof MouseEvent)) return;
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
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) => ({
|
||||
kind: el.dataset.mediakind,
|
||||
src: el.dataset.src,
|
||||
@@ -318,6 +386,9 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | null | undefined} v
|
||||
*/
|
||||
function setViewMode(v) {
|
||||
const mode = v === 'list' ? 'list' : 'grid';
|
||||
viewMode = mode;
|
||||
@@ -327,11 +398,15 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
|
||||
// ignore
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | null} folderId
|
||||
* @param {string | null | undefined} folderName
|
||||
*/
|
||||
function navigate(folderId, folderName) {
|
||||
currentFolderId = folderId;
|
||||
currentFolderName = folderName;
|
||||
@@ -352,7 +427,7 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
|
||||
function wireLazyVideos() {
|
||||
const lazy = $folder.querySelectorAll('.file-thumb video[data-lazy-src]');
|
||||
if (!lazy.length) return;
|
||||
const start = (v) => {
|
||||
const start = (/** @type {HTMLVideoElement} */ v) => {
|
||||
v.addEventListener(
|
||||
'loadedmetadata',
|
||||
() => {
|
||||
@@ -385,20 +460,21 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
if (e.target.dataset.lazySrc && !e.target.src) start(e.target);
|
||||
obs.unobserve(e.target);
|
||||
const target = /** @type {HTMLVideoElement} */ (e.target);
|
||||
if (target.dataset.lazySrc && !target.src) start(target);
|
||||
obs.unobserve(target);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '300px' }
|
||||
);
|
||||
for (const v of lazy) obs.observe(v);
|
||||
} else {
|
||||
for (const v of lazy) start(v);
|
||||
for (const v of lazy) start(/** @type {HTMLVideoElement} */ (v));
|
||||
}
|
||||
}
|
||||
|
||||
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', () => {
|
||||
if (img.dataset.retried === '1') return;
|
||||
img.dataset.retried = '1';
|
||||
@@ -412,7 +488,12 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
/** @type {Array<{kind: string|undefined, src: string|undefined, name: string|undefined}>} */
|
||||
let lbItems = [];
|
||||
let lbIndex = -1;
|
||||
|
||||
@@ -466,11 +547,18 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
|
||||
return lb;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{kind: string|undefined, src: string|undefined, name: string|undefined}>} items
|
||||
* @param {number} index
|
||||
*/
|
||||
function openLightbox(items, index) {
|
||||
ensureLightbox();
|
||||
lbItems = items;
|
||||
showLightboxItem(index);
|
||||
}
|
||||
/**
|
||||
* @param {number} i
|
||||
*/
|
||||
function showLightboxItem(i) {
|
||||
if (i < 0 || i >= lbItems.length) return;
|
||||
lbIndex = i;
|
||||
@@ -497,6 +585,9 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
|
||||
lb.root.classList.remove('hidden');
|
||||
lb.root.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
/**
|
||||
* @param {number} delta
|
||||
*/
|
||||
function stepLightbox(delta) {
|
||||
const next = lbIndex + delta;
|
||||
if (next >= 0 && next < lbItems.length) showLightboxItem(next);
|
||||
|
||||
@@ -6,18 +6,18 @@
|
||||
import { switchToFilesSection } from '../../app/navigation.js';
|
||||
import { ui } from '../../app/ui.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 { 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 sharedView = {
|
||||
// State
|
||||
|
||||
/** @type {Array<Share>} */
|
||||
/** @type {Array<ShareItem>} */
|
||||
items: [],
|
||||
|
||||
_expires: 0,
|
||||
@@ -25,8 +25,10 @@ const sharedView = {
|
||||
/** @type {Map<string, boolean>} key = "file:<id>" | "folder:<id>" */
|
||||
_knownItemsId: new Map(),
|
||||
|
||||
/** @type {Array<Share>} */
|
||||
/** @type {Array<ShareItem>} */
|
||||
filteredItems: [],
|
||||
|
||||
/** @type {ShareItem | null} */
|
||||
currentItem: null,
|
||||
|
||||
/** Auth header helper — tokens are in HttpOnly cookies now */
|
||||
@@ -238,6 +240,7 @@ const sharedView = {
|
||||
// Close dropdowns when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
document.querySelectorAll('.shared-custom-select.open').forEach((sel) => {
|
||||
if (!(e.target instanceof Node)) return;
|
||||
if (!sel.contains(e.target)) sel.classList.remove('open');
|
||||
});
|
||||
});
|
||||
@@ -249,8 +252,8 @@ const sharedView = {
|
||||
if (closeBtn) closeBtn.addEventListener('click', () => this.closeShareDialog());
|
||||
const copyLinkBtn = document.getElementById('sv-copy-link-btn');
|
||||
if (copyLinkBtn) copyLinkBtn.addEventListener('click', () => this.copyShareLink());
|
||||
const enablePw = document.getElementById('sv-enable-password');
|
||||
const pwField = document.getElementById('sv-share-password');
|
||||
const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
|
||||
const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
|
||||
if (enablePw)
|
||||
enablePw.addEventListener('change', () => {
|
||||
if (pwField) {
|
||||
@@ -260,8 +263,8 @@ const sharedView = {
|
||||
});
|
||||
const genPwBtn = document.getElementById('sv-generate-password');
|
||||
if (genPwBtn) genPwBtn.addEventListener('click', () => this.generatePassword());
|
||||
const enableExp = document.getElementById('sv-enable-expiration');
|
||||
const expField = document.getElementById('sv-share-expiration');
|
||||
const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration'));
|
||||
const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration'));
|
||||
if (enableExp)
|
||||
enableExp.addEventListener('change', () => {
|
||||
if (expField) {
|
||||
@@ -294,6 +297,13 @@ const sharedView = {
|
||||
},
|
||||
|
||||
// Initialize a custom select dropdown
|
||||
/**
|
||||
*
|
||||
* @param {string} wrapperId
|
||||
* @param {string} toggleId
|
||||
* @param {string} dropdownId
|
||||
* @returns
|
||||
*/
|
||||
_initCustomSelect(wrapperId, toggleId, dropdownId) {
|
||||
const wrapper = document.getElementById(wrapperId);
|
||||
const toggle = document.getElementById(toggleId);
|
||||
@@ -330,14 +340,14 @@ const sharedView = {
|
||||
|
||||
// Filter and sort items
|
||||
filterAndSortItems() {
|
||||
const filterTypeActive = document.querySelector('#filter-type-dropdown .shared-select-option.active');
|
||||
const sortByActive = document.querySelector('#sort-by-dropdown .shared-select-option.active');
|
||||
const filterTypeActive = /** @type {HTMLDivElement} */ (document.querySelector('#filter-type-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 sort = sortByActive ? sortByActive.dataset.value : 'date';
|
||||
|
||||
// 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() : '';
|
||||
|
||||
this.filteredItems = this.items.filter((item) => {
|
||||
@@ -396,23 +406,23 @@ const sharedView = {
|
||||
nameCell.appendChild(nameSpan);
|
||||
|
||||
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');
|
||||
dateCell.textContent = this.formatDate(item.created_at);
|
||||
dateCell.textContent = formatDateShort(item.created_at);
|
||||
|
||||
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 perms = [];
|
||||
if (item.permissions?.read) perms.push(this.translate('share_permissionRead', 'Read'));
|
||||
if (item.permissions?.write) perms.push(this.translate('share_permissionWrite', 'Write'));
|
||||
if (item.permissions?.reshare) perms.push(this.translate('share_permissionReshare', 'Reshare'));
|
||||
if (item.permissions?.read) perms.push(i18n.t('share_permissionRead', 'Read'));
|
||||
if (item.permissions?.write) perms.push(i18n.t('share_permissionWrite', 'Write'));
|
||||
if (item.permissions?.reshare) perms.push(i18n.t('share_permissionReshare', 'Reshare'));
|
||||
permCell.textContent = perms.join(', ') || 'Read';
|
||||
|
||||
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');
|
||||
actionsCell.className = 'shared-item-actions';
|
||||
@@ -420,30 +430,30 @@ const sharedView = {
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'action-btn edit-btn';
|
||||
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));
|
||||
|
||||
const notifyBtn = document.createElement('button');
|
||||
notifyBtn.className = 'action-btn notify-btn';
|
||||
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));
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.className = 'action-btn copy-btn';
|
||||
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', () => {
|
||||
navigator.clipboard
|
||||
.writeText(item.url)
|
||||
.then(() => this.showNotification(this.translate('shared_linkCopied', 'Link copied!')))
|
||||
.catch(() => this.showNotification(this.translate('shared_linkCopyFailed', 'Failed to copy link'), 'error'));
|
||||
.then(() => ui.showNotification(i18n.t('shared_linkCopied', 'Link copied!'), 'success'))
|
||||
.catch(() => ui.showNotification(i18n.t('shared_linkCopyFailed', 'Failed to copy link'), 'error'));
|
||||
});
|
||||
|
||||
const rmBtn = document.createElement('button');
|
||||
rmBtn.className = 'action-btn remove-btn';
|
||||
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', () => {
|
||||
this.currentItem = item;
|
||||
this.removeSharedItem();
|
||||
@@ -456,6 +466,11 @@ const sharedView = {
|
||||
},
|
||||
|
||||
// Open share dialog
|
||||
/**
|
||||
*
|
||||
* @param {ShareItem} item
|
||||
* @returns {void}
|
||||
*/
|
||||
openShareDialog(item) {
|
||||
this.currentItem = item;
|
||||
const shareDialog = document.getElementById('shared-view-edit-dialog');
|
||||
@@ -463,14 +478,14 @@ const sharedView = {
|
||||
|
||||
const iconEl = document.getElementById('sv-dialog-icon');
|
||||
const nameEl = document.getElementById('sv-dialog-name');
|
||||
const urlEl = document.getElementById('sv-share-link-url');
|
||||
const enablePw = document.getElementById('sv-enable-password');
|
||||
const pwField = document.getElementById('sv-share-password');
|
||||
const enableExp = document.getElementById('sv-enable-expiration');
|
||||
const expField = document.getElementById('sv-share-expiration');
|
||||
const permRead = document.getElementById('sv-permission-read');
|
||||
const permWrite = document.getElementById('sv-permission-write');
|
||||
const permReshare = document.getElementById('sv-permission-reshare');
|
||||
const urlEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-link-url'));
|
||||
const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
|
||||
const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
|
||||
const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration'));
|
||||
const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration'));
|
||||
const permRead = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-read'));
|
||||
const permWrite = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-write'));
|
||||
const permReshare = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-reshare'));
|
||||
|
||||
if (!shareDialog) return;
|
||||
if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁';
|
||||
@@ -505,14 +520,19 @@ const sharedView = {
|
||||
this.currentItem = null;
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {ShareItem} item
|
||||
* @returns {void}
|
||||
*/
|
||||
openNotificationDialog(item) {
|
||||
this.currentItem = item;
|
||||
const dn = item.item_name || item.item_id || 'Unknown';
|
||||
const d = document.getElementById('sv-notification-dialog');
|
||||
const iconEl = document.getElementById('sv-notify-dialog-icon');
|
||||
const nameEl = document.getElementById('sv-notify-dialog-name');
|
||||
const emailEl = document.getElementById('sv-notification-email');
|
||||
const msgEl = document.getElementById('sv-notification-message');
|
||||
const emailEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-email'));
|
||||
const msgEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-message'));
|
||||
|
||||
if (!d) return;
|
||||
if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁';
|
||||
@@ -529,18 +549,18 @@ const sharedView = {
|
||||
},
|
||||
|
||||
copyShareLink() {
|
||||
const el = document.getElementById('sv-share-link-url');
|
||||
const el = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-link-url'));
|
||||
if (!el) return;
|
||||
navigator.clipboard
|
||||
.writeText(el.value)
|
||||
.then(() => this.showNotification(this.translate('shared_linkCopied', 'Link copied!')))
|
||||
.catch(() => this.showNotification(this.translate('shared_linkCopyFailed', 'Failed to copy link'), 'error'));
|
||||
.then(() => ui.showNotification(i18n.t('shared_linkCopied', 'Link copied!'), 'success'))
|
||||
.catch(() => ui.showNotification(i18n.t('shared_linkCopyFailed', 'Failed to copy link'), 'error'));
|
||||
},
|
||||
|
||||
// Generate secure password with crypto API
|
||||
generatePassword() {
|
||||
const pwField = document.getElementById('sv-share-password');
|
||||
const enablePw = document.getElementById('sv-enable-password');
|
||||
const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
|
||||
const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
|
||||
if (!pwField || !enablePw) return;
|
||||
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
|
||||
@@ -559,13 +579,13 @@ const sharedView = {
|
||||
async updateSharedItem() {
|
||||
if (!this.currentItem) return;
|
||||
|
||||
const permRead = document.getElementById('sv-permission-read');
|
||||
const permWrite = document.getElementById('sv-permission-write');
|
||||
const permReshare = document.getElementById('sv-permission-reshare');
|
||||
const enablePw = document.getElementById('sv-enable-password');
|
||||
const pwField = document.getElementById('sv-share-password');
|
||||
const enableExp = document.getElementById('sv-enable-expiration');
|
||||
const expField = document.getElementById('sv-share-expiration');
|
||||
const permRead = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-read'));
|
||||
const permWrite = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-write'));
|
||||
const permReshare = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-reshare'));
|
||||
const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
|
||||
const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
|
||||
const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration'));
|
||||
const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration'));
|
||||
|
||||
const body = {
|
||||
permissions: {
|
||||
@@ -588,10 +608,10 @@ const sharedView = {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
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) {
|
||||
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
|
||||
ui.setSharedVisualState(this.currentItem.item_id, this.currentItem.item_type, true);
|
||||
@@ -611,10 +631,10 @@ const sharedView = {
|
||||
headers: this._headers()
|
||||
});
|
||||
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) {
|
||||
console.error('Error removing share:', err);
|
||||
this.showNotification('Error removing share', 'error');
|
||||
ui.showNotification('Error removing share', 'error');
|
||||
}
|
||||
|
||||
this.closeShareDialog();
|
||||
@@ -627,13 +647,13 @@ const sharedView = {
|
||||
// Send notification (stub)
|
||||
sendNotification() {
|
||||
if (!this.currentItem) return;
|
||||
const emailEl = document.getElementById('sv-notification-email');
|
||||
const msgEl = document.getElementById('sv-notification-message');
|
||||
const emailEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-email'));
|
||||
const msgEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-message'));
|
||||
const email = emailEl ? emailEl.value.trim() : '';
|
||||
const message = msgEl ? msgEl.value.trim() : '';
|
||||
|
||||
if (!email || !this.validateEmail(email)) {
|
||||
this.showNotification(this.translate('shared_invalidEmail', 'Please enter a valid email address'), 'error');
|
||||
if (!email || !isEmailValid(email)) {
|
||||
ui.showNotification(i18n.t('shared_invalidEmail', 'Please enter a valid email address'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -642,30 +662,10 @@ const sharedView = {
|
||||
.sendShareNotification(this.currentItem.url, email, message)
|
||||
.then(() => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -17,3 +17,7 @@ OXICLOUD_WOPI_ENABLED=false
|
||||
OXICLOUD_OIDC_ENABLED=false
|
||||
RUST_LOG=warn
|
||||
#RUST_LOG=debug
|
||||
|
||||
# grow up limits for tests
|
||||
OXICLOUD_RATE_LIMIT_REFRESH_MAX=120
|
||||
OXICLOUD_RATE_LIMIT_LOGIN_MAX=120
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"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"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
@@ -2,6 +2,8 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const startScript = path.join(__dirname, 'start-server.sh');
|
||||
|
||||
/** Parse a KEY=VALUE env file, skipping blank lines and comments. */
|
||||
function loadEnv(filePath: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
@@ -23,13 +25,14 @@ const workspace=process.env.GITHUB_WORKSPACE ?? path.join(__dirname, '../..');
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './scenarios',
|
||||
fullyParallel: true,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? [['github'], ['html']] : 'html',
|
||||
|
||||
globalSetup: require.resolve('./global-setup'),
|
||||
globalTeardown: require.resolve('./global-teardown'),
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:8087',
|
||||
@@ -45,22 +48,18 @@ export default defineConfig({
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: process.env.BUILD_TARGET
|
||||
? `${workspace}/target/${process.env.BUILD_TARGET}/oxicloud`
|
||||
: 'cargo run',
|
||||
? `bash "${startScript}" "${workspace}/target/${process.env.BUILD_TARGET}/oxicloud"`
|
||||
: `bash "${startScript}" cargo run`,
|
||||
url: 'http://localhost:8087',
|
||||
timeout: 600_000,
|
||||
reuseExistingServer: false,
|
||||
cwd: '../..',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
env: {
|
||||
...commonEnv,
|
||||
OXICLOUD_SERVER_PORT: '8087',
|
||||
|
||||
@@ -43,7 +43,7 @@ test.describe('authenticated as admin', () => {
|
||||
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('#user-menu-wrapper')).toBeVisible();
|
||||
await expect(page).toHaveScreenshot('home-files.png', {
|
||||
@@ -52,6 +52,8 @@ test.describe('authenticated as admin', () => {
|
||||
animations: 'disabled',
|
||||
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 }) => {
|
||||
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
@@ -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'),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 120 KiB |
@@ -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 "$@"
|
||||
@@ -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.
|
||||
|
||||
@@ -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;}}}
|
||||