Compare commits

...
Author SHA1 Message Date
Kevin B. 41b6f0cbe4 coderabbit comments 2026-11-14 13:09:52 -05:00
Kevin B. 7086b69cab revert reordering for now 2026-11-14 12:03:53 -05:00
Kevin B. 39300aa492 add typed i18n and migrate i18n to TypeScript 2026-11-14 11:38:49 -05:00
Kevin B. a5799bc2d1 coderabbit suggestion 2026-11-13 19:34:58 -05:00
Kevin B. 56ee77961e address coderabbit review 2026-11-13 19:16:49 -05:00
Kevin B. 82fc156042 remove npm lint:eslint script 2026-11-13 18:21:09 -05:00
Kevin B. 6f7f472bcd Merge branch 'dev/v2.2.0' into update-frontend-deps-v2.2.0 2026-11-13 18:19:33 -05:00
Kevin B. 9a51bd0fac remove vue eslint rules and left only i18n 2026-11-13 18:07:46 -05:00
Kevin B. 5d5af61886 fix remaining warnings 2026-11-13 17:47:23 -05:00
Kevin B. 6734f73847 lint 2026-11-13 17:31:07 -05:00
Kevin B. 45312736b1 fix more linting 2026-11-13 17:21:35 -05:00
Kevin B. f37c25f284 more lint fix 2026-11-13 16:49:54 -05:00
Kevin B. 5cc183c447 fix lint on some files 2026-11-13 16:14:01 -05:00
Kevin B. 7cfd47edd7 Merge branch 'dev/v2.2.0' into update-frontend-deps-v2.2.0 2026-11-13 15:38:58 -05:00
Kevin B. 3f4387bec3 add new nursery (vue-related) rules to biome
with this we can possibly remove vue from eslint.
What a good time reading documentation...
2026-11-13 15:33:19 -05:00
Kevin B. 481dbf41b6 reorder biome config 2026-11-13 11:50:16 -05:00
Kevin B. 25722e484d biome config indentation 2026-11-13 11:36:11 -05:00
Kevin B. 6cc198371d fix more linting 2026-11-13 11:09:25 -05:00
Kevin B. 5302534975 apply biome auto-fix and update config 2026-11-13 10:58:33 -05:00
Kevin B. b7474d6b8f update frontend deps v2.2.0 2026-11-13 10:09:12 -05:00
248 changed files with 5215 additions and 5196 deletions

No files matched your search

-1
View File
@@ -30,7 +30,6 @@
"extensions": [
"golang.go",
"Vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker"
]
+1 -1
View File
@@ -1 +1 @@
22
22.22.2
+258 -134
View File
@@ -1,140 +1,264 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"files": {
"ignoreUnknown": true,
"includes": [
"**",
"!dist",
"!public/config.generated.yaml",
"!public/index.html",
"!node_modules",
"!test-results"
]
},
"formatter": {
"enabled": false
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true,
"root": "../"
},
"files": {
"ignoreUnknown": true,
"includes": [
"**",
"!dist",
"!public/config.generated.yaml",
"!public/index.html",
"!node_modules",
"!test-results"
]
},
"formatter": { "enabled": false },
"linter": {
"enabled": true,
"rules": {
"a11y": {
"noAutofocus": "off",
"noNoninteractiveElementToInteractiveRole": "off",
"noStaticElementInteractions": "off",
"noSvgWithoutTitle": "off",
"useAriaPropsSupportedByRole": "off",
"useGenericFontNames": "off",
"useKeyWithClickEvents": "off",
"useMediaCaption": "off",
"useSemanticElements": "off",
"useValidAnchor": "off"
},
"complexity": {
"noForEach": "on",
"noImportantStyles": "off",
"noRedundantDefaultExport": "warn",
"noUnnecessaryTemplateExpression": "error",
"noUselessStringConcat": "warn",
"useMathMinMax": "warn"
},
"correctness": {
"noUnknownPseudoClass": "off",
"noVueImportCompilerMacros": "error",
"noVueRefAsOperand": "error",
"noVueVOnNumberValues": "error",
"useVueValidVFor": "error",
"noVueVIfWithVFor": "error"
},
"nursery": {
"noFloatingPromises": "error",
"noImpliedEval": "error",
"noJsonUnsafeValues": "error",
"noMisusedPromises": "error",
"noVueDeprecatedScopedSlots": "error",
"noVueUndeclaredDirectives": "off",
"useConsistentObjectKeys": "error",
"useVueBaseImport": "warn",
"noDoubleComparison": "on",
"noNestedSwitch": "on",
"noVueLifecycleAfterAwait": "error",
"noVueShadowNativeEvents": "warn",
"noVueVHtml": "error",
"noVueVText": "warn",
"noVueVTextVHtmlOnComponent": "error",
"useSimplifiedBooleanReturn": "warn",
"useVueConsistentEventHyphenation": "warn",
"noPromiseExecutorReturn": "warn",
"noArgumentsCallerOrCallee": "warn",
"noVueRequiredPropWithDefault": "warn",
"noBooleanSortComparator": "error",
"noObsoleteAttributes": "error",
"noUnsafeReturn": "warn",
"noUselessSplice": "warn",
"noVueWatchAfterAwait": "error",
"useMathAbs": "warn",
"useMathTrunc": "warn",
"useVueComponentIs": "error",
"useVueTypedRef": "error",
"noReturnArrayPush": "warn",
"noUselessRecursion": "error",
"noVueComputedPropertiesInData": "error",
"noVueDeprecatedDeleteSet": "error",
"noVueDeprecatedRouterLinkTagProp": "error",
"noVueMutatingProps": "error",
"noVueMultipleSlotArgs": "warn",
"noVueSideEffectsInComputed": "error",
"noVueUnusedRefs": "error",
"useVueTemplateRef": "warn",
"useVueTypedProps": "warn",
"useVueVModel": "warn",
"useVueValidDefineEmits": "error",
"useVueValidDefineProps": "error",
"useVueValidVIs": "error",
"useVueValidVModel": "error",
"useVueValidVShow": "error",
"useVueValidVSlot": "error",
"noVueDeprecatedListeners": "error",
"noArrayForEach": "error",
"noChainedComparison": "error",
"noDynamicDelete": "warn",
"noLateEventControl": "error",
"noObjectLiteralComparison": "error",
"noPlaywrightUnsafeReferences": "error",
"noUnnecessaryStringTrim": "warn",
"noUselessLengthCheck": "info",
"noUselessTypeAssertion": "warn",
"noVueChildContent": "error",
"noVueDeprecatedConfigKeyCodes": "info",
"noVueDeprecatedEventsApi": "error",
"noVueDeprecatedHtmlElementIs": "error",
"noVueDeprecatedModelDefinition": "error",
"noVueDeprecatedPropsDefaultThis": "error",
"noVueDeprecatedVBindSync": "error",
"noVueLoneTemplate": "error",
"noVueMixin": "error",
"noVueMultipleObjectsInClass": "warn",
"noVueRefObjectReactivityLoss": "error",
"noVueRestrictedCallAfterAwait": "error",
"noVueRestrictedVOn": "warn",
"noVueTemplateKey": "error",
"noVueTemplateShadow": "warn",
"noVueThisInTemplate": "warn",
"noVueUndeclaredEmits": "warn",
"noVueUndeclaredProperties": "error",
"noVueUndeclaredSlots": "warn",
"noVueUselessVBind": "warn",
"noVueUselessTemplateAttributes": "error",
"noVueVForTemplateKeyOnChild": "error",
"useObjectFromEntries": "warn",
"useVueBlockOrder": {
"level": "on",
"options": {
"order": [
"template",
"script",
"style"
]
}
},
"useVueBooleanPropTypeFirst": "warn",
"useVueComputedPropertyReturn": "error",
"useVueEmitValidator": "warn",
"useVueEmitsValidatorReturn": "error",
"useVuePropTypeConstructor": "error",
"useVueRenderReturn": "error",
"useVueSingleEventPayload": "warn",
"useVueTypedObjectProps": "warn",
"useVueValidDefaultProp": "error",
"useVueValidNextTick": "error",
"useVueValidDefineOptions": "error",
"noRedundantTypeConstituents": "warn",
"noUselessSpread": "warn",
"noVueExposeAfterAwait": "error"
},
"performance": { "useVueVapor": "off" },
"security": {
"preset": "recommended",
"useIframeSandbox": "off"
},
"style": {
"noDescendingSpecificity": "off",
"noTopLevelLiterals": "error",
"noVueOptionsApi": "off",
"useVueConsistentDefinePropsDeclaration": "on",
"useVueConsistentVBindStyle": "off",
"useVueHyphenatedAttributes": "off",
"useVueMultiWordComponentNames": "off",
"useVueNextTickPromise": "warn",
"useImportsFirst": "off",
"useVarsOnTop": "on",
"useDomQuerySelector": "warn",
"useVueDefineMacrosOrder": "warn"
},
"suspicious": {
"noEmptyObjectKeys": "error",
"noExplicitAny": "warn",
"noImportCycles": "off",
"noNestedPromises": "error",
"noParametersOnlyUsedInRecursion": "error",
"noShadow": "off",
"noUnnecessaryConditions": "off",
"useArraySortCompare": "error",
"useScopedStyles": "off",
"noConfusingVoidType": "warn"
}
},
"domains": { "vue": "all", "project": "all", "types": "all" }
},
"css": {
"formatter": { "enabled": false },
"linter": { "enabled": false },
"assist": { "enabled": false }
},
"html": {
"experimentalFullSupportEnabled": true,
"parser": { "interpolation": true },
"linter": { "enabled": true }
},
"overrides": [
{
"includes": ["tests/playwright/**"],
"linter": {
"rules": {
"complexity": { "noPlaywrightUselessAwait": "error" },
"correctness": {
"noEmptyPattern": "off",
"noPlaywrightMissingAwait": "error",
"noUnresolvedImports": "off",
"usePlaywrightValidDescribeCallback": "error"
},
"style": {
"noPlaywrightElementHandle": "error",
"noPlaywrightEval": "error",
"noPlaywrightWaitForSelector": "error"
},
"suspicious": {
"noPlaywrightForceOption": "error",
"noPlaywrightNetworkidle": "error",
"noPlaywrightPagePause": "error",
"noPlaywrightWaitForNavigation": "error",
"noPlaywrightWaitForTimeout": "warn",
"noEmptyObjectKeys": "error"
}
}
}
},
{
"includes": ["**/*.js"],
"linter": {
"rules": {
"nursery": { "noUnsafeReturn": "off" }
}
}
},
{
"includes": [
"scripts/postcss-strip-legacy.ts",
"playwright.config.ts",
"playwright.performance.config.ts",
"**/*.test.js",
"**/*.test.ts"
],
"linter": {
"rules": {
"correctness": {
"noUndeclaredDependencies": "off",
"noUnresolvedImports": "off"
}
}
}
}
],
"assist": {
"enabled": true,
"actions": { "source": { "organizeImports": "off" } }
},
"css": {
"linter": { "enabled": false },
"formatter": { "enabled": false },
"assist": { "enabled": false }
},
"html": {
"experimentalFullSupportEnabled": true,
"parser": { "interpolation": true },
"linter": { "enabled": true }
},
"linter": {
"enabled": true,
"domains": { "vue": "all", "project": "all", "types": "all" },
"rules": {
"performance": {
"useVueVapor": "off"
},
"security": {
"preset": "recommended"
},
"a11y": {
"noAutofocus": "off",
"noNoninteractiveElementToInteractiveRole": "off",
"noStaticElementInteractions": "off",
"noSvgWithoutTitle": "off",
"useAriaPropsSupportedByRole": "off",
"useGenericFontNames": "off",
"useKeyWithClickEvents": "off",
"useMediaCaption": "off",
"useSemanticElements": "off",
"useValidAnchor": "off"
},
"complexity": {
"noForEach": "on",
"noImportantStyles": "off",
"noRedundantDefaultExport": "warn"
},
"correctness": {
"noUnknownPseudoClass": "off"
},
"nursery": {
"noEmptyObjectKeys": "error",
"noFloatingPromises": "error",
"noImpliedEval": "error",
"noJsonUnsafeValues": "error",
"noMisusedPromises": "error",
"noTopLevelLiterals": "error",
"noVueDeprecatedScopedSlots": "error",
"noVueImportCompilerMacros": "error",
"noVueRefAsOperand": "error",
"noVueUndeclaredDirectives": "off",
"noVueVOnNumberValues": "error",
"useConsistentObjectKeys": "error",
"useVueBaseImport": "warn",
"useVueConsistentDefinePropsDeclaration": "off",
"useVueNextTickPromise": "warn",
"useVueValidVFor": "error"
},
"style": {
"noDescendingSpecificity": "off",
"noVueOptionsApi": "off",
"useVueConsistentVBindStyle": "off",
"useVueHyphenatedAttributes": "off",
"useVueMultiWordComponentNames": "off"
},
"suspicious": {
"noExplicitAny": "warn",
"noImportCycles": "off",
"noNestedPromises": "error",
"noParametersOnlyUsedInRecursion": "error",
"noShadow": "off",
"noUnnecessaryConditions": "off",
"useArraySortCompare": "error"
"actions": {
"source": {
"organizeImports": "off",
"useGraphqlSortedDefinitions": "on"
}
}
},
"overrides": [
{
"includes": ["tests/playwright/**"],
"linter": {
"rules": {
"correctness": {
"noEmptyPattern": "off",
"noUnresolvedImports": "off"
},
"nursery": {
"noPlaywrightElementHandle": "error",
"noPlaywrightEval": "error",
"noPlaywrightForceOption": "error",
"noPlaywrightMissingAwait": "error",
"noPlaywrightNetworkidle": "error",
"noPlaywrightPagePause": "error",
"noPlaywrightUselessAwait": "error",
"noPlaywrightWaitForNavigation": "error",
"noPlaywrightWaitForSelector": "error",
"noPlaywrightWaitForTimeout": "warn",
"usePlaywrightValidDescribeCallback": "error"
}
}
}
},
{
"includes": [
"scripts/postcss-strip-legacy.ts",
"playwright.config.ts",
"playwright.performance.config.ts",
"**/*.test.js",
"**/*.test.ts"
],
"linter": {
"rules": {
"correctness": {
"noUnresolvedImports": "off",
"noUndeclaredDependencies": "off"
}
}
}
}
]
}
}
-70
View File
@@ -1,70 +0,0 @@
import { defineConfig } from "eslint/config";
import * as tsParser from "@typescript-eslint/parser";
import pluginVue from "eslint-plugin-vue";
import vueI18n from "@intlify/eslint-plugin-vue-i18n";
export default defineConfig(
{ ignores: ["**/dist/**", "tests/playwright-files/**"] },
...pluginVue.configs["flat/essential"],
...vueI18n.configs.recommended,
// js/ts is parsed for vue-i18n to check t() calls
{ files: ["**/*.js", "**/*.ts"], languageOptions: { parser: tsParser } },
{ files: ["**/*.vue"], languageOptions: { parserOptions: { parser: tsParser } } },
{
settings: {
"vue-i18n": {
localeDir: "src/i18n/en.json",
messageSyntaxVersion: "^11.0.0",
},
},
rules: {
"@intlify/vue-i18n/no-missing-keys": "error",
"@intlify/vue-i18n/no-unused-keys": ["error", {
src: "./src",
extensions: [".js", ".vue", ".ts"],
ignores: ["/^languages\\./"],
}],
"@intlify/vue-i18n/no-raw-text": ["error", {
ignoreNodes: ["i", "v-icon"],
}],
"@intlify/vue-i18n/no-missing-keys-in-other-locales": "warn",
},
},
{
files: ["**/*.vue"],
rules: {
// this are the most common ones that likely biome also has
// we can add more to the list or if some if buggy in biome better enable it here.
"vue/no-duplicate-attributes": "off",
"vue/no-use-v-if-with-v-for": "off",
"vue/require-v-for-key": "off",
"vue/valid-template-root": "off",
"vue/valid-v-bind": "off",
"vue/valid-v-cloak": "off",
"vue/valid-v-else": "off",
"vue/valid-v-else-if": "off",
"vue/valid-v-for": "off",
"vue/valid-v-html": "off",
"vue/valid-v-if": "off",
"vue/valid-v-on": "off",
"vue/valid-v-once": "off",
"vue/valid-v-pre": "off",
"vue/valid-v-text": "off",
"vue/no-deprecated-v-on-number-modifiers": "off",
"vue/no-dupe-keys": "off",
"vue/no-reserved-keys": "off",
"vue/no-reserved-props": "off",
"vue/no-deprecated-data-object-declaration": "off",
"vue/no-arrow-functions-in-watch": "off",
"vue/no-ref-as-operand": "off",
"vue/prefer-import-from-vue": "off",
"vue/multi-word-component-names": "off",
"vue/no-reserved-component-names": "off",
"vue/no-parsing-error": "off",
"vue/no-unused-components": "warn",
"vue/no-v-text-v-html-on-component": "warn",
},
},
);
+931 -3090
View File
File diff suppressed because it is too large. Load diff
+37 -43
View File
@@ -4,8 +4,8 @@
"private": true,
"type": "module",
"engines": {
"npm": ">=9.0.0",
"node": "^22.13.0 || ^24.0.0 || >=26.0.0"
"npm": ">=12.0.0",
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"scripts": {
"build": "vite build",
@@ -13,77 +13,71 @@
"dev": "vite",
"watch": "vite build --watch",
"typecheck": "vue-tsc --build",
"lint": "biome check && eslint src/",
"lint:fix": "biome check --write && eslint --fix src/",
"lint": "biome check && npm run i18n:check",
"lint:fix": "biome check --write",
"lint:biome": "biome check",
"lint:biome:fix": "biome check --write",
"lint:eslint": "eslint src/",
"format": "biome format --write",
"i18n:check": "node scripts/i18n/index.ts check",
"i18n:check:strict": "node scripts/i18n/index.ts check --strict",
"lint:css": "stylelint \"**/*.{css,vue}\"",
"lint:css:fix": "stylelint --fix \"**/*.{css,vue}\"",
"i18n:sync": "node ./scripts/sync-translations.js",
"i18n:check": "node ./scripts/sync-translations.js --check",
"i18n:enforce-order": "node scripts/sync-translations.js --enforce-order",
"i18n:cleanup": "node scripts/sync-translations.js --cleanup",
"i18n:types": "node scripts/i18n/index.ts types",
"i18n:sync": "node scripts/i18n/index.ts sync",
"i18n:cleanup": "node scripts/i18n/index.ts sync --cleanup",
"i18n:enforce-order": "node scripts/i18n/index.ts sync --enforce-order",
"icons:sync": "node ./scripts/build-icon-subset.js",
"icons:check": "node ./scripts/build-icon-subset.js --check",
"test": "vitest run "
},
"dependencies": {
"@likecoin/epub-ts": "^0.7.2",
"@likecoin/epub-ts": "^0.7.3",
"@onlyoffice/document-editor-vue": "^1.7.0",
"ace-builds": "^1.45.0",
"chart.js": "^4.5.1",
"ace-builds": "^1.45.2",
"chart.js": "^4.6.1",
"dompurify": "^3.4.17",
"highlight.js": "^11.12.0",
"hls.js": "^1.7.2",
"hls.js": "^1.7.4",
"js-yaml": "^5.5.0",
"mammoth": "^1.13.0",
"marked": "^18.1.1",
"marked": "^18.2.0",
"plyr": "^3.8.5",
"qrcode.vue": "^3.11.0",
"temml": "^0.14.0",
"three": "^0.186.1",
"vue": "^3.5.44",
"vue-i18n": "^11.4.16",
"vue-router": "^5.4.0"
"three": "^0.187.0",
"vue": "^3.5.45",
"vue-i18n": "^11.4.19",
"vue-router": "^5.5.0"
},
"overrides": {
"minimatch": "^10.2.6",
"minimatch": "^11.0.8",
"@xmldom/xmldom": "^0.8.15",
"mammoth": {
"argparse": "^2.0.1"
"argparse": "^3.0.4"
},
"@intlify/vue-i18n-extensions": {
"vue-i18n": "$vue-i18n"
},
"@intlify/eslint-plugin-vue-i18n": {
"glob": "^13.0.0"
}
"glob": "^14.0.6"
},
"devDependencies": {
"@biomejs/biome": "2.5.15",
"@intlify/eslint-plugin-vue-i18n": "^4.5.1",
"@intlify/unplugin-vue-i18n": "^11.2.7",
"@playwright/test": "^1.64.1",
"@types/node": "^26.6.4",
"@typescript-eslint/parser": "^8.72.0",
"@vitejs/plugin-vue": "^6.0.10",
"deepl-node": "^1.28.1",
"eslint": "^10.13.0",
"eslint-plugin-vue": "^10.11.1",
"@biomejs/biome": "2.6.7",
"@intlify/unplugin-vue-i18n": "^11.2.8",
"@playwright/test": "^1.65.0",
"@types/node": "^26.6.6",
"@vitejs/plugin-vue": "^6.0.11",
"deepl-node": "^1.29.0",
"esbuild": "^0.28.2",
"fs-extra": "^11.4.1",
"jsdom": "^30.1.3",
"stylelint": "^17.16.0",
"stylelint-config-standard": "^40.0.0",
"jsdom": "^31.0.1",
"stylelint": "^18.0.0",
"stylelint-config-standard": "^41.0.0",
"stylelint-config-standard-vue": "^2.0.0",
"typescript": "^6.0.3",
"vite": "^8.3.4",
"vite-plugin-checker": "^0.14.5",
"vite-plugin-compression2": "^2.5.3",
"vitest": "^5.0.5",
"vue-eslint-parser": "^10.4.1",
"vue-tsc": "^3.3.13",
"vite": "^8.3.8",
"vite-plugin-checker": "^1.0.0",
"vite-plugin-compression2": "^2.5.4",
"vitest": "^5.1.0",
"vue-tsc": "^3.3.14",
"wawoff2": "^2.0.1"
}
}
+2 -2
View File
@@ -142,7 +142,7 @@ function scanMimetype(icons) {
}
function scanConstants(icons) {
const content = fs.readFileSync(path.join(srcDir, 'utils/constants.js'), 'utf8');
const content = fs.readFileSync(path.join(srcDir, 'utils/constants.ts'), 'utf8');
const iconField = new RegExp(`\\bicon:\\s*["'](${ICON_NAME})["']`, 'g');
for (const match of content.matchAll(iconField)) addIcon(icons, match[1]);
}
@@ -185,7 +185,7 @@ function collectIcons() {
scanSearchFilters(icons);
scanMarkdownViewer(icons);
icons.delete('close_back');
return [...icons].sort();
return [...icons].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
}
function readInventory() {
@@ -0,0 +1,246 @@
/** biome-ignore-all lint/suspicious/noTemplateCurlyInString: the tests need it */
import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { maskJs, maskSource } from "../shared.ts";
import { syncTranslations } from "../sync.ts";
import { literalCalls } from "../types.ts";
// The sync reads and writes the locales folder: point it at a temporary one.
const dir = await vi.hoisted(async () => {
const { mkdtempSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
return mkdtempSync(join(tmpdir(), "i18n-test-"));
});
vi.mock("../shared.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("../shared.ts")>();
const { readdirSync } = await import("node:fs");
return {
...actual,
localesDir: dir,
translationFiles: () => readdirSync(dir).filter((file) => file.endsWith(".json") && file !== "en.json").sort(),
};
});
vi.mock("deepl-node", () => ({
DeepLClient: class {
async translateText(text: string) {
return { text: `[${text}]` };
}
},
}));
const write = (file: string, content: unknown) => fs.writeFileSync(path.join(dir, file), JSON.stringify(content));
const read = (file: string): unknown => JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
describe("sync with a different shape in a locale", () => {
beforeEach(() => {
process.env.DEEPL_API_KEY = "test-key";
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
for (const file of fs.readdirSync(dir)) fs.rmSync(path.join(dir, file));
});
const run = (options: { enforceOrder?: boolean; cleanup?: boolean } = {}) =>
syncTranslations({ enforceOrder: false, cleanup: false, ...options });
describe("an object where en.json has a string", () => {
beforeEach(() => {
write("en.json", { general: { title: "Title" } });
write("de.json", { general: { title: { old: "Alt" } } });
});
it("replaces the subtree with a translation", async () => {
expect(await run()).toBe(0);
expect(read("de.json")).toEqual({ general: { title: "[Title]" } });
});
it("removes the obsolete subtree on cleanup", async () => {
expect(await run({ cleanup: true })).toBe(0);
expect(read("de.json")).toEqual({ general: {} });
});
it("removes the obsolete subtree when enforcing the order", async () => {
expect(await run({ enforceOrder: true })).toBe(0);
expect(read("de.json")).toEqual({ general: {} });
});
});
describe("a string where en.json has an object", () => {
beforeEach(() => {
write("en.json", { group: { a: "A" } });
write("de.json", { group: "legacy" });
});
it("replaces the string with the translated keys", async () => {
await run();
expect(read("de.json")).toEqual({ group: { a: "[A]" } });
});
it("removes the obsolete string on cleanup", async () => {
await run({ cleanup: true });
expect(read("de.json")).toEqual({});
});
});
});
describe("maskJs", () => {
it("blanks comments and keeps every offset and newline", () => {
const code = `a(); // t("x")\n/* t("y")\n */ b();`;
const masked = maskJs(code);
expect(masked).toHaveLength(code.length);
expect(masked).not.toContain("t(");
expect(masked.split("\n")).toHaveLength(3);
expect(masked).toContain("a();");
expect(masked).toContain("b();");
});
it("keeps `//` inside strings", () => {
expect(maskJs(`const u = "https://example.com"; t("k");`)).toContain("https://example.com");
});
it("blanks the content of quoted strings only on request, and keeps template literals", () => {
const code = `x("a 't(' b", 'c'); y(\`\${t("k")}\`);`;
const bare = maskJs(code, true);
expect(bare).toHaveLength(code.length);
expect(bare).not.toContain("a 't(' b");
expect(bare).toContain('t("k")');
expect(maskJs(code)).toContain("a 't(' b");
});
it("does not take an apostrophe left open on a line for a string", () => {
expect(maskJs(`don't\nt("k")`, true)).toContain('t("');
});
});
describe("maskJs with template literals", () => {
const masked = (code: string) => maskJs(code, true);
it("template literal keeps its ${} expressions", () => {
const code = 'const example = `t("example.only") ${t("real.key")} end`;';
const bare = masked(code);
expect(bare).toHaveLength(code.length);
expect(bare).not.toContain("example.only");
expect(bare).toContain('t("real.key")');
expect(bare.startsWith("const example = `")).toBe(true);
expect(bare.endsWith("`;")).toBe(true);
});
it("keeps the text of a template literal when the strings are not masked", () => {
const code = 'const example = `t("example.only") ${t("real.key")}`; // t("c.d")';
const plain = maskJs(code);
expect(plain).toContain('`t("example.only") ${t("real.key")}`');
expect(plain).not.toContain("c.d");
});
it("keeps the offsets and the newlines of a template literal that spans lines", () => {
const code = "const a = `first t(\"x.y\")\nsecond ${\n t(\"real.key\")\n} third`;\nb();";
const bare = masked(code);
expect(bare).toHaveLength(code.length);
expect(bare.split("\n")).toHaveLength(code.split("\n").length);
expect(bare.indexOf('t("real.key")')).toBe(code.indexOf('t("real.key")'));
expect(bare).not.toContain("x.y");
expect(bare).toContain("b();");
});
it("does not take `//` or `/*` in the text of a template for a comment", () => {
const code = "const u = `https://example.com/*path*/` + t('k');";
expect(maskJs(code)).toBe(code);
expect(masked(code)).toMatch(/` \+ t\(' '\);$/);
});
it("masks a comment inside an expression and the text of a template nested in it", () => {
const code = "`${ /* t('c.d') */ cond ? `t(\"nested.text\")` : t('real.key') }`";
const bare = masked(code);
expect(bare).toHaveLength(code.length);
expect(bare).not.toContain("c.d");
expect(bare).not.toContain("nested.text");
expect(bare).toContain("t('real.key')");
});
it("closes an expression at its own brace, not at the brace of an object inside it", () => {
const code = "`a ${ fn({ x: 1 }) } t(\"after.only\") ${ t('real.key') }`";
const bare = masked(code);
expect(bare).not.toContain("after.only");
expect(bare).toContain("t('real.key')");
});
it("treats an escaped `${` and an escaped backtick as text", () => {
const code = "`\\${t('escaped.key')} \\` t('after.escape')` + t('real.key')";
const bare = masked(code);
expect(bare).toHaveLength(code.length);
expect(bare).not.toContain("escaped.key");
expect(bare).not.toContain("after.escape");
expect(bare).toMatch(/` \+ t\(' +'\)$/);
});
it("copes with a template or an expression that is never closed", () => {
for (const code of ["const a = `t('x.y')", "const a = `${t('x.y')", "const a = `trailing \\"]) {
expect(masked(code)).toHaveLength(code.length);
expect(maskJs(code)).toHaveLength(code.length);
}
});
});
describe("maskSource for .vue files", () => {
const vue = [
"<template>",
" <!-- {{ t('a.b') }} -->",
" <p>{{ t('c.d') }}</p>",
"</template>",
'<script setup lang="ts">',
"// t('e.f')",
"const x = t('g.h');",
"</script>",
"<style>",
".a::after { content: 't(\"i.j\")'; }",
"</style>",
].join("\n");
it("drops comments and styles but keeps the template and script code at the same offsets", () => {
const masked = maskSource("A.vue", vue);
expect(masked).toHaveLength(vue.length);
for (const dropped of ["a.b", "e.f", "i.j"]) expect(masked).not.toContain(dropped);
for (const kept of ["c.d", "g.h"]) expect(masked.indexOf(kept)).toBe(vue.indexOf(kept));
});
});
describe("literalCalls", () => {
const calls = (source: string) => [...literalCalls(maskJs(source), maskJs(source, true))];
it("reads a parameter that holds an escaped quote", () => {
const [call] = calls(`t("prompts.uploadSkipped", { count: "a\\"b".length })`);
expect(call?.params).toEqual({ names: new Set(["count"]), dynamic: false });
});
it("skips a key that is concatenated with an expression", () => {
expect(calls(`t('prompts.' + name)`)).toEqual([]);
expect(calls(`t("a" + b, { x: 1 })`)).toEqual([]);
});
it("ignores calls in comments and strings, and finds those in template literals", () => {
const found = calls(`// t('example.key')\nconst s = "t('example.str')";\nconst u = \`\${t("real.key")}\`;`);
expect(found.map((call) => call.key)).toEqual(["real.key"]);
});
it("does not check the text of a template literal as a call", () => {
expect(calls('const example = `t("example.only")`;')).toEqual([]);
expect(calls('const example = `see t("example.only") and ${t("real.key")}`;').map((call) => call.key)).toEqual(["real.key"]);
});
it("finds a call in an expression nested in a template, not in the template nested in that", () => {
const found = calls("const a = `${ cond ? `t('nested.text')` : t('real.key', { count: 1 }) }`;");
expect(found.map((call) => call.key)).toEqual(["real.key"]);
});
it("is not confused by a comment in the params", () => {
const [call] = calls(`t("k", { count, /* it's */ other })`);
expect(call?.params.names).toEqual(new Set(["count", "other"]));
});
});
+63
View File
@@ -0,0 +1,63 @@
import fs from "node:fs";
import { parse } from "vue/compiler-sfc";
import { isTestFile, maskSource, readLocale, rel, sourceFiles } from "./shared.ts";
const IGNORED_KEYS = [/^languages\./];
const IGNORED_ELEMENTS = new Set(["i", "v-icon", "kbd"]);
const IGNORE_COMMENT = "i18n-ignore";
// Vue's NodeTypes values
const ELEMENT = 1;
const TEXT = 2;
const COMMENT = 3;
interface TemplateNode {
type: number;
tag?: string;
content?: unknown;
props?: { name?: string; value?: { content: string } }[];
children?: TemplateNode[];
loc: { start: { line: number } };
}
/** The code of a source file that can use a key -- comments and <style> blocks are dropped */
const codeOf = (file: string) => maskSource(file, fs.readFileSync(file, "utf8"));
/** Every key in en.json must appear as a string literal in src/ or match a dynamic prefix */
export function checkUnusedKeys(): string[] {
const code = [...sourceFiles()].filter((file) => !isTestFile(file)).map(codeOf).join("\n");
const prefixes = [...code.matchAll(/\bt\(\s*["'`]([\w.-]+\.)(?:\$\{|["'`]\s*\+)/g)].map((match) => match[1] ?? "");
const isUsed = (key: string) =>
["'", '"', "`"].some((quote) => code.includes(`${quote}${key}${quote}`)) ||
prefixes.some((prefix) => key.startsWith(prefix));
return Object.keys(readLocale("en.json"))
.filter((key) => !IGNORED_KEYS.some((pattern) => pattern.test(key)) && !isUsed(key))
.map((key) => `en.json: unused key ${key}`);
}
const isIgnored = (node: TemplateNode) =>
IGNORED_ELEMENTS.has(node.tag ?? "") ||
(node.props ?? []).some((prop) => prop.name === "class" && prop.value?.content.includes("material-symbols"));
/** Templates shouldn't have plain text, to ignore them we can use <!-- i18n-ignore --> */
export function checkRawText(): string[] {
const problems: string[] = [];
for (const file of sourceFiles()) {
if (!file.endsWith(".vue")) continue;
const ast = parse(fs.readFileSync(file, "utf8"), { filename: file }).descriptor.template?.ast;
const walk = (node: TemplateNode) => {
if (node.type === ELEMENT && isIgnored(node)) return;
if (node.type === TEXT && typeof node.content === "string" && /\p{L}/u.test(node.content)) {
problems.push(`${rel(file)}:${node.loc.start.line}: raw text "${node.content.trim()}", use t()`);
}
let skip = false;
for (const child of node.children ?? []) {
if (!skip) walk(child);
if (child.type === COMMENT) skip = typeof child.content === "string" && child.content.trim() === IGNORE_COMMENT;
else if (child.type === ELEMENT || (child.type === TEXT && String(child.content).trim())) skip = false;
}
};
if (ast) walk(ast as unknown as TemplateNode);
}
return problems;
}
+40
View File
@@ -0,0 +1,40 @@
/**
* npm run i18n:types regenerate messages.generated.ts
* npm run i18n:sync regenerate the types, translate new keys (DEEPL_API_KEY)
* npm run i18n:enforce-order regenerate the types, reorder locales like en.json
* npm run i18n:cleanup regenerate the types, remove obsolete keys
* npm run i18n:check translations, types, unused keys and raw text
* npm run i18n:check:strict checks for raw `$t` and 'vue-i18n' imports, including JS files
*/
import { checkRawText, checkUnusedKeys } from "./check.ts";
import { report } from "./shared.ts";
import { checkTranslations, syncTranslations } from "./sync.ts";
import { checkTypes, writeTypes } from "./types.ts";
const [command, ...flags] = process.argv.slice(2);
const has = (flag: string) => flags.includes(flag);
try {
if (command === "types") {
process.exitCode = writeTypes();
} else if (command === "sync") {
process.exitCode = writeTypes() || (await syncTranslations({ enforceOrder: has("--enforce-order"), cleanup: has("--cleanup") }));
} else if (command === "check") {
const translations = checkTranslations();
const problems = [...translations, ...checkTypes(has("--strict")), ...checkUnusedKeys(), ...checkRawText()];
if (problems.length) {
report(problems);
if (translations.length) console.error("Run: npm run i18n:sync (or i18n:cleanup)");
process.exitCode = 1;
} else {
console.log("✅ i18n checks passed");
}
} else {
console.error("Usage: node scripts/i18n/index.ts <types | sync [--enforce-order | --cleanup] | check [--strict]>");
process.exitCode = 1;
}
} catch (error) {
console.error("\n❌ i18n script failed:", error);
process.exitCode = 1;
}
+156
View File
@@ -0,0 +1,156 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parse } from "vue/compiler-sfc";
export const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
export const srcDir = path.join(root, "src");
export const i18nDir = path.join(srcDir, "i18n");
export const localesDir = path.join(i18nDir, "locales");
export const generatedFile = path.join(i18nDir, "messages.generated.ts");
export type Messages = Record<string, string>;
/** Tests t/$t with throwaway keys */
export const isTestFile = (file: string) => /\.test\.[jt]s$/.test(file);
export const rel = (file: string) => path.relative(root, file);
export const readJson = (file: string): unknown => JSON.parse(fs.readFileSync(file, "utf8"));
/** Flat nested messages into `{ "a.b.c": "message" }`; null counts as empty message. */
export function flatten(tree: unknown, prefix = ""): Messages {
const result: Messages = {};
for (const [key, value] of Object.entries(tree as Record<string, unknown>)) {
const fullKey = prefix + key;
if (typeof value === "string") {
result[fullKey] = value;
} else if (value === null) {
result[fullKey] = "";
} else if (typeof value === "object") {
Object.assign(result, flatten(value, `${fullKey}.`));
} else {
throw new Error(`${fullKey}: messages must be strings or objects`);
}
}
return result;
}
export const readLocale = (file: string): Messages => flatten(readJson(path.join(localesDir, file)));
/** File names of every locale except en.json, sorted */
export const translationFiles = (): string[] =>
fs
.readdirSync(localesDir)
.filter((file) => file.endsWith(".json") && file !== "en.json")
.sort();
/** .vue, .ts and .js in src/ without the locales and the generated types */
export function* sourceFiles(skipI18nDir = false, dir = srcDir): Generator<string> {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (full !== localesDir && !(skipI18nDir && full === i18nDir)) yield* sourceFiles(skipI18nDir, full);
} else if (/\.(vue|ts|js)$/.test(entry.name) && full !== generatedFile) {
yield full;
}
}
}
export function report(problems: string[]): void {
for (const problem of problems) console.error(`✗ ${problem}`);
console.error(`\n${problems.length} problem(s) found.`);
}
const blank = (text: string) => text.replace(/[^\n]/g, " ");
/** Replaces the comments of JS/TS code by spaces and, with `strings`, the content of '...' and "..." literals too */
export function maskJs(text: string, strings = false): string {
const scan = (start: number, inExpression: boolean): [string, number] => {
let out = "";
let i = start;
let depth = 0;
while (i < text.length) {
const ch = text.charAt(i);
const next = text.charAt(i + 1);
if (ch === "/" && (next === "/" || next === "*")) {
const close = next === "/" ? text.indexOf("\n", i) : text.indexOf("*/", i + 2);
const end = close === -1 ? text.length : next === "/" ? close : close + 2;
out += blank(text.slice(i, end));
i = end;
} else if (ch === "`") {
const [masked, end] = maskTemplate(i);
out += masked;
i = end;
} else if (ch === '"' || ch === "'") {
let end = i + 1;
while (end < text.length && text.charAt(end) !== ch) {
if (text.charAt(end) === "\\") end++;
else if (text.charAt(end) === "\n") break; // it's not a string
end++;
}
const closed = text.charAt(end) === ch;
const literal = text.slice(i, closed ? end + 1 : end);
out += strings && !inExpression && closed ? ch + blank(literal.slice(1, -1)) + ch : literal;
i += literal.length;
} else {
if (inExpression && ch === "{") depth++;
else if (inExpression && ch === "}") {
if (depth === 0) return [out, i];
depth--;
}
out += ch;
i++;
}
}
return [out, i];
};
// A template literal from its opening backtick -- its `${...}` expressions are scanned
const maskTemplate = (start: number): [string, number] => {
const keep = (chunk: string) => (strings ? blank(chunk) : chunk);
let out = "`";
let i = start + 1;
while (i < text.length) {
const ch = text.charAt(i);
if (ch === "\\") {
out += keep(text.slice(i, i + 2));
i += 2;
} else if (ch === "`") {
return [`${out}\``, i + 1];
} else if (ch === "$" && text.charAt(i + 1) === "{") {
const [code, end] = scan(i + 2, true);
out += `\${${code}`;
i = end;
if (text.charAt(i) === "}") {
out += "}";
i++;
}
} else {
out += keep(ch);
i++;
}
}
return [out, i];
};
return scan(0, false)[0];
}
/**
* The code of a source file without comments at the same offsets as the file. In .vue files only the template and the scripts
* are kept while styles no
*/
export function maskSource(file: string, text: string, strings = false): string {
if (!file.endsWith(".vue")) return maskJs(text, strings);
const { descriptor } = parse(text, { filename: file });
let out = text;
// each replacement keeps the length of what it replaces, so the blocks can be masked one after the other
const mask = ({ start, end }: { start: { offset: number }; end: { offset: number } }, apply: (code: string) => string) => {
out = out.slice(0, start.offset) + apply(text.slice(start.offset, end.offset)) + out.slice(end.offset);
};
for (const style of descriptor.styles) mask(style.loc, blank);
for (const script of [descriptor.script, descriptor.scriptSetup]) {
if (script) mask(script.loc, (code) => maskJs(code, strings));
}
if (descriptor.template) mask(descriptor.template.loc, (code) => code.replace(/<!--[\s\S]*?-->/g, blank));
return out;
}
+239
View File
@@ -0,0 +1,239 @@
import fs from "node:fs";
import path from "node:path";
import { DeepLClient, type TargetLanguageCode, type TranslateTextOptions } from "deepl-node";
import { localesDir, readJson, readLocale, translationFiles } from "./shared.ts";
type Tree = { [key: string]: unknown };
const MASTER_LANGUAGE = "en";
const DEEPL_LANGUAGES: Record<string, string> = {
"zh-cn": "ZH-HANS",
"zh-tw": "ZH-HANT",
pt: "PT-PT",
"pt-br": "PT-BR",
en: "EN",
"en-us": "EN-US",
"en-gb": "EN-GB",
"sv-se": "SV",
ua: "UK",
"nl-be": "NL",
cz: "CS",
bg: "BG",
};
let translator: DeepLClient | null = null;
const isTree = (value: unknown): value is Tree => typeof value === "object" && value !== null && !Array.isArray(value);
/** Reports what syncTranslations would change */
export function checkTranslations(): string[] {
const master = Object.keys(readLocale("en.json"));
const problems: string[] = [];
const translatable = (key: string) => !key.startsWith("languages.");
for (const file of translationFiles()) {
let target: Record<string, string>;
try {
target = readLocale(file);
} catch (error) {
problems.push(`${file}: cannot be read (${error instanceof Error ? error.message : error})`);
continue;
}
for (const key of master.filter(translatable)) {
if (!target[key]) problems.push(`${file}: missing "${key}"`);
}
for (const key of Object.keys(target).filter(translatable)) {
if (!master.includes(key)) problems.push(`${file}: obsolete key "${key}"`);
}
}
return problems;
}
async function translateText(text: string, targetLanguage: string, keyPath: string): Promise<string | null> {
if (!text.trim()) {
console.warn(`Skipping translation for empty text: "${text}"`);
return text;
}
if (keyPath === "languages" || keyPath.startsWith("languages.")) return text;
const hasPlaceholders = /\{[^}]+\}/.test(text);
let textToTranslate = text;
const options: TranslateTextOptions = {};
if (hasPlaceholders) {
console.log(`Found placeholder in: "${text}". Wrapping for translation.`);
textToTranslate = text.replace(/(\{[^}]+\})/g, "<ph>$1</ph>");
options.tagHandling = "xml";
options.ignoreTags = ["ph"];
}
try {
console.log(`Translating "${text}" from '${MASTER_LANGUAGE}' to '${targetLanguage}'...`);
const deeplTarget = (DEEPL_LANGUAGES[targetLanguage.toLowerCase()] ?? targetLanguage.toUpperCase()) as TargetLanguageCode;
const result = await translator?.translateText(textToTranslate, MASTER_LANGUAGE, deeplTarget, options);
await new Promise((resolve) => {setTimeout(resolve, 100)}); // avoid rate-limiting
let translated = result?.text ?? "";
if (hasPlaceholders) {
// the translator might add spaces around the tags
translated = translated.replace(/<ph>\s*(\{[^}]+\})\s*<\/ph>/g, "$1");
}
if (!translated.trim()) {
console.error(`⚠️ Translation failed for "${text}" (${keyPath}): empty result`);
return null;
}
return translated;
} catch (error) {
console.error(`⚠️ Translation failed for "${text}" (${keyPath}):`, error instanceof Error ? error.message : error);
return null;
}
}
/** Reorders `value` to match the key order of `master`, dropping keys `master` lacks */
function reorderObject(value: unknown, master: unknown): unknown {
if (!isTree(value) || !isTree(master)) return value;
const ordered: Tree = {};
for (const key of Object.keys(master)) {
if (Object.hasOwn(value, key)) ordered[key] = reorderObject(value[key], master[key]);
}
return ordered;
}
/** Add missing keys, remove obsolete ones */
async function processKeys(
master: Tree,
target: Tree,
lang: string,
parents: string[],
enforceOrder: boolean,
cleanup: boolean,
): Promise<boolean | "UNSUPPORTED"> {
let changed = false;
for (const key of Object.keys(master)) {
const masterValue = master[key];
const keyPath = [...parents, key].join(".");
// the "languages" block is always copied from master
if (key === "languages" && parents.length === 0 && !cleanup) {
if (!enforceOrder) console.log(`Copying entire "languages" object from master to ${lang}.json`);
target[key] = structuredClone(masterValue);
changed = true;
continue;
}
if (Object.hasOwn(target, key) && isTree(masterValue) !== isTree(target[key])) {
console.log(`🗑️ Removing "${keyPath}" from ${lang}.json: its structure differs from en.json`);
Reflect.deleteProperty(target, key);
changed = true;
}
if (isTree(masterValue)) {
if (!cleanup && !isTree(target[key])) {
console.log(`Creating missing object structure for "${keyPath}" in ${lang}.json`);
target[key] = {};
changed = true;
}
const child = target[key];
if (isTree(child)) {
const result = await processKeys(masterValue, child, lang, [...parents, key], enforceOrder, cleanup);
if (result === "UNSUPPORTED") {
console.log(`Skipping translation for "${lang}" due to unsupported structure.`);
return result;
}
if (result) changed = true;
}
} else if (!cleanup) {
if (typeof masterValue === "string") {
const existing = target[key];
if (!Object.hasOwn(target, key) || existing === "" || existing === null) {
if (enforceOrder) {
console.log(`Skipping new key "${keyPath}" for ${lang}.json`);
} else {
const translated = await translateText(masterValue, lang, keyPath);
if (translated === null) return "UNSUPPORTED";
if (target[key] !== translated) {
target[key] = translated;
changed = true;
}
}
}
} else if (!Object.hasOwn(target, key)) {
console.log(`Key "${keyPath}" (non-string) missing in ${lang}.json. Copying from English.`);
target[key] = masterValue;
changed = true;
}
}
}
for (const key of Object.keys(target)) {
if (!Object.hasOwn(master, key)) {
console.log(`🗑️ Removing obsolete key "${[...parents, key].join(".")}" from ${lang}.json`);
Reflect.deleteProperty(target, key);
changed = true;
}
}
return changed;
}
export async function syncTranslations({ enforceOrder, cleanup }: { enforceOrder: boolean; cleanup: boolean }): Promise<number> {
const reorder = enforceOrder && !cleanup;
if (!enforceOrder && !cleanup) {
const apiKey = process.env.DEEPL_API_KEY;
if (!apiKey) {
console.error("❌ Missing DEEPL_API_KEY in environment.");
return 1;
}
translator = new DeepLClient(apiKey);
console.warn("--- Using DeepL API for translation ---");
} else if (cleanup) {
console.warn("--- Only obsolete keys will be removed - No translations, or reordering will happen ---");
} else {
console.warn("--- Enforcing order of all files to match master language (no translations will be performed) ---");
}
const master = readJson(path.join(localesDir, "en.json")) as Tree;
const failed: string[] = [];
for (const file of translationFiles()) {
const targetFile = path.join(localesDir, file);
const lang = path.basename(file, ".json");
let original: Tree;
try {
const parsed = readJson(targetFile);
if (!isTree(parsed)) throw new Error("the root must be an object");
original = parsed;
} catch (error) {
console.error(`❌ Could not read ${targetFile}, leaving it unchanged: ${error instanceof Error ? error.message : error}`);
failed.push(file);
continue;
}
let target = structuredClone(original);
const result = await processKeys(master, target, lang, [], reorder, cleanup);
if (result === "UNSUPPORTED") {
console.error(`❌ ${targetFile} was left unchanged: a translation failed.`);
failed.push(file);
continue;
}
let orderChanged = false;
if (reorder) {
target = reorderObject(target, master) as Tree;
orderChanged = JSON.stringify(original) !== JSON.stringify(target);
if (orderChanged) console.log(`- Reordered keys in ${lang}.json`);
}
if (result || orderChanged) {
fs.writeFileSync(targetFile, `${JSON.stringify(target, null, 2)}\n`);
if (!cleanup && !reorder) console.log(`Successfully updated ${targetFile}`);
} else {
console.log(`No changes needed for ${targetFile}`);
}
}
if (failed.length) {
console.error(`\n❌ Synchronization incomplete, ${failed.length} locale(s) were not changed: ${failed.join(", ")}`);
return 1;
}
if (cleanup) console.log("\n✅ Cleanup complete (obsolete keys removed).");
else if (reorder) console.log("\n✅ Enforce order complete.");
else console.log("\n✅ Translation synchronization complete (via DeepL).");
return 0;
}
+289
View File
@@ -0,0 +1,289 @@
import fs from "node:fs";
import { generatedFile, isTestFile, maskSource, type Messages, readLocale, rel, sourceFiles, translationFiles } from "./shared.ts";
const OPTIONAL_PLACEHOLDERS = new Set(["suffix", "prefix"]);
const NAMED_PLACEHOLDER = /\{([A-Za-z_]\w*)\}/g;
/** vue-i18n literal interpolation, like {'|'} to print a pipe or @ */
const LITERAL_INTERPOLATION = /\{'[^']*'\}/g;
const REGENERATE = "Run: npm run i18n:types";
/** Named placeholders of a message in order */
function placeholders(message: string): string[] {
const names: string[] = [];
for (const match of message.matchAll(NAMED_PLACEHOLDER)) {
const name = match[1] ?? "";
if (!names.includes(name)) names.push(name);
}
return names;
}
/** Returns a description of unsupported vue-i18n syntax, or null. */
function unsupportedSyntax(message: string): string | null {
const rest = message.replace(LITERAL_INTERPOLATION, "").replace(NAMED_PLACEHOLDER, "");
if (/[{}]/.test(rest)) return "braces other than {name} placeholders";
if (rest.includes("|")) return "plural separator '|'";
if (/@[.:]/.test(rest)) return "linked message '@:' / '@.'";
return null;
}
function generate() {
const messages = readLocale("en.json");
const problems: string[] = [];
const lines: string[] = [];
for (const [key, message] of Object.entries(messages)) {
const unsupported = unsupportedSyntax(message);
if (unsupported) problems.push(`en.json: ${key} uses ${unsupported}, which the types do not support`);
const names = placeholders(message);
const params = names.length ? names.map((name) => JSON.stringify(name)).join(" | ") : "never";
lines.push(` ${JSON.stringify(key)}: ${params};`);
}
const content = `// This file is generated by scripts/i18n from src/i18n/locales/en.json.
// Run \`make generate-translation-types\` \`npm run i18n:types\` after changing en.json. Do not edit manually.
/** Every message in en.json mapped to the named placeholders. Used to type-check \`t()\` calls */
export interface MessageParams {
${lines.join("\n")}
}
/** Placeholders that callers may omit */
export type OptionalParam = ${[...OPTIONAL_PLACEHOLDERS].map((name) => JSON.stringify(name)).join(" | ")};
`;
return { content, messages, problems };
}
/** Regenerates messages.generated.ts; returns the exit code. */
export function writeTypes(): number {
const { content, messages, problems } = generate();
if (problems.length) {
for (const problem of problems) console.error(`✗ ${problem}`);
return 1;
}
fs.writeFileSync(generatedFile, content);
console.log(`Wrote ${rel(generatedFile)} (${Object.keys(messages).length} messages).`);
return 0;
}
function checkGeneratedFile(expected: string): string[] {
if (!fs.existsSync(generatedFile)) return [`${rel(generatedFile)} is missing. ${REGENERATE}`];
if (fs.readFileSync(generatedFile, "utf8") !== expected) return [`${rel(generatedFile)} is out of date. ${REGENERATE}`];
return [];
}
function checkLocalePlaceholders(enMessages: Messages): string[] {
const problems: string[] = [];
for (const file of translationFiles()) {
for (const [key, message] of Object.entries(readLocale(file))) {
const english = enMessages[key];
if (english === undefined) continue;
const expected = new Set(placeholders(english));
const actual = new Set(placeholders(message));
for (const name of actual) {
if (!expected.has(name)) problems.push(`${file}: ${key} uses {${name}}, which en.json does not provide`);
}
for (const name of expected) {
if (!actual.has(name) && !OPTIONAL_PLACEHOLDERS.has(name)) problems.push(`${file}: ${key} is missing {${name}}`);
}
const unsupported = unsupportedSyntax(message);
if (unsupported) problems.push(`${file}: ${key} uses ${unsupported}`);
}
}
return problems;
}
/** .ts files and .vue files with <script lang="ts"> are checked by vue-tsc */
function isTypeScript(file: string, text: string): boolean {
if (file.endsWith(".ts")) return true;
return file.endsWith(".vue") && /<script[^>]*lang=["']ts["']/.test(text);
}
const BYPASS_RULES = [
{
pattern: /from\s+["']vue-i18n["']/,
message: "import useI18n from '@/i18n/index.ts' instead of 'vue-i18n'",
inStrings: true,
},
{
pattern: /(^|[^\w$])\$t\b/,
message: "use t(...) from useI18n() in '@/i18n/index.ts' instead of $t(...), so the key and params are type-checked",
inStrings: false,
},
];
// The start of a t(...) call: t(, $t(, this.$t( or i18n.global.t(
const CALL_START = /(?:(?<![\w$.])(?:\$t|t|i18n\.global\.t)|\bthis\.\$t)\(/g;
interface Params {
names: Set<string>;
dynamic: boolean;
}
const lineOf = (text: string, index: number) => text.slice(0, index).split("\n").length;
function skipSpaces(text: string, index: number): number {
let i = index;
while (i < text.length && /\s/.test(text.charAt(i))) i++;
return i;
}
/** Reads a string literal starting at `index`; returns null for anything else or a dynamic template */
function readStringLiteral(text: string, index: number): { value: string; end: number } | null {
const quote = text.charAt(index);
if (quote !== '"' && quote !== "'" && quote !== "`") return null;
let i = index + 1;
let value = "";
while (i < text.length && text.charAt(i) !== quote) {
if (text.charAt(i) === "\\") {
value += text.charAt(i + 1);
i += 2;
continue;
}
if (quote === "`" && text.charAt(i) === "$" && text.charAt(i + 1) === "{") return null;
value += text.charAt(i);
i++;
}
return i < text.length ? { value, end: i + 1 } : null;
}
const isQuote = (ch: string) => ch === '"' || ch === "'" || ch === "`";
function skipString(text: string, index: number): number {
const quote = text.charAt(index);
let i = index + 1;
while (i < text.length) {
const ch = text.charAt(i);
if (ch === "\\") i++;
else if (ch === quote) return i + 1;
i++;
}
return -1;
}
const CLOSERS: Record<string, string> = { "{": "}", "[": "]", "(": ")" };
/** Finds the index of the bracket closing the one at `index`, skipping strings. */
function findClosing(text: string, index: number): number {
const stack = [CLOSERS[text.charAt(index)]];
let i = index + 1;
while (i < text.length && stack.length) {
const ch = text.charAt(i);
if (isQuote(ch)) {
const end = skipString(text, i);
if (end === -1) return -1;
i = end;
continue;
}
const closer = CLOSERS[ch];
if (closer) stack.push(closer);
else if (ch === stack.at(-1)) stack.pop();
i++;
}
return stack.length ? -1 : i - 1;
}
/** Property names of the object literal at `index`; `dynamic` when a spread or computed key hides some */
function readObjectKeys(text: string, index: number): Params | null {
const close = findClosing(text, index);
if (close === -1) return null;
const names = new Set<string>();
let dynamic = false;
let segmentStart = index + 1;
let i = index + 1;
const flush = (end: number) => {
const segment = text.slice(segmentStart, end).trim();
if (!segment) return;
if (segment.startsWith("...") || segment.startsWith("[")) {
dynamic = true;
return;
}
const match = segment.match(/^(?:([A-Za-z_$][\w$]*)|'([^']*)'|"([^"]*)")\s*(?::|$)/);
const name = match?.[1] ?? match?.[2] ?? match?.[3];
if (name === undefined) dynamic = true;
else names.add(name);
};
while (i < close) {
const ch = text.charAt(i);
if (isQuote(ch)) {
i = skipString(text, i);
continue;
}
if (ch === "{" || ch === "[" || ch === "(") {
i = findClosing(text, i) + 1;
continue;
}
if (ch === ",") {
flush(i);
segmentStart = i + 1;
}
i++;
}
flush(close);
return { names, dynamic };
}
/**
* Yields every t(...) call whose first argument is a literal key. `text` is the source without
* comments, `bare` the same without the content of its strings
*/
export function* literalCalls(text: string, bare: string): Generator<{ key: string; params: Params; index: number }> {
for (const match of bare.matchAll(CALL_START)) {
const keyStart = skipSpaces(text, match.index + match[0].length);
const key = readStringLiteral(text, keyStart);
if (!key) continue; // only vue-tsc can check it
let i = skipSpaces(text, key.end);
if (text.charAt(i) !== "," && text.charAt(i) !== ")") continue; // 'prefix.' + name if the key isn't literal
let params: Params = { names: new Set(), dynamic: false };
if (text.charAt(i) === ",") {
i = skipSpaces(text, i + 1);
if (text.charAt(i) === "{") params = readObjectKeys(text, i) ?? params;
else if (text.charAt(i) !== ")") params = { names: new Set(), dynamic: true };
}
yield { key: key.value, params, index: match.index };
}
}
function checkCall(key: string, params: Params, messages: Messages): string[] {
const expected = messages[key];
if (expected === undefined) return [`unknown translation key "${key}"`];
if (params.dynamic) return []; // spread or variable: names unknown
const known = new Set(placeholders(expected));
const problems: string[] = [];
for (const name of params.names) {
if (!known.has(name)) problems.push(`"${key}" does not take a {${name}} param`);
}
for (const name of known) {
if (!params.names.has(name) && !OPTIONAL_PLACEHOLDERS.has(name)) problems.push(`"${key}" is missing its {${name}} param`);
}
return problems;
}
function checkTypedUsage(messages: Messages, strict: boolean): string[] {
const problems: string[] = [];
for (const file of sourceFiles(true)) {
if (isTestFile(file)) continue; // tests t/$t with throwaway keys
const text = fs.readFileSync(file, "utf8");
const code = maskSource(file, text);
const bare = maskSource(file, text, true);
if (strict || isTypeScript(file, text)) {
const codeLines = code.split("\n");
const bareLines = bare.split("\n");
for (const rule of BYPASS_RULES) {
const lines = rule.inStrings ? codeLines : bareLines;
for (const [index, line] of lines.entries()) {
if (rule.pattern.test(line)) problems.push(`${rel(file)}:${index + 1}: ${rule.message}`);
}
}
}
for (const call of literalCalls(code, bare)) {
for (const problem of checkCall(call.key, call.params, messages)) {
problems.push(`${rel(file)}:${lineOf(text, call.index)}: ${problem}`);
}
}
}
return problems;
}
/** Checks the generated types, the placeholders of every locale and the t() calls in src/. With `strict`, raw `$t` and 'vue-i18n' */
export function checkTypes(strict: boolean): string[] {
const { content, messages, problems } = generate();
return [...problems, ...checkGeneratedFile(content), ...checkLocalePlaceholders(messages), ...checkTypedUsage(messages, strict)];
}
-351
View File
@@ -1,351 +0,0 @@
// scripts/sync-translations.js
import path from 'node:path';
import { globSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { Translator } from 'deepl-node';
import fs from 'fs-extra';
// Parse command line arguments
const args = process.argv.slice(2);
const checkOnly = args.includes('--check') || args.includes('-c');
const enforceOrder = args.includes('--enforce-order'); // When using this will force the order of eng, will not perform new translations. If we move keys to a different block will count as new translations and will delete it from all the languages
const cleanupOnly = args.includes('--cleanup'); // Will only delete keys that not longer exist in eng, no translation or reorder, just deletion.
// --- Configuration ---
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const localesDir = path.resolve(__dirname, '../src/i18n');
const masterLocaleFile = path.join(localesDir, 'en.json');
const masterLanguageCode = 'en';
const targetLocaleFiles = globSync(path.join(localesDir, '*.json'))
.filter(file => path.basename(file) !== `${masterLanguageCode}.json`)
const requireApiKey = !checkOnly && !enforceOrder && !cleanupOnly;
const DEEPL_API_KEY = process.env.DEEPL_API_KEY;
if (requireApiKey && !DEEPL_API_KEY) {
console.error("❌ Missing DEEPL_API_KEY in environment.");
process.exit(1);
}
const translator = requireApiKey ? new Translator(DEEPL_API_KEY) : null;
const deeplLangMap = {
'zh-cn': 'ZH-HANS',
'zh-tw': 'ZH-HANT',
'pt': 'PT-PT', // or 'PT-BR' if you want Brazilian Portuguese
'pt-br': 'PT-BR',
'en': 'EN',
'en-us': 'EN-US',
'en-gb': 'EN-GB',
'sv-se': 'SV',
'ua': 'UK',
'nl-be': 'NL',
'cz': 'CS',
'bg': 'BG',
// Add more as needed
};
// --- Translation Function using DeepL ---
async function translateText(text, targetLanguage, keyPath = '') {
if (checkOnly) {
// In check mode, just return the original text to detect changes
return text;
}
if (!text || typeof text !== 'string' || text.trim() === '') {
console.warn(`Skipping translation for empty or non-string text: "${text}"`);
return text;
}
if (keyPath === 'languages' || keyPath.startsWith('languages.')) {
return text;
}
const hasPlaceholders = /\{[^}]+\}/.test(text);
let textToTranslate = text;
const options = {};
if (hasPlaceholders) {
console.log(`Found placeholder in: "${text}". Wrapping for translation.`);
textToTranslate = text.replace(/(\{[^}]+\})/g, '<ph>$1</ph>');
options.tagHandling = 'xml';
options.ignoreTags = ['ph'];
}
try {
console.log(`Translating "${text}" from '${masterLanguageCode}' to '${targetLanguage}'...`);
const deeplTargetLang = deeplLangMap[targetLanguage.toLowerCase()] || targetLanguage.toUpperCase();
const result = await translator.translateText(textToTranslate, masterLanguageCode, deeplTargetLang, options);
// Delay to avoid rate-limiting
await new Promise(resolve => setTimeout(resolve, 100));
let translatedText = result.text;
if (hasPlaceholders) {
// Unwrap the placeholders. The translator might add spaces around tags.
translatedText = translatedText.replace(/<ph>\s*(\{[^}]+\})\s*<\/ph>/g, '$1');
}
return translatedText;
} catch (err) {
console.error(`⚠️ Translation failed for "${text}" (${keyPath}):`, err.message || err);
return ``;
}
}
// Reorder objects to match eng (master lang) key order
function reorderObject(obj, masterObj) {
// If obj is not an object, return as is
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return obj;
const newObj = {};
for (const key in masterObj) {
if (Object.hasOwn(masterObj, key)) {
if (Object.hasOwn(obj, key)) {
newObj[key] = reorderObject(obj[key], masterObj[key]);
}
}
}
return newObj;
}
// --- Recursive key processor ---
async function processKeys(masterObj, targetObj, targetLangCode, currentPathParts = [], doEnforceOrder, doCleanupOnly = false) {
let changesMade = false;
let meaningfulChanges = 0; // Only count meaningful changes
// First pass: Add/update keys from master to target
for (const key in masterObj) {
if (Object.hasOwn(masterObj, key)) {
const currentPathPartsNext = [...currentPathParts, key];
const currentKeyPath = currentPathPartsNext.join('.');
const masterValue = masterObj[key];
// Special handling for "languages" key - always copy the entire object from master
if (key === 'languages' && currentPathParts.length === 0 && !doCleanupOnly) {
if (!checkOnly && !doEnforceOrder) {
console.log(`Copying entire "languages" object from master to ${targetLangCode}.json`);
}
targetObj[key] = JSON.parse(JSON.stringify(masterValue)); // Deep copy
changesMade = true;
continue;
}
if (typeof masterValue === 'object' && masterValue !== null && !Array.isArray(masterValue)) {
if (!doCleanupOnly && (!targetObj[key] || typeof targetObj[key] !== 'object')) {
if (checkOnly) {
console.log(`Would create missing object structure for "${currentKeyPath}" in ${targetLangCode}.json`);
meaningfulChanges++; // Creating new object structures is meaningful
// In check mode, create a temporary object for processing
targetObj[key] = {};
} else {
console.log(`Creating missing object structure for "${currentKeyPath}" in ${targetLangCode}.json`);
targetObj[key] = {};
}
changesMade = true;
}
if (targetObj[key] && typeof targetObj[key] === 'object') {
const result = await processKeys( masterValue, targetObj[key], targetLangCode, currentPathPartsNext,
doEnforceOrder, doCleanupOnly);
if (result === "UNSUPPORTED") {
console.log(`Skipping translation for "${targetLangCode}" due to unsupported structure.`);
return "UNSUPPORTED";
}
if (typeof result === 'number') {
meaningfulChanges += result;
changesMade = true;
} else if (result) {
changesMade = true;
}
}
} else if (!doCleanupOnly) {
// Only handle non‑object values
if (typeof masterValue === 'string') {
const existingTargetValue = targetObj[key];
const isMissing = !Object.hasOwn(targetObj, key) || existingTargetValue === '' || existingTargetValue === null;
if (isMissing) {
if (checkOnly) {
console.log(`Would translate "${currentKeyPath}" for ${targetLangCode}.json`);
meaningfulChanges++; // Translation is meaningful
} else if (doEnforceOrder) {
// Skip translations for new keys
console.log(`Skipping new key "${currentKeyPath}" for ${targetLangCode}.json`);
} else {
// Sync translation
const result = await translateText(masterValue, targetLangCode, currentKeyPath);
if (result === "") {
return "UNSUPPORTED";
}
targetObj[key] = result;
changesMade = true;
}
}
} else {
if (!Object.hasOwn(targetObj, key)) {
if (checkOnly) {
console.log(`Would copy key "${currentKeyPath}" (non-string) from English to ${targetLangCode}.json`);
meaningfulChanges++; // Copying non-string values is meaningful
} else {
console.log(`Key "${currentKeyPath}" (non-string) missing in ${targetLangCode}.json. Copying from English.`);
targetObj[key] = masterValue;
}
changesMade = true;
}
}
}
}
}
// Second pass: Remove obsolete keys that exist in target but not in master
const keysToRemove = [];
for (const key in targetObj) {
if (Object.hasOwn(targetObj, key)) {
if (!Object.hasOwn(masterObj, key)) {
keysToRemove.push(key);
}
}
}
for (const key of keysToRemove) {
const currentKeyPath = [...currentPathParts, key].join('.');
if (checkOnly) {
console.log(`Would remove obsolete key "${currentKeyPath}" from ${targetLangCode}.json`);
meaningfulChanges++; // Removing obsolete keys is meaningful
} else {
console.log(`🗑️ Removing obsolete key "${currentKeyPath}" from ${targetLangCode}.json`);
delete targetObj[key];
}
changesMade = true;
}
return checkOnly ? meaningfulChanges : changesMade;
}
// --- Main synchronization ---
async function syncAllTranslations() {
const isSync = !checkOnly && !enforceOrder && !cleanupOnly;
const isEnforceOrder = enforceOrder && !cleanupOnly;
const isCleanup = cleanupOnly;
if (checkOnly) {
console.log("--- Checking for translation changes (no translations will be performed) ---");
} else if (isCleanup) {
console.warn("--- Only obsolete keys will be removed - No translations, or reordering will happen ---");
} else if (isEnforceOrder) {
console.warn("--- Enforcing order of all files to match master language (no translations will be performed) ---");
} else if (isSync) {
console.warn("--- Using DeepL API for translation ---");
}
if (!await fs.pathExists(masterLocaleFile)) {
console.error(`Master locale file not found: ${masterLocaleFile}`);
process.exit(1);
}
const masterContent = await fs.readJson(masterLocaleFile);
console.log(`Loaded master translations from ${masterLocaleFile}`);
let meaningfulChanges = 0;
let hasMeaningfulChanges = false;
for (const targetFile of targetLocaleFiles) {
const targetLangCode = path.basename(targetFile, '.json');
let targetContent = {};
let originalTargetContent = null;
const fileExisted = await fs.pathExists(targetFile);
if (fileExisted) {
try {
originalTargetContent = await fs.readJson(targetFile);
targetContent = JSON.parse(JSON.stringify(originalTargetContent)); // start from original
} catch (e) {
console.warn(`Warning: Could not parse ${targetFile}. Starting fresh. Error: ${e.message}`);
targetContent = {};
originalTargetContent = null;
}
} else {
if (isCleanup) continue;
console.log(`\nTarget file ${targetFile} not found. Will create for language: ${targetLangCode}.`);
}
const result = await processKeys(masterContent, targetContent, targetLangCode, [], isEnforceOrder, isCleanup);
let reorderedContent = targetContent;
let orderChanged = false;
if (isEnforceOrder && originalTargetContent && !checkOnly) {
reorderedContent = reorderObject(targetContent, masterContent);
// Compare the reordered content with the original to see if order changed
const origStr = JSON.stringify(originalTargetContent);
const reorderedStr = JSON.stringify(reorderedContent);
if (origStr !== reorderedStr) {
orderChanged = true;
console.log(`- Reordered keys in ${targetLangCode}.json`);
}
}
if (checkOnly) {
let wouldChange = false;
if (typeof result === 'number' && result > 0) {
meaningfulChanges += result;
wouldChange = true;
console.log(`Found ${result} meaningful changes needed for ${targetLangCode}.json`);
}
if (!fileExisted && !isCleanup) {
wouldChange = true;
console.log(`Would create new file for ${targetLangCode}.json`);
}
if (wouldChange) {
hasMeaningfulChanges = true;
}
} else {
const needsWrite = result || orderChanged || (!isCleanup && !fileExisted);
if (needsWrite) {
const finalContent = (isEnforceOrder && orderChanged) ? reorderedContent : targetContent;
try {
await fs.writeJson(targetFile, finalContent, { spaces: 2 });
if (!isCleanup && !isEnforceOrder) {
console.log(`Successfully ${result ? 'updated' : 'created'} ${targetFile}`);
}
} catch (error) {
console.error(`Error writing to ${targetFile}:`, error);
}
} else {
console.log(`No changes needed for ${targetFile}`);
}
}
}
if (checkOnly) {
if (hasMeaningfulChanges) {
console.log(`\n⚠️ Found ${meaningfulChanges} meaningful translation changes needed. -- update via "make sync-translations"`);
return 1; // Exit code 1 for meaningful changes needed
} else {
console.log('\n✅ No meaningful translation changes needed.');
return 0; // Exit code 0 for no meaningful changes
}
} else {
if (isCleanup) {
console.log('\n✅ Cleanup complete (obsolete keys removed).');
} else if (isEnforceOrder) {
console.log('\n✅ Enforce order complete.');
} else {
console.log('\n✅ Translation synchronization complete (via DeepL).');
}
return 0;
}
}
syncAllTranslations()
.then(exitCode => {
process.exit(exitCode);
})
.catch(error => {
console.error("\n❌ An error occurred during translation synchronization:", error);
process.exit(1);
});
+2 -2
View File
@@ -4,8 +4,8 @@
<script>
import { onMounted } from "vue";
import { mutations } from "@/store"; // Import your store's mutations
import { scheduleFullIcons } from "@/utils/loadFullIcons";
import { mutations } from "@/store/index.ts"; // Import your store's mutations
import { scheduleFullIcons } from "@/utils/loadFullIcons.js";
mutations.setLoading("main-app", true);
export default {
+2 -2
View File
@@ -1,6 +1,6 @@
import { notify } from "@/notify/index.ts"
import { getters, mutations, state } from "@/store/index.ts"
import { globalVars } from "@/utils/constants.js"
import { globalVars } from "@/utils/constants.ts"
import { downloadManager } from "@/utils/downloadManager.ts"
import {
notifyDownloadComplete,
@@ -34,7 +34,7 @@ function parseMockDataCount(value, defaultCount) {
if (value === undefined || value === null || value === '') {
return defaultCount;
}
const n = parseInt(String(value), 10);
const n = Math.trunc(Number(value));
if (!Number.isFinite(n) || n < 0) {
return defaultCount;
}
+1 -1
View File
@@ -51,7 +51,7 @@ function readCacheForScope(scope) {
if (!parsed?.viewToken || !parsed?.expiresAt) {
return null;
}
return parsed;
return { viewToken: parsed.viewToken, expiresAt: parsed.expiresAt };
} catch {
return null;
}
+6 -3
View File
@@ -18,7 +18,7 @@
close: multistate === 'close',
}"
>
<title id="toggle-navbar-title">{{ $t('general.close') }}</title>
<title id="toggle-navbar-title">{{ t('general.close') }}</title>
<path class="line top" d="M 30,33 H 70" />
<path class="line middle" d="M 30,50 H 70" />
<path class="line bottom" d="M 30,67 H 70" />
@@ -56,8 +56,9 @@ export function actionIconClass(icon: string, customIconClass?: string): string
<script setup lang="ts">
import { computed } from "vue";
import { getters, mutations } from "@/store";
import type { Prompt } from "@/store/types";
import { getters, mutations } from "@/store/index.ts";
import type { Prompt } from "@/store/types.ts";
import { useI18n } from "@/i18n/index.ts";
const props = withDefaults(
defineProps<{
@@ -82,6 +83,8 @@ const emit = defineEmits<{
action: [];
}>();
const { t } = useI18n();
const multistate = computed(() => getters.multibuttonState());
const iconClass = computed(() => actionIconClass(props.icon, props.customIconClass));
+8 -3
View File
@@ -44,6 +44,12 @@ export default {
default: false,
},
},
emits: {
"button-clicked": (_value) => true,
"remove-button-clicked": (_value) => true,
disableAll: () => true,
enableAll: () => true,
},
data() {
return {
activeButton: null, // Initially no button is active
@@ -77,12 +83,11 @@ export default {
this.$emit("remove-button-clicked", value);
} else {
// Emit remove-button-clicked for all other indexes
this.buttons.forEach((button, idx) => {
for (const [idx, button] of this.buttons.entries()) {
if (idx !== index) {
this.$emit("remove-button-clicked", button.value);
}
});
}
this.activeButton = index;
this.$emit("button-clicked", value);
}
@@ -47,7 +47,10 @@ vi.mock("@/store", () => ({
isSplitViewActive: () => false,
},
}));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/i18n/index.ts", () => ({
default: { global: { t: (key: string) => key } },
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("vue-router", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) }));
vi.mock("@/api", () => ({ resourcesApi: {}, shareApi: {}, usersApi: {} }));
vi.mock("@/notify", () => ({ notify: {} }));
+15 -15
View File
@@ -22,7 +22,7 @@
</div>
<hr class="divider">
<p v-if="newFileTemplates.length === 0 && isLoggedIn" class="context-menu-empty-message">
{{ $t('prompts.noTemplate') }}
{{ t('prompts.noTemplate') }}
</p>
<button
v-for="(item, index) in newFileTemplateItems"
@@ -39,12 +39,12 @@
<button
type="button"
class="action no-select"
:aria-label="$t('files.emptyFile')"
:title="$t('files.emptyFile')"
:aria-label="t('files.emptyFile')"
:title="t('files.emptyFile')"
@click="createEmptyFile"
>
<i :class="actionIconClass('insert_drive_file')">insert_drive_file</i>
<span>{{ $t('files.emptyFile') }}</span>
<span>{{ t('files.emptyFile') }}</span>
</button>
</template>
<template v-else>
@@ -66,7 +66,7 @@
<div
v-if="selectedCount > 0"
@mouseleave="hideInteractiveTooltip()"
@mouseenter="showHoverTooltip($t('buttons.selectedCount'), $event)"
@mouseenter="showHoverTooltip(t('buttons.selectedCount'), $event)"
class="button selected-count-header"
:class="{ 'selected-count-header--circle': selectedCount <= 99 }"
>
@@ -112,21 +112,21 @@
<script setup lang="ts">
import { computed, markRaw, onBeforeUnmount, onMounted, ref, toRaw, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useI18n } from "@/i18n/index.ts";
import { useRouter } from "vue-router";
import { resourcesApi, shareApi, usersApi } from "@/api";
import { notify } from "@/notify";
import { getters, mutations, state } from "@/store/index.js";
import { resourcesApi, shareApi, usersApi } from "@/api/index.js";
import { notify } from "@/notify/index.ts";
import { getters, mutations, state } from "@/store/index.ts";
import type { FileListItem, ReqObject } from "@/store/types.ts";
import { hideInteractiveTooltip, showHoverTooltip } from "@/utils/tooltipHelp.js";
import { goToItem, getParentDir, removeLastDir } from "@/utils/url.js";
import { copyToClipboard } from "@/utils/clipboard";
import downloadFiles from "@/utils/download";
import { canNativeShare, nativeShareFile } from "@/utils/nativeShare";
import { isRichTextPreviewMimeType, getTypeInfoFromExt } from "@/utils/mimetype";
import { isMediaRequest } from "@/utils/mediaFile";
import { copyToClipboard } from "@/utils/clipboard.js";
import downloadFiles from "@/utils/download.js";
import { canNativeShare, nativeShareFile } from "@/utils/nativeShare.js";
import { isRichTextPreviewMimeType, getTypeInfoFromExt } from "@/utils/mimetype.js";
import { isMediaRequest } from "@/utils/mediaFile.js";
import { actionIconClass } from "@/components/Action.vue";
import { popEnter, popLeave } from "@/utils/transitions";
import { popEnter, popLeave } from "@/utils/transitions.ts";
type MenuItem = FileListItem & { isDir?: boolean; from?: string };
@@ -27,7 +27,7 @@ import {
createLightningStrike,
prefersReducedMotion,
type LightningStrike,
} from "@/utils/lightning-bolt";
} from "@/utils/lightning-bolt.ts";
type Bolt = LightningStrike & { id: number };
+1 -1
View File
@@ -8,7 +8,7 @@
</template>
<script>
import { notify } from "@/notify";
import { notify } from "@/notify/index.ts";
export default {
name: "load-failed",
+7 -5
View File
@@ -11,7 +11,7 @@
@keydown.esc.prevent="$emit('close')"
/>
</div>
<div ref="scroll" class="menu-option-list-scroll" :style="scrollStyle">
<div class="menu-option-list-scroll" :style="scrollStyle">
<button
v-for="option in filteredOptions"
:key="optionKey(option.value)"
@@ -24,7 +24,7 @@
}"
:aria-selected="isSelected(option.value) ? 'true' : 'false'"
:disabled="option.disabled"
@click="$emit('select', option)"
@click="$emit('option-select', option)"
>
<span>{{ option.label }}</span>
</button>
@@ -69,9 +69,11 @@ export default {
default: undefined,
},
},
emits: ["select", "update:searchQuery", "close"],
emits: {
"option-select": (_option) => true,
"update:searchQuery": (_query) => true,
close: () => true,
},
computed: {
resolvedSearchPlaceholder() {
return this.searchPlaceholder || this.$t("general.search");
+1 -1
View File
@@ -55,7 +55,7 @@
</template>
<script>
import { notify } from "@/notify";
import { notify } from "@/notify/index.ts";
export default {
name: "notifications",
+16 -12
View File
@@ -47,12 +47,15 @@ export default {
default: 0,
},
max: {
type: [Number, String],
default: 100,
},
"val-background": {
valBackground: {
type: [Number, String],
default: null,
},
"val-text": {
valText: {
type: [Number, String],
default: null,
},
unit: {
@@ -60,23 +63,24 @@ export default {
default: "",
},
size: {
type: [Number, String],
// either a number (pixel width/height) or 'tiny', 'small',
// 'medium', 'large', 'huge', 'massive' for common sizes
default: "big",
},
"bg-color": {
bgColor: {
type: String,
default: "#eee",
},
"bar-color": {
barColor: {
type: String,
default: "var(--primaryColor)", // match .blue color to Material Design's 'Blue 500' color
},
"bar-transition": {
barTransition: {
type: String,
default: "all 0.5s ease",
},
"bar-border-radius": {
barBorderRadius: {
type: Number,
default: 8, // ~0.5em at default font size
},
@@ -88,19 +92,19 @@ export default {
type: String,
default: "",
},
"text-align": {
textAlign: {
type: String,
default: "center", // 'left', 'right'
},
"text-position": {
textPosition: {
type: String,
default: "inside", // 'bottom', 'top', 'middle', 'inside'
},
"font-size": {
fontSize: {
type: Number,
default: 13,
},
"text-fg-color": {
textFgColor: {
type: String,
default: "#000",
},
@@ -108,7 +112,7 @@ export default {
type: String,
default: 'default',
},
"help-text": {
helpText: {
type: String,
default: "",
},
@@ -133,7 +137,7 @@ export default {
displayed_text() {
// Use valText if provided (for hybrid mode text override), otherwise use val
const displayVal = this.valText !== null ? this.valText : this.val;
if (!isNumber(displayVal)) return displayVal;
// Cap % like the bar fill so nested-mount indexed size > partition total
+23 -24
View File
@@ -3,8 +3,8 @@
<!-- Search input section -->
<div class="search-input-container">
<!-- Close button visible when search is active -->
<button v-if="active" type="button" class="action" @click="close" :aria-label="$t('general.close')"
:title="$t('general.close')">
<button v-if="active" type="button" class="action" @click="close" :aria-label="t('general.close')"
:title="t('general.close')">
<i class="material-symbols">close</i>
</button>
<!-- Search icon when search is not active -->
@@ -12,13 +12,13 @@
<!-- Input field for search -->
<input id="search-input" type="text"
@keyup.exact="keyup" @input="onInput" ref="input" :autofocus="active" v-model.trim="value"
aria-label="search input" :placeholder="$t('general.search', { suffix: '...' })" />
aria-label="search input" :placeholder="t('general.search', { suffix: '...' })" />
<div v-if="eventTheme === 'halloween'" class="halloween-eyes" aria-hidden="true">
<div class="eye"><div class="pupil"></div></div>
<div class="eye right"><div class="pupil"></div></div>
</div>
</div>
<div v-show="active" id="results" ref="result">
<div v-show="active" id="results">
<div class="inputWrapper">
<ExpandDropdown
v-if="multipleSources"
@@ -27,13 +27,13 @@
transparent
:options="searchSourceOptions"
all-value="__all__"
:all-selected-label="$t('general.all')"
:all-selected-label="t('general.all')"
aria-label="search sources dropdown"
@update:model-value="updateSource"
/>
<!-- Formatted display of selected value -->
<div class="searchContext">{{ $t("search.searchContext", { context: getContext }) }}</div>
<div class="searchContext">{{ t("search.searchContext", { context: getContext }) }}</div>
</div>
<div id="result-list" ref="resultListEl">
@@ -48,8 +48,8 @@
<ToggleSwitch
class="item"
v-model="showPreviewImages"
:name="$t('search.showPreviewImages')"
:description="$t('search.showPreviewImagesDescription')"
:name="t('search.showPreviewImages')"
:description="t('search.showPreviewImagesDescription')"
/>
</template>
</SearchFilters>
@@ -90,7 +90,6 @@
:thumbnail-url="showPreviewImages ? getThumbnailUrl(s) : ''"
/>
<span class="text-container">
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
{{ basePath(s.path, s.type === "directory") }}{{ baseName(s.path) }}
</span>
<div class="filesize">{{ humanSize(s.size) }}</div>
@@ -105,7 +104,7 @@
class="button open-advanced-search-button"
@click.stop.prevent="openInAdvancedSearch"
>
{{ $t("search.openInAdvancedSearch") }}
{{ t("search.openInAdvancedSearch") }}
</button>
</div>
</div>
@@ -113,26 +112,26 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch, type Ref } from "vue";
import { useI18n } from "vue-i18n";
import { useListingContextMenu } from "@/composables/useListingContextMenu";
import { useSearchFilters } from "@/composables/useSearchFilters";
import { useSearchRequest } from "@/composables/useSearchRequest";
import { resourcesApi, toolsApi } from "@/api";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, useTemplateRef, watch, type Ref } from "vue";
import { useI18n } from "@/i18n/index.ts";
import { useListingContextMenu } from "@/composables/useListingContextMenu.ts";
import { useSearchFilters } from "@/composables/useSearchFilters.ts";
import { useSearchRequest } from "@/composables/useSearchRequest.ts";
import { resourcesApi, toolsApi } from "@/api/index.js";
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
import Icon from "@/components/files/Icon.vue";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import SearchFilters from "@/components/tools/SearchFilters.vue";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import { router } from "@/router";
import { getters, mutations, state } from "@/store";
import type { FileListItem } from "@/store/types";
import { url } from "@/utils/";
import { globalVars } from "@/utils/constants";
import { router } from "@/router/index.ts";
import { getters, mutations, state } from "@/store/index.ts";
import type { FileListItem } from "@/store/types.ts";
import { url } from "@/utils/index.ts";
import { globalVars } from "@/utils/constants.ts";
import { debounce } from "@/utils/debounce.js";
import { sortedItems } from "@/utils/sort.ts";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
import { isTypeAheadSessionActive } from "@/utils/listingTypeAhead.js";
import { shouldIgnoreOutsideTap } from "@/utils/tooltipHelp.js";
@@ -150,8 +149,8 @@ interface SearchResult {
const { t } = useI18n();
const resultListEl = ref<HTMLElement | null>(null);
const input = ref<HTMLInputElement | null>(null);
const resultListEl = useTemplateRef<HTMLElement>("resultListEl");
const input = useTemplateRef<HTMLInputElement>("input");
const noneMessage = ref(t("search.typeToSearch", { minSearchLength: globalVars.minSearchLength }));
const showPreviewImages = ref(false);
const advancedOptionsExpanded = ref(false);
+2 -2
View File
@@ -23,8 +23,8 @@
import Breadcrumbs from "@/components/files/Breadcrumbs.vue";
import ListingHeader from "@/components/files/ListingHeader.vue";
import DuplicateFinderActions from "@/components/tools/DuplicateFinderActions.vue";
import { getters, state } from "@/store";
import { eventBus } from "@/store/eventBus";
import { getters, state } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
export default {
name: "Shelf",
+4 -3
View File
@@ -36,7 +36,8 @@
max="24"
step="1"
/>
<span class="size-label">{{ editorFontSize }}px</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<!-- i18n-ignore -->
<span class="size-label">{{ editorFontSize }}px</span>
</div>
</div>
</div>
@@ -44,8 +45,8 @@
</template>
<script>
import { getters, mutations, state } from "@/store";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { getters, mutations, state } from "@/store/index.ts";
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
export default {
name: "StatusBar",
+1 -2
View File
@@ -12,14 +12,13 @@
</template>
<script>
import { getIconClass } from "@/utils/material-symbols";
import { getIconClass } from "@/utils/material-symbols.js";
export default {
name: "Toast",
props: {
toasts: {
type: Array,
required: true,
default: () => [],
},
},
+3 -3
View File
@@ -19,16 +19,16 @@
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { computed, nextTick, ref, useTemplateRef, watch } from "vue";
import type { CSSProperties } from "vue";
import { useRoute } from "vue-router";
import { state } from "@/store";
import { state } from "@/store/index.ts";
import { hideInteractiveTooltip } from "@/utils/tooltipHelp.js";
const margin = 15;
const adjustedX = ref(0);
const adjustedY = ref(0);
const tooltipRef = ref<HTMLElement | null>(null);
const tooltipRef = useTemplateRef<HTMLElement>("tooltipRef");
const route = useRoute();
const tooltip = computed(() => state.tooltip);
+23 -19
View File
@@ -18,7 +18,7 @@
position-mode="absolute"
:edge-offset="{ top: '0.5em', right: '0.5em' }"
:auto-hide="false"
@click="lyricsScrollLocked = !lyricsScrollLocked"
@press="lyricsScrollLocked = !lyricsScrollLocked"
:label="lyricsLockToggleLabel"
/>
<!-- Scrollable area -->
@@ -61,7 +61,7 @@
position-mode="absolute"
:edge-offset="{ top: '0.5em', right: '0.5em' }"
:auto-hide="false"
@click="showVisualizerSettings"
@press="showVisualizerSettings"
:label="visualizerSettingsLabel"
/>
<canvas ref="visualizerCanvas" class="visualizer-canvas"></canvas>
@@ -74,7 +74,7 @@
import SlidingTabs from "@/components/settings/SlidingTabs.vue";
import PlaybackQueue from "@/components/prompts/PlaybackQueue.vue";
import FloatingActionButton from "@/components/settings/FloatingActionButton.vue";
import { mutations, state } from "@/store";
import { mutations, state } from "@/store/index.ts";
import { visualizerConfig } from "@/utils/visualizerConfig.js";
const LAST_TAB_KEY = 'plyrSidePanelActiveTab';
@@ -111,7 +111,10 @@ export default {
audioContext: { type: Object, default: null },
audioSource: { type: Object, default: null }, // MediaElementAudioSourceNode, see https://developer.mozilla.org/en-US/docs/Web/API/MediaElementAudioSourceNode
},
emits: ["seek", "needs-audio-graph"],
emits: {
seek: (timestamp) => typeof timestamp === "number",
"needs-audio-graph": () => true,
},
data() {
return {
activeTab: (() => {
@@ -119,6 +122,7 @@ export default {
return stored && ['queue', 'lyrics', 'visualizer'].includes(stored) ? stored : 'queue';
})(),
lyricsScrollLocked: false,
resizeObserver: null,
// Visualizer state
visualizerAnalyserLeft: null,
visualizerAnalyserRight: null,
@@ -244,19 +248,8 @@ export default {
this.resizeObserver = new ResizeObserver(() => {
if (this.activeTab === 'visualizer' && this.visualizerAnalyserLeft) this.resizeVisualizer();
});
this.windowResizeHandler = () => {
if (this.activeTab === 'visualizer' && this.visualizerAnalyserLeft) this.resizeVisualizer();
};
window.addEventListener('resize', this.windowResizeHandler);
this.visibilityChangeHandler = () => {
if (this.activeTab !== 'visualizer') return;
if (document.hidden) {
this.stopVisualizer();
} else {
this.startVisualizer();
}
};
document.addEventListener('visibilitychange', this.visibilityChangeHandler);
window.addEventListener('resize', this.onWindowResize);
document.addEventListener('visibilitychange', this.onVisibilityChange);
await this.$nextTick();
const container = this.$el?.querySelector('.tab-visualizer');
if (container) this.resizeObserver.observe(container);
@@ -264,12 +257,23 @@ export default {
beforeUnmount() {
document.removeEventListener('keydown', this.onKeyDown);
this.resizeObserver?.disconnect();
window.removeEventListener('resize', this.windowResizeHandler);
document.removeEventListener('visibilitychange', this.visibilityChangeHandler);
window.removeEventListener('resize', this.onWindowResize);
document.removeEventListener('visibilitychange', this.onVisibilityChange);
this.stopVisualizer();
this.fullCleanup();
},
methods: {
onWindowResize() {
if (this.activeTab === 'visualizer' && this.visualizerAnalyserLeft) this.resizeVisualizer();
},
onVisibilityChange() {
if (this.activeTab !== 'visualizer') return;
if (document.hidden) {
this.stopVisualizer();
} else {
this.startVisualizer();
}
},
scrollToActiveLine() {
if (this.lyricsScrollLocked) return;
const el = this.$refs.lyricsScrollable;
+12 -7
View File
@@ -32,13 +32,13 @@
</template>
<script>
import { resourcesApi } from "@/api";
import { notify } from "@/notify";
import { getters, mutations, state } from "@/store";
import { url } from "@/utils";
import { resourcesApi } from "@/api/index.js";
import { notify } from "@/notify/index.ts";
import { getters, mutations, state } from "@/store/index.ts";
import { url } from "@/utils/index.ts";
import { getObjectProperty } from '@/utils/object.js';
import { goToItemNotificationButton } from "@/utils/notificationActions";
import { notifyMoveCopyFailure } from "@/utils/appNotifications";
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
import { notifyMoveCopyFailure } from "@/utils/appNotifications.js";
export default {
name: "breadcrumbs",
@@ -48,7 +48,12 @@ export default {
dragOverItem: null, // Track which breadcrumb is being dragged over
};
},
props: ["noLink"],
props: {
noLink: {
type: Boolean,
default: false,
},
},
mounted() {
this.updatePaths();
window.addEventListener('dragend', this.clearDragState);
@@ -104,8 +104,8 @@
ref="extraMenuTrigger"
type="button"
class="editor-toolbar-btn"
:title="$t('editor.md.moreOptions')"
:aria-label="$t('editor.md.moreOptions')"
:title="t('editor.md.moreOptions')"
:aria-label="t('editor.md.moreOptions')"
@mousedown.prevent
@click="toggleMenu('extra')"
>
@@ -134,16 +134,16 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { computed, onBeforeUnmount, onMounted, reactive, ref, useTemplateRef, watch } from "vue";
import { useI18n } from "@/i18n/index.ts";
import ace from "ace-builds";
import type { Ace } from "ace-builds";
import { mutations, state, getters } from "@/store";
import { eventBus } from "@/store/eventBus";
import { mutations, state, getters } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import { removeLastDir } from "@/utils/url.js";
import { popEnter, popLeave } from "@/utils/transitions.ts";
import { copyToClipboard } from "@/utils/clipboard.js";
import { notify } from "@/notify";
import { notify } from "@/notify/index.ts";
interface AnchorRange {
start: Ace.Anchor;
@@ -221,8 +221,8 @@ const lastColors = reactive(new Map<string, string>([
const colorInputRefs = new Map<string, HTMLInputElement>();
const iconMenuTriggerEls = new Map<"align" | "clipboard", HTMLElement>();
const iconMenuEls = new Map<"align" | "clipboard", HTMLElement>();
const extraMenuTrigger = ref<HTMLElement | null>(null);
const extraMenu = ref<HTMLElement | null>(null);
const extraMenuTrigger = useTemplateRef<HTMLElement>("extraMenuTrigger");
const extraMenu = useTemplateRef<HTMLElement>("extraMenu");
const toolbarButtons = computed((): ToolbarButton[] => {
// alwaysAvailable are for buttons that will be available for all filetypes, not just markdown.
+13 -11
View File
@@ -6,7 +6,6 @@
v-if="thumbnailSrc && !fullImageLoaded && !isTiff"
:src="thumbnailSrc"
class="image-ex-img"
ref="thumbnail"
aria-hidden="true"
/>
@@ -38,13 +37,13 @@
<script>
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import { notify } from "@/notify";
import { getters, mutations, state } from '@/store';
import { notify } from "@/notify/index.ts";
import { getters, mutations, state } from "@/store/index.ts";
import { acquireBestCachedThumbnailBlob, getBestCachedImage, releaseThumbnailBlob } from "@/utils/cache/imageCache.ts";
import { isRawImageMimeType } from "@/utils/mimetype";
import { resourcesApi } from "@/api";
import { isRawImageMimeType } from "@/utils/mimetype.js";
import { resourcesApi } from "@/api/index.js";
import { refreshViewToken, requestViewIdentity } from "@/api/viewToken.js";
import throttle from "@/utils/throttle";
import throttle from "@/utils/throttle.js";
export default {
components: {
@@ -56,8 +55,14 @@ export default {
default: null,
},
},
emits: {
"navigate-previous": () => true,
"navigate-next": () => true,
"close-preview": () => true,
},
data() {
return {
thumbBlobKey: null, // only read in lifecycle/sync code, never during render
moveDisabledTime: 200,
classList: [],
zoomStep: 0.25,
@@ -213,9 +218,6 @@ export default {
return this.edgeDy >= this.edgeCommitY && ay >= ax;
},
},
beforeCreate() {
this.thumbBlobKey = null;
},
created() {
this.syncThumbnailBlob();
},
@@ -525,7 +527,7 @@ export default {
this.showDismissHint = false;
} else {
this.dragOffsetX = 0;
const downward = this.edgeDy > 0 ? this.edgeDy : 0;
const downward = Math.max(this.edgeDy, 0);
this.dragOffsetY = this.rubberband(downward, this.edgeRubberMax);
this.showDismissHint = this.edgeDy >= this.edgeHintPx;
this.showNavHint = false;
@@ -549,7 +551,7 @@ export default {
this.showDismissHint = false;
} else {
this.dragOffsetX = 0;
const downward = this.edgeDy > 0 ? this.edgeDy : 0;
const downward = Math.max(this.edgeDy, 0);
this.dragOffsetY = this.rubberband(downward, this.edgeRubberMax);
this.showDismissHint = this.edgeDy >= this.edgeHintPx;
this.showNavHint = false;
+19 -12
View File
@@ -3,21 +3,21 @@
<!-- Source Selection Dropdown (hidden when browsing a fixed source, e.g. user scope row) -->
<div v-if="!hidePathChrome && showSourceSelector" class="source-selector" style="margin-bottom: 1rem;">
<label for="destinationSource" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
{{ $t("prompts.destinationSource") }}
{{ t("prompts.destinationSource") }}
</label>
<ExpandDropdown
input-id="destinationSource"
v-model="currentSource"
:options="sourceOptions"
:aria-label="$t('prompts.destinationSource')"
:aria-label="t('prompts.destinationSource')"
/>
</div>
<!-- Sortable Column Header (opt-in via sortable prop, e.g. destination pickers) -->
<div v-if="(!hidePathChrome && !fileList) || sortable || $slots.pinned" class="sticky-header" :class="{ 'header-hidden': headerHidden }">
<div v-if="(!hidePathChrome && !fileList) || sortable || $slots.sticky" class="sticky-header" :class="{ 'header-hidden': headerHidden }">
<!-- Current Path Display -->
<div v-if="!hidePathChrome && !fileList" aria-label="filelist-path" class="searchContext button clickable">
{{ $t('general.path', { suffix: ':' }) }} {{ sourcePath.path }}
{{ t('general.path', { suffix: ':' }) }} {{ sourcePath.path }}
</div>
<slot name="sticky" />
<ListingHeader v-if="sortable && !loading" :use-picker-sorting="!fileList" :use-navigation-sorting="!!fileList" />
@@ -68,24 +68,25 @@
</div>
<h2 v-if="filterQuery && !visibleItems.some((item) => item.name !== '..')" class="no-results">
<i class="material-symbols-outlined">search_off</i>
<span>{{ $t("tools.advancedSearch.noResults") }}</span>
<span>{{ t("tools.advancedSearch.noResults") }}</span>
</h2>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { state, mutations, getters } from "@/store";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from "vue";
import { state, mutations, getters } from "@/store/index.ts";
import type { FileListItem, MediaMetadata } from "@/store/types.ts";
import { url } from "@/utils";
import { resourcesApi } from "@/api";
import { url } from "@/utils/index.ts";
import { resourcesApi } from "@/api/index.js";
import ListingHeader from "@/components/files/ListingHeader.vue";
import ListingItem from "@/components/files/ListingItem.vue";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import { sortedItems } from "@/utils/sort.ts";
import { findScrollParents, findScroller, useListingVirtualization } from "@/composables/useListingVirtualization.ts";
import { useI18n } from "@/i18n/index.ts";
type ItemInfo = FileListItem & { isDir?: boolean; hasDuration?: boolean };
@@ -161,8 +162,14 @@ const emit = defineEmits<{
"update:filterQuery": [value: string];
}>();
const root = ref<HTMLElement | null>(null);
const list = ref<HTMLElement | null>(null);
defineSlots<{
sticky?(): unknown;
}>();
const { t } = useI18n();
const root = useTemplateRef<HTMLElement>("root");
const list = useTemplateRef<HTMLElement>("list");
const fallbackModified = new Date().toISOString();
const initialSource = props.browseSource || state.req.source;
@@ -373,7 +380,7 @@ async function withLoading(operation: () => Promise<unknown>) {
} finally {
const elapsed = Date.now() - startTime;
const remaining = Math.max(0, 200 - elapsed);
await new Promise((resolve) => setTimeout(resolve, remaining));
await new Promise((resolve) => { setTimeout(resolve, remaining); });
loading.value = false;
}
}
+13 -13
View File
@@ -7,7 +7,7 @@
<!-- Error state -->
<div v-if="error" class="tree-error">
<i class="material-symbols">error</i>
<span>{{ $t("prompts.error") }}</span>
<span>{{ t("prompts.error") }}</span>
</div>
<div v-if="rootNodes.length" ref="listEl" class="tree-list">
<div data-virtual-group="rows" :style="virtual.group('rows').style">
@@ -60,7 +60,7 @@
</a>
<div v-else-if="row.kind === 'empty'" class="tree-empty-folder">
<i class="material-symbols-outlined">sentiment_dissatisfied</i>
<span>{{ $t('files.lonely') }}</span>
<span>{{ t('files.lonely') }}</span>
</div>
<div v-else-if="row.kind === 'loading'" class="tree-loading">
<LoadingSpinner size="small" mode="placeholder" />
@@ -74,24 +74,24 @@
<!-- Empty state -->
<div v-if="rootNodes.length === 0 && !loading && !error" class="tree-empty">
<i class="material-symbols-outlined">sentiment_dissatisfied</i>
{{ $t('files.lonely') }}
{{ t('files.lonely') }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { resourcesApi } from '@/api';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
import { resourcesApi } from "@/api/index.js";
import Icon from '@/components/files/Icon.vue';
import LoadingSpinner from '@/components/LoadingSpinner.vue';
import i18n from '@/i18n/index.ts';
import { state, getters, mutations } from '@/store';
import { eventBus } from '@/store/eventBus';
import { state, getters, mutations } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import type { FileListItem } from '@/store/types.ts';
import { buildItemUrl, goToItem, joinPath } from '@/utils/url';
import { goToItemNotificationButton } from '@/utils/notificationActions';
import { notify } from '@/notify';
import { notifyMoveCopyFailure } from '@/utils/appNotifications';
import { buildItemUrl, goToItem, joinPath } from "@/utils/url.js";
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
import { notify } from "@/notify/index.ts";
import { notifyMoveCopyFailure } from "@/utils/appNotifications.js";
import { getObjectProperty } from '@/utils/object.js';
import { sortedItems } from '@/utils/sort.ts';
import { findScrollParents, useListingVirtualization } from '@/composables/useListingVirtualization.ts';
@@ -162,8 +162,8 @@ const props = withDefaults(
const t = i18n.global.t.bind(i18n.global);
const treeEl = ref<HTMLElement | null>(null);
const listEl = ref<HTMLElement | null>(null);
const treeEl = useTemplateRef<HTMLElement>("treeEl");
const listEl = useTemplateRef<HTMLElement>("listEl");
const rootNodes = ref<TreeNode[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
+9 -9
View File
@@ -18,12 +18,12 @@
:fbdata="threeDFbdata"
:is-thumbnail="true"
:add-load-delay="true"
@error="handle3DError" />
@load-error="handle3DError" />
<TextPreview v-else-if="shouldUseTextPreview"
:url="textPreviewUrl"
:language="textPreviewLanguage"
:modified="modified"
@error="handleTextPreviewError" />
@load-error="handleTextPreviewError" />
</span>
<!-- Regular material icon (no preview) -->
<span v-else ref="tileEl" class="image-preview" @mouseenter="hasTextPreview && handleMouseEnter()" @mouseleave="hasTextPreview && handleMouseLeave()">
@@ -40,11 +40,11 @@
</template>
<script setup lang="ts">
import { computed, inject, onBeforeUnmount, onMounted, ref, unref, watch, type Ref } from "vue";
import { fetchPreviewImage } from "@/api/resources";
import { computed, inject, onBeforeUnmount, onMounted, ref, unref, useTemplateRef, watch, type Ref } from "vue";
import { fetchPreviewImage } from "@/api/resources.js";
import { createAsyncComponent } from "@/utils/asyncComponent.js";
import { getTypeInfo } from "@/utils/mimetype";
import { getters, mutations, state } from "@/store";
import { getTypeInfo } from "@/utils/mimetype.js";
import { getters, mutations, state } from "@/store/index.ts";
import {
acquireThumbnailBlob,
getThumbnailBlobKey,
@@ -234,7 +234,7 @@ const textPreviewLanguage = computed(() => languageFromPath(props.filename));
// Measured tile width; only observed for text-previewable files so image tiles pay nothing.
const tileWidth = ref(0);
const tileEl = ref<HTMLElement | null>(null);
const tileEl = useTemplateRef<HTMLElement>("tileEl");
let tileResizeObserver: ResizeObserver | null = null;
function observeTileWidth(el: HTMLElement | null): void {
@@ -319,7 +319,7 @@ function clearPopupHoverTimeout(): void {
function cancelPreloadRequests(): void {
clearPopupHoverTimeout();
previewTimeouts.forEach(clearTimeout);
for (const timeoutId of previewTimeouts) clearTimeout(timeoutId);
previewTimeouts = [];
for (const controller of preloadAbortControllers) {
controller.abort();
@@ -483,7 +483,7 @@ function showPopupPreview(): void {
const updateThumbnail = () => {
if (!state.popupPreviewSourceInfo || state.popupPreviewSourceInfo.path !== props.path) {
previewTimeouts.forEach(clearTimeout);
for (const timeoutId of previewTimeouts) clearTimeout(timeoutId);
previewTimeouts = [];
return;
}
+2 -2
View File
@@ -10,8 +10,8 @@
</template>
<script>
import { state } from "@/store";
import { fromNow } from "@/utils/moment";
import { state } from "@/store/index.ts";
import { fromNow } from "@/utils/moment.ts";
import SettingsTable from "@/components/settings/Table.vue";
export default {
@@ -7,7 +7,6 @@
:aria-label="open ? $t('general.cancel') : $t('general.search')"
:title="open ? $t('general.cancel') : $t('general.search')"
>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<i class="material-symbols">{{ open ? "close" : "search" }}</i>
</button>
<input
@@ -37,7 +36,9 @@ export default {
default: false,
},
},
emits: ["update:modelValue"],
emits: {
"update:modelValue": (value) => typeof value === "string",
},
data() {
return {
open: false,
@@ -97,7 +97,7 @@
</template>
<script>
import { state, getters, mutations } from "@/store";
import { state, getters, mutations } from "@/store/index.ts";
export default {
name: "ListingHeader",
+44 -44
View File
@@ -53,7 +53,7 @@
class="material-symbols overlay-icon overlay-icon--bottom clickable"
aria-label="Download"
@click.stop.prevent="downloadFile"
>file_download</button> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
>file_download</button>
</div>
<div class="text">
@@ -93,23 +93,23 @@
</template>
<script>
import { computed, shallowRef } from "vue";
import { globalVars } from "@/utils/constants";
import downloadFiles from "@/utils/download";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { getKindKey } from "@/utils/mimetype";
import { isTextPreviewableType } from "@/utils/textPreview";
import { computed, provide, shallowRef } from "vue";
import { globalVars } from "@/utils/constants.ts";
import downloadFiles from "@/utils/download.js";
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
import { getKindKey } from "@/utils/mimetype.js";
import { isTextPreviewableType } from "@/utils/textPreview.ts";
import { hasCachedThumbnailBlob } from "@/utils/cache/imageCache.ts";
import { formatDuration } from "@/utils/files.js";
import { getObjectProperty } from '@/utils/object.js';
import { resourcesApi } from "@/api";
import { checkConflict } from "@/utils/upload";
import { state, getters, mutations } from "@/store"; // Import your custom store
import { url } from "@/utils";
import { formatTimestamp, fromNow } from "@/utils/moment";
import { notify } from "@/notify";
import { goToItemNotificationButton } from "@/utils/notificationActions";
import { notifyMoveCopyFailure } from "@/utils/appNotifications";
import { resourcesApi } from "@/api/index.js";
import { checkConflict } from "@/utils/upload.ts";
import { state, getters, mutations } from "@/store/index.ts"; // Import your custom store
import { url } from "@/utils/index.ts";
import { formatTimestamp, fromNow } from "@/utils/moment.ts";
import { notify } from "@/notify/index.ts";
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
import { notifyMoveCopyFailure } from "@/utils/appNotifications.js";
import Icon from "@/components/files/Icon.vue";
const selectedIndexSet = computed(() => {
@@ -204,25 +204,29 @@ export default {
localSelected: false,
};
},
beforeCreate() {
// runs before provide(), non-reactive holder on purpose
this.viewportRef = shallowRef(false);
emits: {
"item-select": (payload) => payload != null && "index" in payload,
"select-range": (payload) => payload != null && "startIndex" in payload && "endIndex" in payload,
},
provide() {
// the Icons inside this item inject it, see Icon.vue
return { inView: this.viewportRef };
},
created() {
// non-reactive on purpose, they're never read by the template
this.touches = 0;
this.touchStartX = 0;
this.touchStartY = 0;
this.isLongPress = false;
this.isSwipe = false;
this.contextTimeout = null;
this.observedEl = null;
this.observer = null;
this.isUnmounted = false;
setup() {
// The Icons inside this item inject it with Icon.vue
// we're using setup for now temporarily as is more easy than convert the whole component to script setup.
const viewportRef = shallowRef(false);
provide("inView", viewportRef);
return {
setInView(visible) {
viewportRef.value = visible;
},
touches: 0,
touchStartX: 0,
touchStartY: 0,
isLongPress: false,
isSwipe: false,
contextTimeout: null,
observedEl: null,
observer: null,
isUnmounted: false,
};
},
props: {
name: String,
@@ -491,7 +495,7 @@ export default {
if (this.$el && this.$el instanceof Element) {
this.observedEl = this.$el;
viewportCallbacks.set(this.$el, (visible) => {
this.viewportRef.value = visible;
this.setInView(visible);
if (visible) {
this.isThumbnailInView = true;
}
@@ -516,7 +520,7 @@ export default {
downloadFiles(state.selected);
} else {
// Emit selection event for local handling
this.$emit('select', { index: this.index, selected: true });
this.$emit('item-select', { index: this.index, selected: true });
}
},
/** @param {TouchEvent} event */
@@ -614,7 +618,7 @@ export default {
} else {
// Emit selection event for local handling
if (!this.localSelected) {
this.$emit('select', { index: this.index, selected: true });
this.$emit('item-select', { index: this.index, selected: true });
}
}
if (event.dataTransfer) {
@@ -687,11 +691,7 @@ export default {
const destinationDir = this.path;
// If destination dir is the same as or contains the source path, skip
if (fromPath === destinationDir || fromPath.startsWith(`${destinationDir}/`)) {
return false;
}
return true;
return !(fromPath === destinationDir || fromPath.startsWith(`${destinationDir}/`));
});
// If all items were filtered out, silently skip the operation
@@ -805,7 +805,7 @@ export default {
mutations.resetSelected();
mutations.addSelected(this.index);
} else {
this.$emit('select', { index: this.index, selected: true });
this.$emit('item-select', { index: this.index, selected: true });
}
}
}
@@ -899,7 +899,7 @@ export default {
// Local selection handling - emit events instead of updating global state
if (event.shiftKey) {
// Shift-click: select range (emit event for parent to handle)
this.$emit('selectRange', {
this.$emit('select-range', {
startIndex: state.lastSelectedIndex !== null ? state.lastSelectedIndex : this.index,
endIndex: this.index
});
@@ -908,7 +908,7 @@ export default {
// Always toggle - parent component will handle the toggle logic
// Just emit the index, parent will check current state and toggle
this.$emit('select', { index: this.index });
this.$emit('item-select', { index: this.index });
}
},
open() {
@@ -20,18 +20,19 @@
v-if="showSplitViewToggle"
icon="vertical_split"
:label="splitViewActionLabel"
@click="toggleSplitView"
@press="toggleSplitView"
/>
</template>
<script lang="ts">
import type { PropType } from "vue";
import type { Ace } from "ace-builds";
import { state, getters, mutations } from "@/store";
import i18n from "@/i18n/index.ts";
import { state, getters, mutations } from "@/store/index.ts";
import { createAsyncComponent } from "@/utils/asyncComponent.js";
import Scrollbar from "@/components/files/Scrollbar.vue";
import FloatingActionButton from "@/components/settings/FloatingActionButton.vue";
import { createScrollSyncGuard } from "@/utils/markdownScrollSync";
import { createScrollSyncGuard } from "@/utils/markdownScrollSync.ts";
const MD_SPLIT_PERCENT_KEY = "mdSplitPercent";
const LIVE_CONTENT_INTERVAL_MS = 185; // Throttle preview updates to avoid performance issues
@@ -62,7 +63,9 @@ export default {
default: null,
},
},
emits: ["resize"],
emits: {
resize: (percent: number) => typeof percent === "number",
},
data: () => ({
liveMarkdownContent: "", // editor current buffer
liveContentTimer: null as ReturnType<typeof setTimeout> | null,
@@ -112,7 +115,7 @@ export default {
return getters.showSplitViewToggle();
},
splitViewActionLabel() {
return getters.isSplitViewActive() ? this.$t("editor.exitSplitView") : this.$t("editor.splitView");
return getters.isSplitViewActive() ? i18n.global.t("editor.exitSplitView") : i18n.global.t("editor.splitView");
},
},
methods: {
@@ -13,8 +13,8 @@
</template>
<script>
import { mutations, state } from "@/store";
import { eventBus } from "@/store/eventBus";
import { mutations, state } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
function normalizeContextId() {
return `path-picker-btn-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
@@ -67,7 +67,11 @@ export default {
},
},
emits: ["update:path", "update:source", "navigate"],
emits: {
"update:path": (path) => typeof path === "string",
"update:source": (source) => typeof source === "string",
navigate: () => true,
},
data() {
return {
@@ -13,7 +13,7 @@
:key="sourceInfo.path"
:url="sourceInfo.textUrl"
:modified="sourceInfo.modified"
@error="onTextError"
@load-error="onTextError"
/>
</div>
<img v-else-if="sourceInfo?.url" :src="sourceInfo.url" alt="Popup img" @load="onImageLoad" />
@@ -22,7 +22,7 @@
<script>
import { createAsyncComponent } from "@/utils/asyncComponent.js";
import { state, getters, mutations } from "@/store";
import { state, getters, mutations } from "@/store/index.ts";
import { setImageLoaded } from "@/utils/cache/imageCache.ts";
export default {
@@ -37,14 +37,11 @@ export default {
top: "0px",
left: "0px",
},
cursorX: 0,
cursorY: 0,
rafId: null,
};
},
beforeCreate() {
// Plain fields on purpose cuz nothing in the template reads them
this.cursorX = 0;
this.cursorY = 0;
this.rafId = null;
},
watch: {
async sourceInfo(newVal) {
if (newVal) {
+4 -5
View File
@@ -32,7 +32,7 @@
</template>
<script>
import { state, mutations, getters } from "@/store";
import { state, mutations, getters } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import { isHtmlMimeType } from "@/utils/mimetype.js";
@@ -63,12 +63,11 @@ export default {
canScroll: false,
resizeObserver: null,
mutationObserver: null,
wrapperObserver: null,
wrapperRect: null,
readyTimeout: null,
};
},
created() {
this.wrapperRect = null;
this.readyTimeout = null;
},
computed: {
isScrollable() {
return getters.isScrollable();
+3 -3
View File
@@ -30,9 +30,9 @@
</template>
<script>
import { state, getters } from "@/store";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { getTypeInfo } from "@/utils/mimetype";
import { state, getters } from "@/store/index.ts";
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
import { getTypeInfo } from "@/utils/mimetype.js";
export default {
name: "ShareInfo",
@@ -1,96 +1,96 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApp, h, nextTick } from "vue";
vi.mock("@/store", () => ({
getters: { isDarkMode: () => false },
state: { sessionId: "test-session" },
}));
import TextPreview from "./TextPreview.vue";
import { clearTextPreviewCache } from "@/utils/textPreview.ts";
function payload(overrides: Record<string, unknown> = {}) {
return {
content: "",
kind: "plain",
format: "text",
truncated: false,
size: 1,
...overrides,
};
}
function stubFetch(data: unknown) {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify(data), { status: 200 })),
);
}
async function flush() {
// Wait for the fetch promise chain and queued limiter to settle.
await new Promise((resolve) => setTimeout(resolve, 0));
await nextTick();
await nextTick();
}
function mount(props: { url: string; modified?: string | null; language?: string | null }) {
const el = document.createElement("div");
document.body.appendChild(el);
const app = createApp({ render: () => h(TextPreview, props) });
app.mount(el);
return { el, app };
}
describe("TextPreview rendering", () => {
afterEach(() => {
clearTextPreviewCache();
vi.unstubAllGlobals();
document.body.innerHTML = "";
});
it("does not render a truncation fade for a truncated payload", async () => {
stubFetch(payload({ content: "<p>hello world</p>", kind: "html", format: "html", truncated: true, size: 99_999 }));
const { el, app } = mount({ url: "/api/resources/preview/text?path=%2Fa.html", modified: "1" });
await flush();
expect(el.querySelector(".text-preview-html")?.textContent).toContain("hello world");
expect(el.querySelector(".text-preview-fade")).toBeNull();
app.unmount();
});
it("uses the language prop to pick the CSV delimiter", async () => {
stubFetch(payload({ content: "a\tb\n1\t2", kind: "csv" }));
const { el, app } = mount({
url: "/api/resources/preview/text?path=%2Fdata.tsv",
modified: "1",
language: "tsv",
});
await flush();
const cells = el.querySelectorAll(".text-preview-table thead th");
expect(cells).toHaveLength(2);
expect(cells[0]?.textContent).toBe("a");
expect(cells[1]?.textContent).toBe("b");
app.unmount();
});
it("reuses the rendered HTML cache for the same url and modified time", async () => {
stubFetch(payload({ content: "<p>cached</p>", kind: "html", format: "html" }));
const props = { url: "/api/resources/preview/text?path=%2Fb.html", modified: "7" };
const first = mount(props);
await flush();
expect(first.el.querySelector(".text-preview-html")?.textContent).toContain("cached");
first.app.unmount();
// A second mount (as happens when the popup reopens) must not re-fetch.
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
fetchMock.mockClear();
const second = mount(props);
await flush();
expect(second.el.querySelector(".text-preview-html")?.textContent).toContain("cached");
expect(fetchMock).not.toHaveBeenCalled();
second.app.unmount();
});
});
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApp, h, nextTick } from "vue";
vi.mock("@/store", () => ({
getters: { isDarkMode: () => false },
state: { sessionId: "test-session" },
}));
import TextPreview from "./TextPreview.vue";
import { clearTextPreviewCache } from "@/utils/textPreview.ts";
function payload(overrides: Record<string, unknown> = {}) {
return {
content: "",
kind: "plain",
format: "text",
truncated: false,
size: 1,
...overrides,
};
}
function stubFetch(data: unknown) {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify(data), { status: 200 })),
);
}
async function flush() {
// Wait for the fetch promise chain and queued limiter to settle.
await new Promise((resolve) => { setTimeout(resolve, 0); });
await nextTick();
await nextTick();
}
function mount(props: { url: string; modified?: string | null; language?: string | null }) {
const el = document.createElement("div");
document.body.appendChild(el);
const app = createApp({ render: () => h(TextPreview, props) });
app.mount(el);
return { el, app };
}
describe("TextPreview rendering", () => {
afterEach(() => {
clearTextPreviewCache();
vi.unstubAllGlobals();
document.body.innerHTML = "";
});
it("does not render a truncation fade for a truncated payload", async () => {
stubFetch(payload({ content: "<p>hello world</p>", kind: "html", format: "html", truncated: true, size: 99_999 }));
const { el, app } = mount({ url: "/api/resources/preview/text?path=%2Fa.html", modified: "1" });
await flush();
expect(el.querySelector(".text-preview-html")?.textContent).toContain("hello world");
expect(el.querySelector(".text-preview-fade")).toBeNull();
app.unmount();
});
it("uses the language prop to pick the CSV delimiter", async () => {
stubFetch(payload({ content: "a\tb\n1\t2", kind: "csv" }));
const { el, app } = mount({
url: "/api/resources/preview/text?path=%2Fdata.tsv",
modified: "1",
language: "tsv",
});
await flush();
const cells = el.querySelectorAll(".text-preview-table thead th");
expect(cells).toHaveLength(2);
expect(cells[0]?.textContent).toBe("a");
expect(cells[1]?.textContent).toBe("b");
app.unmount();
});
it("reuses the rendered HTML cache for the same url and modified time", async () => {
stubFetch(payload({ content: "<p>cached</p>", kind: "html", format: "html" }));
const props = { url: "/api/resources/preview/text?path=%2Fb.html", modified: "7" };
const first = mount(props);
await flush();
expect(first.el.querySelector(".text-preview-html")?.textContent).toContain("cached");
first.app.unmount();
// A second mount (as happens when the popup reopens) must not re-fetch.
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
fetchMock.mockClear();
const second = mount(props);
await flush();
expect(second.el.querySelector(".text-preview-html")?.textContent).toContain("cached");
expect(fetchMock).not.toHaveBeenCalled();
second.app.unmount();
});
});
@@ -1,5 +1,5 @@
<template>
<div ref="root" class="text-preview" :class="{ 'text-preview--dark': isDark }" aria-hidden="true">
<div class="text-preview" :class="{ 'text-preview--dark': isDark }" aria-hidden="true">
<span v-if="status === 'loading'" class="material-symbols text-preview-placeholder">{{ PLACEHOLDER_ICON }}</span>
<div v-else-if="status === 'ready' && csvTable" class="text-preview-body text-preview-table-wrap">
<table class="text-preview-table">
@@ -15,7 +15,9 @@
</tbody>
</table>
</div>
<!-- biome-ignore lint/nursery/noVueVHtml: renderedHtml is sanitized -->
<div v-else-if="status === 'ready' && renderedHtml" class="text-preview-body text-preview-html" v-html="renderedHtml"></div>
<!-- biome-ignore lint/nursery/noVueVHtml: highlighted is sanitized -->
<pre v-else-if="status === 'ready' && highlighted !== null" class="text-preview-body text-preview-plain hljs" v-html="highlighted"></pre>
<pre v-else-if="status === 'ready' && payload" class="text-preview-body text-preview-plain">{{ payload.content }}</pre>
</div>
@@ -23,7 +25,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, shallowRef, watch } from "vue";
import { getters } from "@/store";
import { getters } from "@/store/index.ts";
import { applyHighlightTheme, loadHljs } from "@/utils/highlight.ts";
import { parseDelimited } from "@/utils/delimited.ts";
import {
@@ -49,7 +51,7 @@ const props = defineProps<{
language?: string | null;
}>();
const emit = defineEmits<(e: "error") => void>();
const emit = defineEmits<(e: "load-error") => void>();
// Only plain source files are syntax highlighted. Markdown and HTML are rendered instead.
const HIGHLIGHT_KINDS = new Set(["code", "css"]);
@@ -180,7 +182,7 @@ watch(
} catch {
if (current !== requestId) return;
status.value = "error";
emit("error");
emit("load-error");
}
},
{ immediate: true },
+23 -28
View File
@@ -19,7 +19,6 @@
<!-- Previous button -->
<button
v-if="enabled && hasPrevious"
ref="prevButton"
type="button"
:tabindex="showNav ? 0 : -1"
@click.prevent="handlePrevClick"
@@ -44,14 +43,13 @@
:title="$t('general.previous')"
>
<i class="material-symbols">
{{ dragState.type === 'previous' && dragState.atFullExtent ? 'list_alt' : 'chevron_left' }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ dragState.type === 'previous' && dragState.atFullExtent ? 'list_alt' : 'chevron_left' }}
</i>
</button>
<!-- Next button -->
<button
v-if="enabled && hasNext"
ref="nextButton"
type="button"
:tabindex="showNav ? 0 : -1"
@click.prevent="handleNextClick"
@@ -70,14 +68,13 @@
:title="$t('general.next')"
>
<i class="material-symbols">
{{ dragState.type === 'next' && dragState.atFullExtent ? 'list_alt' : 'chevron_right' }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ dragState.type === 'next' && dragState.atFullExtent ? 'list_alt' : 'chevron_right' }}
</i>
</button>
<!-- Close preview (same control as swipe-down / back) -->
<button
v-if="enabled && showPreviewCloseButton"
ref="closeButton"
type="button"
:tabindex="showCloseNavChrome ? 0 : -1"
@click.prevent="handleClosePreviewClick"
@@ -99,11 +96,11 @@
</template>
<script>
import { state, getters, mutations } from "@/store";
import throttle from "@/utils/throttle";
import { previewViews } from "@/utils/constants";
import { url } from "@/utils";
import { resourcesApi } from "@/api";
import { state, getters, mutations } from "@/store/index.ts";
import throttle from "@/utils/throttle.js";
import { previewViews } from "@/utils/constants.ts";
import { url } from "@/utils/index.ts";
import { resourcesApi } from "@/api/index.js";
import { navigatePlaybackQueue } from "@/utils/playbackQueue.js";
import {
isInLeftNavHoverZone,
@@ -127,6 +124,7 @@ export default {
threshold: 0, // Will be calculated as 10em in pixels
atFullExtent: false,
triggered: false,
wasDrag: false,
},
// State tracking
navigationTimeout: null,
@@ -220,6 +218,12 @@ export default {
nextRaw() {
return state.navigation.nextRaw;
},
navigationRequestKey() {
return state.req ? `${state.req.source ?? ""}\u0000${state.req.path ?? ""}` : "";
},
navigationParentItems() {
return state.req?.parentDirItems;
},
currentView() {
const view = getters.currentView();
return view;
@@ -281,18 +285,6 @@ export default {
await this.setupNavigationForCurrentItem();
}
},
'state.req': {
async handler() {
this.updateNavigationEnabled();
// Auto-setup navigation when request changes and we're enabled
if (this.enabled) {
await this.$nextTick();
await this.setupNavigationForCurrentItem();
}
},
deep: true,
immediate: false
},
async enabled(newEnabled) {
if (newEnabled && state.req) {
await this.$nextTick();
@@ -309,17 +301,20 @@ export default {
});
}, 100);
},
navigationSortOverride() {
navigationRequestKey() {
if (this.enabled && state.req) {
void this.setupNavigationForCurrentItem();
}
},
// Watch for when navigation links are set up
'state.navigation.previousLink'() {
this.showInitialNavigation();
navigationParentItems() {
if (this.enabled && state.req) {
void this.setupNavigationForCurrentItem();
}
},
'state.navigation.nextLink'() {
this.showInitialNavigation();
navigationSortOverride() {
if (this.enabled && state.req) {
void this.setupNavigationForCurrentItem();
}
},
},
async mounted() {
+10 -10
View File
@@ -38,7 +38,7 @@
<!-- Default behavior banner -->
<div class="card item">
<div class="card-content banner-content">
<i class="material-symbols-outlined">{{ sourceDenyDefault ? 'do_not_disturb_on' : 'check_circle' }}</i> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<i class="material-symbols-outlined">{{ sourceDenyDefault ? 'do_not_disturb_on' : 'check_circle' }}</i>
{{ $t("access.defaultBehavior", { suffix: ":" }) }} {{ sourceDenyDefault ? $t("access.deny") : $t("access.allow")
}}
<HelpTooltipIcon :text="$t('access.defaultBehaviorDescription')" />
@@ -66,7 +66,7 @@
:icon="addType === 'group' ? 'group_add' : 'person_add'"
class="flat-left form-compact"
:exclude="existingNames"
@select="addEntries"
@selected="addEntries"
/>
<button v-else type="button" class="button form-button flat-left form-compact" @click="addEntries([])">
<i class="material-symbols-outlined">add</i>
@@ -115,9 +115,9 @@
</template>
<script>
import { notify } from "@/notify";
import { mutations } from "@/store";
import { accessApi } from "@/api";
import { notify } from "@/notify/index.ts";
import { mutations } from "@/store/index.ts";
import { accessApi } from "@/api/index.js";
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
import FileList from "../files/FileList.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
@@ -127,8 +127,8 @@ import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import ActivityViewerButton from "@/components/settings/ActivityViewerButton.vue";
import SettingsTable from "@/components/settings/Table.vue";
import EntityPickerButton from "@/components/settings/EntityPickerButton.vue";
import { activityViewerPresets } from "@/utils/activityViewerLink";
import { eventBus } from "@/store/eventBus";
import { activityViewerPresets } from "@/utils/activityViewerLink.js";
import { eventBus } from "@/store/eventBus.ts";
export default {
name: "access",
@@ -146,7 +146,7 @@ export default {
props: {
promptId: { type: [String, Number], default: null },
sourceName: { type: String, required: true },
path: { type: String, required: true, default: "/" },
path: { type: String, default: "/" },
embedded: { type: Boolean, default: false }
},
data() {
@@ -402,7 +402,7 @@ export default {
this.saving = true;
let failed = false;
try {
for (const op of [...this.pendingDeletes]) {
for (const op of this.pendingDeletes.slice()) {
await accessApi.del(this.currentSource, this.currentPath, {
allow: op.allow,
ruleCategory: op.ruleCategory,
@@ -411,7 +411,7 @@ export default {
});
this.pendingDeletes.splice(this.pendingDeletes.indexOf(op), 1);
}
for (const op of [...this.pendingAdds]) {
for (const op of this.pendingAdds.slice()) {
await accessApi.add(this.currentSource, this.currentPath, {
allow: op.allow,
ruleCategory: op.ruleCategory,
@@ -52,7 +52,7 @@
>
<span class="permission-name">{{ permission }}</span>
<span class="permission-status" :class="{ enabled: isEnabled, disabled: !isEnabled }">
{{ isEnabled ? '✓' : '✗' }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ isEnabled ? '✓' : '✗' }}
</span>
</div>
</div>
@@ -72,11 +72,11 @@
</template>
<script>
import { mutations } from "@/store";
import { notify } from "@/notify";
import { authApi } from "@/api";
import { eventBus } from "@/store/eventBus";
import { copyToClipboard } from "@/utils/clipboard";
import { mutations } from "@/store/index.ts";
import { notify } from "@/notify/index.ts";
import { authApi } from "@/api/index.js";
import { eventBus } from "@/store/eventBus.ts";
import { copyToClipboard } from "@/utils/clipboard.js";
export default {
name: "ActionApi",
@@ -25,13 +25,13 @@
<script>
import SettingsTable from "@/components/settings/Table.vue";
import { formatTimestamp } from "@/utils/moment";
import { formatTimestamp } from "@/utils/moment.ts";
import {
buildActivityDetailRows,
activityEventLabel,
activityRowPath,
activityRowSource,
} from "@/utils/activityDetails";
} from "@/utils/activityDetails.js";
export default {
name: "ActivityEventDetails",
@@ -4,7 +4,7 @@
<Editor
:viewer-mode="true"
:content="previewContent"
:editor-mode="'json'"
editor-mode="json"
:read-only="true"
/>
</div>
@@ -13,8 +13,8 @@
<script>
import { createAsyncComponent } from "@/utils/asyncComponent.js";
import { notify } from "@/notify";
import { getAnalyticsPreview } from "@/api/settings";
import { notify } from "@/notify/index.ts";
import { getAnalyticsPreview } from "@/api/settings.js";
export default {
name: "AnalyticsDiagnostic",
+4 -4
View File
@@ -142,10 +142,10 @@
</template>
<script>
import { state, mutations, getters } from "@/store";
import { notify } from "@/notify";
import { resourcesApi } from "@/api";
import { goToItemNotificationButton } from "@/utils/notificationActions";
import { state, mutations, getters } from "@/store/index.ts";
import { notify } from "@/notify/index.ts";
import { resourcesApi } from "@/api/index.js";
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import FileList from "@/components/files/FileList.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
@@ -24,7 +24,7 @@
</template>
<script>
import { getters } from "@/store";
import { getters } from "@/store/index.ts";
export default {
name: "closeWithActiveUploads",
@@ -41,18 +41,18 @@ export default {
},
created() {
// Listen for ESC key to treat it as "Stay on Prompt"
this.escKeyHandler = (event) => {
if (event.keyCode === 27 && getters.currentPromptName() === "CloseWithActiveUploads") {
event.stopImmediatePropagation();
this.stayOnPrompt();
}
};
window.addEventListener("keydown", this.escKeyHandler, true);
},
beforeUnmount() {
window.removeEventListener("keydown", this.escKeyHandler, true);
},
methods: {
escKeyHandler(event) {
if (event.keyCode === 27 && getters.currentPromptName() === "CloseWithActiveUploads") {
event.stopImmediatePropagation();
this.stayOnPrompt();
}
},
stayOnPrompt() {
if (this.currentPrompt?.cancel) {
this.currentPrompt.cancel();
@@ -4,22 +4,21 @@
<ToggleSwitch
class="item"
v-model="showFull"
@update:modelValue="fetchConfig"
@update:model-value="fetchConfig"
:name="$t('settings.configViewerShowFull')"
/>
<ToggleSwitch
class="item"
v-model="showComments"
@update:modelValue="fetchConfig"
@update:model-value="fetchConfig"
:name="$t('settings.configViewerShowComments')"
/>
</div>
<div class="editor-container">
<Editor
:viewer-mode="true"
:content="configContent"
:editor-mode="'yaml'"
editor-mode="yaml"
:read-only="true"
/>
</div>
@@ -28,7 +27,7 @@
<script>
import { createAsyncComponent } from "@/utils/asyncComponent.js";
import { config } from "@/api/settings";
import { config } from "@/api/settings.js";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
export default {
@@ -44,8 +44,8 @@
</template>
<script>
import { mutations } from "@/store";
import { notifyMoveCopyFailure } from "@/utils/appNotifications";
import { mutations } from "@/store/index.ts";
import { notifyMoveCopyFailure } from "@/utils/appNotifications.js";
export default {
name: "copy-paste-confirm",
@@ -62,12 +62,12 @@
</template>
<script>
import { mutations } from "@/store";
import { notify } from "@/notify";
import { authApi } from "@/api";
import { mutations } from "@/store/index.ts";
import { notify } from "@/notify/index.ts";
import { authApi } from "@/api/index.js";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import { eventBus } from "@/store/eventBus";
import { eventBus } from "@/store/eventBus.ts";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
export default {
@@ -53,7 +53,7 @@
</template>
<script>
import { getters, mutations } from "@/store";
import { getters, mutations } from "@/store/index.ts";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
+18 -18
View File
@@ -3,14 +3,14 @@
<!-- Loading spinner overlay -->
<div v-show="deleting" class="loading-content">
<LoadingSpinner size="small" />
<p class="loading-text">{{ $t("prompts.operationInProgress") }}</p>
<p class="loading-text">{{ t("prompts.operationInProgress") }}</p>
</div>
<div v-show="!deleting">
<p class="card-message" v-if="itemsToDelete.length === 1">
{{ $t("prompts.deleteMessageSingle") }}
{{ t("prompts.deleteMessageSingle") }}
</p>
<p class="card-message" v-else>
{{ $t("prompts.deleteMessageMultiple", { count: itemsToDelete.length }) }}
{{ t("prompts.deleteMessageMultiple", { count: itemsToDelete.length }) }}
</p>
<div ref="list" class="delete-items-list" @contextmenu.capture.prevent.stop>
<div data-virtual-group="items" :style="virtual.group('items').style">
@@ -52,9 +52,9 @@
@click="closeTopPrompt"
class="button button--flat button--grey"
:disabled="deleting"
:aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
{{ $t("general.cancel") }}
:aria-label="t('general.cancel')"
:title="t('general.cancel')">
{{ t("general.cancel") }}
</button>
<button
type="button"
@@ -62,25 +62,25 @@
class="button button--flat button--red"
:disabled="deleting"
aria-label="Confirm-Delete"
:title="$t('general.delete')">
{{ $t("general.delete") }}
:title="t('general.delete')">
{{ t("general.delete") }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { resourcesApi } from "@/api";
import buttons from "@/utils/buttons";
import { state, getters, mutations } from "@/store";
import type { FileListItem } from "@/store/types";
import { notify } from "@/notify";
import { computed, onMounted, ref, useTemplateRef, watch } from "vue";
import { useI18n } from "@/i18n/index.ts";
import { resourcesApi } from "@/api/index.js";
import buttons from "@/utils/buttons.js";
import { state, getters, mutations } from "@/store/index.ts";
import type { FileListItem } from "@/store/types.ts";
import { notify } from "@/notify/index.ts";
import ListingItem from "@/components/files/ListingItem.vue";
import { eventBus } from "@/store/eventBus";
import { eventBus } from "@/store/eventBus.ts";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import { useListingVirtualization } from "@/composables/useListingVirtualization.ts";
import url from "@/utils/url";
import url from "@/utils/url.js";
import { removeFromFolderLists } from "@/utils/cache/listingCache.ts";
export interface DeleteItem {
@@ -111,7 +111,7 @@ defineOptions({ name: "delete" });
const { t } = useI18n();
const list = ref<HTMLElement | null>(null);
const list = useTemplateRef<HTMLElement>("list");
const failedItems = ref<FailedItem[]>([]);
const deleting = ref(false);
+16 -16
View File
@@ -1,6 +1,6 @@
<template>
<div v-if="!hasDownloads && currentPrompt && currentPrompt.confirm" class="card-content">
<p>{{ $t("prompts.downloadMessage") }}</p>
<p>{{ t("prompts.downloadMessage") }}</p>
<button
type="button"
@@ -15,7 +15,7 @@
</button>
</div>
<div v-if="!hasDownloads && (!currentPrompt?.confirm)" class="card-content lonely-message">
<span>{{ $t("files.lonely") }}</span>
<span>{{ t("files.lonely") }}</span>
</div>
<div v-if="hasDownloads" class="card-content">
<div ref="list" class="download-list">
@@ -33,9 +33,9 @@
<p class="file-name">{{ download.name }}</p>
<ProgressBar
:val="download.status === 'completed'
? $t('prompts.completed')
? t('prompts.completed')
: download.status === 'error'
? $t('prompts.error')
? t('prompts.error')
: download.loaded
"
:unit="download.status === 'completed' || download.status === 'error' ? '' : 'bytes'"
@@ -51,15 +51,15 @@
v-if="download.status === 'error'"
@click="retryDownload(download.id)"
class="action"
:aria-label="$t('general.retry')" :title="$t('general.retry')"
:aria-label="t('general.retry')" :title="t('general.retry')"
>
<i class="material-symbols">replay</i>
</button>
<button
type="button"
@click="downloadManager.cancel(download.id)"
class="action" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')"
class="action" :aria-label="t('general.cancel')"
:title="t('general.cancel')"
>
<i class="material-symbols">close</i>
</button>
@@ -80,20 +80,20 @@
@click="downloadManager.clearCompleted()"
class="button button--flat"
:disabled="!hasClearable"
:aria-label="$t('buttons.clearCompleted')" :title="$t('buttons.clearCompleted')"
:aria-label="t('buttons.clearCompleted')" :title="t('buttons.clearCompleted')"
>
{{ $t("buttons.clearCompleted") }}
{{ t("buttons.clearCompleted") }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { getters } from "@/store";
import { downloadManager } from "@/utils/downloadManager";
import type { DownloadItem } from "@/utils/downloadManager";
import { resourcesApi } from "@/api";
import { computed, onBeforeUnmount, useTemplateRef, watch } from "vue";
import { useI18n } from "@/i18n/index.ts";
import { getters } from "@/store/index.ts";
import { downloadManager } from "@/utils/downloadManager.ts";
import type { DownloadItem } from "@/utils/downloadManager.ts";
import { resourcesApi } from "@/api/index.js";
import ProgressBar from "@/components/ProgressBar.vue";
import { findScrollParents, useListingVirtualization } from "@/composables/useListingVirtualization.ts";
@@ -116,7 +116,7 @@ const vFocus = {
const formats = { zip: "zip", targz: "tar.gz" } as const;
const list = ref<HTMLElement | null>(null);
const list = useTemplateRef<HTMLElement>("list");
const currentPrompt = computed(() => getters.currentPrompt());
const downloads = computed(() => downloadManager.queue);
const hasDownloads = computed(() => downloads.value.length > 0);
@@ -45,26 +45,26 @@
type="button"
class="button button--flat button--grey"
@click="resetEditorConfig"
:aria-label="$t('player.visualizer.resetDefaults')"
:title="$t('player.visualizer.resetDefaults')"
> {{ $t("player.visualizer.resetDefaults") }}
:aria-label="t('player.visualizer.resetDefaults')"
:title="t('player.visualizer.resetDefaults')"
> {{ t("player.visualizer.resetDefaults") }}
</button>
<button
type="button"
class="button button--flat"
@click="closeTopPrompt"
:aria-label="$t('general.ok')"
:title="$t('general.ok')"
> {{ $t("general.ok") }}
:aria-label="t('general.ok')"
:title="t('general.ok')"
> {{ t("general.ok") }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { mutations } from "@/store";
import { editorConfig, saveEditorConfig, resetEditorConfig, type EditorConfig } from "@/utils/editorConfig";
import { useI18n } from "@/i18n/index.ts";
import { mutations } from "@/store/index.ts";
import { editorConfig, saveEditorConfig, resetEditorConfig, type EditorConfig } from "@/utils/editorConfig.ts";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
@@ -80,9 +80,12 @@ interface EditorSettingField {
options?: { value: string | number; label: string }[];
}
defineProps({
promptId: { type: [String, Number], default: null },
});
withDefaults(
defineProps<{
promptId?: string | number | null;
}>(),
{ promptId: null },
);
defineOptions({ name: "EditorSettings" });
@@ -38,8 +38,8 @@
</template>
<script>
import { mutations } from "@/store";
import { eventBus } from "@/store/eventBus";
import { mutations } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import EntityPickerList from "./EntityPickerList.vue";
export default {
@@ -78,9 +78,9 @@
</template>
<script>
import { accessApi, usersApi } from "@/api";
import { eventBus } from "@/store/eventBus";
import { notify } from "@/notify";
import { accessApi, usersApi } from "@/api/index.js";
import { eventBus } from "@/store/eventBus.ts";
import { notify } from "@/notify/index.ts";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import { groupNameErrorText } from "@/utils/groups.js";
@@ -123,7 +123,10 @@ export default {
default: true,
},
},
emits: ["update:modelValue", "enter"],
emits: {
"update:modelValue": (names) => Array.isArray(names),
enter: () => true,
},
data() {
return {
loading: true,
@@ -24,7 +24,7 @@
<script>
import FileList from "@/components/files/FileList.vue";
import ListingFilter from "@/components/files/ListingFilter.vue";
import { mutations } from "@/store";
import { mutations } from "@/store/index.ts";
export default {
name: "file-list",
@@ -49,10 +49,10 @@
</template>
<script>
import { accessApi } from "@/api";
import { mutations } from "@/store";
import { notify } from "@/notify";
import { eventBus } from "@/store/eventBus";
import { accessApi } from "@/api/index.js";
import { mutations } from "@/store/index.ts";
import { notify } from "@/notify/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import EntityPickerList from "./EntityPickerList.vue";
import { groupNameErrorText } from "@/utils/groups.js";
+35 -35
View File
@@ -12,23 +12,23 @@
<div class="shortcut-section">
<h3 class="section-title">{{ $t("help.functionKeys") }}</h3>
<div class="shortcut-item">
<kbd>F1</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>F1</kbd>
<span>{{ $t("help.f1") }}</span>
</div>
<div class="shortcut-item">
<kbd>F2</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>F2</kbd>
<span>{{ $t("help.f2") }}</span>
</div>
<div class="shortcut-item">
<kbd>F4</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>F4</kbd>
<span>{{ $t("help.f4") }}</span>
</div>
<div class="shortcut-item">
<kbd>DEL</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>DEL</kbd>
<span>{{ $t("help.del") }}</span>
</div>
<div class="shortcut-item">
<kbd>ESC</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>ESC</kbd>
<span>{{ $t("help.esc") }}</span>
</div>
</div>
@@ -37,23 +37,23 @@
<div class="shortcut-section">
<h3 class="section-title">{{ $t("help.mouseActions") }}</h3>
<div class="shortcut-item">
<kbd>CLICK</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CLICK</kbd>
<span>{{ $t("help.click") }}</span>
</div>
<div class="shortcut-item">
<kbd>DOUBLE CLICK</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>DOUBLE CLICK</kbd>
<span>{{ $t("help.dbClick") }}</span>
</div>
<div class="shortcut-item">
<kbd>CLICK + DRAG</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CLICK + DRAG</kbd>
<span>{{ $t("help.marqueeSelect") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + CLICK</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CTRL + CLICK</kbd>
<span>{{ $t("help.ctrl.click") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + SCROLL</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CTRL + SCROLL</kbd>
<span>{{ $t("help.ctrl.scroll") }}</span>
</div>
</div>
@@ -62,19 +62,19 @@
<div class="shortcut-section">
<h3 class="section-title">{{ $t("help.fileOperations") }}</h3>
<div class="shortcut-item">
<kbd>CTRL + C</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CTRL + C</kbd>
<span>{{ $t("help.ctrl.c") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + X</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CTRL + X</kbd>
<span>{{ $t("help.ctrl.x") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + V</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CTRL + V</kbd>
<span>{{ $t("help.ctrl.v") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + D</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CTRL + D</kbd>
<span>{{ $t("help.ctrl.d") }}</span>
</div>
@@ -84,39 +84,39 @@
<div class="shortcut-section">
<h3 class="section-title">{{ $t("general.navigation") }}</h3>
<div class="shortcut-item">
<kbd>BACKSPACE {{ $t("general.or") }} ALT + ↑</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>BACKSPACE {{ $t("general.or") }} ALT + ↑</kbd>
<span>{{ $t("help.altArrowUp") }}</span>
</div>
<div class="shortcut-item">
<kbd>ENTER {{ $t("general.or") }} ALT + ↓</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>ENTER {{ $t("general.or") }} ALT + ↓</kbd>
<span>{{ $t("help.altArrowDown") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + B</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CTRL + B</kbd>
<span>{{ $t("help.ctrl.b") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + ,</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CTRL + ,</kbd>
<span>{{ $t("help.ctrl.comma") }}</span>
</div>
<div class="shortcut-item">
<kbd>SPACE {{ $t("general.or") }} /</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>SPACE {{ $t("general.or") }} /</kbd>
<span>{{ $t("help.slash") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + E</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CTRL + E</kbd>
<span>{{ $t("help.ctrl.e") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + F1</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CTRL + F1</kbd>
<span>{{ $t("help.ctrl.f1") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + F2</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CTRL + F2</kbd>
<span>{{ $t("help.ctrl.f2") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + F3</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CTRL + F3</kbd>
<span>{{ $t("help.ctrl.f3") }}</span>
</div>
</div>
@@ -125,47 +125,47 @@
<div class="shortcut-section">
<h3 class="section-title">{{ $t("help.media.title") }}</h3>
<div class="shortcut-item">
<kbd>SPACE / K</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>SPACE / K</kbd>
<span>{{ $t("help.media.playPause") }}</span>
</div>
<div class="shortcut-item">
<kbd>1 {{ $t("general.to") }} 9</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>1 {{ $t("general.to") }} 9</kbd>
<span>{{ $t("help.media.seekPercentage") }}</span>
</div>
<div class="shortcut-item">
<kbd>← / →</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>← / →</kbd>
<span>{{ $t("help.media.seek") }}</span>
</div>
<div class="shortcut-item">
<kbd>↑ / ↓</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>↑ / ↓</kbd>
<span>{{ $t("help.media.volume") }}</span>
</div>
<div class="shortcut-item">
<kbd>Q</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>Q</kbd>
<span>{{ $t("help.media.playbackQueue") }}</span>
</div>
<div class="shortcut-item">
<kbd>E</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>E</kbd>
<span>{{ $t("help.media.switchTabs") }}</span>
</div>
<div class="shortcut-item">
<kbd>L</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>L</kbd>
<span>{{ $t("help.media.loop") }}</span>
</div>
<div class="shortcut-item">
<kbd>P</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>P</kbd>
<span>{{ $t("help.media.playbackModes") }}</span>
</div>
<div class="shortcut-item">
<kbd>F</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>F</kbd>
<span>{{ $t("help.media.fullscreen") }}</span>
</div>
<div class="shortcut-item">
<kbd>M</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>M</kbd>
<span>{{ $t("help.media.mute") }}</span>
</div>
<div class="shortcut-item">
<kbd>C</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>C</kbd>
<span>{{ $t("help.media.captions") }}</span>
</div>
</div>
@@ -181,7 +181,7 @@
</template>
<script>
import { mutations } from "@/store"; // Import the mutations
import { mutations } from "@/store/index.ts"; // Import the mutations
export default {
name: "help",
@@ -97,8 +97,8 @@
import {
materialSymbols,
getIconClass,
} from "@/utils/material-symbols";
import { mutations } from "@/store";
} from "@/utils/material-symbols.js";
import { mutations } from "@/store/index.ts";
export default {
name: "IconPicker",
+38 -38
View File
@@ -6,40 +6,40 @@
<div class="info-grid" :class="{ 'info-grid--fill': activeTab === 'access' }">
<div class="info-section" v-if="activeTab === 'basic'">
<div class="info-item">
<strong>{{ $t("prompts.displayName") }}</strong>
<strong>{{ t("prompts.displayName") }}</strong>
<span role="group" aria-label="info display name">{{ item.name }}</span>
</div>
<div class="info-item">
<strong>{{ $t("general.size") }}</strong>
<strong>{{ t("general.size") }}</strong>
<span role="group" aria-label="info size">{{ humanSize }}</span>
</div>
<div class="info-item">
<strong>{{ $t("general.type") }}</strong>
<strong>{{ t("general.type") }}</strong>
<span role="group" aria-label="info type">{{ item.type }}</span>
</div>
<div class="info-item" v-if="humanTime">
<strong>{{ $t("files.lastModified") }}</strong>
<strong>{{ t("files.lastModified") }}</strong>
<span role="group" aria-label="info last modified" :title="modTime">{{ humanTime }}</span>
</div>
<div class="info-item" v-if="humanCreatedTime">
<strong>{{ $t("files.creationTime") }}</strong>
<strong>{{ t("files.creationTime") }}</strong>
<span aria-label="info date added" :title="createdTime">{{ humanCreatedTime }}</span>
</div>
<div class="info-item" v-if="source">
<strong>{{ $t("general.source") }}</strong>
<strong>{{ t("general.source") }}</strong>
<span role="group" aria-label="info source">{{ source }}</span>
</div>
<div class="info-item" v-if="filePath">
<strong>{{ $t("general.path") }}</strong>
<strong>{{ t("general.path") }}</strong>
<span role="group" aria-label="info path" class="break-word">{{ filePath }}</span>
</div>
<div class="info-item" v-if="item.hidden !== undefined">
<strong>{{ $t("prompts.hidden") }}</strong>
<span role="group" aria-label="info hidden">{{ item.hidden ? "✓" : "✗" }}</span><!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<strong>{{ t("prompts.hidden") }}</strong>
<span role="group" aria-label="info hidden">{{ item.hidden ? "✓" : "✗" }}</span>
</div>
<div class="info-item" v-if="item.hasPreview !== undefined">
<strong>{{ $t("prompts.hasPreview") }}</strong>
<span role="group" aria-label="info has preview">{{ item.hasPreview ? "✓" : "✗" }}</span><!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<strong>{{ t("prompts.hasPreview") }}</strong>
<span role="group" aria-label="info has preview">{{ item.hasPreview ? "✓" : "✗" }}</span>
</div>
<div class="settings-items" v-if="source && !isShare">
<ActivityViewerButton :href="activityViewerHref" />
@@ -53,27 +53,27 @@
</div>
<!-- Lyrics -->
<div class="info-item" v-if="hasLyrics">
<strong>{{ $t("player.lyrics") }}</strong>
<strong>{{ t("player.lyrics") }}</strong>
<span>
<a class="info-toggle" role="button" tabindex="0" @click="toggleLyrics" @keydown.enter="toggleLyrics">
{{ showLyrics ? $t("general.hide") : $t("general.show") }}
{{ showLyrics ? t("general.hide") : t("general.show") }}
</a>
<span v-if="showLyrics" class="info-multiline">{{ lyricsText }}</span>
</span>
</div>
<!-- Subtitles -->
<div class="info-item" v-for="(track, idx) in subtitleTracks" :key="track.name">
<strong>{{ $t("prompts.subtitle", { n: idx + 1 }) }}</strong>
<strong>{{ t("prompts.subtitle", { n: idx + 1 }) }}</strong>
<span>
{{ subtitleLabel(track) }}
<a class="info-toggle" role="button" tabindex="0" @click="toggleSubtitle(track)" @keydown.enter="toggleSubtitle(track)">
{{ subtitleContent[track.name] !== undefined ? $t("general.hide") : $t("general.show") }}
{{ subtitleContent[track.name] !== undefined ? t("general.hide") : t("general.show") }}
</a>
<span v-if="subtitleContent[track.name] !== undefined" class="info-multiline">{{ subtitleContent[track.name] }}</span>
</span>
</div>
<div class="info-item" v-if="!showMediaContent">
<span>{{ $t("general.none") }}</span>
<span>{{ t("general.none") }}</span>
</div>
</div>
<div v-else-if="activeTab === 'quota'" class="info-section">
@@ -81,13 +81,13 @@
<SettingsButton
class="info-manage-link"
:name="t('quotas.title')"
:description="$t('quotas.openDescription')"
@click="openQuotaPrompt"
:description="t('quotas.openDescription')"
@activate="openQuotaPrompt"
/>
</div>
<div class="info-item">
<strong>{{ $t("general.enabled") }}</strong>
<span>{{ quotaEnabled ? $t("general.yes") : $t("general.no") }}</span>
<strong>{{ t("general.enabled") }}</strong>
<span>{{ quotaEnabled ? t("general.yes") : t("general.no") }}</span>
</div>
<div v-if="quotaEnabled" class="info-quota-usage">
<QuotaFolderBar
@@ -109,33 +109,33 @@
<div class="info-section" v-else-if="activeTab === 'checksums'">
<div class="hash-generator">
<div class="hash-select">
<label for="hash-algo">{{ $t("prompts.hashAlgorithm") }}</label>
<label for="hash-algo">{{ t("prompts.hashAlgorithm") }}</label>
<div class="form-flex-group">
<ExpandDropdown
input-id="hash-algo"
v-model="selectedHashAlgo"
class="form-form flat-right"
:options="hashAlgoOptions"
:aria-label="$t('prompts.hashAlgorithm')"
:aria-label="t('prompts.hashAlgorithm')"
/>
<button type="button" class="button form-button flat-left" @click="generateHash"
:title="$t('prompts.generate')" :aria-label="$t('prompts.generate')">
{{ $t("prompts.generate") }}
:title="t('prompts.generate')" :aria-label="t('prompts.generate')">
{{ t("prompts.generate") }}
</button>
</div>
</div>
<div class="hash-result">
<label for="hash-result">{{ $t("prompts.hashValue") }}</label>
<label for="hash-result">{{ t("prompts.hashValue") }}</label>
<div class="form-flex-group">
<input id="hash-result" class="input form-form flat-right" type="text" :value="hashResult" readonly
:placeholder="$t('prompts.selectHashAlgorithm')" />
:placeholder="t('prompts.selectHashAlgorithm')" />
<button
type="button"
class="button form-button flat-left"
@click="copyHash"
:disabled="!hashResult"
:title="$t('buttons.copyToClipboard')"
:aria-label="$t('buttons.copyToClipboard')"
:title="t('buttons.copyToClipboard')"
:aria-label="t('buttons.copyToClipboard')"
>
<i class="material-symbols-outlined" style="font-size: 16px;">content_copy</i>
</button>
@@ -152,16 +152,16 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { formatTimestamp, fromNow } from "@/utils/moment";
import { copyToClipboard } from "@/utils/clipboard";
import { formatDuration } from "@/utils/files";
import { resourcesApi, quotasApi, mediaApi } from "@/api";
import { getters, mutations, state } from "@/store";
import { useI18n } from "@/i18n/index.ts";
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
import { formatTimestamp, fromNow } from "@/utils/moment.ts";
import { copyToClipboard } from "@/utils/clipboard.js";
import { formatDuration } from "@/utils/files.js";
import { resourcesApi, quotasApi, mediaApi } from "@/api/index.js";
import { getters, mutations, state } from "@/store/index.ts";
import type { FileListItem, SubtitleTrack } from "@/store/types.ts";
import { notify } from "@/notify";
import { activityViewerPresets } from "@/utils/activityViewerLink";
import { notify } from "@/notify/index.ts";
import { activityViewerPresets } from "@/utils/activityViewerLink.js";
import SlidingTabs from "@/components/settings/SlidingTabs.vue";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import ActivityViewerButton from "@/components/settings/ActivityViewerButton.vue";
@@ -355,7 +355,7 @@ function subtitleLabel(track: SubtitleTrack) {
// Raw subtitle content are fetched on first show
async function toggleSubtitle(track: SubtitleTrack) {
if (track.name in subtitleContent.value) {
delete subtitleContent.value[track.name];
Reflect.deleteProperty(subtitleContent.value, track.name);
return;
}
try {
@@ -56,10 +56,10 @@
</template>
<script>
import { notify } from "@/notify";
import { mutations } from "@/store";
import { eventBus } from "@/store/eventBus";
import { getMediaSettings } from "@/api/settings";
import { notify } from "@/notify/index.ts";
import { mutations } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import { getMediaSettings } from "@/api/settings.js";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import {
@@ -67,7 +67,7 @@ import {
emptyMediaSettings,
mergeMediaSettings,
saveMediaSettingsPatch,
} from "@/utils/mediaSettings";
} from "@/utils/mediaSettings.js";
export default {
name: "MediaConversionFormats",
@@ -97,7 +97,7 @@
:options="backendSelectOptions"
:aria-label="$t('media.encoderBackend')"
:disabled="isLocked(`transcode.profiles.${profile.id}.encoderBackend`)"
@update:modelValue="onProfileBackendChange(profile.id, $event)" />
@update:model-value="onProfileBackendChange(profile.id, $event)" />
</div>
</div>
</template>
@@ -128,10 +128,10 @@
</template>
<script>
import { notify } from "@/notify";
import { mutations } from "@/store";
import { eventBus } from "@/store/eventBus";
import { getMediaSettings } from "@/api/settings";
import { notify } from "@/notify/index.ts";
import { mutations } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import { getMediaSettings } from "@/api/settings.js";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
@@ -142,7 +142,7 @@ import {
emptyMediaSettings,
mergeMediaSettings,
saveMediaSettingsPatch,
} from "@/utils/mediaSettings";
} from "@/utils/mediaSettings.js";
export default {
name: "MediaTranscodeSettings",
+9 -9
View File
@@ -79,24 +79,24 @@
</template>
<script>
import { mutations, state, getters } from "@/store";
import { mutations, state, getters } from "@/store/index.ts";
import FileList from "../files/FileList.vue";
import { resourcesApi } from "@/api";
import buttons from "@/utils/buttons";
import { checkConflict } from "@/utils/upload";
import { url } from "@/utils";
import { notify } from "@/notify";
import { resourcesApi } from "@/api/index.js";
import buttons from "@/utils/buttons.js";
import { checkConflict } from "@/utils/upload.ts";
import { url } from "@/utils/index.ts";
import { notify } from "@/notify/index.ts";
import {
notifyMoveCopyComplete,
notifyOperationError,
notifyMoveCopyFailure,
extractMoveCopyErrorMessage,
} from "@/utils/appNotifications";
import { goToItemNotificationButton } from "@/utils/notificationActions";
} from "@/utils/appNotifications.js";
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import PathPickerButton from "@/components/files/PathPickerButton.vue";
import ListingFilter from "@/components/files/ListingFilter.vue";
import { eventBus } from '@/store/eventBus';
import { eventBus } from "@/store/eventBus.ts";
import { removeFromFolderLists } from "@/utils/cache/listingCache.ts";
export default {
+5 -5
View File
@@ -35,11 +35,11 @@
</div>
</template>
<script>
import { resourcesApi } from "@/api";
import { getters, mutations, state } from "@/store"; // Import your custom store
import { url } from "@/utils";
import { notify } from "@/notify";
import { goToItemNotificationButton } from "@/utils/notificationActions";
import { resourcesApi } from "@/api/index.js";
import { getters, mutations, state } from "@/store/index.ts"; // Import your custom store
import { url } from "@/utils/index.ts";
import { notify } from "@/notify/index.ts";
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
export default {
name: "new-dir",
+8 -8
View File
@@ -12,7 +12,7 @@
<input ref="filenameInput" class="input" aria-label="FileName Field" v-focus type="text" @keyup.enter="submit"
v-model.trim="fileName" @input="updateFullName" />
<template v-if="fileExtension">
<span class="extension-separator">.</span> <!--eslint-disable-line @intlify/vue-i18n/no-raw-text-->
<span class="extension-separator">.</span>
<input
class="input extension-input"
type="text"
@@ -51,13 +51,13 @@
</div>
</template>
<script>
import { state } from "@/store";
import { resourcesApi } from "@/api";
import { getters, mutations } from "@/store"; // Import your custom store
import { notify } from "@/notify";
import { url } from "@/utils";
import { goToItemNotificationButton } from "@/utils/notificationActions";
import { getTypeInfoFromExt } from "@/utils/mimetype";
import { state } from "@/store/index.ts";
import { resourcesApi } from "@/api/index.js";
import { getters, mutations } from "@/store/index.ts"; // Import your custom store
import { notify } from "@/notify/index.ts";
import { url } from "@/utils/index.ts";
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
import { getTypeInfoFromExt } from "@/utils/mimetype.js";
import { getFileExtension, removePrefix } from '@/utils/files.js';
import LoadingSpinner from "@/components/LoadingSpinner.vue";
export default {
@@ -89,8 +89,8 @@
</template>
<script>
import { getters, mutations } from "@/store";
import { getTypeInfoFromExt } from "@/utils/mimetype";
import { getters, mutations } from "@/store/index.ts";
import { getTypeInfoFromExt } from "@/utils/mimetype.js";
import { createDragReorder } from "@/utils/dragAndDropReorder.js";
export default {
+30 -31
View File
@@ -2,38 +2,38 @@
<div class="card-content no-buttons">
<div class="debug-tooltip">
<div class="debug-header">
<h3 class="debug-title">🔧 {{ $t("onlyoffice.debugTitle") }}</h3> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<h3 class="debug-title">🔧 {{ $t("onlyoffice.debugTitle") }}</h3>
</div>
<div class="debug-section debug-section-basic">
<div class="debug-section-header">
<strong class="debug-section-title">⚙️ {{ $t("onlyoffice.basicConfiguration") }}</strong> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<strong class="debug-section-title">⚙️ {{ $t("onlyoffice.basicConfiguration") }}</strong>
</div>
{{ $t("onlyoffice.onlyOffice") }} {{ $t("general.path", { suffix: ":" }) }} {{ onlyOfficeUrl }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.internalUrl") }}: {{ internalUrlInfo.message }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("general.path", { suffix: ":" }) }} {{ baseURL }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("general.source", { suffix: ":" }) }} {{ source }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("general.path", { suffix: ":" }) }} {{ path }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("general.name", { suffix: ":" }) }} {{ filename }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ isShare ? `${shareHashLabel()}: ${shareHash}` : $t("onlyoffice.userRequest") }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.onlyOffice") }} {{ $t("general.path", { suffix: ":" }) }} {{ onlyOfficeUrl }}<br/>
{{ $t("onlyoffice.internalUrl") }}: {{ internalUrlInfo.message }}<br/>
{{ $t("general.path", { suffix: ":" }) }} {{ baseURL }}<br/>
{{ $t("general.source", { suffix: ":" }) }} {{ source }}<br/>
{{ $t("general.path", { suffix: ":" }) }} {{ path }}<br/>
{{ $t("general.name", { suffix: ":" }) }} {{ filename }}<br/>
{{ isShare ? `${shareHashLabel()}: ${shareHash}` : $t("onlyoffice.userRequest") }}<br/>
</div>
<div v-if="clientConfig?.document" class="debug-section debug-section-config">
<div class="debug-section-header">
<strong class="debug-section-title">🔧 {{ $t("onlyoffice.configurationDetails") }}</strong> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<strong class="debug-section-title">🔧 {{ $t("onlyoffice.configurationDetails") }}</strong>
</div>
{{ $t("onlyoffice.documentKey") }}: {{ clientConfig.document.key }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.fileType") }}: {{ clientConfig.document.fileType }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.editMode") }}: {{ clientConfig.editorConfig ? clientConfig.editorConfig.mode : 'N/A' }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.downloadURL") }}: {{ clientConfig.document.url ? `${clientConfig.document.url.substring(0, 80)}...` : 'N/A' }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.callbackURL") }}: {{ clientConfig.editorConfig?.callbackUrl ? `${clientConfig.editorConfig.callbackUrl.substring(0, 80)}...` : 'N/A' }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.documentKey") }}: {{ clientConfig.document.key }}<br/>
{{ $t("onlyoffice.fileType") }}: {{ clientConfig.document.fileType }}<br/>
{{ $t("onlyoffice.editMode") }}: {{ clientConfig.editorConfig ? clientConfig.editorConfig.mode : 'N/A' }}<br/>
{{ $t("onlyoffice.downloadURL") }}: {{ clientConfig.document.url ? `${clientConfig.document.url.substring(0, 80)}...` : 'N/A' }}<br/>
{{ $t("onlyoffice.callbackURL") }}: {{ clientConfig.editorConfig?.callbackUrl ? `${clientConfig.editorConfig.callbackUrl.substring(0, 80)}...` : 'N/A' }}<br/>
<br/>
<strong>{{ $t("onlyoffice.networkFlow") }}:</strong><br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.browser") }} ({{ windowOrigin }}) ↔ {{ $t("onlyoffice.onlyOffice") }}: {{ onlyOfficeUrl }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.onlyOffice") }} → {{ $t("onlyoffice.fileBrowser") }}: {{ downloadDomain }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<strong>{{ $t("onlyoffice.networkFlow") }}:</strong><br/>
{{ $t("onlyoffice.browser") }} ({{ windowOrigin }}) ↔ {{ $t("onlyoffice.onlyOffice") }}: {{ onlyOfficeUrl }}<br/>
{{ $t("onlyoffice.onlyOffice") }} → {{ $t("onlyoffice.fileBrowser") }}: {{ downloadDomain }}<br/>
</div>
<div class="debug-section">
<strong>{{ $t("onlyoffice.processSteps") }}:</strong><br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<strong>{{ $t("onlyoffice.processSteps") }}:</strong><br/>
<div v-for="(step, i) in debugInfo" :key="i">
{{ step.text }}
<template v-for="(line, j) in step.lines" :key="j">
@@ -42,20 +42,20 @@
</template>
<template v-if="step.link">
<br/>
<a :href="wikiLink" target="_blank" rel="noopener noreferrer">📖 {{ $t("onlyoffice.troubleshootingGuide") }}</a> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<a :href="wikiLink" target="_blank" rel="noopener noreferrer">📖 {{ $t("onlyoffice.troubleshootingGuide") }}</a>
</template>
</div>
</div>
<div v-if="onlyOfficeLogs.length > 0" class="debug-section debug-section-logs">
<div class="debug-section-header-logs">
<strong class="debug-section-title-logs">📋 {{ $t("onlyoffice.backendLogs") }}</strong> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<strong class="debug-section-title-logs">📋 {{ $t("onlyoffice.backendLogs") }}</strong>
<span class="debug-section-counter">{{ onlyOfficeLogs.length }} {{ $t("onlyoffice.entries") }}</span>
</div>
<div v-for="log in displayedLogs" :key="log.id" class="debug-log-entry">
<span class="debug-log-level" :style="{ color: getLogLevelColor(log.level) }">[{{ log.level }}]</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span class="debug-log-level" :style="{ color: getLogLevelColor(log.level) }">[{{ log.level }}]</span>
<span class="debug-log-timestamp">{{ formatTimestamp(log.timestamp) }}</span>
<span class="debug-log-component">[{{ log.component }}]</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span class="debug-log-component">[{{ log.component }}]</span>
<span class="debug-log-message">{{ log.message }}</span>
</div>
</div>
@@ -64,15 +64,14 @@
</div>
<div v-if="overallStatus === 'error'" class="debug-status-error">
<strong>❌ {{ $t("onlyoffice.errorDetected") }}</strong><br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<strong>❌ {{ $t("onlyoffice.errorDetected") }}</strong><br/>
{{ $t("onlyoffice.integrationFailed") }}<br/>
<a :href="wikiLink" target="_blank" rel="noopener noreferrer">
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
📖 {{ $t("onlyoffice.troubleshootingGuide") }}
</a>
</div>
<div v-else-if="overallStatus === 'success'" class="debug-status-success">
<strong>🎉 {{ $t("onlyoffice.documentInitialized") }}</strong><br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<strong>🎉 {{ $t("onlyoffice.documentInitialized") }}</strong><br/>
{{ $t("onlyoffice.integrationSuccess") }}
</div>
</div>
@@ -80,10 +79,10 @@
</template>
<script>
import { state, getters } from "@/store";
import { globalVars } from "@/utils/constants";
import { officeApi } from "@/api";
import { events } from "@/notify";
import { state, getters } from "@/store/index.ts";
import { globalVars } from "@/utils/constants.ts";
import { officeApi } from "@/api/index.js";
import { events } from "@/notify/index.ts";
const wikiLink = "https://filebrowserquantum.com/en/docs/integrations/office/troubleshooting/";
@@ -111,7 +110,7 @@ export default {
return {
debugInfo: [{ text: `⏳ ${this.$t("onlyoffice.startingInit")}` }],
wikiLink,
clientConfig: {},
clientConfig: { document: null, editorConfig: null },
hasErrors: false,
onlyOfficeLogs: [],
documentId: null,
+1 -1
View File
@@ -33,7 +33,7 @@
</template>
<script>
import { mutations } from "@/store";
import { mutations } from "@/store/index.ts";
export default {
name: "password",
@@ -1,6 +1,6 @@
<template>
<div class="card-content">
<file-list ref="fileList" @update:selected="updateSelection" :browseSource="listBrowseSource"
<file-list @update:selected="updateSelection" :browseSource="listBrowseSource"
:browse-path="initialBrowsePath" :hide-destination-source="hideDestinationSource" :showFiles="showFiles"
:showFolders="showFolders" :require-file-selection="requireFileSelection"
:allowed-file-types="allowedFileTypes" :title="listTitle" v-model:filter-query="filterQuery">
@@ -22,10 +22,10 @@
</template>
<script>
import { mutations, getters } from "@/store";
import { mutations, getters } from "@/store/index.ts";
import FileList from "../files/FileList.vue";
import ListingFilter from "@/components/files/ListingFilter.vue";
import { eventBus } from "@/store/eventBus";
import { eventBus } from "@/store/eventBus.ts";
export default {
name: "path-picker",
@@ -16,8 +16,8 @@
type="button"
class="clear-queue-btn"
@click="clearQueue"
:title="$t('player.clearQueue')"
:aria-label="$t('player.clearQueue')"
:title="t('player.clearQueue')"
:aria-label="t('player.clearQueue')"
>
<i class="material-symbols">delete</i>
</button>
@@ -63,7 +63,6 @@
<span v-if="item.fileType" class="file-type-badge">{{ item.fileType }}</span>
<span class="queue-item-indicator">
<span v-if="item.index === currentQueueIndex" class="current-track">
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<i class="material-symbols">{{ isPlaying ? 'pause' : 'play_arrow' }}</i>
</span>
<span v-else class="track-number">{{ item.index + 1 }}</span>
@@ -76,7 +75,7 @@
<!-- Empty state -->
<div v-else class="empty">
<i class="material-symbols">queue_music</i>
<p>{{ $t('player.emptyQueue') }}</p>
<p>{{ t('player.emptyQueue') }}</p>
</div>
</div>
@@ -96,11 +95,11 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { state, mutations, getters } from "@/store";
import { computed, nextTick, onMounted, useTemplateRef, watch } from "vue";
import { useI18n } from "@/i18n/index.ts";
import { state, mutations, getters } from "@/store/index.ts";
import type { MediaMetadata } from "@/store/types.ts";
import { url } from "@/utils";
import { url } from "@/utils/index.ts";
import {
cyclePlaybackModes,
cycleLoopState,
@@ -114,8 +113,8 @@ import {
} from '@/utils/playbackQueue.js';
import Icon from "@/components/files/Icon.vue";
import SlidingTabs from "@/components/settings/SlidingTabs.vue";
import { resourcesApi } from "@/api";
import { globalVars } from "@/utils/constants";
import { resourcesApi } from "@/api/index.js";
import { globalVars } from "@/utils/constants.ts";
import { formatDuration, getTypeFromMime, removeExtension } from "@/utils/files.js";
import { findScrollParents, useListingVirtualization } from "@/composables/useListingVirtualization.ts";
import type { VirtualItem } from "@/composables/useListingVirtualization.ts";
@@ -145,7 +144,7 @@ const props = withDefaults(
const { t } = useI18n();
const QueueList = ref<HTMLElement | null>(null);
const QueueList = useTemplateRef<HTMLElement>("QueueList");
// Only renders the rows around the screen when the queue is long
const virtual = useListingVirtualization<VirtualItem>({
container: QueueList,
+1 -1
View File
@@ -139,7 +139,7 @@ import NewFileTemplate from "./NewFileTemplate.vue";
import DefaultViewPrefs from "./DefaultViewPrefs.vue";
import MediaConversionFormats from "./MediaConversionFormats.vue";
import MediaTranscodeSettings from "./MediaTranscodeSettings.vue";
import { state, getters, mutations } from "@/store";
import { state, getters, mutations } from "@/store/index.ts";
import { getObjectProperty, omitObjectProperty, setObjectProperty } from "@/utils/object.js";
export default {
+7 -9
View File
@@ -34,11 +34,9 @@
<p>{{ $t("general.limit") }}</p>
<div class="quota-custom-row">
<QuotaCustomLimitInput
:amount="customAmount"
:unit="customUnit"
v-model:amount="customAmount"
v-model:unit="customUnit"
:aria-label="$t('general.limit')"
@update:amount="customAmount = $event"
@update:unit="customUnit = $event"
/>
</div>
</div>
@@ -53,19 +51,19 @@
</template>
<script>
import { mutations, state } from "@/store";
import { quotasApi } from "@/api";
import { mutations, state } from "@/store/index.ts";
import { quotasApi } from "@/api/index.js";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import QuotaFolderBar from "@/components/prompts/QuotaFolderBar.vue";
import ActivityViewerButton from "@/components/settings/ActivityViewerButton.vue";
import QuotaCustomLimitInput from "@/components/settings/QuotaCustomLimitInput.vue";
import { activityViewerPresets } from "@/utils/activityViewerLink";
import { notify } from "@/notify";
import { activityViewerPresets } from "@/utils/activityViewerLink.js";
import { notify } from "@/notify/index.ts";
import {
bytesFromCustomAmount,
customAmountFromBytes,
} from "@/utils/quotaUnits";
} from "@/utils/quotaUnits.js";
export default {
name: "Quota",
+7 -8
View File
@@ -11,7 +11,7 @@
<div v-if="item.type !== 'directory'" class="filename-inputs">
<i class="file-type-icon" :class="typeInfo.classes" aria-hidden="true">{{ typeInfo.materialSymbol }}</i>
<input ref="filenameInput" aria-label="New Name" class="input" :class="{ 'form-invalid': !validation.valid }" v-focus type="text" @keydown="onKeydown" @keyup="onKeyup" v-model.trim="fileName" @input="updateFullName" />
<span class="extension-separator">.</span> <!--eslint-disable-line @intlify/vue-i18n/no-raw-text-->
<span class="extension-separator">.</span>
<input
class="input extension-input"
type="text"
@@ -54,13 +54,13 @@
</div>
</template>
<script>
import { resourcesApi } from "@/api";
import { mutations, state, getters } from "@/store";
import { eventBus } from '@/store/eventBus';
import { notify } from "@/notify";
import { resourcesApi } from "@/api/index.js";
import { mutations, state, getters } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import { notify } from "@/notify/index.ts";
import { getFileExtension, removePrefix } from '@/utils/files.js';
import { getTypeInfo, getTypeInfoFromExt } from "@/utils/mimetype";
import { url } from "@/utils";
import { getTypeInfo, getTypeInfoFromExt } from "@/utils/mimetype.js";
import { url } from "@/utils/index.ts";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
export default {
name: "rename",
@@ -74,7 +74,6 @@ export default {
},
item: {
type: Object,
required: true,
default: () => ({ source: "", path: "", name: "" })
},
parentItems: { // Parent items for comparison of filenames in a preview
+1 -1
View File
@@ -24,7 +24,7 @@
</div>
</template>
<script>
import { getters, mutations } from "@/store"; // Import your custom store
import { getters, mutations } from "@/store/index.ts"; // Import your custom store
export default {
name: "replace",
@@ -54,7 +54,7 @@
</template>
<script>
import { getters, state } from "@/store";
import { getters, state } from "@/store/index.ts";
export default {
name: "replace-rename",
@@ -50,10 +50,10 @@
</template>
<script>
import { mutations } from "@/store";
import { notify } from "@/notify";
import { authApi } from "@/api";
import { initAuth } from "@/utils/auth";
import { mutations } from "@/store/index.ts";
import { notify } from "@/notify/index.ts";
import { authApi } from "@/api/index.js";
import { initAuth } from "@/utils/auth.js";
import { evaluatePasswordPolicy } from "@/utils/passwordPolicy.js";
import PasswordRequirementsHint from "@/components/PasswordRequirementsHint.vue";
@@ -32,7 +32,7 @@
</template>
<script>
import { getters, mutations } from "@/store";
import { getters, mutations } from "@/store/index.ts";
export default {
name: "saveBeforeExit",
@@ -49,18 +49,18 @@ export default {
},
created() {
// Listen for ESC key to treat it as "Keep Editing"
this.escKeyHandler = (event) => {
if (event.keyCode === 27 && getters.currentPromptName() === "SaveBeforeExit") {
event.stopImmediatePropagation();
this.keepEditing();
}
};
window.addEventListener("keydown", this.escKeyHandler, true);
},
beforeUnmount() {
window.removeEventListener("keydown", this.escKeyHandler, true);
},
methods: {
escKeyHandler(event) {
if (event.keyCode === 27 && getters.currentPromptName() === "SaveBeforeExit") {
event.stopImmediatePropagation();
this.keepEditing();
}
},
keepEditing() {
if (this.currentPrompt?.cancel) {
this.currentPrompt.cancel();
+10 -11
View File
@@ -183,24 +183,24 @@
</div>
</template>
<script>
import { notify } from "@/notify";
import { state, getters, mutations } from "@/store";
import { shareApi } from "@/api";
import { copyToClipboard } from "@/utils/clipboard";
import { fromNowSeconds } from "@/utils/moment";
import { buildItemUrl } from "@/utils/url";
import { notify } from "@/notify/index.ts";
import { state, getters, mutations } from "@/store/index.ts";
import { shareApi } from "@/api/index.js";
import { copyToClipboard } from "@/utils/clipboard.js";
import { fromNowSeconds } from "@/utils/moment.ts";
import { buildItemUrl } from "@/utils/url.js";
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
import SettingsTable from "@/components/settings/Table.vue";
import ActivityViewerButton from "@/components/settings/ActivityViewerButton.vue";
import ShareOptionsForm from "@/components/share/ShareOptionsForm.vue";
import { activityViewerPresets } from "@/utils/activityViewerLink";
import { activityViewerPresets } from "@/utils/activityViewerLink.js";
import FileList from "../files/FileList.vue";
import { eventBus } from "@/store/eventBus";
import { eventBus } from "@/store/eventBus.ts";
import {
bytesFromCustomAmount,
customAmountFromBytes,
} from "@/utils/quotaUnits";
import { applyShareDefaultsToForm } from "@/utils/shareDefaultsForm";
} from "@/utils/quotaUnits.js";
import { applyShareDefaultsToForm } from "@/utils/shareDefaultsForm.js";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
//import ViewMode from "@/components/settings/ViewMode.vue";
@@ -603,7 +603,6 @@ export default {
},
beforeUnmount() {
// Clean up event listeners
eventBus.off('apiKeysChanged', this.reloadApiKeys);
eventBus.off('shareSidebarLinksUpdated', this.handleSidebarLinksUpdate);
eventBus.off('pathSelected', this.onBannerFaviconPathSelected);
eventBus.off('pathPickerCancelled', this.onBannerFaviconPathPickerCancelled);
@@ -8,7 +8,7 @@
enforceable
:enforced="enforced"
:read-only-source="false"
@enforced-change="onEnforcedChange"
@enforced-change="({ field, value }) => onEnforcedChange(field, value)"
@customize-sidebar-links="openSidebarLinksCustomization"
/>
</div>
@@ -38,17 +38,17 @@
</template>
<script>
import { notify } from "@/notify";
import { mutations } from "@/store";
import { eventBus } from "@/store/eventBus";
import { getShareDefaults, patchShareDefaults } from "@/api/settings";
import { notify } from "@/notify/index.ts";
import { mutations } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
import { getShareDefaults, patchShareDefaults } from "@/api/settings.js";
import ShareOptionsForm from "@/components/share/ShareOptionsForm.vue";
import {
emptyShareDefaultsEnforced,
emptyShareDefaultsValues,
shareDefaultsFromApi,
shareDefaultsToApiPayload,
} from "@/utils/shareDefaultsForm";
} from "@/utils/shareDefaultsForm.js";
export default {
name: "share-defaults",
@@ -34,8 +34,8 @@
</template>
<script>
import { state, getters } from "@/store";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { state, getters } from "@/store/index.ts";
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
import QrcodeVue from "qrcode.vue";
export default {
@@ -52,11 +52,11 @@
</template>
<script>
import { shareApi } from "@/api";
import { shareApi } from "@/api/index.js";
import SettingsTable from "@/components/settings/Table.vue";
import Errors from "@/views/Errors.vue";
import { mutations } from "@/store";
import { eventBus } from "@/store/eventBus";
import { mutations } from "@/store/index.ts";
import { eventBus } from "@/store/eventBus.ts";
export default {
name: "share-picker",
@@ -37,9 +37,9 @@
</template>
<script>
import { notify } from "@/notify";
import { mutations } from "@/store";
import { getSidebarLinkDefaults, patchSidebarLinkDefaults } from "@/api/settings";
import { notify } from "@/notify/index.ts";
import { mutations } from "@/store/index.ts";
import { getSidebarLinkDefaults, patchSidebarLinkDefaults } from "@/api/settings.js";
import SidebarLinksEditor from "@/components/sidebar/SidebarLinksEditor.vue";
export default {
@@ -1,6 +1,5 @@
<template>
<SidebarLinksEditor
ref="editor"
embedded
:mode="context"
:share-data="shareData"
@@ -11,10 +10,10 @@
</template>
<script>
import { notify } from "@/notify";
import { mutations } from "@/store";
import { shareApi } from "@/api";
import { eventBus } from "@/store/eventBus";
import { notify } from "@/notify/index.ts";
import { mutations } from "@/store/index.ts";
import { shareApi } from "@/api/index.js";
import { eventBus } from "@/store/eventBus.ts";
import SidebarLinksEditor from "@/components/sidebar/SidebarLinksEditor.vue";
export default {
@@ -1,31 +1,30 @@
<template>
<div class="card-content no-buttons">
<div v-if="!isMobile" class="shortcut-section">
<h3 class="section-title">⌨️ {{ $t("threejs.keyboard") }}</h3> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<h3 class="section-title">⌨️ {{ $t("threejs.keyboard") }}</h3>
<div class="shortcut-item">
<kbd>{{ $t("general.space") }}</kbd>
<span>{{ spaceText }}</span>
</div>
<div class="shortcut-item">
<kbd>R</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>R</kbd>
<span>{{ $t("general.reset") }}</span>
</div>
<div class="shortcut-item">
<kbd>Q / E</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>Q / E</kbd>
<span>{{ $t("threejs.rotateY") }}</span>
</div>
<div class="shortcut-item">
<kbd>W / S</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>W / S</kbd>
<span>{{ $t("threejs.rotateX") }}</span>
</div>
<div class="shortcut-item">
<kbd>+ / -</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>+ / -</kbd>
<span>{{ $t("general.zoom") }}</span>
</div>
</div>
<div class="shortcut-section">
<h3 class="section-title">🎨 {{ $t("general.background") }}</h3> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<h3 class="section-title">🎨 {{ $t("general.background") }}</h3>
<div class="color-control">
<input
type="color"
@@ -61,6 +60,9 @@ export default {
default: false,
},
},
emits: {
"update:backgroundColor": (color) => typeof color === "string",
},
computed: {
spaceText() {
return this.hasAnimations
@@ -38,9 +38,9 @@
</template>
<script>
import { notify } from "@/notify";
import { mutations } from "@/store";
import { getToolAccessDefaults, patchToolAccessDefaults } from "@/api/settings";
import { notify } from "@/notify/index.ts";
import { mutations } from "@/store/index.ts";
import { getToolAccessDefaults, patchToolAccessDefaults } from "@/api/settings.js";
import ToolsAccessEditor from "@/components/tools/ToolsAccessEditor.vue";
export default {
+5 -5
View File
@@ -35,11 +35,11 @@
</template>
<script>
import { mutations } from "@/store";
import { notify } from "@/notify";
import { authApi } from "@/api";
import { initAuth } from "@/utils/auth";
import { globalVars } from "@/utils/constants";
import { mutations } from "@/store/index.ts";
import { notify } from "@/notify/index.ts";
import { authApi } from "@/api/index.js";
import { initAuth } from "@/utils/auth.js";
import { globalVars } from "@/utils/constants.ts";
import QrcodeVue from "qrcode.vue";
export default {
@@ -146,11 +146,11 @@
</template>
<script>
import { state, mutations, getters } from "@/store";
import { url } from "@/utils";
import { notify } from "@/notify";
import { resourcesApi } from "@/api";
import { goToItemNotificationButton } from "@/utils/notificationActions";
import { state, mutations, getters } from "@/store/index.ts";
import { url } from "@/utils/index.ts";
import { notify } from "@/notify/index.ts";
import { resourcesApi } from "@/api/index.js";
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
import FileList from "@/components/files/FileList.vue";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
+43 -43
View File
@@ -2,12 +2,12 @@
<div class="card-content">
<p style="text-align: center">{{ uploadSettingsDescription }}</p>
<div>
<SettingsItem :title="$t('fileLoading.uploadSettings')" :collapsable="true" :start-collapsed="true">
<SettingsItem :title="t('fileLoading.uploadSettings')" :collapsable="true" :start-collapsed="true">
<div class="settings-items upload-settings">
<div class="settings-number-input item">
<div class="no-padding">
<label for="maxConcurrentUpload">{{ $t("fileLoading.maxConcurrentUpload") }}</label>
<HelpTooltipIcon :text="$t('fileLoading.maxConcurrentUploadHelp')" />
<label for="maxConcurrentUpload">{{ t("fileLoading.maxConcurrentUpload") }}</label>
<HelpTooltipIcon :text="t('fileLoading.maxConcurrentUploadHelp')" />
</div>
<div>
<input v-model.number="maxConcurrentUpload" type="range" min="1" max="10" @change="updateUploadSettings" />
@@ -16,16 +16,16 @@
</div>
<div class="settings-number-input item">
<div class="no-padding">
<label for="uploadChunkSizeMb">{{ $t("fileLoading.uploadChunkSizeMb") }}</label>
<HelpTooltipIcon :text="$t('fileLoading.uploadChunkSizeMbHelp')" />
<label for="uploadChunkSizeMb">{{ t("fileLoading.uploadChunkSizeMb") }}</label>
<HelpTooltipIcon :text="t('fileLoading.uploadChunkSizeMbHelp')" />
</div>
<div class="no-padding">
<input class="sizeInput input" v-model.number="uploadChunkSizeMb" type="number" min="0" @change="updateUploadSettings" />
</div>
</div>
<ToggleSwitch class="item" v-model="clearAll" @change="updateUploadSettings"
:name="$t('fileLoading.clearAll')"
:description="$t('fileLoading.clearAllDescription')" />
:name="t('fileLoading.clearAll')"
:description="t('fileLoading.clearAllDescription')" />
</div>
</SettingsItem>
<div class="upload-prompt" :class="{ dropping: isDragging }"
@@ -33,7 +33,7 @@
@dragover.prevent="onDragOver" @dragleave.prevent="onDragLeave" @drop.prevent="onDrop">
<div class="upload-prompt-container">
<i v-if="files.length === 0" class="material-symbols">cloud_upload</i>
<p v-if="files.length === 0">{{ $t("prompts.dragAndDrop") }}</p>
<p v-if="files.length === 0">{{ t("prompts.dragAndDrop") }}</p>
<ButtonGroup
class="upload-action-buttons"
:buttons="uploadActionButtons"
@@ -47,7 +47,7 @@
<div v-if="showConflictPrompt" class="conflict-overlay">
<div class="card">
<div class="card-content">
<p>{{ $t("prompts.conflictsDetected") }}</p>
<p>{{ t("prompts.conflictsDetected") }}</p>
</div>
<div class="card-actions">
<button
@@ -55,7 +55,7 @@
@click="resolveConflict(true)"
class="button button--flat button--red"
>
{{ $t("general.replace") }}
{{ t("general.replace") }}
</button>
</div>
</div>
@@ -67,11 +67,11 @@
<div class="file-info">
<p class="file-name">{{ file.name }}</p>
<ProgressBar v-if="file.type !== 'directory'" :val="file.status === 'completed'
? $t('prompts.completed')
? t('prompts.completed')
: file.status === 'error'
? $t('prompts.error')
? t('prompts.error')
: file.status === 'conflict'
? $t('prompts.conflictsDetected')
? t('prompts.conflictsDetected')
: (file.progress / 100) * file.size
" :unit="file.status === 'completed' || file.status === 'error' ? '' : 'bytes'" :max="file.size"
:status="file.status" text-position="inside" size="20"
@@ -87,8 +87,8 @@
type="button"
@click="uploadManager.pause(file.id)"
class="action"
:aria-label="$t('general.pause')"
:title="$t('general.pause')"
:aria-label="t('general.pause')"
:title="t('general.pause')"
>
<i class="material-symbols">pause</i>
</button>
@@ -97,8 +97,8 @@
type="button"
@click="uploadManager.resume(file.id)"
class="action"
:aria-label="$t('general.resume')"
:title="$t('general.resume')"
:aria-label="t('general.resume')"
:title="t('general.resume')"
>
<i class="material-symbols">play_arrow</i>
</button>
@@ -107,8 +107,8 @@
type="button"
@click="uploadManager.retry(file.id)"
class="action"
:aria-label="$t('general.retry')"
:title="$t('general.retry')"
:aria-label="t('general.retry')"
:title="t('general.retry')"
>
<i class="material-symbols">replay</i>
</button>
@@ -117,8 +117,8 @@
type="button"
@click="handleConflictAction(file)"
class="action"
:aria-label="$t('general.replace')"
:title="$t('general.replace')"
:aria-label="t('general.replace')"
:title="t('general.replace')"
>
<i class="material-symbols">sync_problem</i>
</button>
@@ -126,8 +126,8 @@
type="button"
@click="uploadManager.cancel(file.id)"
class="action"
:aria-label="$t('general.cancel')"
:title="$t('general.cancel')"
:aria-label="t('general.cancel')"
:title="t('general.cancel')"
>
<i class="material-symbols">close</i>
</button>
@@ -144,30 +144,30 @@
type="button"
@click="uploadManager.pauseAll()"
class="button button--flat"
:aria-label="$t('buttons.pauseAll')"
:title="$t('buttons.pauseAll')"
:aria-label="t('buttons.pauseAll')"
:title="t('buttons.pauseAll')"
>
{{ $t("buttons.pauseAll") }}
{{ t("buttons.pauseAll") }}
</button>
<button
v-if="canResumeAll"
type="button"
@click="uploadManager.resumeAll()"
class="button button--flat"
:aria-label="$t('buttons.resumeAll')"
:title="$t('buttons.resumeAll')"
:aria-label="t('buttons.resumeAll')"
:title="t('buttons.resumeAll')"
>
{{ $t("buttons.resumeAll") }}
{{ t("buttons.resumeAll") }}
</button>
<button
type="button"
@click="uploadManager.clearCompleted()"
class="button button--flat"
:disabled="!hasClearable"
:aria-label="$t('buttons.clearCompleted')"
:title="$t('buttons.clearCompleted')"
:aria-label="t('buttons.clearCompleted')"
:title="t('buttons.clearCompleted')"
>
{{ $t("buttons.clearCompleted") }}
{{ t("buttons.clearCompleted") }}
</button>
</div>
@@ -181,13 +181,13 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { readAllDirectoryEntries, uploadManager } from "@/utils/upload";
import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch } from "vue";
import { useI18n } from "@/i18n/index.ts";
import { readAllDirectoryEntries, uploadManager } from "@/utils/upload.ts";
import type { UploadConflictResolution, UploadEntry, UploadItem, UploadStatus } from "@/utils/upload.ts";
import { getTypeInfo, getTypeInfoFromExt } from "@/utils/mimetype";
import { getters, mutations, state } from "@/store";
import { notify } from "@/notify";
import { getTypeInfo, getTypeInfoFromExt } from "@/utils/mimetype.js";
import { getters, mutations, state } from "@/store/index.ts";
import { notify } from "@/notify/index.ts";
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
import ProgressBar from "@/components/ProgressBar.vue";
import SettingsItem from "@/components/settings/SettingsItem.vue";
@@ -219,11 +219,11 @@ defineOptions({ name: "UploadFiles" });
const { t } = useI18n();
const fileInput = ref<HTMLInputElement | null>(null);
const folderInput = ref<HTMLInputElement | null>(null);
const cameraInput = ref<HTMLInputElement | null>(null);
const videoInput = ref<HTMLInputElement | null>(null);
const list = ref<HTMLElement | null>(null);
const fileInput = useTemplateRef<HTMLInputElement>("fileInput");
const folderInput = useTemplateRef<HTMLInputElement>("folderInput");
const cameraInput = useTemplateRef<HTMLInputElement>("cameraInput");
const videoInput = useTemplateRef<HTMLInputElement>("videoInput");
const list = useTemplateRef<HTMLElement>("list");
const files = computed(() => uploadManager.queue);
const isDragging = ref(false);
const showConflictPrompt = ref(false);
@@ -10,7 +10,7 @@
:config-locked-paths="lockedFromConfigPaths"
show-extension-inputs
:show-thumbnail-master="false"
@change="onPreferenceSectionChange"
@changed="onPreferenceSectionChange"
@enforced-change="onEnforcedChange"
/>
<UserDefaultsAccountSection
@@ -19,17 +19,17 @@
:enforced-permissions="enforced.account?.permissions || {}"
:config-locked-paths="lockedFromConfigPaths"
:show-require-password-change="passwordAccountDefaults"
@account-change="onAccountFieldChange"
@enforced-change="(field, value) => patchEnforcedFlag('account', field, value)"
@enforced-permission-change="(field, value) => patchEnforcedFlag('account', `permissions.${field}`, value)"
@account-change="({ field, value }) => onAccountFieldChange(field, value)"
@enforced-change="({ field, value }) => patchEnforcedFlag('account', field, value)"
@enforced-permission-change="({ field, value }) => patchEnforcedFlag('account', `permissions.${field}`, value)"
/>
</template>
</div>
</template>
<script>
import { notify } from "@/notify";
import { getUserDefaults, patchUserDefaults } from "@/api/settings";
import { notify } from "@/notify/index.ts";
import { getUserDefaults, patchUserDefaults } from "@/api/settings.js";
import { getObjectProperty } from "@/utils/object.js";
import UserProfilePreferences from "@/components/settings/UserProfilePreferences.vue";
import UserDefaultsAccountSection from "@/components/settings/UserDefaultsAccountSection.vue";
+24 -13
View File
@@ -121,7 +121,7 @@
</div>
<source-file-permissions
:permissions="sourcePermissionsFor(source.name)"
@changed="(key, value) => onSourcePermissionChange(source.name, key, value)"
@changed="({ key, value }) => onSourcePermissionChange(source.name, key, value)"
/>
<div class="scope-quota-block">
<ToggleSwitch
@@ -172,19 +172,19 @@
class="item"
:name="$t('settings.userEditPreferences')"
:description="$t('settings.userDefaultsDescription')"
@click="openPreferencesPrompt"
@activate="openPreferencesPrompt"
/>
<SettingsButton
class="item"
:name="$t('settings.userEditTools')"
:description="$t('settings.userEditToolsDescription')"
@click="openToolsPrompt"
@activate="openToolsPrompt"
/>
<SettingsButton
class="item"
:name="$t('settings.userEditSidebarLinks')"
:description="$t('sidebar.customizeLinksDescription')"
@click="openSidebarLinksPrompt"
@activate="openSidebarLinksPrompt"
/>
<UserGroupsField
class="item"
@@ -205,7 +205,7 @@
:enforced-permissions="enforcedAccountPermissions"
:show-require-password-change="user.loginMethod === 'password'"
respect-enforced-policy
@account-change="onEditAccountChange"
@account-change="({ field, value }) => onEditAccountChange(field, value)"
/>
</div>
</div>
@@ -226,8 +226,8 @@
</template>
<script>
import { mutations, state } from "@/store";
import { usersApi, settingsApi, authApi, accessApi } from "@/api";
import { mutations, state } from "@/store/index.ts";
import { usersApi, settingsApi, authApi, accessApi } from "@/api/index.js";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import SourceFilePermissions from "@/components/settings/SourceFilePermissions.vue";
import SettingsItem from "@/components/settings/SettingsItem.vue";
@@ -240,16 +240,16 @@ import PasswordRequirementsHint from "@/components/PasswordRequirementsHint.vue"
import UserGroupsField from "@/components/settings/UserGroupsField.vue";
import Errors from "@/views/Errors.vue";
import { evaluatePasswordPolicy } from "@/utils/passwordPolicy.js";
import { notify } from "@/notify";
import { validateLogin } from "@/utils/auth";
import { globalVars } from "@/utils/constants";
import { eventBus } from "@/store/eventBus";
import { notify } from "@/notify/index.ts";
import { validateLogin } from "@/utils/auth.js";
import { globalVars } from "@/utils/constants.ts";
import { eventBus } from "@/store/eventBus.ts";
import { getObjectProperty, setObjectProperty } from "@/utils/object.js";
import {
GB,
bytesFromCustomAmount,
customAmountFromBytes,
} from "@/utils/quotaUnits";
} from "@/utils/quotaUnits.js";
import {
sectionsFromFlatUser,
applySectionsToFlatUser,
@@ -336,6 +336,16 @@ export default {
permissions: { admin: false },
otpEnabled: false,
loginMethod: null, // Will be set based on available methods
// Placeholders: the whole object is replaced by the loaded/default user
lockPassword: false,
requirePasswordChange: false,
disableSettings: false,
disableUpdateNotifications: false,
showAdvancedProfile: false,
maxConcurrentTranscodes: 1,
toolAccess: {},
sidebarLinks: [],
passkeyCredentials: [],
},
showDelete: false,
createUserDir: false,
@@ -1149,13 +1159,14 @@ export default {
this.applyEditAccountField(field, value);
this.applyEditAccountToUser();
this.emitUpdate();
this.syncSessionState();
},
deletePrompt() {
mutations.showPrompt({
name: "generic",
props: {
title: this.$t("general.delete"),
body: this.$t("prompts.deleteUserMessage", { username: this.user.username }),
body: this.$t("prompts.deleteUserMessage"),
buttons: [
{
label: this.$t("general.delete"),
Loaded 100 of 248 files, more files were not shown because too many files have changed in this diff. Show more