Merge pull request 'Migrate to vue3 (fix #126)' (#127) from vue3 into main
Some checks failed
repod / nodejs (push) Waiting to run
repod / release (push) Waiting to run
repod / xml (push) Successful in 14s
repod / php (push) Has been cancelled

Reviewed-on: #127
This commit is contained in:
Michel Roux 2024-08-17 12:24:26 +00:00
commit 5b08cf970e
38 changed files with 3877 additions and 5652 deletions

View File

@ -1,5 +1,9 @@
module.exports = { module.exports = {
extends: ['@nextcloud', 'plugin:prettier/recommended'], extends: [
'@nextcloud',
'plugin:pinia/recommended',
'plugin:prettier/recommended',
],
rules: { rules: {
'jsdoc/require-jsdoc': 'off', 'jsdoc/require-jsdoc': 'off',
'vue/first-attribute-linebreak': 'off', 'vue/first-attribute-linebreak': 'off',

View File

@ -1,9 +0,0 @@
In the Nextcloud community, participants from all over the world come together to create Free Software for a free internet. This is made possible by the support, hard work and enthusiasm of thousands of people, including those who create and use Nextcloud software.
Our code of conduct offers some guidance to ensure Nextcloud participants can cooperate effectively in a positive and inspiring atmosphere, and to explain how together we can strengthen and support each other.
The Code of Conduct is shared by all contributors and users who engage with the Nextcloud team and its community services. It presents a summary of the shared values and “common sense” thinking in our community.
You can find our full code of conduct on our website: https://nextcloud.com/code-of-conduct/
Please, keep our CoC in mind when you contribute! That way, everyone can be a part of our community in a productive, positive, creative and fun way.

View File

@ -103,14 +103,14 @@ dist: build
source: source:
rm -rf $(source_build_directory) rm -rf $(source_build_directory)
mkdir -p $(source_build_directory) mkdir -p $(source_build_directory)
tar cvzf $(source_package_name).tar.gz \ tar -C .. -cvzf $(source_package_name).tar.gz \
--exclude-vcs \ --exclude-vcs \
--exclude="../$(app_name)/build" \ --exclude="$(app_name)/build" \
--exclude="../$(app_name)/js/node_modules" \ --exclude="$(app_name)/js/node_modules" \
--exclude="../$(app_name)/node_modules" \ --exclude="$(app_name)/node_modules" \
--exclude="../$(app_name)/*.log" \ --exclude="$(app_name)/*.log" \
--exclude="../$(app_name)/js/*.log" \ --exclude="$(app_name)/js/*.log" \
../$(app_name) $(app_name)
# Builds the source package for the app store, ignores php tests, js tests # Builds the source package for the app store, ignores php tests, js tests
# and build related folders that are unnecessary for an appstore release # and build related folders that are unnecessary for an appstore release
@ -118,42 +118,43 @@ source:
appstore: appstore:
rm -rf $(appstore_build_directory) rm -rf $(appstore_build_directory)
mkdir -p $(appstore_build_directory) mkdir -p $(appstore_build_directory)
tar cvzf $(appstore_package_name).tar.gz \ tar -C .. -cvzf $(appstore_package_name).tar.gz \
--exclude="../$(app_name)/build" \ --exclude="$(app_name)/build" \
--exclude="../$(app_name)/tests" \ --exclude="$(app_name)/tests" \
--exclude="../$(app_name)/Makefile" \ --exclude="$(app_name)/Makefile" \
--exclude="../$(app_name)/*.log" \ --exclude="$(app_name)/*.log" \
--exclude="../$(app_name)/phpunit*xml" \ --exclude="$(app_name)/phpunit*xml" \
--exclude="../$(app_name)/composer.*" \ --exclude="$(app_name)/composer.*" \
--exclude="../$(app_name)/node_modules" \ --exclude="$(app_name)/node_modules" \
--exclude="../$(app_name)/js/node_modules" \ --exclude="$(app_name)/js/node_modules" \
--exclude="../$(app_name)/js/tests" \ --exclude="$(app_name)/js/tests" \
--exclude="../$(app_name)/js/test" \ --exclude="$(app_name)/js/test" \
--exclude="../$(app_name)/js/*.log" \ --exclude="$(app_name)/js/*.log" \
--exclude="../$(app_name)/js/package.json" \ --exclude="$(app_name)/js/package.json" \
--exclude="../$(app_name)/js/bower.json" \ --exclude="$(app_name)/js/bower.json" \
--exclude="../$(app_name)/js/karma.*" \ --exclude="$(app_name)/js/karma.*" \
--exclude="../$(app_name)/js/protractor.*" \ --exclude="$(app_name)/js/protractor.*" \
--exclude="../$(app_name)/package.json" \ --exclude="$(app_name)/package.json" \
--exclude="../$(app_name)/bower.json" \ --exclude="$(app_name)/bower.json" \
--exclude="../$(app_name)/karma.*" \ --exclude="$(app_name)/karma.*" \
--exclude="../$(app_name)/protractor\.*" \ --exclude="$(app_name)/protractor\.*" \
--exclude="../$(app_name)/.*" \ --exclude="$(app_name)/.*" \
--exclude="../$(app_name)/js/.*" \ --exclude="$(app_name)/js/.*" \
--exclude="../$(app_name)/webpack.js" \ --exclude="$(app_name)/webpack.js" \
--exclude="../$(app_name)/stylelint.config.js" \ --exclude="$(app_name)/stylelint.config.js" \
--exclude="../$(app_name)/README.md" \ --exclude="$(app_name)/README.md" \
--exclude="../$(app_name)/package-lock.json" \ --exclude="$(app_name)/package-lock.json" \
--exclude="../$(app_name)/LICENSE" \ --exclude="$(app_name)/LICENSE" \
--exclude="../$(app_name)/src" \ --exclude="$(app_name)/src" \
--exclude="../$(app_name)/stubs" \ --exclude="$(app_name)/stubs" \
--exclude="../$(app_name)/screens" \ --exclude="$(app_name)/screens" \
--exclude="../$(app_name)/vendor" \ --exclude="$(app_name)/vendor" \
--exclude="../$(app_name)/translationfiles" \ --exclude="$(app_name)/translationfiles" \
--exclude="../$(app_name)/Dockerfile" \ --exclude="$(app_name)/Dockerfile" \
--exclude="../$(app_name)/psalm.xml" \ --exclude="$(app_name)/psalm.xml" \
--exclude="../$(app_name)/renovate.json" \ --exclude="$(app_name)/renovate.json" \
../$(app_name) --exclude="$(app_name)/vite.config.mjs" \
$(app_name)
# Start a nextcloud server on Docker to kickstart developement # Start a nextcloud server on Docker to kickstart developement
.PHONY: dev .PHONY: dev

18
composer.lock generated
View File

@ -1312,12 +1312,12 @@
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git", "url": "https://github.com/Roave/SecurityAdvisories.git",
"reference": "176422aa2c339a0f4e56b92862c67a94e2b584fb" "reference": "251a4f1fefcc6e6cc90d50514fee6b6e3745cb3e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/176422aa2c339a0f4e56b92862c67a94e2b584fb", "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/251a4f1fefcc6e6cc90d50514fee6b6e3745cb3e",
"reference": "176422aa2c339a0f4e56b92862c67a94e2b584fb", "reference": "251a4f1fefcc6e6cc90d50514fee6b6e3745cb3e",
"shasum": "" "shasum": ""
}, },
"conflict": { "conflict": {
@ -1414,7 +1414,7 @@
"codeigniter4/shield": "<1.0.0.0-beta8", "codeigniter4/shield": "<1.0.0.0-beta8",
"codiad/codiad": "<=2.8.4", "codiad/codiad": "<=2.8.4",
"composer/composer": "<1.10.27|>=2,<2.2.24|>=2.3,<2.7.7", "composer/composer": "<1.10.27|>=2,<2.2.24|>=2.3,<2.7.7",
"concrete5/concrete5": "<=9.3.2", "concrete5/concrete5": "<9.3.3",
"concrete5/core": "<8.5.8|>=9,<9.1", "concrete5/core": "<8.5.8|>=9,<9.1",
"contao-components/mediaelement": ">=2.14.2,<2.21.1", "contao-components/mediaelement": ">=2.14.2,<2.21.1",
"contao/comments-bundle": ">=2,<4.13.40|>=5.0.0.0-RC1-dev,<5.3.4", "contao/comments-bundle": ">=2,<4.13.40|>=5.0.0.0-RC1-dev,<5.3.4",
@ -1483,7 +1483,7 @@
"ezsystems/ezplatform-graphql": ">=1.0.0.0-RC1-dev,<1.0.13|>=2.0.0.0-beta1,<2.3.12", "ezsystems/ezplatform-graphql": ">=1.0.0.0-RC1-dev,<1.0.13|>=2.0.0.0-beta1,<2.3.12",
"ezsystems/ezplatform-kernel": "<1.2.5.1-dev|>=1.3,<1.3.35", "ezsystems/ezplatform-kernel": "<1.2.5.1-dev|>=1.3,<1.3.35",
"ezsystems/ezplatform-rest": ">=1.2,<=1.2.2|>=1.3,<1.3.8", "ezsystems/ezplatform-rest": ">=1.2,<=1.2.2|>=1.3,<1.3.8",
"ezsystems/ezplatform-richtext": ">=2.3,<2.3.7.1-dev", "ezsystems/ezplatform-richtext": ">=2.3,<2.3.7.1-dev|>=3.3,<3.3.40",
"ezsystems/ezplatform-solr-search-engine": ">=1.7,<1.7.12|>=2,<2.0.2|>=3.3,<3.3.15", "ezsystems/ezplatform-solr-search-engine": ">=1.7,<1.7.12|>=2,<2.0.2|>=3.3,<3.3.15",
"ezsystems/ezplatform-user": ">=1,<1.0.1", "ezsystems/ezplatform-user": ">=1,<1.0.1",
"ezsystems/ezpublish-kernel": "<6.13.8.2-dev|>=7,<7.5.31", "ezsystems/ezpublish-kernel": "<6.13.8.2-dev|>=7,<7.5.31",
@ -1564,6 +1564,7 @@
"hyn/multi-tenant": ">=5.6,<5.7.2", "hyn/multi-tenant": ">=5.6,<5.7.2",
"ibexa/admin-ui": ">=4.2,<4.2.3|>=4.6.0.0-beta1,<4.6.9", "ibexa/admin-ui": ">=4.2,<4.2.3|>=4.6.0.0-beta1,<4.6.9",
"ibexa/core": ">=4,<4.0.7|>=4.1,<4.1.4|>=4.2,<4.2.3|>=4.5,<4.5.6|>=4.6,<4.6.2", "ibexa/core": ">=4,<4.0.7|>=4.1,<4.1.4|>=4.2,<4.2.3|>=4.5,<4.5.6|>=4.6,<4.6.2",
"ibexa/fieldtype-richtext": ">=4.6,<4.6.10",
"ibexa/graphql": ">=2.5,<2.5.31|>=3.3,<3.3.28|>=4.2,<4.2.3", "ibexa/graphql": ">=2.5,<2.5.31|>=3.3,<3.3.28|>=4.2,<4.2.3",
"ibexa/post-install": "<=1.0.4", "ibexa/post-install": "<=1.0.4",
"ibexa/solr": ">=4.5,<4.5.4", "ibexa/solr": ">=4.5,<4.5.4",
@ -1806,6 +1807,7 @@
"pubnub/pubnub": "<6.1", "pubnub/pubnub": "<6.1",
"pusher/pusher-php-server": "<2.2.1", "pusher/pusher-php-server": "<2.2.1",
"pwweb/laravel-core": "<=0.3.6.0-beta", "pwweb/laravel-core": "<=0.3.6.0-beta",
"pxlrbt/filament-excel": "<2.3.3",
"pyrocms/pyrocms": "<=3.9.1", "pyrocms/pyrocms": "<=3.9.1",
"qcubed/qcubed": "<=3.1.1", "qcubed/qcubed": "<=3.1.1",
"quickapps/cms": "<=2.0.0.0-beta2", "quickapps/cms": "<=2.0.0.0-beta2",
@ -1833,8 +1835,8 @@
"serluck/phpwhois": "<=4.2.6", "serluck/phpwhois": "<=4.2.6",
"sfroemken/url_redirect": "<=1.2.1", "sfroemken/url_redirect": "<=1.2.1",
"sheng/yiicms": "<=1.2", "sheng/yiicms": "<=1.2",
"shopware/core": "<6.5.8.8-dev|>=6.6.0.0-RC1-dev,<6.6.1", "shopware/core": "<=6.5.8.12|>=6.6,<=6.6.5",
"shopware/platform": "<6.5.8.8-dev|>=6.6.0.0-RC1-dev,<6.6.1", "shopware/platform": "<=6.5.8.12|>=6.6,<=6.6.5",
"shopware/production": "<=6.3.5.2", "shopware/production": "<=6.3.5.2",
"shopware/shopware": "<=5.7.17", "shopware/shopware": "<=5.7.17",
"shopware/storefront": "<=6.4.8.1|>=6.5.8,<6.5.8.7-dev", "shopware/storefront": "<=6.4.8.1|>=6.5.8,<6.5.8.7-dev",
@ -2119,7 +2121,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2024-08-05T22:04:39+00:00" "time": "2024-08-14T19:05:08+00:00"
}, },
{ {
"name": "sebastian/diff", "name": "sebastian/diff",

8526
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,11 +2,14 @@
"name": "repod", "name": "repod",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"scripts": { "scripts": {
"build": "NODE_ENV=production webpack --config webpack.js --progress", "build": "vite build --mode production",
"dev": "NODE_ENV=development webpack --config webpack.js --progress", "dev": "vite build --mode development",
"watch": "NODE_ENV=development webpack --config webpack.js --progress --watch", "dev:watch": "vite build --mode development --watch",
"watch": "npm run dev:watch",
"lint": "eslint src", "lint": "eslint src",
"stylelint": "stylelint src/**/*.vue src/**/*.scss src/**/*.css" "lint:fix": "eslint src --fix",
"stylelint": "stylelint src/**/*.vue src/**/*.scss src/**/*.css",
"stylelint:fix": "stylelint src/**/*.vue src/**/*.scss src/**/*.css --fix"
}, },
"browserslist": [ "browserslist": [
"extends @nextcloud/browserslist-config" "extends @nextcloud/browserslist-config"
@ -14,27 +17,27 @@
"prettier": "@nextcloud/prettier-config", "prettier": "@nextcloud/prettier-config",
"dependencies": { "dependencies": {
"@nextcloud/axios": "^2.5.0", "@nextcloud/axios": "^2.5.0",
"@nextcloud/dialogs": "^6.0.0",
"@nextcloud/initial-state": "^2.2.0", "@nextcloud/initial-state": "^2.2.0",
"@nextcloud/l10n": "^3.1.0", "@nextcloud/l10n": "^3.1.0",
"@nextcloud/router": "^3.0.1", "@nextcloud/router": "^3.0.1",
"@nextcloud/vue": "^8.16.0", "@nextcloud/vite-config": "^2.2.2",
"@nextcloud/vue": "9.0.0-alpha.5",
"dompurify": "^3.1.6", "dompurify": "^3.1.6",
"linkify-html": "^4.1.3", "linkify-html": "^4.1.3",
"vue": "^2", "pinia": "^2.2.2",
"toastify-js": "^1.12.0",
"vite": "^5.4.1",
"vue": "^3.4.38",
"vue-material-design-icons": "^5.3.0", "vue-material-design-icons": "^5.3.0",
"vue-router": "^3", "vue-router": "^4.4.3"
"vuex": "^3"
}, },
"devDependencies": { "devDependencies": {
"@nextcloud/browserslist-config": "^3.0.1", "@nextcloud/browserslist-config": "^3.0.1",
"@nextcloud/eslint-config": "^8.4.1", "@nextcloud/eslint-config": "^8.4.1",
"@nextcloud/prettier-config": "^1.1.0", "@nextcloud/prettier-config": "^1.1.0",
"@nextcloud/stylelint-config": "^3.0.1", "@nextcloud/stylelint-config": "^3.0.1",
"@nextcloud/webpack-vue-config": "^6.0.1",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1", "eslint-plugin-pinia": "^0.2.0",
"eslint-webpack-plugin": "^4.2.0", "eslint-plugin-prettier": "^5.2.1"
"stylelint-webpack-plugin": "^5.0.1"
} }
} }

View File

@ -8,12 +8,14 @@
</template> </template>
<script> <script>
import '@nextcloud/dialogs/style.css' import 'toastify-js/src/toastify.css'
import { mapActions, mapState } from 'pinia'
import Bar from './components/Player/Bar.vue' import Bar from './components/Player/Bar.vue'
import GPodder from './views/GPodder.vue' import GPodder from './views/GPodder.vue'
import { NcContent } from '@nextcloud/vue' import { NcContent } from '@nextcloud/vue'
import Subscriptions from './components/Sidebar/Subscriptions.vue' import Subscriptions from './components/Sidebar/Subscriptions.vue'
import { loadState } from '@nextcloud/initial-state' import { loadState } from '@nextcloud/initial-state'
import { usePlayer } from './store/player.js'
export default { export default {
name: 'App', name: 'App',
@ -24,9 +26,22 @@ export default {
Subscriptions, Subscriptions,
}, },
computed: { computed: {
...mapState(usePlayer, ['paused']),
gpodder() { gpodder() {
return loadState('repod', 'gpodder', false) return loadState('repod', 'gpodder', false)
}, },
}, },
mounted() {
this.init()
setInterval(this.loop, 40000)
},
methods: {
...mapActions(usePlayer, ['init', 'time']),
loop() {
if (this.paused === false) {
this.time()
}
},
},
} }
</script> </script>

View File

@ -1,11 +1,13 @@
<template> <template>
<NcAppContent :class="episode ? 'padding' : ''"> <NcAppContent :class="{ padding: episode }">
<slot /> <slot />
</NcAppContent> </NcAppContent>
</template> </template>
<script> <script>
import { NcAppContent } from '@nextcloud/vue' import { NcAppContent } from '@nextcloud/vue'
import { mapState } from 'pinia'
import { usePlayer } from '../../store/player.js'
export default { export default {
name: 'AppContent', name: 'AppContent',
@ -13,9 +15,7 @@ export default {
NcAppContent, NcAppContent,
}, },
computed: { computed: {
episode() { ...mapState(usePlayer, ['episode']),
return this.$store.state.player.episode
},
}, },
} }
</script> </script>

View File

@ -1,5 +1,5 @@
<template> <template>
<NcAppNavigation :class="episode ? 'padding' : ''"> <NcAppNavigation :class="{ padding: episode }">
<slot /> <slot />
<template #list> <template #list>
<slot name="list" /> <slot name="list" />
@ -12,6 +12,8 @@
<script> <script>
import { NcAppNavigation } from '@nextcloud/vue' import { NcAppNavigation } from '@nextcloud/vue'
import { mapState } from 'pinia'
import { usePlayer } from '../../store/player.js'
export default { export default {
name: 'AppNavigation', name: 'AppNavigation',
@ -19,9 +21,7 @@ export default {
NcAppNavigation, NcAppNavigation,
}, },
computed: { computed: {
episode() { ...mapState(usePlayer, ['episode']),
return this.$store.state.player.episode
},
}, },
} }
</script> </script>

View File

@ -1,20 +1,25 @@
<template> <template>
<div> <div class="flex">
<NcAvatar :display-name="name" :is-no-user="true" :size="256" :url="image" /> <NcAvatar :display-name="name" :is-no-user="true" :size="256" :url="image" />
<h2>{{ name }}</h2> <h2>{{ name }}</h2>
<SafeHtml :source="description" /> <SafeHtml :source="description" />
<div> <div class="flex">
<NcButton v-if="link" :href="link" target="_blank"> <NcButton v-if="link" :href="link" target="_blank">
<template #icon> <template #icon>
<OpenInNewIcon :size="20" /> <OpenInNewIcon :size="20" />
</template> </template>
{{ title }} {{ title }}
</NcButton> </NcButton>
<NcButton v-if="url" :href="url" target="_blank"> <NcButton
v-if="url"
:download="filenameFromUrl(url)"
:href="url"
target="_blank">
<template #icon> <template #icon>
<DownloadIcon :size="20" /> <DownloadIcon :size="20" />
</template> </template>
{{ t('repod', 'Download') }} {{ size ? `(${episodeFileSize})` : '' }} {{ t('repod', 'Download') }}
{{ size ? `(${humanFileSize(size)})` : '' }}
</NcButton> </NcButton>
</div> </div>
</div> </div>
@ -25,6 +30,7 @@ import { NcAvatar, NcButton } from '@nextcloud/vue'
import DownloadIcon from 'vue-material-design-icons/Download.vue' import DownloadIcon from 'vue-material-design-icons/Download.vue'
import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue' import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue'
import SafeHtml from './SafeHtml.vue' import SafeHtml from './SafeHtml.vue'
import { filenameFromUrl } from '../../utils/url.js'
import { humanFileSize } from '../../utils/size.js' import { humanFileSize } from '../../utils/size.js'
export default { export default {
@ -66,16 +72,15 @@ export default {
required: true, required: true,
}, },
}, },
computed: { methods: {
episodeFileSize() { filenameFromUrl,
return humanFileSize(this.size) humanFileSize,
},
}, },
} }
</script> </script>
<style scoped> <style scoped>
div { .flex {
align-items: center; align-items: center;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -3,15 +3,15 @@
</template> </template>
<script> <script>
import dompurify from 'dompurify'
import linkifyHtml from 'linkify-html' import linkifyHtml from 'linkify-html'
import { sanitize } from 'dompurify'
export default { export default {
name: 'SafeHtml', name: 'SafeHtml',
directives: { directives: {
sanitize: { sanitize: {
inserted(el, binding) { mounted(el, binding) {
el.innerHTML = sanitize( el.innerHTML = dompurify.sanitize(
linkifyHtml(binding.value, { linkifyHtml(binding.value, {
nl2br: true, nl2br: true,
target: '_blank', target: '_blank',

View File

@ -19,7 +19,7 @@
</template> </template>
<template #actions> <template #actions>
<NcActionButton <NcActionButton
v-if="!isSubscribed(feed.link)" v-if="!subscriptions.includes(feed.link)"
:aria-label="t('repod', 'Subscribe')" :aria-label="t('repod', 'Subscribe')"
:name="t('repod', 'Subscribe')" :name="t('repod', 'Subscribe')"
:title="t('repod', 'Subscribe')" :title="t('repod', 'Subscribe')"
@ -36,14 +36,16 @@
<script> <script>
import { NcActionButton, NcAvatar, NcListItem } from '@nextcloud/vue' import { NcActionButton, NcAvatar, NcListItem } from '@nextcloud/vue'
import { mapActions, mapState } from 'pinia'
import Loading from '../Atoms/Loading.vue' import Loading from '../Atoms/Loading.vue'
import PlusIcon from 'vue-material-design-icons/Plus.vue' import PlusIcon from 'vue-material-design-icons/Plus.vue'
import axios from '@nextcloud/axios' import axios from '@nextcloud/axios'
import { debounce } from '../../utils/debounce.js' import { debounce } from '../../utils/debounce.js'
import { formatLocaleDate } from '../../utils/time.js' import { formatLocaleDate } from '../../utils/time.js'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import { showError } from '@nextcloud/dialogs' import { showError } from '../../utils/toast.js'
import { toUrl } from '../../utils/url.js' import { toUrl } from '../../utils/url.js'
import { useSubscriptions } from '../../store/subscriptions.js'
export default { export default {
name: 'Search', name: 'Search',
@ -66,12 +68,16 @@ export default {
loading: false, loading: false,
} }
}, },
computed: {
...mapState(useSubscriptions, ['subscriptions']),
},
watch: { watch: {
value() { value() {
this.search() this.search()
}, },
}, },
methods: { methods: {
...mapActions(useSubscriptions, ['fetch']),
formatLocaleDate, formatLocaleDate,
toUrl, toUrl,
async addSubscription(url) { async addSubscription(url) {
@ -88,10 +94,7 @@ export default {
showError(t('repod', 'Error while adding the feed')) showError(t('repod', 'Error while adding the feed'))
} }
this.$store.dispatch('subscriptions/fetch') this.fetch()
},
isSubscribed(url) {
return this.$store.state.subscriptions.subscriptions.includes(url)
}, },
search: debounce(async function value() { search: debounce(async function value() {
try { try {

View File

@ -16,7 +16,7 @@
import Loading from '../Atoms/Loading.vue' import Loading from '../Atoms/Loading.vue'
import axios from '@nextcloud/axios' import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import { showError } from '@nextcloud/dialogs' import { showError } from '../../utils/toast.js'
import { toUrl } from '../../utils/url.js' import { toUrl } from '../../utils/url.js'
export default { export default {

View File

@ -23,7 +23,7 @@
<SafeHtml :source="description" /> <SafeHtml :source="description" />
</div> </div>
<NcAppNavigationNew <NcAppNavigationNew
v-if="!isSubscribed" v-if="!subscriptions.includes(url)"
:text="t('repod', 'Subscribe')" :text="t('repod', 'Subscribe')"
@click="addSubscription"> @click="addSubscription">
<template #icon> <template #icon>
@ -37,13 +37,15 @@
<script> <script>
import { NcAppNavigationNew, NcAvatar } from '@nextcloud/vue' import { NcAppNavigationNew, NcAvatar } from '@nextcloud/vue'
import { showError, showSuccess } from '@nextcloud/dialogs' import { mapActions, mapState } from 'pinia'
import { showError, showSuccess } from '../../utils/toast.js'
import PlusIcon from 'vue-material-design-icons/Plus.vue' import PlusIcon from 'vue-material-design-icons/Plus.vue'
import RssIcon from 'vue-material-design-icons/Rss.vue' import RssIcon from 'vue-material-design-icons/Rss.vue'
import SafeHtml from '../Atoms/SafeHtml.vue' import SafeHtml from '../Atoms/SafeHtml.vue'
import axios from '@nextcloud/axios' import axios from '@nextcloud/axios'
import { decodeUrl } from '../../utils/url.js' import { decodeUrl } from '../../utils/url.js'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import { useSubscriptions } from '../../store/subscriptions.js'
export default { export default {
name: 'Banner', name: 'Banner',
@ -77,14 +79,13 @@ export default {
}, },
}, },
computed: { computed: {
...mapState(useSubscriptions, ['subscriptions']),
url() { url() {
return decodeUrl(this.$route.params.url) return decodeUrl(this.$route.params.url)
}, },
isSubscribed() {
return this.$store.state.subscriptions.subscriptions.includes(this.url)
},
}, },
methods: { methods: {
...mapActions(useSubscriptions, ['fetch']),
async addSubscription() { async addSubscription() {
try { try {
await axios.post( await axios.post(
@ -99,7 +100,7 @@ export default {
showError(t('repod', 'Error while adding the feed')) showError(t('repod', 'Error while adding the feed'))
} }
this.$store.dispatch('subscriptions/fetch') this.fetch()
}, },
copyFeed() { copyFeed() {
window.navigator.clipboard.writeText(this.url) window.navigator.clipboard.writeText(this.url)

View File

@ -6,18 +6,20 @@
v-for="episode in filteredEpisodes" v-for="episode in filteredEpisodes"
:key="episode.guid" :key="episode.guid"
:active="isCurrentEpisode(episode)" :active="isCurrentEpisode(episode)"
:class="hasEnded(episode) ? 'ended' : ''"
:details="formatLocaleDate(new Date(episode.pubDate?.date))" :details="formatLocaleDate(new Date(episode.pubDate?.date))"
:force-display-actions="true" :force-display-actions="true"
:href="$route.href"
:name="episode.name" :name="episode.name"
:style="{ opacity: hasEnded(episode) ? 0.4 : 1 }"
target="_self"
:title="episode.description" :title="episode.description"
@click="modalEpisode = episode"> @click="modalEpisode = episode">
<template #extra-actions> <template #actions>
<NcActionButton <NcActionButton
v-if="!isCurrentEpisode(episode)" v-if="!isCurrentEpisode(episode)"
:aria-label="t('repod', 'Play')" :aria-label="t('repod', 'Play')"
:title="t('repod', 'Play')" :title="t('repod', 'Play')"
@click="load(episode)"> @click="load(episode, url)">
<template #icon> <template #icon>
<PlayIcon :size="20" /> <PlayIcon :size="20" />
</template> </template>
@ -32,7 +34,8 @@
</template> </template>
</NcActionButton> </NcActionButton>
</template> </template>
<template #actions> <template #extra>
<NcActions>
<NcActionButton <NcActionButton
v-if="episode.duration && !hasEnded(episode)" v-if="episode.duration && !hasEnded(episode)"
:aria-label="t('repod', 'Mark as read')" :aria-label="t('repod', 'Mark as read')"
@ -67,6 +70,7 @@
</NcActionLink> </NcActionLink>
<NcActionLink <NcActionLink
v-if="episode.url" v-if="episode.url"
:download="filenameFromUrl(episode.url)"
:href="episode.url" :href="episode.url"
:name="t('repod', 'Download')" :name="t('repod', 'Download')"
target="_blank" target="_blank"
@ -75,6 +79,7 @@
<DownloadIcon :size="20" /> <DownloadIcon :size="20" />
</template> </template>
</NcActionLink> </NcActionLink>
</NcActions>
</template> </template>
<template #icon> <template #icon>
<NcAvatar <NcAvatar
@ -112,18 +117,20 @@
import { import {
NcActionButton, NcActionButton,
NcActionLink, NcActionLink,
NcActions,
NcAvatar, NcAvatar,
NcListItem, NcListItem,
NcModal, NcModal,
NcProgressBar, NcProgressBar,
} from '@nextcloud/vue' } from '@nextcloud/vue'
import { decodeUrl, filenameFromUrl } from '../../utils/url.js'
import { import {
durationToSeconds, durationToSeconds,
formatEpisodeTimestamp, formatEpisodeTimestamp,
formatLocaleDate, formatLocaleDate,
} from '../../utils/time.js' } from '../../utils/time.js'
import { mapActions, mapState } from 'pinia'
import DownloadIcon from 'vue-material-design-icons/Download.vue' import DownloadIcon from 'vue-material-design-icons/Download.vue'
import { EventBus } from '../../store/bus.js'
import Loading from '../Atoms/Loading.vue' import Loading from '../Atoms/Loading.vue'
import Modal from '../Atoms/Modal.vue' import Modal from '../Atoms/Modal.vue'
import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue' import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue'
@ -132,9 +139,10 @@ import PlaylistPlayIcon from 'vue-material-design-icons/PlaylistPlay.vue'
import PlaylistRemoveIcon from 'vue-material-design-icons/PlaylistRemove.vue' import PlaylistRemoveIcon from 'vue-material-design-icons/PlaylistRemove.vue'
import StopIcon from 'vue-material-design-icons/Stop.vue' import StopIcon from 'vue-material-design-icons/Stop.vue'
import axios from '@nextcloud/axios' import axios from '@nextcloud/axios'
import { decodeUrl } from '../../utils/url.js'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import { showError } from '@nextcloud/dialogs' import { showError } from '../../utils/toast.js'
import { usePlayer } from '../../store/player.js'
import { useSettings } from '../../store/settings.js'
export default { export default {
name: 'Episodes', name: 'Episodes',
@ -144,6 +152,7 @@ export default {
Modal, Modal,
NcActionButton, NcActionButton,
NcActionLink, NcActionLink,
NcActions,
NcAvatar, NcAvatar,
NcListItem, NcListItem,
NcModal, NcModal,
@ -163,12 +172,8 @@ export default {
} }
}, },
computed: { computed: {
currentEpisode() { ...mapState(usePlayer, ['episode']),
return this.$store.state.player.episode ...mapState(useSettings, ['filters']),
},
filters() {
return this.$store.state.settings.filters
},
filteredEpisodes() { filteredEpisodes() {
return this.episodes.filter((episode) => { return this.episodes.filter((episode) => {
if (!this.filters.listened && this.hasEnded(episode)) { if (!this.filters.listened && this.hasEnded(episode)) {
@ -190,6 +195,15 @@ export default {
return decodeUrl(this.$route.params.url) return decodeUrl(this.$route.params.url)
}, },
}, },
watch: {
episode() {
if (this.episode) {
this.episodes = this.episodes.map((e) =>
e.url === this.episode.url ? this.episode : e,
)
}
},
},
async mounted() { async mounted() {
try { try {
this.loading = true this.loading = true
@ -201,7 +215,6 @@ export default {
this.episodes = [...episodes.data].sort( this.episodes = [...episodes.data].sort(
(a, b) => new Date(b.pubDate?.date) - new Date(a.pubDate?.date), (a, b) => new Date(b.pubDate?.date) - new Date(a.pubDate?.date),
) )
EventBus.$on('updateEpisodesList', this.updateList)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
showError(t('repod', 'Could not fetch episodes')) showError(t('repod', 'Could not fetch episodes'))
@ -209,10 +222,9 @@ export default {
this.loading = false this.loading = false
} }
}, },
destroyed() {
EventBus.$off('updateEpisodesList')
},
methods: { methods: {
...mapActions(usePlayer, ['load']),
filenameFromUrl,
formatLocaleDate, formatLocaleDate,
hasEnded(episode) { hasEnded(episode) {
return ( return (
@ -224,18 +236,17 @@ export default {
) )
}, },
isCurrentEpisode(episode) { isCurrentEpisode(episode) {
return this.currentEpisode && this.currentEpisode.url === episode.url return this.episode && this.episode.url === episode.url
}, },
isListening(episode) { isListening(episode) {
return ( return (
episode.action && episode.action &&
episode.action.action &&
episode.action.action.toLowerCase() === 'play' && episode.action.action.toLowerCase() === 'play' &&
episode.action.position > 0 &&
!this.hasEnded(episode) !this.hasEnded(episode)
) )
}, },
load(episode) {
this.$store.dispatch('player/load', episode)
},
async markAs(episode, read) { async markAs(episode, read) {
try { try {
this.loadingAction = true this.loadingAction = true
@ -253,7 +264,9 @@ export default {
generateUrl('/apps/gpoddersync/episode_action/create'), generateUrl('/apps/gpoddersync/episode_action/create'),
[episode.action], [episode.action],
) )
this.updateList(episode) if (read && this.episode && episode.url === this.episode.url) {
this.load(null)
}
} catch (e) { } catch (e) {
console.error(e) console.error(e)
showError(t('repod', 'Could not change the status of the episode')) showError(t('repod', 'Could not change the status of the episode'))
@ -261,20 +274,11 @@ export default {
this.loadingAction = false this.loadingAction = false
} }
}, },
updateList(episode) {
this.episodes = this.episodes.map((e) =>
e.url === episode.url ? episode : e,
)
},
}, },
} }
</script> </script>
<style scoped> <style scoped>
.ended {
opacity: 0.4;
}
.progress { .progress {
margin-top: 0.4rem; margin-top: 0.4rem;
} }

View File

@ -1,10 +1,10 @@
<template> <template>
<div v-if="player.episode" class="footer"> <div v-if="episode" class="footer">
<img class="background" :src="player.episode.image" /> <img class="background" :src="episode.image" />
<Loading v-if="!player.loaded" /> <Loading v-if="!loaded" />
<ProgressBar v-if="player.loaded" /> <ProgressBar v-if="loaded" />
<div v-if="player.loaded" class="player"> <div v-if="loaded" class="player">
<img :src="player.episode.image" /> <img :src="episode.image" />
<Infos class="infos" /> <Infos class="infos" />
<Controls class="controls" /> <Controls class="controls" />
<Timer class="timer" /> <Timer class="timer" />
@ -20,6 +20,8 @@ import Loading from '../Atoms/Loading.vue'
import ProgressBar from './ProgressBar.vue' import ProgressBar from './ProgressBar.vue'
import Timer from './Timer.vue' import Timer from './Timer.vue'
import Volume from './Volume.vue' import Volume from './Volume.vue'
import { mapState } from 'pinia'
import { usePlayer } from '../../store/player.js'
export default { export default {
name: 'Bar', name: 'Bar',
@ -32,9 +34,7 @@ export default {
Volume, Volume,
}, },
computed: { computed: {
player() { ...mapState(usePlayer, ['episode', 'loaded']),
return this.$store.state.player
},
}, },
} }
</script> </script>

View File

@ -1,21 +1,15 @@
<template> <template>
<div class="controls"> <div class="controls">
<PauseIcon <PauseIcon v-if="!paused" class="pointer" :size="50" @click="pause" />
v-if="!player.paused" <PlayIcon v-if="paused" class="pointer" :size="50" @click="play" />
class="pointer"
:size="50"
@click="$store.dispatch('player/pause')" />
<PlayIcon
v-if="player.paused"
class="pointer"
:size="50"
@click="$store.dispatch('player/play')" />
</div> </div>
</template> </template>
<script> <script>
import { mapActions, mapState } from 'pinia'
import PauseIcon from 'vue-material-design-icons/Pause.vue' import PauseIcon from 'vue-material-design-icons/Pause.vue'
import PlayIcon from 'vue-material-design-icons/Play.vue' import PlayIcon from 'vue-material-design-icons/Play.vue'
import { usePlayer } from '../../store/player.js'
export default { export default {
name: 'Controls', name: 'Controls',
@ -24,9 +18,10 @@ export default {
PlayIcon, PlayIcon,
}, },
computed: { computed: {
player() { ...mapState(usePlayer, ['paused']),
return this.$store.state.player
}, },
methods: {
...mapActions(usePlayer, ['play', 'pause']),
}, },
} }
</script> </script>

View File

@ -1,20 +1,20 @@
<template> <template>
<div class="root"> <div class="root">
<strong class="pointer" @click="modal = true"> <strong class="pointer" @click="modal = true">
{{ player.episode.name }} {{ episode.name }}
</strong> </strong>
<router-link :to="hash"> <router-link :to="hash">
<i>{{ player.episode.title }}</i> <i>{{ episode.title }}</i>
</router-link> </router-link>
<NcModal v-if="modal" @close="modal = false"> <NcModal v-if="modal" @close="modal = false">
<Modal <Modal
:description="player.episode.description" :description="episode.description"
:image="player.episode.image" :image="episode.image"
:link="player.episode.link" :link="episode.link"
:name="player.episode.name" :name="episode.name"
:size="player.episode.size" :size="episode.size"
:title="player.episode.title" :title="episode.title"
:url="player.episode.url" /> :url="episode.url" />
</NcModal> </NcModal>
</div> </div>
</template> </template>
@ -22,7 +22,9 @@
<script> <script>
import Modal from '../Atoms/Modal.vue' import Modal from '../Atoms/Modal.vue'
import { NcModal } from '@nextcloud/vue' import { NcModal } from '@nextcloud/vue'
import { mapState } from 'pinia'
import { toUrl } from '../../utils/url.js' import { toUrl } from '../../utils/url.js'
import { usePlayer } from '../../store/player.js'
export default { export default {
name: 'Infos', name: 'Infos',
@ -36,11 +38,9 @@ export default {
} }
}, },
computed: { computed: {
player() { ...mapState(usePlayer, ['episode', 'podcastUrl']),
return this.$store.state.player
},
hash() { hash() {
return toUrl(this.player.podcastUrl) return toUrl(this.podcastUrl)
}, },
}, },
} }

View File

@ -1,20 +1,24 @@
<template> <template>
<input <input
class="progress" class="progress"
:max="player.duration" :max="duration"
min="0" min="0"
type="range" type="range"
:value="player.currentTime" :value="currentTime"
@change="(event) => $store.dispatch('player/seek', event.target.value)" /> @change="(event) => seek(event.target.value)" />
</template> </template>
<script> <script>
import { mapActions, mapState } from 'pinia'
import { usePlayer } from '../../store/player.js'
export default { export default {
name: 'ProgressBar', name: 'ProgressBar',
computed: { computed: {
player() { ...mapState(usePlayer, ['duration', 'currentTime']),
return this.$store.state.player
}, },
methods: {
...mapActions(usePlayer, ['seek']),
}, },
} }
</script> </script>

View File

@ -1,20 +1,20 @@
<template> <template>
<div> <div>
<span>{{ formatTimer(new Date(player.currentTime * 1000)) }}</span> <span>{{ formatTimer(new Date(currentTime * 1000)) }}</span>
<span>/</span> <span>/</span>
<span>{{ formatTimer(new Date(player.duration * 1000)) }}</span> <span>{{ formatTimer(new Date(duration * 1000)) }}</span>
</div> </div>
</template> </template>
<script> <script>
import { formatTimer } from '../../utils/time.js' import { formatTimer } from '../../utils/time.js'
import { mapState } from 'pinia'
import { usePlayer } from '../../store/player.js'
export default { export default {
name: 'Timer', name: 'Timer',
computed: { computed: {
player() { ...mapState(usePlayer, ['duration', 'currentTime']),
return this.$store.state.player
},
}, },
methods: { methods: {
formatTimer, formatTimer,

View File

@ -1,42 +1,42 @@
<template> <template>
<div> <div>
<VolumeHighIcon <VolumeHighIcon
v-if="player.volume > 0.7" v-if="volume > 0.7"
class="pointer" class="pointer"
:size="30" :size="30"
@click="mute" /> @click="mute" />
<VolumeLowIcon <VolumeLowIcon
v-if="player.volume > 0 && player.volume <= 0.3" v-if="volume > 0 && volume <= 0.3"
class="pointer" class="pointer"
:size="30" :size="30"
@click="mute" /> @click="mute" />
<VolumeMediumIcon <VolumeMediumIcon
v-if="player.volume > 0.3 && player.volume <= 0.7" v-if="volume > 0.3 && volume <= 0.7"
class="pointer" class="pointer"
:size="30" :size="30"
@click="mute" /> @click="mute" />
<VolumeMuteIcon <VolumeMuteIcon
v-if="player.volume === 0" v-if="volume === 0"
class="pointer" class="pointer"
:size="30" :size="30"
@click="$store.dispatch('player/volume', volumeMuted)" /> @click="setVolume(volumeMuted)" />
<input <input
max="1" max="1"
min="0" min="0"
step="0.1" step="0.1"
type="range" type="range"
:value="player.volume" :value="volume"
@change=" @change="(event) => setVolume(event.target.value)" />
(event) => $store.dispatch('player/volume', event.target.value)
" />
</div> </div>
</template> </template>
<script> <script>
import { mapActions, mapState } from 'pinia'
import VolumeHighIcon from 'vue-material-design-icons/VolumeHigh.vue' import VolumeHighIcon from 'vue-material-design-icons/VolumeHigh.vue'
import VolumeLowIcon from 'vue-material-design-icons/VolumeLow.vue' import VolumeLowIcon from 'vue-material-design-icons/VolumeLow.vue'
import VolumeMediumIcon from 'vue-material-design-icons/VolumeMedium.vue' import VolumeMediumIcon from 'vue-material-design-icons/VolumeMedium.vue'
import VolumeMuteIcon from 'vue-material-design-icons/VolumeMute.vue' import VolumeMuteIcon from 'vue-material-design-icons/VolumeMute.vue'
import { usePlayer } from '../../store/player.js'
export default { export default {
name: 'Volume', name: 'Volume',
@ -52,14 +52,13 @@ export default {
} }
}, },
computed: { computed: {
player() { ...mapState(usePlayer, ['volume']),
return this.$store.state.player
},
}, },
methods: { methods: {
...mapActions(usePlayer, ['setVolume']),
mute() { mute() {
this.volumeMuted = this.player.volume this.volumeMuted = this.volume
this.$store.dispatch('player/volume', 0) this.setVolume(0)
}, },
}, },
} }

View File

@ -5,40 +5,30 @@
:name="t('repod', 'Filtering episodes')"> :name="t('repod', 'Filtering episodes')">
<template #actions> <template #actions>
<NcActionCheckbox <NcActionCheckbox
:checked="all"
:disabled="all" :disabled="all"
@update:checked=" :model-value="all"
(checked) => @change="
$store.commit('settings/filters', { setFilters({
listened: checked, listened: true,
listening: checked, listening: true,
unlistened: checked, unlistened: true,
}) })
"> ">
{{ t('repod', 'Show all') }} {{ t('repod', 'Show all') }}
</NcActionCheckbox> </NcActionCheckbox>
<NcActionCheckbox <NcActionCheckbox
:checked="filters.listened" :model-value="filters.listened"
@update:checked=" @change="setFilters({ listened: !filters.listened })">
(checked) =>
$store.commit('settings/filters', { listened: checked })
">
{{ t('repod', 'Listened') }} {{ t('repod', 'Listened') }}
</NcActionCheckbox> </NcActionCheckbox>
<NcActionCheckbox <NcActionCheckbox
:checked="filters.listening" :model-value="filters.listening"
@update:checked=" @change="setFilters({ listening: !filters.listening })">
(checked) =>
$store.commit('settings/filters', { listening: checked })
">
{{ t('repod', 'Listening') }} {{ t('repod', 'Listening') }}
</NcActionCheckbox> </NcActionCheckbox>
<NcActionCheckbox <NcActionCheckbox
:checked="filters.unlistened" :model-value="filters.unlistened"
@update:checked=" @change="setFilters({ unlistened: !filters.unlistened })">
(checked) =>
$store.commit('settings/filters', { unlistened: checked })
">
{{ t('repod', 'Unlistened') }} {{ t('repod', 'Unlistened') }}
</NcActionCheckbox> </NcActionCheckbox>
</template> </template>
@ -51,8 +41,11 @@
<script> <script>
import { NcActionCheckbox, NcAppNavigationItem } from '@nextcloud/vue' import { NcActionCheckbox, NcAppNavigationItem } from '@nextcloud/vue'
import { mapActions, mapState } from 'pinia'
import FilterIcon from 'vue-material-design-icons/Filter.vue' import FilterIcon from 'vue-material-design-icons/Filter.vue'
import FilterSettingsIcon from 'vue-material-design-icons/FilterSettings.vue' import FilterSettingsIcon from 'vue-material-design-icons/FilterSettings.vue'
import { getCookie } from '../../utils/cookies.js'
import { useSettings } from '../../store/settings.js'
export default { export default {
name: 'Filters', name: 'Filters',
@ -63,6 +56,7 @@ export default {
NcActionCheckbox, NcActionCheckbox,
}, },
computed: { computed: {
...mapState(useSettings, ['filters']),
all() { all() {
return ( return (
this.filters.listened && this.filters.listened &&
@ -70,12 +64,15 @@ export default {
this.filters.unlistened this.filters.unlistened
) )
}, },
filters() {
return this.$store.state.settings.filters
},
}, },
mounted() { mounted() {
this.$store.dispatch('settings/fetch') try {
const filters = getCookie('repod.filters')
this.filters = JSON.parse(filters)
} catch {}
},
methods: {
...mapActions(useSettings, ['setFilters']),
}, },
} }
</script> </script>

View File

@ -3,27 +3,27 @@
<template #extra> <template #extra>
<div class="extra"> <div class="extra">
<MinusIcon class="pointer" :size="20" @click="changeRate(-0.1)" /> <MinusIcon class="pointer" :size="20" @click="changeRate(-0.1)" />
<NcCounterBubble class="counter"> <NcCounterBubble class="counter">x{{ rate }}</NcCounterBubble>
x{{ player.rate }}
</NcCounterBubble>
<PlusIcon class="pointer" :size="20" @click="changeRate(0.1)" /> <PlusIcon class="pointer" :size="20" @click="changeRate(0.1)" />
</div> </div>
</template> </template>
<template #icon> <template #icon>
<SpeedometerSlowIcon v-if="player.rate < 1" :size="20" /> <SpeedometerSlowIcon v-if="rate < 1" :size="20" />
<SpeedometerMediumIcon v-if="player.rate === 1" :size="20" /> <SpeedometerMediumIcon v-if="rate === 1" :size="20" />
<SpeedometerIcon v-if="player.rate > 1" :size="20" /> <SpeedometerIcon v-if="rate > 1" :size="20" />
</template> </template>
</NcAppNavigationItem> </NcAppNavigationItem>
</template> </template>
<script> <script>
import { NcAppNavigationItem, NcCounterBubble } from '@nextcloud/vue' import { NcAppNavigationItem, NcCounterBubble } from '@nextcloud/vue'
import { mapActions, mapState } from 'pinia'
import MinusIcon from 'vue-material-design-icons/Minus.vue' import MinusIcon from 'vue-material-design-icons/Minus.vue'
import PlusIcon from 'vue-material-design-icons/Plus.vue' import PlusIcon from 'vue-material-design-icons/Plus.vue'
import SpeedometerIcon from 'vue-material-design-icons/Speedometer.vue' import SpeedometerIcon from 'vue-material-design-icons/Speedometer.vue'
import SpeedometerMediumIcon from 'vue-material-design-icons/SpeedometerMedium.vue' import SpeedometerMediumIcon from 'vue-material-design-icons/SpeedometerMedium.vue'
import SpeedometerSlowIcon from 'vue-material-design-icons/SpeedometerSlow.vue' import SpeedometerSlowIcon from 'vue-material-design-icons/SpeedometerSlow.vue'
import { usePlayer } from '../../store/player.js'
export default { export default {
name: 'Speed', name: 'Speed',
@ -37,17 +37,13 @@ export default {
SpeedometerSlowIcon, SpeedometerSlowIcon,
}, },
computed: { computed: {
player() { ...mapState(usePlayer, ['rate']),
return this.$store.state.player
},
}, },
methods: { methods: {
...mapActions(usePlayer, ['setRate']),
changeRate(diff) { changeRate(diff) {
const newRate = (this.player.rate + diff).toPrecision(2) const newRate = (this.rate + diff).toPrecision(2)
this.$store.dispatch( this.setRate(newRate > 0 ? newRate : this.rate)
'player/rate',
newRate > 0 ? newRate : this.player.rate,
)
}, },
}, },
} }

View File

@ -31,8 +31,10 @@ import AlertIcon from 'vue-material-design-icons/Alert.vue'
import DeleteIcon from 'vue-material-design-icons/Delete.vue' import DeleteIcon from 'vue-material-design-icons/Delete.vue'
import axios from '@nextcloud/axios' import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import { showError } from '@nextcloud/dialogs' import { mapActions } from 'pinia'
import { showError } from '../../utils/toast.js'
import { toUrl } from '../../utils/url.js' import { toUrl } from '../../utils/url.js'
import { useSubscriptions } from '../../store/subscriptions.js'
export default { export default {
name: 'Item', name: 'Item',
@ -80,6 +82,7 @@ export default {
} }
}, },
methods: { methods: {
...mapActions(useSubscriptions, ['fetch']),
async deleteSubscription() { async deleteSubscription() {
if ( if (
confirm( confirm(
@ -97,7 +100,7 @@ export default {
showError(t('repod', 'Error while removing the feed')) showError(t('repod', 'Error while removing the feed'))
} finally { } finally {
this.loading = false this.loading = false
this.$store.dispatch('subscriptions/fetch') this.fetch()
} }
} }
}, },

View File

@ -30,12 +30,14 @@ import {
NcAppNavigationList, NcAppNavigationList,
NcAppNavigationNew, NcAppNavigationNew,
} from '@nextcloud/vue' } from '@nextcloud/vue'
import { mapActions, mapState } from 'pinia'
import AppNavigation from '../Atoms/AppNavigation.vue' import AppNavigation from '../Atoms/AppNavigation.vue'
import Item from './Item.vue' import Item from './Item.vue'
import Loading from '../Atoms/Loading.vue' import Loading from '../Atoms/Loading.vue'
import PlusIcon from 'vue-material-design-icons/Plus.vue' import PlusIcon from 'vue-material-design-icons/Plus.vue'
import Settings from '../Settings/Settings.vue' import Settings from '../Settings/Settings.vue'
import { showError } from '@nextcloud/dialogs' import { showError } from '../../utils/toast.js'
import { useSubscriptions } from '../../store/subscriptions.js'
export default { export default {
name: 'Subscriptions', name: 'Subscriptions',
@ -55,13 +57,11 @@ export default {
} }
}, },
computed: { computed: {
subscriptions() { ...mapState(useSubscriptions, ['subscriptions']),
return this.$store.state.subscriptions.subscriptions
},
}, },
async mounted() { async mounted() {
try { try {
await this.$store.dispatch('subscriptions/fetch') await this.fetch()
} catch (e) { } catch (e) {
console.error(e) console.error(e)
showError(t('repod', 'Could not fetch subscriptions')) showError(t('repod', 'Could not fetch subscriptions'))
@ -69,5 +69,8 @@ export default {
this.loading = false this.loading = false
} }
}, },
methods: {
...mapActions(useSubscriptions, ['fetch']),
},
} }
</script> </script>

View File

@ -1,13 +1,13 @@
import { n, t } from '@nextcloud/l10n' import { n, t } from '@nextcloud/l10n'
import App from './App.vue' import App from './App.vue'
import Vue from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router.js' import router from './router.js'
import store from './store/main.js'
const Vue = createApp(App)
const pinia = createPinia()
Vue.mixin({ methods: { t, n } }) Vue.mixin({ methods: { t, n } })
Vue.use(pinia)
const View = Vue.extend(App) Vue.use(router)
new View({ Vue.mount('#content')
router,
store,
}).$mount('#content')

View File

@ -1,13 +1,10 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import Discover from './views/Discover.vue' import Discover from './views/Discover.vue'
import Feed from './views/Feed.vue' import Feed from './views/Feed.vue'
import Router from 'vue-router'
import Vue from 'vue'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
Vue.use(Router) const router = createRouter({
history: createWebHashHistory(generateUrl('apps/repod')),
const router = new Router({
base: generateUrl('apps/repod'),
routes: [ routes: [
{ {
path: '/', path: '/',

View File

@ -1,3 +0,0 @@
import Vue from 'vue'
export const EventBus = new Vue()

View File

@ -1,17 +0,0 @@
import Vuex, { Store } from 'vuex'
import Vue from 'vue'
import { player } from './player.js'
import { settings } from './settings.js'
import { subscriptions } from './subscriptions.js'
Vue.use(Vuex)
const store = new Store({
modules: {
player,
settings,
subscriptions,
},
})
export default store

View File

@ -1,25 +1,12 @@
import { EventBus } from './bus.js'
import axios from '@nextcloud/axios' import axios from '@nextcloud/axios'
import { decodeUrl } from '../utils/url.js' import { defineStore } from 'pinia'
import { formatEpisodeTimestamp } from '../utils/time.js' import { formatEpisodeTimestamp } from '../utils/time.js'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import router from '../router.js'
import store from './main.js'
const audio = new Audio() const audio = new Audio()
audio.ondurationchange = () => store.commit('player/duration', audio.duration)
audio.onended = () => store.dispatch('player/stop')
audio.onloadeddata = () => store.commit('player/loaded', true)
audio.onplay = () => store.dispatch('player/play')
audio.onpause = () => store.dispatch('player/pause')
audio.onratechange = () => store.commit('player/rate', audio.playbackRate)
audio.onseeked = () => store.commit('player/currentTime', audio.currentTime)
audio.ontimeupdate = () => store.commit('player/currentTime', audio.currentTime)
audio.onvolumechange = () => store.commit('player/volume', audio.volume)
export const player = { export const usePlayer = defineStore('player', {
namespaced: true, state: () => ({
state: {
currentTime: null, currentTime: null,
duration: null, duration: null,
episode: null, episode: null,
@ -29,119 +16,91 @@ export const player = {
volume: 1, volume: 1,
rate: 1, rate: 1,
started: 0, started: 0,
}, }),
mutations: {
action: (state, action) => {
state.episode.action = action
if (action && action.position && action.position < action.total) {
audio.currentTime = action.position
state.started = audio.currentTime
}
},
currentTime: (state, currentTime) => {
state.currentTime = currentTime
},
duration: (state, duration) => {
state.duration = duration
},
episode: (state, episode) => {
state.episode = episode
if (episode) {
state.podcastUrl = decodeUrl(router.currentRoute.params.url)
audio.src = episode.url
audio.load()
audio.play()
if (
episode.action &&
episode.action.position &&
episode.action.position < episode.action.total
) {
audio.currentTime = episode.action.position
state.started = audio.currentTime
}
} else {
state.loaded = false
state.podcastUrl = null
audio.src = ''
}
},
loaded: (state, loaded) => {
state.loaded = loaded
},
paused: (state, paused) => {
state.paused = paused
},
volume: (state, volume) => {
state.volume = volume
},
rate: (state, rate) => {
state.rate = rate
},
started: (state, started) => {
state.started = started
},
},
actions: { actions: {
load: async (context, episode) => { init() {
context.commit('episode', episode) audio.ondurationchange = () => (this.duration = audio.duration)
audio.onended = () => this.stop()
audio.onloadeddata = () => (this.loaded = true)
audio.onpause = () => this.pause()
audio.onplay = () => this.play()
audio.onratechange = () => (this.rate = audio.playbackRate)
audio.onseeked = () => (this.currentTime = audio.currentTime)
audio.ontimeupdate = () => (this.currentTime = audio.currentTime)
audio.onvolumechange = () => (this.volume = audio.volume)
},
async load(episode, podcastUrl) {
this.episode = episode
this.podcastUrl = podcastUrl
if (this.episode) {
audio.src = this.episode.url
audio.load()
try { try {
const action = await axios.get( const action = await axios.get(
generateUrl('/apps/repod/episodes/action?url={url}', { generateUrl('/apps/repod/episodes/action?url={url}', {
url: episode.url, url: this.episode.url,
}), }),
) )
context.commit('action', action.data)
this.episode.action = action
} catch {} } catch {}
},
pause: (context) => { if (
audio.pause() this.episode.action &&
context.commit('paused', true) this.episode.action.position &&
context.dispatch('time') this.episode.action.position < this.episode.action.total
}, ) {
play: (context) => { audio.currentTime = this.episode.action.position
this.started = audio.currentTime
}
audio.play() audio.play()
context.commit('paused', false) } else {
context.commit('started', audio.currentTime) this.loaded = false
this.podcastUrl = null
audio.src = ''
}
}, },
seek: (context, currentTime) => { pause() {
audio.pause()
this.paused = true
this.time()
},
play() {
audio.play()
this.paused = false
this.started = audio.currentTime
},
seek(currentTime) {
audio.currentTime = currentTime audio.currentTime = currentTime
context.dispatch('time') this.time()
}, },
stop: (context) => { stop() {
context.dispatch('pause') this.pause()
context.commit('episode', null) this.episode = null
}, },
time: async (context) => { time() {
const episode = context.state.episode this.episode.action = {
episode.action = { podcast: this.podcastUrl,
podcast: context.state.podcastUrl, episode: this.episode.url,
episode: context.state.episode.url, guid: this.episode.guid,
guid: context.state.episode.guid,
action: 'play', action: 'play',
timestamp: formatEpisodeTimestamp(new Date()), timestamp: formatEpisodeTimestamp(new Date()),
started: Math.round(context.state.started), started: Math.round(this.started),
position: Math.round(audio.currentTime), position: Math.round(audio.currentTime),
total: Math.round(audio.duration), total: Math.round(audio.duration),
} }
axios.post(generateUrl('/apps/gpoddersync/episode_action/create'), [ axios.post(generateUrl('/apps/gpoddersync/episode_action/create'), [
episode.action, this.episode.action,
]) ])
EventBus.$emit('updateEpisodesList', episode)
}, },
volume: (_, volume) => { setVolume(volume) {
audio.volume = volume audio.volume = volume
}, },
rate: (_, rate) => { setRate(rate) {
audio.playbackRate = rate audio.playbackRate = rate
}, },
}, },
} })
setInterval(() => {
if (player.state.paused === false) {
store.dispatch('player/time')
}
}, 40000)

View File

@ -1,28 +1,18 @@
import { getCookie, setCookie } from '../utils/cookies.js' import { defineStore } from 'pinia'
import { setCookie } from '../utils/cookies.js'
export const settings = { export const useSettings = defineStore('settings', {
namespaced: true, state: () => ({
state: {
filters: { filters: {
listened: true, listened: true,
listening: true, listening: true,
unlistened: true, unlistened: true,
}, },
}, }),
mutations: {
filters: (state, filters) => {
state.filters = { ...state.filters, ...filters }
setCookie('repod.filters', JSON.stringify(state.filters), 365)
},
},
actions: { actions: {
fetch: (context) => { setFilters(filters) {
try { this.filters = { ...this.filters, ...filters }
const filters = getCookie('repod.filters') setCookie('repod.filters', JSON.stringify(this.filters), 365)
context.commit('filters', JSON.parse(filters))
} catch (e) {
// nothing
}
}, },
}, },
} })

View File

@ -1,28 +1,20 @@
import axios from '@nextcloud/axios' import axios from '@nextcloud/axios'
import { defineStore } from 'pinia'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
export const subscriptions = { export const useSubscriptions = defineStore('subscriptions', {
namespaced: true, state: () => ({
state: {
subscriptions: [], subscriptions: [],
}, }),
mutations: {
set: (state, subscriptions) => {
state.subscriptions = subscriptions
},
},
actions: { actions: {
fetch: async (context) => { async fetch() {
const metrics = await axios.get( const metrics = await axios.get(
generateUrl('/apps/gpoddersync/personal_settings/metrics'), generateUrl('/apps/gpoddersync/personal_settings/metrics'),
) )
const subs = [...metrics.data.subscriptions].sort( const subs = [...metrics.data.subscriptions].sort(
(a, b) => b.listenedSeconds - a.listenedSeconds, (a, b) => b.listenedSeconds - a.listenedSeconds,
) )
context.commit( this.subscriptions = subs.map((sub) => sub.url)
'set',
subs.map((sub) => sub.url),
)
}, },
}, },
} })

12
src/utils/toast.js Normal file
View File

@ -0,0 +1,12 @@
import toastify from 'toastify-js'
export const showMessage = (text, backgroundColor) =>
toastify({
text,
backgroundColor,
}).showToast()
export const showError = (text) => showMessage(text, 'var(--color-error)')
export const showWarning = (text) => showMessage(text, 'var(--color-warning)')
export const showInfo = (text) => showMessage(text, 'var(--color-primary)')
export const showSuccess = (text) => showMessage(text, 'var(--color-success)')

View File

@ -1,3 +1,7 @@
export const encodeUrl = (url) => encodeURIComponent(btoa(url)) export const encodeUrl = (url) => encodeURIComponent(btoa(url))
export const decodeUrl = (url) => atob(decodeURIComponent(url)) export const decodeUrl = (url) => atob(decodeURIComponent(url))
export const toUrl = (url) => `/${encodeUrl(url)}` export const toUrl = (url) => `/${encodeUrl(url)}`
export const filenameFromUrl = (str) => {
const url = new URL(str)
return url.pathname.split('/').pop()
}

View File

@ -1,7 +1,9 @@
<template> <template>
<AppContent class="main"> <AppContent class="main">
<NcTextField :label="t('repod', 'Find a podcast')" :value.sync="search"> <NcTextField v-model="search" :label="t('repod', 'Find a podcast')">
<template #icon>
<Magnify :size="20" /> <Magnify :size="20" />
</template>
</NcTextField> </NcTextField>
<Search v-if="search" :value="search" /> <Search v-if="search" :value="search" />
<Toplist v-if="!search" type="hot" /> <Toplist v-if="!search" type="hot" />

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Nextcloud 3.14159\n" "Project-Id-Version: Nextcloud 3.14159\n"
"Report-Msgid-Bugs-To: translations\\@example.com\n" "Report-Msgid-Bugs-To: translations\\@example.com\n"
"POT-Creation-Date: 2024-07-15 14:40+0000\n" "POT-Creation-Date: 2024-08-09 10:10+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -48,8 +48,8 @@ msgid ""
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:1 #: /app/specialVueFakeDummyForL10nScript.js:1
#: /app/specialVueFakeDummyForL10nScript.js:29 #: /app/specialVueFakeDummyForL10nScript.js:27
#: /app/specialVueFakeDummyForL10nScript.js:30 #: /app/specialVueFakeDummyForL10nScript.js:28
msgid "Download" msgid "Download"
msgstr "" msgstr ""
@ -95,109 +95,107 @@ msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:15 #: /app/specialVueFakeDummyForL10nScript.js:15
#: /app/specialVueFakeDummyForL10nScript.js:16 #: /app/specialVueFakeDummyForL10nScript.js:16
#: /app/specialVueFakeDummyForL10nScript.js:17
msgid "Play" msgid "Play"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:17
#: /app/specialVueFakeDummyForL10nScript.js:18 #: /app/specialVueFakeDummyForL10nScript.js:18
#: /app/specialVueFakeDummyForL10nScript.js:19
#: /app/specialVueFakeDummyForL10nScript.js:20
msgid "Stop" msgid "Stop"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:19
#: /app/specialVueFakeDummyForL10nScript.js:20
#: /app/specialVueFakeDummyForL10nScript.js:21 #: /app/specialVueFakeDummyForL10nScript.js:21
#: /app/specialVueFakeDummyForL10nScript.js:22
#: /app/specialVueFakeDummyForL10nScript.js:23
msgid "Mark as read" msgid "Mark as read"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:22
#: /app/specialVueFakeDummyForL10nScript.js:23
#: /app/specialVueFakeDummyForL10nScript.js:24 #: /app/specialVueFakeDummyForL10nScript.js:24
#: /app/specialVueFakeDummyForL10nScript.js:25
#: /app/specialVueFakeDummyForL10nScript.js:26
msgid "Mark as unread" msgid "Mark as unread"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:27 #: /app/specialVueFakeDummyForL10nScript.js:25
#: /app/specialVueFakeDummyForL10nScript.js:28 #: /app/specialVueFakeDummyForL10nScript.js:26
msgid "Open website" msgid "Open website"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:31 #: /app/specialVueFakeDummyForL10nScript.js:29
msgid "Could not fetch episodes" msgid "Could not fetch episodes"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:32 #: /app/specialVueFakeDummyForL10nScript.js:30
msgid "Could not change the status of the episode" msgid "Could not change the status of the episode"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:33 #: /app/specialVueFakeDummyForL10nScript.js:31
msgid "Export subscriptions" msgid "Export subscriptions"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:34 #: /app/specialVueFakeDummyForL10nScript.js:32
msgid "Filtering episodes" msgid "Filtering episodes"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:35 #: /app/specialVueFakeDummyForL10nScript.js:33
msgid "Show all" msgid "Show all"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:36 #: /app/specialVueFakeDummyForL10nScript.js:34
msgid "Listened" msgid "Listened"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:37 #: /app/specialVueFakeDummyForL10nScript.js:35
msgid "Listening" msgid "Listening"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:38 #: /app/specialVueFakeDummyForL10nScript.js:36
msgid "Unlistened" msgid "Unlistened"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:39 #: /app/specialVueFakeDummyForL10nScript.js:37
msgid "Import subscriptions" msgid "Import subscriptions"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:40 #: /app/specialVueFakeDummyForL10nScript.js:38
msgid "Import OPML file" msgid "Import OPML file"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:41 #: /app/specialVueFakeDummyForL10nScript.js:39
msgid "Rate RePod ❤️" msgid "Rate RePod ❤️"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:42 #: /app/specialVueFakeDummyForL10nScript.js:40
msgid "Playback speed" msgid "Playback speed"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:43 #: /app/specialVueFakeDummyForL10nScript.js:41
msgid "Are you sure you want to delete this subscription?" msgid "Are you sure you want to delete this subscription?"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:44 #: /app/specialVueFakeDummyForL10nScript.js:42
msgid "Error while removing the feed" msgid "Error while removing the feed"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:45 #: /app/specialVueFakeDummyForL10nScript.js:43
msgid "Add a podcast" msgid "Add a podcast"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:46 #: /app/specialVueFakeDummyForL10nScript.js:44
msgid "Could not fetch subscriptions" msgid "Could not fetch subscriptions"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:47 #: /app/specialVueFakeDummyForL10nScript.js:45
msgid "Find a podcast" msgid "Find a podcast"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:48 #: /app/specialVueFakeDummyForL10nScript.js:46
msgid "Error loading feed" msgid "Error loading feed"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:49 #: /app/specialVueFakeDummyForL10nScript.js:47
msgid "Missing required app" msgid "Missing required app"
msgstr "" msgstr ""
#: /app/specialVueFakeDummyForL10nScript.js:50 #: /app/specialVueFakeDummyForL10nScript.js:48
msgid "Install GPodder Sync" msgid "Install GPodder Sync"
msgstr "" msgstr ""

22
vite.config.mjs Normal file
View File

@ -0,0 +1,22 @@
import { createAppConfig } from '@nextcloud/vite-config'
import { defineConfig } from 'vite'
const config = defineConfig(({ mode }) => ({
build: {
rollupOptions: {
output: {
entryFileNames: 'js/[name].js',
format: 'iife',
manualChunks: false,
},
},
sourcemap: mode === 'development',
},
}))
export default createAppConfig(
{
main: 'src/main.js',
},
{ config },
)

View File

@ -1,31 +0,0 @@
const webpackConfig = require('@nextcloud/webpack-vue-config')
const ESLintPlugin = require('eslint-webpack-plugin')
const StyleLintPlugin = require('stylelint-webpack-plugin')
const path = require('path')
webpackConfig.entry = {
main: { import: path.join(__dirname, 'src', 'main.js'), filename: 'main.js' },
}
webpackConfig.plugins.push(
new ESLintPlugin({
extensions: ['js', 'vue'],
files: 'src',
}),
)
webpackConfig.plugins.push(
new StyleLintPlugin({
files: 'src/**/*.{css,scss,vue}',
}),
)
webpackConfig.module.rules.push({
test: /\.svg$/i,
type: 'asset/source',
})
webpackConfig.devtool =
webpackConfig.mode !== 'production' ? webpackConfig.devtool : false
module.exports = webpackConfig