mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-14 13:12:00 -05:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41b6f0cbe4 | ||
|
|
7086b69cab | ||
|
|
39300aa492 | ||
|
|
a5799bc2d1 | ||
|
|
56ee77961e | ||
|
|
82fc156042 | ||
|
|
6f7f472bcd | ||
|
|
9a51bd0fac | ||
|
|
5d5af61886 | ||
|
|
6734f73847 | ||
|
|
45312736b1 | ||
|
|
f37c25f284 | ||
|
|
5cc183c447 | ||
|
|
b46ffd410f | ||
|
|
7cfd47edd7 | ||
|
|
3f4387bec3 | ||
|
|
481dbf41b6 | ||
|
|
25722e484d | ||
|
|
6cc198371d | ||
|
|
5302534975 | ||
|
|
b7474d6b8f |
No files matched your search
@@ -30,7 +30,6 @@
|
||||
"extensions": [
|
||||
"golang.go",
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"ms-azuretools.vscode-docker"
|
||||
]
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
22
|
||||
22.22.2
|
||||
+258
-134
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+931
-3090
File diff suppressed because it is too large.
Load diff
+37
-43
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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: {} }));
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { notify } from "@/notify";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
|
||||
export default {
|
||||
name: "load-failed",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { notify } from "@/notify";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
|
||||
export default {
|
||||
name: "notifications",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: () => [],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
Reference in new issue
Block a user