1
0
Fork 0
mirror of https://github.com/Luzifer/share.git synced 2024-10-18 05:14:23 +00:00

Port frontend to Vue 3 / Bootstrap 5.3

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2024-03-18 20:10:55 +01:00
parent 2c2605d016
commit 549f0d1f36
Signed by: luzifer
SSH key fingerprint: SHA256:/xtE5lCgiRDQr8SLxHMS92ZBlACmATUmF1crK16Ks4E
14 changed files with 4849 additions and 1437 deletions

View file

@ -8,7 +8,7 @@
const Module = require('module') const Module = require('module')
const hacks = [ const hacks = [
'babel-eslint', '@babel/eslint-parser',
'eslint-plugin-vue', 'eslint-plugin-vue',
] ]
@ -34,7 +34,7 @@ module.exports = {
}, },
extends: [ extends: [
'plugin:vue/recommended', 'plugin:vue/vue3-recommended',
'eslint:recommended', // https://eslint.org/docs/rules/ 'eslint:recommended', // https://eslint.org/docs/rules/
], ],
@ -44,12 +44,14 @@ module.exports = {
parserOptions: { parserOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
parser: 'babel-eslint', parser: '@typescript-eslint/parser',
requireConfigFile: false,
}, },
plugins: [ plugins: [
// required to lint *.vue files // required to lint *.vue files
'vue', 'vue',
'@typescript-eslint',
], ],
reportUnusedDisableDirectives: true, reportUnusedDisableDirectives: true,
@ -134,6 +136,7 @@ module.exports = {
'switch-colon-spacing': ['error'], 'switch-colon-spacing': ['error'],
'template-curly-spacing': ['error', 'never'], 'template-curly-spacing': ['error', 'never'],
'unicode-bom': ['error', 'never'], 'unicode-bom': ['error', 'never'],
'vue/comment-directive': 'off',
'vue/new-line-between-multi-line-property': ['error'], 'vue/new-line-between-multi-line-property': ['error'],
'vue/no-empty-component-block': ['error'], 'vue/no-empty-component-block': ['error'],
'vue/no-reserved-component-names': ['error'], 'vue/no-reserved-component-names': ['error'],
@ -141,6 +144,7 @@ module.exports = {
'vue/no-unused-properties': ['error'], 'vue/no-unused-properties': ['error'],
'vue/no-unused-refs': ['error'], 'vue/no-unused-refs': ['error'],
'vue/no-useless-mustaches': ['error'], 'vue/no-useless-mustaches': ['error'],
'vue/no-v-html': 'off', // Disabled for this project
'vue/order-in-components': ['off'], // Collides with sort-keys 'vue/order-in-components': ['off'], // Collides with sort-keys
'vue/require-name-property': ['error'], 'vue/require-name-property': ['error'],
'vue/v-for-delimiter-style': ['error'], 'vue/v-for-delimiter-style': ['error'],

4
.gitignore vendored
View file

@ -1,2 +1,4 @@
frontend/fa-*
frontend/app.*
node_modules
share share
src/node_modules

View file

@ -4,7 +4,12 @@ COPY . /src/share
WORKDIR /src/share WORKDIR /src/share
RUN set -ex \ RUN set -ex \
&& apk add --update git \ && apk add --update \
nodejs \
npm \
git \
make \
&& make frontend \
&& go install \ && go install \
-ldflags "-X main.version=$(git describe --tags --always || echo dev)" \ -ldflags "-X main.version=$(git describe --tags --always || echo dev)" \
-mod=readonly \ -mod=readonly \

View file

@ -7,24 +7,12 @@ lint:
node:12-alpine \ node:12-alpine \
npx eslint --ext .js --fix frontend/app.js npx eslint --ext .js --fix frontend/app.js
assets: frontend/bundle.css .PHONY: frontend
assets: frontend/bundle.js frontend: node_modules
node ci/build.mjs
frontend/bundle.css: node_modules:
./ci/combine.sh $@ \ npm ci
npm/bootstrap@4/dist/css/bootstrap.min.css \
npm/bootstrap-vue@2/dist/bootstrap-vue.min.css \
npm/bootswatch@5/dist/darkly/bootstrap.min.css \
gh/highlightjs/cdn-release@11.2.0/build/styles/androidstudio.min.css
frontend/bundle.js: publish: frontend
./ci/combine.sh $@ \
npm/axios@0.21.1 \
npm/vue@2 \
npm/vue-i18n@8.25.0/dist/vue-i18n.min.js \
npm/bootstrap-vue@2/dist/bootstrap-vue.min.js \
npm/showdown@1 \
gh/highlightjs/cdn-release@11.2.0/build/highlight.min.js
publish:
bash ci/build.sh bash ci/build.sh

34
ci/build.mjs Normal file
View file

@ -0,0 +1,34 @@
import esbuild from 'esbuild'
import { sassPlugin } from 'esbuild-sass-plugin'
import vuePlugin from 'esbuild-plugin-vue3'
const buildOpts = {
assetNames: '[name]-[hash]',
bundle: true,
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'dev'),
},
entryPoints: ['frontend-src/main.ts'],
legalComments: 'none',
loader: {
'.ttf': 'file',
'.woff2': 'file',
},
minify: true,
outfile: 'frontend/app.js',
plugins: [
sassPlugin(),
vuePlugin(),
],
target: [
'chrome109',
'edge116',
'es2020',
'firefox115',
'safari15',
],
}
export { buildOpts }
esbuild.build(buildOpts)

220
frontend-src/display.vue Normal file
View file

@ -0,0 +1,220 @@
<template>
<div>
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a
class="navbar-brand"
href="#"
@click.prevent=""
>
<i class="fas fa-fw fa-share-alt-square mr-1" /> Share
</a>
</div>
</nav>
<div class="container mt-4">
<div class="row">
<div class="col">
<div
v-if="loading"
class="card"
>
<div class="card-body text-center">
<h2><i class="fas fa-spinner fa-pulse" /></h2>
{{ $t('loading') }}
</div>
</div>
<template v-else>
<div
v-if="error"
class="card text-bg-danger"
>
<div class="card-body text-center">
<h2><i class="fas fa-exclamation-circle" /></h2>
{{ error }}
</div>
</div>
<div
v-else-if="fileType.startsWith('image/')"
class="card"
>
<div class="card-body text-center">
<a :href="path">
<img
:src="path"
class="img-fluid"
>
</a>
</div>
</div>
<div
v-else-if="fileType.startsWith('video/')"
class="card"
>
<div class="card-body text-center">
<div class="ratio ratio-16x9">
<video controls>
<source :src="path">
</video>
</div>
</div>
</div>
<div
v-else-if="fileType.startsWith('audio/')"
class="card"
>
<div class="card-body text-center">
<audio
controls
:src="path"
/>
</div>
</div>
<div
v-else-if="fileType.startsWith('text/markdown')"
class="card"
>
<div
class="card-body"
v-html="renderMarkdown(text)"
/>
</div>
<div
v-else-if="fileType.startsWith('text/')"
class="card"
>
<div class="card-body">
<pre><code>{{ text }}</code></pre>
</div>
</div>
<div
v-else
class="card"
>
<div class="card-body text-center">
<h2><i class="fas fa-cloud-download-alt" /></h2>
<button class="btn btn-success">
{{ fileName }}
</button>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import hljs from 'highlight.js'
import showdown from 'showdown'
const rewrites = {
'application/javascript': 'text/javascript',
}
export default defineComponent({
data() {
return {
error: null,
fileName: '',
fileType: null,
loading: true,
path: '',
text: '',
}
},
methods: {
hashChange() {
const hash = window.location.hash
if (hash.length > 0) {
this.path = hash.substring(1)
} else {
this.error = this.$i18n.t('fileNotFound')
this.loading = false
}
},
renderMarkdown(text) {
return new showdown.Converter().makeHtml(text)
},
},
mounted() {
window.onhashchange = this.hashChange
this.hashChange()
},
name: 'ShareContentDisplay',
watch: {
fileType(v) {
// Rewrite known file types not matching the expectations above
if (rewrites[v]) {
this.fileType = rewrites[v]
return
}
// Load text files directly and highlight them
if (v.startsWith('text/')) {
this.loading = true
fetch(this.path)
.then(resp => resp.text())
.then(text => {
this.text = text
if (this.text.length < 200 * 1024 && v !== 'text/plain') {
// Only highlight up to 200k and not on text/plain
window.setTimeout(() => hljs.initHighlighting(), 100)
}
this.loading = false
})
.catch(err => console.log(err))
}
},
path() {
if (this.path.indexOf('://') >= 0) {
// Strictly disallow loading files having any protocol in them
this.error = this.$i18n.t('notPermitted')
this.loading = false
return
}
fetch(this.path, {
method: 'HEAD',
})
.then(resp => {
this.loading = false
switch (resp.status) {
case 200:
break
case 403:
this.error = this.$i18n.t('notPermitted')
return
case 404:
this.error = this.$i18n.t('fileNotFound')
return
default:
this.error = this.$i18n.t('genericError', { status: resp.status })
return
}
this.fileType = resp?.headers?.get('content-type') || 'application/octet-stream'
this.fileName = this.path.substring(this.path.lastIndexOf('/') + 1)
})
},
},
})
</script>

39
frontend-src/main.ts Normal file
View file

@ -0,0 +1,39 @@
/* eslint-disable sort-imports */
import 'bootstrap/dist/css/bootstrap.css'
import '@fortawesome/fontawesome-free/css/all.css'
import { createApp, h } from 'vue'
import { createI18n } from 'vue-i18n'
import ContentDisplay from './display.vue'
const messages = {
en: {
fileNotFound: 'The requested file has not been found.',
genericError: 'Something went wrong (Status {status})',
loading: 'Loading file details...',
notPermitted: 'Access to this file was denied.',
},
de: {
fileNotFound: 'Die angegebene Datei wurde nicht gefunden.',
genericError: 'Irgendwas lief schief... (Status {status})',
loading: 'Lade Datei-Informationen...',
notPermitted: 'Der Zugriff auf diese Datei wurde verweigert.',
},
}
const app = createApp({
name: 'Share',
render() {
return h(ContentDisplay)
},
})
app.use(createI18n({
fallbackLocale: 'en',
locale: new URLSearchParams(window.location.search).get('hl') || navigator.languages?.[0].split('-')[0] || navigator.language?.split('-')[0] || 'en',
messages,
}))
app.mount('#app')

View file

@ -1,123 +0,0 @@
/* global axios, hljs, showdown, Vue */
const rewrites = {
'application/javascript': 'text/javascript',
}
const messages = {
en: {
fileNotFound: 'The requested file has not been found.',
genericError: 'Something went wrong (Status {status})',
loading: 'Loading file details...',
notPermitted: 'Access to this file was denied.',
},
de: {
fileNotFound: 'Die angegebene Datei wurde nicht gefunden.',
genericError: 'Irgendwas lief schief... (Status {status})',
loading: 'Lade Datei-Informationen...',
notPermitted: 'Der Zugriff auf diese Datei wurde verweigert.',
},
}
new Vue({
data: {
error: null,
fileName: '',
fileType: null,
loading: true,
path: '',
text: '',
},
el: '#app',
i18n: new VueI18n({
fallbackLocale: 'en',
locale: new URLSearchParams(window.location.search).get('hl') || navigator.languages?.[0].split('-')[0] || navigator.language?.split('-')[0] || 'en',
messages,
}),
methods: {
hashChange() {
const hash = window.location.hash
if (hash.length > 0) {
this.path = hash.substring(1)
} else {
this.error = this.$i18n.t('fileNotFound')
this.loading = false
}
},
renderMarkdown(text) {
return new showdown.Converter().makeHtml(text)
},
},
mounted() {
window.onhashchange = this.hashChange
this.hashChange()
},
name: 'App',
watch: {
fileType(v) {
// Rewrite known file types not matching the expectations above
if (rewrites[v]) {
this.fileType = rewrites[v]
return
}
// Load text files directly and highlight them
if (v.startsWith('text/')) {
this.loading = true
axios.get(this.path)
.then(resp => {
this.text = resp.data
if (this.text.length < 200 * 1024 && v !== 'text/plain') {
// Only highlight up to 200k and not on text/plain
window.setTimeout(() => hljs.initHighlighting(), 100)
}
this.loading = false
})
.catch(err => console.log(err))
}
},
path() {
if (this.path.indexOf('://') >= 0) {
// Strictly disallow loading files having any protocol in them
this.error = this.$i18n.t('notPermitted')
this.loading = false
return
}
axios.head(this.path)
.then(resp => {
let contentType = 'application/octet-stream'
if (resp && resp.headers && resp.headers['content-type']) {
contentType = resp.headers['content-type']
}
this.loading = false
this.fileType = contentType
this.fileName = this.path.substring(this.path.lastIndexOf('/') + 1)
})
.catch(err => {
switch (err.response.status) {
case 403:
this.error = this.$i18n.t('notPermitted')
break
case 404:
this.error = this.$i18n.t('fileNotFound')
break
default:
this.error = this.$i18n.t('genericError', { status: err.response.status })
}
this.loading = false
})
},
},
})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,15 +1,12 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" data-bs-theme="dark">
<head> <head>
<!-- Required meta tags --> <!-- Required meta tags -->
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="bundle.css"> <link rel="stylesheet" href="app.css">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.2/css/all.css"
integrity="sha384-oS3vJWv+0UjzBfQzYUhtDYW+Pj2yciDJxpsK1OYPAYjqT085Qq/1cq5FLXAZQ7Ay" crossorigin="anonymous">
<style> <style>
[v-cloak] { display: none; } [v-cloak] { display: none; }
@ -19,98 +16,8 @@
<title>Share</title> <title>Share</title>
</head> </head>
<body> <body>
<div id="app" v-cloak> <div id="app" v-cloak></div>
<b-navbar
variant="primary"
type="dark"
>
<b-navbar-brand href="#">
<i class="fas fa-fw fa-share-alt-square mr-1"></i> Share
</b-navbar-brand>
</b-navbar>
<b-container class="mt-4">
<b-row>
<b-col>
<b-card v-if="loading">
<b-card-text class="text-center">
<h2><i class="fas fa-spinner fa-pulse"></i></h2>
{{ $t('loading') }}
</b-card-text>
</b-card>
<template v-else>
<b-card
v-if="error"
bg-variant="danger"
text-variant="white"
>
<b-card-text class="text-center">
<h2><i class="fas fa-exclamation-circle"></i></h2>
{{ error }}
</b-card-text>
</b-card>
<b-card v-else-if="fileType.startsWith('image/')">
<b-card-text class="text-center">
<a :href="path">
<b-img
:src="path"
fluid
/>
</a>
</b-card-text>
</b-card>
<b-card v-else-if="fileType.startsWith('video/')">
<b-embed
type="video"
:src="path"
allowfullscreen
controls
/>
</b-card>
<b-card v-else-if="fileType.startsWith('audio/')">
<b-card-text class="text-center">
<audio
:src="path"
controls
/>
</b-card-text>
</b-card>
<b-card
v-else-if="fileType.startsWith('text/markdown')"
no-body
>
<b-card-body
v-html="renderMarkdown(text)"
/>
</b-card>
<b-card v-else-if="fileType.startsWith('text/')">
<pre><code>{{ text }}</code></pre>
</b-card>
<b-card v-else>
<b-card-text class="text-center">
<h2><i class="fas fa-cloud-download-alt"></i></h2>
<b-button
:href="path"
variant="success"
>
{{ fileName }}
</b-button>
</b-card-text>
</b-card>
</template>
</b-col>
</b-row>
</b-container>
</div>
<script src="bundle.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

11
main.go
View file

@ -135,13 +135,18 @@ func doCLIUpload() error {
} }
func doBootstrap() error { func doBootstrap() error {
for _, asset := range []string{"index.html", "app.js", "bundle.css", "bundle.js"} { files, err := frontend.ReadDir("frontend")
content, err := frontend.ReadFile(strings.Join([]string{"frontend", asset}, "/")) if err != nil {
return fmt.Errorf("listing embedded files: %w", err)
}
for _, asset := range files {
content, err := frontend.ReadFile(strings.Join([]string{"frontend", asset.Name()}, "/"))
if err != nil { if err != nil {
return errors.Wrap(err, "reading baked asset") return errors.Wrap(err, "reading baked asset")
} }
if _, err := executeUpload(asset, bytes.NewReader(content), false, "", true); err != nil { if _, err := executeUpload(asset.Name(), bytes.NewReader(content), false, "", true); err != nil {
return errors.Wrapf(err, "uploading bootstrap asset %q", asset) return errors.Wrapf(err, "uploading bootstrap asset %q", asset)
} }
} }

4500
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

23
package.json Normal file
View file

@ -0,0 +1,23 @@
{
"devDependencies": {
"@babel/eslint-parser": "^7.23.10",
"@typescript-eslint/eslint-plugin": "^7.3.0",
"@typescript-eslint/parser": "^7.3.0",
"esbuild": "^0.20.2",
"esbuild-plugin-vue3": "^0.4.2",
"esbuild-sass-plugin": "^3.2.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0",
"typescript": "^5.4.2"
},
"name": "share",
"private": true,
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.1",
"bootstrap": "^5.3.3",
"highlight.js": "^11.9.0",
"showdown": "^2.1.0",
"vue": "^3.4.21",
"vue-i18n": "^9.10.2"
}
}