Port to Vue3 and TypeScript

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2025-05-01 13:25:11 +02:00
parent b447417d0f
commit e572d2f545
No known key found for this signature in database
33 changed files with 2297 additions and 5930 deletions

View file

@ -1,151 +0,0 @@
/*
* Hack to automatically load globally installed eslint modules
* on Archlinux systems placed in /usr/lib/node_modules
*
* Source: https://github.com/eslint/eslint/issues/11914#issuecomment-569108633
*/
const Module = require('module')
const hacks = [
'babel-eslint',
'eslint-plugin-vue',
]
const ModuleFindPath = Module._findPath
Module._findPath = (request, paths, isMain) => {
const r = ModuleFindPath(request, paths, isMain)
if (!r && hacks.includes(request)) {
return require.resolve(`/usr/lib/node_modules/${request}`)
}
return r
}
/*
* ESLint configuration derived as differences from eslint:recommended
* with changes I found useful to ensure code quality and equal formatting
* https://eslint.org/docs/user-guide/configuring
*/
module.exports = {
env: {
browser: true,
node: true,
},
extends: [
'plugin:vue/recommended',
'eslint:recommended', // https://eslint.org/docs/rules/
],
globals: {
process: true,
},
parserOptions: {
ecmaVersion: 2020,
parser: '@babel/eslint-parser',
requireConfigFile: false,
},
plugins: [
// required to lint *.vue files
'vue',
],
reportUnusedDisableDirectives: true,
root: true,
rules: {
'array-bracket-newline': ['error', { multiline: true }],
'array-bracket-spacing': ['error'],
'arrow-body-style': ['error', 'as-needed'],
'arrow-parens': ['error', 'as-needed'],
'arrow-spacing': ['error', { after: true, before: true }],
'block-spacing': ['error'],
'brace-style': ['error', '1tbs'],
'comma-dangle': ['error', 'always-multiline'],
'comma-spacing': ['error'],
'comma-style': ['error', 'last'],
'curly': ['error'],
'default-case-last': ['error'],
'default-param-last': ['error'],
'dot-location': ['error', 'property'],
'dot-notation': ['error'],
'eol-last': ['error', 'always'],
'eqeqeq': ['error', 'always', { null: 'ignore' }],
'func-call-spacing': ['error', 'never'],
'function-paren-newline': ['error', 'multiline'],
'generator-star-spacing': ['off'], // allow async-await
'implicit-arrow-linebreak': ['error'],
'indent': ['error', 2],
'key-spacing': ['error', { afterColon: true, beforeColon: false, mode: 'strict' }],
'keyword-spacing': ['error'],
'linebreak-style': ['error', 'unix'],
'lines-between-class-members': ['error'],
'multiline-comment-style': ['warn'],
'newline-per-chained-call': ['error'],
'no-alert': ['error'],
'no-console': ['off'],
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', // allow debugger during development
'no-duplicate-imports': ['error'],
'no-else-return': ['error'],
'no-empty-function': ['error'],
'no-extra-parens': ['error'],
'no-implicit-coercion': ['error'],
'no-lonely-if': ['error'],
'no-multi-spaces': ['error'],
'no-multiple-empty-lines': ['warn', { max: 2, maxBOF: 0, maxEOF: 0 }],
'no-promise-executor-return': ['error'],
'no-return-assign': ['error'],
'no-script-url': ['error'],
'no-template-curly-in-string': ['error'],
'no-trailing-spaces': ['error'],
'no-unneeded-ternary': ['error'],
'no-unreachable-loop': ['error'],
'no-unsafe-optional-chaining': ['error'],
'no-useless-return': ['error'],
'no-var': ['error'],
'no-warning-comments': ['error'],
'no-whitespace-before-property': ['error'],
'object-curly-newline': ['error', { consistent: true }],
'object-curly-spacing': ['error', 'always'],
'object-shorthand': ['error'],
'padded-blocks': ['error', 'never'],
'prefer-arrow-callback': ['error'],
'prefer-const': ['error'],
'prefer-object-spread': ['error'],
'prefer-rest-params': ['error'],
'prefer-template': ['error'],
'quote-props': ['error', 'consistent-as-needed', { keywords: false }],
'quotes': ['error', 'single', { allowTemplateLiterals: true }],
'require-atomic-updates': ['error'],
'require-await': ['error'],
'semi': ['error', 'never'],
'sort-imports': ['error', { ignoreCase: true, ignoreDeclarationSort: false, ignoreMemberSort: false }],
'sort-keys': ['error', 'asc', { caseSensitive: true, natural: false }],
'space-before-blocks': ['error', 'always'],
'space-before-function-paren': ['error', 'never'],
'space-in-parens': ['error', 'never'],
'space-infix-ops': ['error'],
'space-unary-ops': ['error', { nonwords: false, words: true }],
'spaced-comment': ['warn', 'always'],
'switch-colon-spacing': ['error'],
'template-curly-spacing': ['error', 'never'],
'unicode-bom': ['error', 'never'],
'vue/new-line-between-multi-line-property': ['error'],
'vue/no-empty-component-block': ['error'],
'vue/no-reserved-component-names': ['error'],
'vue/no-template-target-blank': ['error'],
'vue/no-unused-properties': ['error'],
'vue/no-unused-refs': ['error'],
'vue/no-useless-mustaches': ['error'],
'vue/order-in-components': ['off'], // Collides with sort-keys
'vue/require-name-property': ['error'],
'vue/v-for-delimiter-style': ['error'],
'vue/v-on-function-call': ['error'],
'wrap-iife': ['error'],
'yoda': ['error'],
},
}

1
.gitignore vendored
View file

@ -8,6 +8,7 @@ frontend/css
frontend/js
frontend/locale/*.untranslated.json
frontend/webfonts
frontend/*.ttf
frontend/*.woff2
node_modules
ots

View file

@ -1,49 +1,35 @@
VER_FONTAWESOME:=6.4.0
default: build-local
default: generate download_libs
build-local: download_libs generate-inner generate-apidocs
build-local: frontend generate-apidocs
go build \
-buildmode=pie \
-ldflags "-s -w -X main.version=$(shell git describe --tags --always || echo dev)" \
-mod=readonly \
-trimpath
generate:
docker run --rm -i -v $(CURDIR):$(CURDIR) -w $(CURDIR) node:22-alpine \
sh -exc "apk add make && make generate-inner generate-apidocs && chown -R $(shell id -u) frontend node_modules"
generate-apidocs:
npx --yes @redocly/cli build-docs docs/openapi.yaml --disableGoogleFont true -o /tmp/api.html
mv /tmp/api.html frontend/
generate-inner:
npm ci --include=dev
node ./ci/build.mjs
frontend_prod: export NODE_ENV=production
frontend_prod: frontend
frontend: node_modules
corepack yarn@1 node ci/build.mjs
frontend_lint: node_modules
corepack yarn@1 eslint --fix src
node_modules:
corepack yarn@1 install --production=false --frozen-lockfile
publish: export NODE_ENV=production
publish: download_libs generate-inner generate-apidocs
publish: frontend_prod generate-apidocs
bash ./ci/build.sh
translate:
cd ci/translate && go run . --write-issue-file
# -- Download / refresh external libraries --
clean_libs:
rm -rf \
frontend/css \
frontend/js \
frontend/webfonts
download_libs: clean_libs
download_libs: fontawesome
fontawesome:
curl -sSfL https://github.com/FortAwesome/Font-Awesome/archive/$(VER_FONTAWESOME).tar.gz | \
tar -vC frontend -xz --strip-components=1 --wildcards --exclude='*/js-packages' '*/css' '*/webfonts'
# -- Vulnerability scanning --
trivy:
@ -56,3 +42,5 @@ trivy:
--scanners config,license,secret,vuln \
--severity HIGH,CRITICAL \
--skip-dirs docs
.PHONY: node_modules

View file

@ -1,16 +1,16 @@
# Install Node deps on change of package.json
local_resource(
'npm',
cmd='npm i',
'yarn',
cmd='corepack yarn@1 install', # Not using the make target to edit the lockfile
deps=['package.json'],
)
# Rebuild frontend if source files change
local_resource(
'frontend',
cmd='node ./ci/build.mjs',
cmd='make frontend',
deps=['src'],
resource_deps=['npm'],
resource_deps=['yarn'],
)
# Generate translation files on source change
@ -33,7 +33,9 @@ local_resource(
'tplFuncs.go',
'go.mod', 'go.sum',
],
ignore=['ots', 'src'],
ignore=[
'src'
],
serve_cmd='go run . --listen=:15641',
serve_env={
'CUSTOMIZE': 'customize.yaml',
@ -42,5 +44,8 @@ local_resource(
http_get=http_get_action(15641, path='/api/healthz'),
initial_delay_secs=1,
),
resource_deps=['frontend', 'translations'],
resource_deps=[
'frontend',
'translations',
],
)

View file

@ -1,6 +1,6 @@
import { sassPlugin } from 'esbuild-sass-plugin'
import vuePlugin from 'esbuild-vue'
import esbuild from 'esbuild'
import { sassPlugin } from 'esbuild-sass-plugin'
import vuePlugin from 'esbuild-plugin-vue3'
esbuild.build({
assetNames: '[name]',
@ -8,9 +8,10 @@ esbuild.build({
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'dev'),
},
entryPoints: ['src/main.js'],
entryPoints: ['src/main.ts'],
legalComments: 'none',
loader: {
'.ttf': 'empty', // Drop files, all targets below support woff2
'.woff2': 'file',
},
minify: true,
@ -20,10 +21,10 @@ esbuild.build({
vuePlugin(),
],
target: [
'chrome87',
'edge87',
'chrome109',
'edge132',
'es2020',
'firefox84',
'safari14',
'firefox115',
'safari16',
],
})

126
eslint.config.mjs Normal file
View file

@ -0,0 +1,126 @@
import globals from 'globals'
import js from '@eslint/js'
import typescriptEslint from '@typescript-eslint/eslint-plugin'
import vue from 'eslint-plugin-vue'
export default [
js.configs.recommended,
...vue.configs['flat/recommended'],
{
languageOptions: {
ecmaVersion: 'latest',
globals: {
...globals.browser,
...globals.node,
process: true,
},
parserOptions: {
parser: '@typescript-eslint/parser',
requireConfigFile: true,
},
},
linterOptions: {
reportUnusedDisableDirectives: true,
},
plugins: {
'@typescript-eslint': typescriptEslint,
vue,
},
rules: {
'array-bracket-newline': ['error', { multiline: true }],
'array-bracket-spacing': ['error'],
'arrow-body-style': ['error', 'as-needed'],
'arrow-parens': ['error', 'as-needed'],
'arrow-spacing': ['error', { after: true, before: true }],
'block-spacing': ['error'],
'brace-style': ['error', '1tbs'],
'camelcase': ['warn'],
'comma-dangle': ['error', 'always-multiline'],
'comma-spacing': ['error'],
'comma-style': ['error', 'last'],
'curly': ['error'],
'default-case-last': ['error'],
'default-param-last': ['error'],
'dot-location': ['error', 'property'],
'dot-notation': ['error'],
'eol-last': ['error', 'always'],
'eqeqeq': ['error', 'always', { null: 'ignore' }],
'func-call-spacing': ['error', 'never'],
'function-paren-newline': ['error', 'multiline'],
'generator-star-spacing': ['off'],
'implicit-arrow-linebreak': ['error'],
'indent': ['error', 2],
'key-spacing': ['error', { afterColon: true, beforeColon: false, mode: 'strict' }],
'keyword-spacing': ['error'],
'linebreak-style': ['error', 'unix'],
'lines-between-class-members': ['error'],
'multiline-comment-style': ['off'],
'newline-per-chained-call': ['error'],
'no-alert': ['error'],
'no-console': ['off'],
'no-debugger': 'off',
'no-duplicate-imports': ['error'],
'no-else-return': ['error'],
'no-empty-function': ['error'],
'no-extra-parens': ['error'],
'no-implicit-coercion': ['error'],
'no-lonely-if': ['error'],
'no-multi-spaces': ['error'],
'no-multiple-empty-lines': ['warn', { max: 2, maxBOF: 0, maxEOF: 0 }],
'no-promise-executor-return': ['error'],
'no-return-assign': ['error'],
'no-script-url': ['error'],
'no-template-curly-in-string': ['error'],
'no-trailing-spaces': ['error'],
'no-unneeded-ternary': ['error'],
'no-unreachable-loop': ['error'],
'no-unsafe-optional-chaining': ['error'],
'no-useless-return': ['error'],
'no-var': ['error'],
'no-warning-comments': ['error'],
'no-whitespace-before-property': ['error'],
'object-curly-newline': ['error', { consistent: true }],
'object-curly-spacing': ['error', 'always'],
'object-shorthand': ['error'],
'padded-blocks': ['error', 'never'],
'prefer-arrow-callback': ['error'],
'prefer-const': ['error'],
'prefer-object-spread': ['error'],
'prefer-rest-params': ['error'],
'prefer-template': ['error'],
'quote-props': ['error', 'consistent-as-needed', { keywords: false }],
'quotes': ['error', 'single', { allowTemplateLiterals: true }],
'require-atomic-updates': ['error'],
'require-await': ['error'],
'semi': ['error', 'never'],
'sort-imports': ['error', { ignoreCase: true, ignoreDeclarationSort: false, ignoreMemberSort: false }],
'sort-keys': ['error', 'asc', { caseSensitive: true, natural: false }],
'space-before-blocks': ['error', 'always'],
'space-before-function-paren': ['error', 'never'],
'space-in-parens': ['error', 'never'],
'space-infix-ops': ['error'],
'space-unary-ops': ['error', { nonwords: false, words: true }],
'spaced-comment': ['warn', 'always'],
'switch-colon-spacing': ['error'],
'template-curly-spacing': ['error', 'never'],
'unicode-bom': ['error', 'never'],
'vue/comment-directive': 'off',
'vue/new-line-between-multi-line-property': ['error'],
'vue/no-empty-component-block': ['error'],
'vue/no-reserved-component-names': ['error'],
'vue/no-template-target-blank': ['error'],
'vue/no-unused-properties': ['error'],
'vue/no-unused-refs': ['error'],
'vue/no-useless-mustaches': ['error'],
'vue/order-in-components': ['off'],
'vue/require-name-property': ['error'],
'vue/v-for-delimiter-style': ['error'],
'wrap-iife': ['error'],
'yoda': ['error'],
},
},
]

View file

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
{{ range (list "webfonts/fa-solid-900.woff2" "webfonts/fa-brands-400.woff2" "lato-v20-latin-ext_latin-regular.woff2" "lato-v20-latin-ext_latin-700.woff2") }}
{{ range (list "fa-solid-900.woff2" "fa-brands-400.woff2") }}
<link
as="font"
crossorigin="anonymous"
@ -21,13 +21,6 @@
rel="stylesheet"
>
<link
crossorigin="anonymous"
href="css/all.min.css"
integrity="{{ assetSRI `css/all.min.css` }}"
rel="stylesheet"
>
<title>{{ .Customize.AppTitle }}</title>
<script nonce="{{ .InlineContentNonce }}">
@ -56,8 +49,8 @@
document.addEventListener('DOMContentLoaded', () => window.refreshTheme())
// Template variable from Golang process
const maxSecretExpire = {{ .MaxSecretExpiry }}
const version = "{{ .Version }}"
window.maxSecretExpire = {{ .MaxSecretExpiry }}
window.version = "{{ .Version }}"
window.OTSCustomize = JSON.parse('{{ .Customize.ToJSON }}')
window.useFormalLanguage = {{ .Customize.UseFormalLanguage | mustToJson }}
</script>

5369
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,28 @@
{
"devDependencies": {
"@babel/eslint-parser": "^7.26.8",
"esbuild": "^0.25.0",
"esbuild-sass-plugin": "^3.3.1",
"esbuild-vue": "^1.2.2",
"eslint": "^9.20.1",
"eslint-plugin-vue": "^9.32.0",
"vue-template-compiler": "^2.7.16"
"@babel/eslint-parser": "7.27.1",
"@types/bootstrap": "5.2.10",
"@typescript-eslint/eslint-plugin": "8.31.1",
"@typescript-eslint/parser": "8.31.1",
"@vue/tsconfig": "0.7.0",
"esbuild": "0.25.3",
"esbuild-plugin-vue3": "0.4.2",
"esbuild-sass-plugin": "3.3.1",
"eslint": "9.25.1",
"eslint-plugin-vue": "10.1.0",
"typescript": "5.8.3",
"vue-eslint-parser": "10.1.3"
},
"name": "ots",
"private": true,
"dependencies": {
"base64-js": "^1.5.1",
"bootstrap": "^5.3.3",
"qrcode": "^1.5.4",
"vue": "^2.7.16",
"vue-i18n": "^8.28.2",
"vue-router": "^3.6.5"
}
"@fortawesome/fontawesome-free": "6.7.2",
"base64-js": "1.5.1",
"bootstrap": "5.3.5",
"qrcode": "1.5.4",
"vue": "3.5.13",
"vue-i18n": "11.1.3",
"vue-router": "4.5.1"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View file

@ -1,7 +1,9 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div id="app">
<app-navbar />
<app-navbar
v-model:theme="theme"
@navigate="navigate"
/>
<div class="container mt-4">
<div
@ -19,7 +21,10 @@
<div class="row">
<div class="col">
<router-view @error="displayError" />
<router-view
@error="displayError"
@navigate="navigate"
/>
</div>
</div>
@ -28,15 +33,15 @@
>
<div class="col form-text text-center">
<span
v-if="!$root.customize.disablePoweredBy"
v-if="!customize.disablePoweredBy"
class="mx-2"
>
{{ $t('text-powered-by') }}
<a href="https://github.com/Luzifer/ots"><i class="fab fa-github" /> OTS</a>
{{ $root.version }}
{{ version }}
</span>
<span
v-for="link in $root.customize.footerLinks"
v-for="link in customize.footerLinks"
:key="link.url"
class="mx-2"
>
@ -48,26 +53,39 @@
</div>
</template>
<script>
import AppNavbar from './components/navbar.vue'
<script lang="ts">
import { isNavigationFailure, NavigationFailureType } from 'vue-router'
export default {
components: {
AppNavbar,
import AppNavbar from './components/navbar.vue'
import { defineComponent } from 'vue'
export default defineComponent({
components: { AppNavbar },
computed: {
isSecureEnvironment(): boolean {
return Boolean(window.crypto.subtle)
},
version(): string {
return window.version
},
},
created() {
this.$root.navigate('/')
this.navigate('/')
},
data() {
return {
error: '',
customize: {} as any,
error: '' as string | null,
theme: 'auto',
}
},
methods: {
displayError(error) {
displayError(error: string | null) {
this.error = error
},
@ -80,13 +98,13 @@ export default {
const parts = hash.substring(1).split('|')
const secretId = parts[0]
let securePassword = null
let securePassword = null as string | null
if (parts.length === 2) {
securePassword = parts[1]
}
this.$root.navigate({
this.navigate({
path: '/secret',
query: {
secretId,
@ -94,18 +112,44 @@ export default {
},
})
},
navigate(to: string | any): void {
this.error = ''
this.$router.replace(to)
.catch(err => {
if (isNavigationFailure(err, NavigationFailureType.duplicated)) {
// Hide duplicate nav errors
return
}
throw err
})
},
},
// Trigger initialization functions
mounted() {
this.customize = window.OTSCustomize
window.onhashchange = this.hashLoad
this.hashLoad()
if (!this.$root.isSecureEnvironment) {
if (!this.isSecureEnvironment) {
this.error = this.$t('alert-insecure-environment')
}
this.theme = window.getThemeFromStorage()
window.matchMedia('(prefers-color-scheme: light)')
.addEventListener('change', () => {
window.refreshTheme()
})
},
name: 'App',
}
watch: {
theme(to): void {
window.setTheme(to)
},
},
})
</script>

View file

@ -8,10 +8,13 @@
<i :class="{'fas fa-fw fa-clipboard': !copyToClipboardSuccess, 'fas fa-fw fa-circle-check': copyToClipboardSuccess}" />
</button>
</template>
<script>
export default {
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
computed: {
hasClipboard() {
hasClipboard(): boolean {
return Boolean(navigator.clipboard && navigator.clipboard.writeText)
},
},
@ -23,7 +26,7 @@ export default {
},
methods: {
copy() {
copy(): void {
navigator.clipboard.writeText(this.content)
.then(() => {
this.copyToClipboardSuccess = true
@ -43,5 +46,5 @@ export default {
type: String,
},
},
}
})
</script>

View file

@ -1,4 +1,3 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<!-- Creation disabled -->
<div
@ -36,11 +35,11 @@
v-model="secret"
class="form-control"
:rows="2"
@pasteFile="handlePasteFile"
@paste-file="handlePasteFile"
/>
</div>
<div
v-if="!$root.customize.disableFileAttachment"
v-if="!customize.disableFileAttachment"
class="col-12 mb-3"
>
<label for="createSecretFiles">{{ $t('label-secret-files') }}</label>
@ -50,7 +49,7 @@
class="form-control"
type="file"
multiple
:accept="$root.customize.acceptedFileTypes"
:accept="customize.acceptedFileTypes"
@change="handleSelectFiles"
>
<div class="form-text">
@ -74,7 +73,7 @@
:can-delete="true"
:track-download="false"
:files="attachedFiles"
@fileClicked="deleteFile"
@file-clicked="deleteFile"
/>
</div>
<div class="col-md-6 col-12 order-2 order-md-1">
@ -93,7 +92,7 @@
</button>
</div>
<div
v-if="!$root.customize.disableExpiryOverride"
v-if="!customize.disableExpiryOverride"
class="col-md-6 col-12 order-1 order-md-2"
>
<div class="row mb-3 justify-content-end">
@ -108,7 +107,7 @@
>
<option
v-for="opt in expiryChoices"
:key="opt.value"
:key="opt.value || 'null'"
:value="opt.value"
>
{{ opt.text }}
@ -121,11 +120,11 @@
</div>
</div>
</template>
<script>
/* global maxSecretExpire */
import appCrypto from '../crypto.js'
<script lang="ts">
import appCrypto from '../crypto.ts'
import { bytesToHuman } from '../helpers'
import { defineComponent } from 'vue'
import FilesDisplay from './fileDisplay.vue'
import GrowArea from './growarea.vue'
import OTSMeta from '../ots-meta'
@ -157,30 +156,35 @@ const internalMaxFileSize = 64 * 1024 * 1024 // 64 MiB
const passwordCharset = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
const passwordLength = 20
export default {
export default defineComponent({
components: { FilesDisplay, GrowArea },
computed: {
canCreate() {
canCreate(): boolean {
return (this.secret.trim().length > 0 || this.selectedFileMeta.length > 0) && !this.maxFileSizeExceeded && !this.invalidFilesSelected
},
expiryChoices() {
const choices = [{ text: this.$t('expire-default'), value: null }]
for (const choice of this.$root.customize.expiryChoices || defaultExpiryChoices) {
if (maxSecretExpire > 0 && choice > maxSecretExpire) {
customize(): any {
return window.OTSCustomize || {}
},
expiryChoices(): Record<string, string | null>[] {
const choices = [{ text: this.$t('expire-default'), value: null as string | null }]
for (const choice of this.customize.expiryChoices || defaultExpiryChoices) {
if (window.maxSecretExpire > 0 && choice > window.maxSecretExpire) {
continue
}
const option = { value: choice }
const option = { text: '', value: choice }
if (choice >= 86400) {
option.text = this.$tc('expire-n-days', Math.round(choice / 86400))
option.text = this.$t('expire-n-days', Math.round(choice / 86400))
} else if (choice >= 3600) {
option.text = this.$tc('expire-n-hours', Math.round(choice / 3600))
option.text = this.$t('expire-n-hours', Math.round(choice / 3600))
} else if (choice >= 60) {
option.text = this.$tc('expire-n-minutes', Math.round(choice / 60))
option.text = this.$t('expire-n-minutes', Math.round(choice / 60))
} else {
option.text = this.$tc('expire-n-seconds', choice)
option.text = this.$t('expire-n-seconds', choice)
}
choices.push(option)
@ -189,13 +193,13 @@ export default {
return choices
},
invalidFilesSelected() {
if (this.$root.customize.acceptedFileTypes === '') {
invalidFilesSelected(): boolean {
if (this.customize.acceptedFileTypes === '') {
// No limitation configured, no need to check
return false
}
const accepted = this.$root.customize.acceptedFileTypes.split(',')
const accepted = this.customize.acceptedFileTypes.split(',')
for (const fm of this.selectedFileMeta) {
let isAccepted = false
@ -213,20 +217,24 @@ export default {
return false
},
maxFileSize() {
return this.$root.customize.maxAttachmentSizeTotal === 0 ? internalMaxFileSize : Math.min(internalMaxFileSize, this.$root.customize.maxAttachmentSizeTotal)
isSecureEnvironment(): boolean {
return Boolean(window.crypto.subtle)
},
maxFileSizeExceeded() {
maxFileSize(): number {
return this.customize.maxAttachmentSizeTotal === 0 ? internalMaxFileSize : Math.min(internalMaxFileSize, this.customize.maxAttachmentSizeTotal)
},
maxFileSizeExceeded(): boolean {
return this.fileSize > this.maxFileSize
},
showCreateForm() {
return this.canWrite && this.$root.isSecureEnvironment
showCreateForm(): boolean {
return this.canWrite && this.isSecureEnvironment
},
},
created() {
created(): void {
this.checkWriteAccess()
},
@ -243,11 +251,13 @@ export default {
}
},
emits: ['error', 'navigate'],
methods: {
bytesToHuman,
checkWriteAccess() {
fetch('api/isWritable', {
checkWriteAccess(): Promise<void> {
return fetch('api/isWritable', {
credentials: 'same-origin',
method: 'GET',
redirect: 'error',
@ -264,9 +274,9 @@ export default {
},
// createSecret executes the secret creation after encrypting the secret
createSecret() {
createSecret(): void {
if (!this.canCreate) {
return false
return
}
// Encoding large files takes a while, prevent duplicate click on "create"
@ -309,7 +319,7 @@ export default {
resp.json()
.then(data => {
this.$root.navigate({
this.$emit('navigate', {
path: '/display-secret-url',
query: {
expiresAt: data.expires_at,
@ -324,16 +334,14 @@ export default {
this.$emit('error', this.$t('alert-something-went-wrong'))
})
})
return false
},
deleteFile(fileId) {
deleteFile(fileId: string): void {
this.attachedFiles = [...this.attachedFiles].filter(file => file.id !== fileId)
this.updateFileMeta()
},
handlePasteFile(file) {
handlePasteFile(file: File): void {
this.attachedFiles.push({
fileObj: file,
id: window.crypto.randomUUID(),
@ -344,7 +352,7 @@ export default {
this.updateFileMeta()
},
handleSelectFiles() {
handleSelectFiles(): void {
for (const file of this.$refs.createSecretFiles.files) {
this.attachedFiles.push({
fileObj: file,
@ -359,7 +367,7 @@ export default {
this.$refs.createSecretFiles.value = ''
},
isAcceptedBy(fileMeta, accept) {
isAcceptedBy(fileMeta: any, accept: string): boolean {
if (/^(?:[a-z]+|\*)\/(?:[a-zA-Z0-9.+_-]+|\*)$/.test(accept)) {
// That's likely supposed to be a mime-type
return RegExp(`^${accept.replaceAll('*', '.*')}$`).test(fileMeta.type)
@ -372,7 +380,7 @@ export default {
return false
},
updateFileMeta() {
updateFileMeta(): void {
let cumSize = 0
for (const f of this.attachedFiles) {
cumSize += f.size
@ -387,5 +395,5 @@ export default {
},
name: 'AppCreate',
}
})
</script>

View file

@ -17,7 +17,7 @@
type="text"
readonly
:value="secretUrl"
@focus="$refs.secretUrl.select()"
@focus="selectURL"
>
<app-clipboard-button
:content="secretUrl"
@ -46,14 +46,17 @@
</div>
</div>
</template>
<script>
<script lang="ts">
import appClipboardButton from './clipboard-button.vue'
import appQrButton from './qr-button.vue'
import { defineComponent } from 'vue'
export default {
export default defineComponent({
components: { appClipboardButton, appQrButton },
computed: {
secretUrl() {
secretUrl(): string {
return [
window.location.href.split('#')[0],
encodeURIComponent([
@ -72,20 +75,25 @@ export default {
},
methods: {
burnSecret() {
burnSecret(): Promise<void> {
return fetch(`api/get/${this.secretId}`)
.then(() => {
this.burned = true
})
},
selectURL(): void {
this.$refs.secretUrl.select()
},
},
mounted() {
mounted(): void {
// Give the interface a moment to transistion and focus
window.setTimeout(() => this.$refs.secretUrl.focus(), 100)
},
name: 'AppDisplayURL',
props: {
expiresAt: {
default: null,
@ -103,5 +111,5 @@ export default {
type: String,
},
},
}
})
</script>

View file

@ -1,4 +1,3 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="card border-primary-subtle mb-3">
<div
@ -8,7 +7,7 @@
<div class="card-body">
<ul>
<li
v-for="(explanation, idx) in $t('items-explanation')"
v-for="(explanation, idx) in $tm('items-explanation')"
:key="`idx${idx}`"
>
{{ explanation }}
@ -17,6 +16,11 @@
</div>
</div>
</template>
<script>
export default { name: 'AppExplanation' }
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'AppExplanation',
})
</script>

View file

@ -30,20 +30,23 @@
</div>
</template>
<script>
<script lang="ts">
import { bytesToHuman } from '../helpers'
import { defineComponent } from 'vue'
export default {
export default defineComponent({
data() {
return {
hasDownloaded: {},
}
},
emits: ['fileClicked'],
methods: {
bytesToHuman,
fasFileType(type) {
fasFileType(type: string): string {
return [
'fas',
'fa-fw',
@ -60,8 +63,8 @@ export default {
].join(' ')
},
handleClick(file) {
this.$set(this.hasDownloaded, file.id, true)
handleClick(file: any): void {
this.hasDownloaded[file.id] = true
this.$emit('fileClicked', file.id)
},
},
@ -77,7 +80,7 @@ export default {
files: {
required: true,
type: Array,
type: Array<any>,
},
trackDownload: {
@ -86,5 +89,5 @@ export default {
type: Boolean,
},
},
}
})
</script>

View file

@ -7,8 +7,10 @@
/>
</template>
<script>
export default {
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
created() {
this.data = this.value
},
@ -19,8 +21,14 @@ export default {
}
},
emits: ['input', 'pasteFile'],
methods: {
changeSize() {
changeSize(): void {
if (!this.$refs.area) {
return
}
const verticalBorderSize = this.getStyle('borderTopWidth') + this.getStyle('borderBottomWidth') || 0
const verticalPaddingSize = this.getStyle('paddingTop') + this.getStyle('paddingBottom') || 0
@ -31,12 +39,12 @@ export default {
this.$refs.area.style.height = `${newHeight}px`
},
getStyle(name) {
getStyle(name: string): number {
return parseInt(getComputedStyle(this.$refs.area, null)[name])
},
handlePaste(evt) {
if ([...evt.clipboardData.items]
handlePaste(evt: ClipboardEvent): void {
if ([...evt.clipboardData?.items || []]
.filter(item => item.kind !== 'string')
.length === 0) {
return
@ -49,7 +57,7 @@ export default {
evt.stopPropagation()
evt.preventDefault()
for (const item of evt.clipboardData.items) {
for (const item of evt.clipboardData?.items || []) {
if (item.kind === 'string') {
continue
}
@ -59,8 +67,7 @@ export default {
},
},
mounted() {
mounted(): void {
this.changeSize()
},
@ -92,5 +99,5 @@ export default {
}
},
},
}
})
</script>

View file

@ -4,18 +4,18 @@
<a
class="navbar-brand"
href="#"
@click.prevent="$root.navigate('/')"
@click.prevent="$emit('navigate', '/')"
>
<i
v-if="!appIcon"
class="fas fa-user-secret mr-1"
class="fas fa-user-secret me-1"
/>
<img
v-else
class="mr-1"
class="me-1"
:src="appIcon"
>
<span v-if="!$root.customize.disableAppTitle">{{ $root.customize.appTitle }}</span>
<span v-if="!customize.disableAppTitle">{{ customize.appTitle }}</span>
</a>
<button
@ -39,7 +39,7 @@
<a
class="nav-link"
href="#"
@click.prevent="$root.navigate('/explanation')"
@click.prevent="$emit('navigate', '/explanation')"
>
<i class="fas fa-circle-info" /> {{ $t('btn-show-explanation') }}
</a>
@ -48,19 +48,19 @@
<a
class="nav-link"
href="#"
@click.prevent="$root.navigate('/')"
@click.prevent="$emit('navigate', '/')"
>
<i class="fas fa-plus" /> {{ $t('btn-new-secret') }}
</a>
</li>
</ul>
<form
v-if="!$root.customize.disableThemeSwitcher"
v-if="!customize.disableThemeSwitcher"
class="d-flex align-items-center btn-group"
>
<input
id="theme-light"
v-model="$root.theme"
v-model="intTheme"
type="radio"
name="theme"
class="btn-check"
@ -75,7 +75,7 @@
<input
id="theme-auto"
v-model="$root.theme"
v-model="intTheme"
type="radio"
name="theme"
class="btn-check"
@ -90,7 +90,7 @@
<input
id="theme-dark"
v-model="$root.theme"
v-model="intTheme"
type="radio"
name="theme"
class="btn-check"
@ -108,19 +108,62 @@
</nav>
</template>
<script>
export default {
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
computed: {
appIcon() {
appIcon(): string {
// Use specified icon or fall back to null
const appIcon = this.$root.customize.appIcon || null
const appIcon = this.$parent.customize.appIcon || null
// Use specified icon or fall back to light-mode appIcon (which might be null)
const darkIcon = this.$root.customize.appIconDark || appIcon
const darkIcon = this.$parent.customize.appIconDark || appIcon
return this.$root.theme === 'dark' ? darkIcon : appIcon
},
customize(): any {
return this.$parent.customize || {}
},
},
data() {
return {
intTheme: '',
}
},
emits: ['navigate', 'update:theme'],
mounted(): void {
this.intTheme = this.theme
},
name: 'AppNavbar',
props: {
theme: {
required: true,
type: String,
},
},
watch: {
intTheme(to: string, from: string): void {
if (to === from) {
return
}
this.$emit('update:theme', to)
},
theme(to: string, from: string): void {
if (to === from) {
return
}
this.intTheme = to
},
},
})
</script>

View file

@ -1,6 +1,6 @@
<template>
<button
v-if="!$root.customize.disableQRSupport"
v-if="!customize.disableQRSupport"
id="secret-url-qrcode"
ref="qrButton"
class="btn btn-secondary"
@ -9,11 +9,19 @@
<i class="fas fa-qrcode" />
</button>
</template>
<script>
<script lang="ts">
import { defineComponent } from 'vue'
import { Popover } from 'bootstrap'
import qrcode from 'qrcode'
export default {
export default defineComponent({
computed: {
customize(): any {
return window.OTSCustomize || {}
},
},
data() {
return {
qrDataURL: null,
@ -21,8 +29,8 @@ export default {
},
methods: {
generateQR() {
if (this.$root.customize.disableQRSupport) {
generateQR(): void {
if (window.OTSCustomize.disableQRSupport) {
return
}
@ -33,7 +41,7 @@ export default {
},
},
mounted() {
mounted(): void {
this.generateQR()
},
@ -51,7 +59,7 @@ export default {
this.generateQR()
},
qrDataURL(to) {
qrDataURL(to: string): void {
if (this.popover) {
this.popover.dispose()
}
@ -69,5 +77,5 @@ export default {
})
},
},
}
})
</script>

View file

@ -1,4 +1,3 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="card border-primary-subtle mb-3">
<div
@ -44,7 +43,7 @@
/>
<a
class="btn btn-secondary"
:href="secretContentBlobURL"
:href="secretContentBlobURL || ''"
download
:title="$t('tooltip-download-as-file')"
>
@ -63,15 +62,17 @@
</div>
</div>
</template>
<script>
<script lang="ts">
import appClipboardButton from './clipboard-button.vue'
import appCrypto from '../crypto.js'
import appCrypto from '../crypto.ts'
import appQrButton from './qr-button.vue'
import { defineComponent } from 'vue'
import FilesDisplay from './fileDisplay.vue'
import GrowArea from './growarea.vue'
import OTSMeta from '../ots-meta'
export default {
export default defineComponent({
components: { FilesDisplay, GrowArea, appClipboardButton, appQrButton },
data() {
@ -84,9 +85,11 @@ export default {
}
},
emits: ['error'],
methods: {
// requestSecret requests the encrypted secret from the backend
requestSecret() {
requestSecret(): void {
this.secretLoading = true
window.history.replaceState({}, '', window.location.href.split('#')[0])
fetch(`api/get/${this.secretId}`)
@ -163,5 +166,5 @@ export default {
this.secretContentBlobURL = window.URL.createObjectURL(new Blob([to], { type: 'text/plain' }))
},
},
}
})
</script>

View file

@ -1,14 +1,13 @@
import base64 from 'base64-js'
const opensslBanner = new Uint8Array(new TextEncoder('utf8').encode('Salted__'))
const opensslBanner = new Uint8Array(new TextEncoder().encode('Salted__'))
const pbkdf2Params = { hash: 'SHA-512', iterations: 300000, name: 'PBKDF2' }
/**
* @param {String} cipherText Encrypted data in base64 encoded form
* @param {String} passphrase Encryption passphrase used for key-derivation
* @returns String
* @param {string} cipherText Encrypted data in base64 encoded form
* @param {string} passphrase Encryption passphrase used for key-derivation
*/
function dec(cipherText, passphrase) {
function dec(cipherText: string, passphrase: string): Promise<string> {
return decrypt(passphrase, cipherText)
}
/**
@ -17,7 +16,7 @@ function dec(cipherText, passphrase) {
* @param {String} passphrase Encryption passphrase used for key-derivation
* @returns String
*/
function enc(plainText, passphrase) {
function enc(plainText: string, passphrase: string): Promise<string> {
return encrypt(passphrase, generateSalt(), plainText)
}
@ -26,7 +25,7 @@ function enc(plainText, passphrase) {
* @param {String} encData Encrypted data in base64 encoded form
* @returns String
*/
function decrypt(passphrase, encData) {
function decrypt(passphrase: string, encData: string): Promise<string> {
const data = base64.toByteArray(encData)
return deriveKey(passphrase, data.slice(8, 16))
@ -40,8 +39,8 @@ function decrypt(passphrase, encData) {
* @param {Uint8Array} salt
* @returns Object
*/
function deriveKey(passphrase, salt) {
return window.crypto.subtle.importKey('raw', new TextEncoder('utf8').encode(passphrase), 'PBKDF2', false, ['deriveBits'])
function deriveKey(passphrase: string, salt: Uint8Array): any {
return window.crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), 'PBKDF2', false, ['deriveBits'])
.then(passwordKey => window.crypto.subtle.deriveBits({ ...pbkdf2Params, salt }, passwordKey, 384))
.then(key => window.crypto.subtle.importKey('raw', key.slice(0, 32), { name: 'AES-CBC' }, false, ['encrypt', 'decrypt'])
.then(aesKey => ({ iv: key.slice(32, 48), key: aesKey })))
@ -53,9 +52,9 @@ function deriveKey(passphrase, salt) {
* @param {String} plainData Data to encrypt
* @returns String
*/
function encrypt(passphrase, salt, plainData) {
function encrypt(passphrase: string, salt: Uint8Array, plainData: string): Promise<string> {
return deriveKey(passphrase, salt)
.then(({ iv, key }) => window.crypto.subtle.encrypt({ iv, name: 'AES-CBC' }, key, new TextEncoder('utf8').encode(plainData)))
.then(({ iv, key }) => window.crypto.subtle.encrypt({ iv, name: 'AES-CBC' }, key, new TextEncoder().encode(plainData)))
.then(encData => new Uint8Array([...opensslBanner, ...salt, ...new Uint8Array(encData)]))
.then(data => base64.fromByteArray(data))
}
@ -65,7 +64,7 @@ function encrypt(passphrase, salt, plainData) {
*
* @returns Uint8Array
*/
function generateSalt() {
function generateSalt(): Uint8Array {
const salt = new Uint8Array(8) // Salt MUST consist of 8 byte
return window.crypto.getRandomValues(salt)
}

14
src/global.d.ts vendored Normal file
View file

@ -0,0 +1,14 @@
export { }
declare global {
interface Window {
getTheme: () => string;
getThemeFromStorage: () => string;
maxSecretExpire: number;
OTSCustomize: any;
refreshTheme: () => void;
setTheme: (string) => void;
useFormalLanguage: boolean;
version: string;
}
}

View file

@ -1,9 +1,9 @@
/**
* Converts number of bytes into human format (524288 -> "512.0 KiB")
* @param {Number} bytes Byte amount to convert into human readable format
* @returns String
* @param {number} bytes Byte amount to convert into human readable format
* @returns string
*/
function bytesToHuman(bytes) {
function bytesToHuman(bytes: number): string {
for (const t of [
{ thresh: 1024 * 1024, unit: 'MiB' },
{ thresh: 1024, unit: 'KiB' },

16
src/i18n.ts Normal file
View file

@ -0,0 +1,16 @@
import { createI18n } from "vue-i18n";
import messages from './langs/langs.js'
const cookieSet = Object.fromEntries(document.cookie.split('; ')
.map(el => el.split('=')
.map(el => decodeURIComponent(el))))
const i18n = createI18n({
legacy: false,
fallbackLocale: 'en',
locale: cookieSet.lang || navigator?.language || 'en',
messages,
})
export default i18n

View file

@ -1,21 +0,0 @@
/* lato-regular - latin-ext_latin */
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 400;
src: url('latofont/lato-v20-latin-ext_latin-regular.woff2') format('woff2'); /* Chrome 26+, Opera 23+, Firefox 39+ */
}
/* lato-italic - latin-ext_latin */
@font-face {
font-family: 'Lato';
font-style: italic;
font-weight: 400;
src: url('latofont/lato-v20-latin-ext_latin-italic.woff2') format('woff2'); /* Chrome 26+, Opera 23+, Firefox 39+ */
}
/* lato-700 - latin-ext_latin */
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 700;
src: url('latofont/lato-v20-latin-ext_latin-700.woff2') format('woff2'); /* Chrome 26+, Opera 23+, Firefox 39+ */
}

View file

@ -1,94 +0,0 @@
Copyright (c) 2010-2015, Łukasz Dziedzic (dziedzic@typoland.com),
with Reserved Font Name Lato.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -1,84 +0,0 @@
/* eslint-disable sort-imports */
/* global version */
import Vue from 'vue'
import VueI18n from 'vue-i18n'
import VueRouter from 'vue-router'
import './style.scss'
import app from './app.vue'
import messages from './langs/langs.js'
import router from './router'
Vue.use(VueI18n)
Vue.use(VueRouter)
const cookieSet = Object.fromEntries(document.cookie.split('; ')
.map(el => el.split('=')
.map(el => decodeURIComponent(el))))
const i18n = new VueI18n({
fallbackLocale: 'en',
locale: cookieSet.lang || navigator?.language || 'en',
messages,
})
Vue.mixin({
beforeRouteLeave(_to, _from, next) {
// Before leaving the component, reset the errors the component displayed
this.$emit('error', null)
next()
},
})
new Vue({
components: { app },
computed: {
isSecureEnvironment() {
return Boolean(window.crypto.subtle)
},
},
data: {
customize: {},
theme: 'auto',
version,
},
el: '#app',
i18n,
methods: {
navigate(to) {
this.$router.replace(to)
.catch(err => {
if (VueRouter.isNavigationFailure(err, VueRouter.NavigationFailureType.duplicated)) {
// Hide duplicate nav errors
return
}
throw err
})
},
},
mounted() {
this.customize = window.OTSCustomize
this.theme = window.getThemeFromStorage()
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => {
window.refreshTheme()
})
},
name: 'OTS',
render: createElement => createElement('app'),
router,
watch: {
theme(to) {
window.setTheme(to)
},
},
})

18
src/main.ts Normal file
View file

@ -0,0 +1,18 @@
import { createApp, h } from 'vue'
import './style.scss'
import '@fortawesome/fontawesome-free/css/all.css' // All FA free icons
import appView from './app.vue'
import i18n from './i18n.ts'
import router from './router.ts'
const app = createApp({
name: 'OTS',
render() { return h(appView) },
})
app.use(i18n)
app.use(router)
app.mount('#app')

View file

@ -1,10 +1,10 @@
import { createMemoryHistory, createRouter } from 'vue-router'
import AppCreate from './components/create.vue'
import AppDisplayURL from './components/display-url.vue'
import AppExplanation from './components/explanation.vue'
import AppSecretDisplay from './components/secret-display.vue'
import VueRouter from 'vue-router'
const routes = [
{
component: AppCreate,
@ -37,8 +37,8 @@ const routes = [
},
]
const router = new VueRouter({
mode: 'abstract',
const router = createRouter({
history: createMemoryHistory(),
routes,
})

View file

@ -1,8 +1,7 @@
// Force local fonts
$web-font-path: '';
@import "../node_modules/bootstrap/dist/css/bootstrap.css";
@import "lato";
@use "../node_modules/bootstrap/dist/css/bootstrap.css";
:root {
textarea {

1788
yarn.lock Normal file

File diff suppressed because it is too large Load diff