diff --git a/changes/changelog.d/872.feature b/changes/changelog.d/872.feature new file mode 100644 index 0000000000000000000000000000000000000000..083601ecfb05ad68debd48ba4ba6fe9d6dee8b7e --- /dev/null +++ b/changes/changelog.d/872.feature @@ -0,0 +1 @@ +Redesign of the landing and about pages (#872) diff --git a/changes/notes.rst b/changes/notes.rst index 442fc0528f7f6c5d8a022d16457c850efefe9b03..51c53e85792877782e4649cf96601e2bd3856aba 100644 --- a/changes/notes.rst +++ b/changes/notes.rst @@ -62,6 +62,39 @@ For more information about this feature, please check out our documentation: - `User documentation <https://docs.funkwhale.audio/users/account.html>`_ +Landing and about page redesign [Manual action suggested] +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In this release, we've completely redesigned the landing and about page, by making it more useful and adapted to your pod +configuration. Among other things, the landing page will now include: + +- your pod and an excerpt from your pod's description +- your pod banner image, if any +- your contact email, if any +- the login form +- the signup form (if registrations are open on your pod) +- some basic statistics about your pod +- a widget including recently uploaded albums, if anonymous access is enabled + +The landing page will still include some information about Funkwhale, but in a less intrusive and proeminent way than before. + +Additionally, the about page now includes: + +- your pod name, description, rules and terms +- your pod banner image, if any +- your contact email, if any +- comprehensive statistics about your pod +- some info about your pod configuration, such as registration and federation status or the default upload quota for new users + +With this redesign, we've added a handful of additional pod settings: + +- Pod banner image +- Contact email +- Rules +- Terms of service + +We recommend taking a few moments to fill these accordingly to your needs, by visiting ``/manage/settings``. + Allow-list to restrict federation to trusted domains ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/front/src/App.vue b/front/src/App.vue index 9881554f9f102a6b5507de2c40e2f9f8279d0de3..5be97dfb5655f43f5093353fcfffd55e034aa310 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -62,13 +62,12 @@ export default { data () { return { bridge: null, - nodeinfo: null, instanceUrl: null, showShortcutsModal: false, showSetInstanceModal: false, } }, - created () { + async created () { this.openWebsocket() let self = this if (!this.$store.state.ui.selectedLanguage) { @@ -78,7 +77,12 @@ export default { // used to redraw ago dates every minute self.$store.commit('ui/computeLastDate') }, 1000 * 60) - if (!this.$store.state.instance.instanceUrl) { + const urlParams = new URLSearchParams(window.location.search); + const serverUrl = urlParams.get('_server') + if (serverUrl) { + this.$store.commit('instance/instanceUrl', serverUrl) + } + else if (!this.$store.state.instance.instanceUrl) { // we have several way to guess the API server url. By order of precedence: // 1. use the url provided in settings.json, if any // 2. use the url specified when building via VUE_APP_INSTANCE_URL @@ -89,9 +93,9 @@ export default { // needed to trigger initialization of axios this.$store.commit('instance/instanceUrl', this.$store.state.instance.instanceUrl) } + await this.fetchNodeInfo() this.$store.dispatch('auth/check') this.$store.dispatch('instance/fetchSettings') - this.fetchNodeInfo() this.$store.commit('ui/addWebsocketEventHandler', { eventName: 'inbox.item_added', id: 'sidebarCount', @@ -152,14 +156,11 @@ export default { this.$store.commit('ui/incrementNotifications', {type: 'pendingReviewEdits', value: event.pending_review_count}) }, incrementPendingReviewReportsCountInSidebar (event) { - console.log('HELLO', event) this.$store.commit('ui/incrementNotifications', {type: 'pendingReviewReports', value: event.unresolved_count}) }, - fetchNodeInfo () { - let self = this - axios.get('instance/nodeinfo/2.0/').then(response => { - self.nodeinfo = response.data - }) + async fetchNodeInfo () { + let response = await axios.get('instance/nodeinfo/2.0/') + this.$store.commit('instance/nodeinfo', response.data) }, autodetectLanguage () { let userLanguage = navigator.language || navigator.userLanguage @@ -235,7 +236,8 @@ export default { }, computed: { ...mapState({ - messages: state => state.ui.messages + messages: state => state.ui.messages, + nodeinfo: state => state.instance.nodeinfo, }), ...mapGetters({ currentTrack: 'queue/currentTrack' diff --git a/front/src/assets/network.png b/front/src/assets/network.png new file mode 100644 index 0000000000000000000000000000000000000000..e9d5f4ddd465afcb18acd5d7359c4b788b706d9a Binary files /dev/null and b/front/src/assets/network.png differ diff --git a/front/src/components/About.vue b/front/src/components/About.vue index 87f741aa819f38ba84c856522979f25e6f6016d1..3c84b48f9642bc132b54cd16a8b0508fb669711e 100644 --- a/front/src/components/About.vue +++ b/front/src/components/About.vue @@ -1,38 +1,197 @@ <template> - <main class="main pusher" v-title="labels.title"> - <section class="ui vertical center aligned stripe segment"> - <div class="ui text container"> - <h1 class="ui huge header"> - <span v-translate="{instance: instance.name.value}" translate-context="Content/About/Title/Short, Noun" v-if="instance.name.value" :translate-params="{instance: instance.name.value}"> - About %{ instance } - </span> - <translate translate-context="Content/About/Title" v-else>About this instance</translate> + <main class="main pusher"> + <section :class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle"> + <div class="segment-content"> + <h1 class="ui center aligned large header"> + <translate translate-context="Content/Home/Header" + :translate-params="{podName: podName}"> + About %{ podName } + </translate> + <div v-if="shortDescription" class="sub header"> + {{ shortDescription }} + </div> </h1> - <stats></stats> </div> </section> <section class="ui vertical stripe segment"> - <div - class="ui middle aligned stackable text container"> - <p - v-if="!instance.short_description.value && !instance.long_description.value"><translate translate-context="Content/About/Paragraph">Unfortunately, the owners of this instance did not yet take the time to complete this page.</translate></p> - <router-link - class="ui button" - v-if="$store.state.auth.availablePermissions['settings']" - :to="{path: '/manage/settings', hash: 'instance'}"> - <i class="pencil icon"></i><translate translate-context="Content/Settings/Button.Label/Verb">Edit instance info</translate> - </router-link> - <div class="ui hidden divider"></div> - </div> - <div - v-if="instance.short_description.value" - class="ui middle aligned stackable text container"> - <p>{{ instance.short_description.value }}</p> - </div> - <div - v-if="markdown && instance.long_description.value" - class="ui middle aligned stackable text container" - v-html="markdown.makeHtml(instance.long_description.value)"> + <div class="ui container"> + <div class="ui mobile reversed stackable grid"> + <div class="ten wide column"> + <div class="ui text container"> + <h3 class="ui header" id="description"> + <translate translate-context="Content/About/Header">About this pod</translate> + </h3> + <div v-html="markdown.makeHtml(longDescription)" v-if="longDescription"></div> + <p v-else> + <translate translate-context="Content/Home/Paragraph">No description available.</translate> + </p> + <h3 class="ui header" id="rules"> + <translate translate-context="Content/About/Header">Rules</translate> + </h3> + <div v-html="markdown.makeHtml(rules)" v-if="rules"></div> + <p v-else> + <translate translate-context="Content/Home/Paragraph">No rules available.</translate> + </p> + <h3 class="ui header" id="terms"> + <translate translate-context="Content/About/Header">Terms and privacy policy</translate> + </h3> + <div v-html="markdown.makeHtml(terms)" v-if="terms"></div> + <p v-else> + <translate translate-context="Content/Home/Paragraph">No terms available.</translate> + </p> + </div> + </div> + <div class="six wide column"> + <div class="ui raised segment"> + <h3 class="ui header"> + <translate translate-context="Content/About/Header">Contents</translate> + </h3> + <div class="ui list"> + <div class="ui item"> + <a href="#description"> + <translate translate-context="Content/About/Header">About this pod</translate> + </a> + </div> + <div class="ui item"> + <a href="#rules"> + <translate translate-context="Content/About/Header">Rules</translate> + </a> + </div> + <div class="ui item"> + <a href="#terms"> + <translate translate-context="Content/About/Header">Terms and privacy policy</translate> + </a> + </div> + </div> + <template v-if="contactEmail"> + <h3 class="header"> + <translate translate-context="Content/Home/Header/Name">Contact</translate> + </h3> + <a :href="`mailto:${contactEmail}`">{{ contactEmail }}</a> + </template> + <h3 class="header"> + <translate translate-context="Content/About/Header/Name">Pod configuration</translate> + </h3> + <table class="ui very basic table"> + <tbody> + <tr v-if="version"> + <td> + <translate translate-context="*/*/*">Funkwhale version</translate> + </td> + <td> + {{ version }} + </td> + </tr> + <tr> + <td> + <translate translate-context="*/*/*">Registrations</translate> + </td> + <td v-if="openRegistrations"> + <i class="check icon"></i> + <translate translate-context="*/*/*/State of registrations">Open</translate> + </td> + <td v-else> + <i class="x icon"></i> + <translate translate-context="*/*/*/State of registrations">Closed</translate> + </td> + </tr> + <tr> + <td> + <translate translate-context="*/*/*">Upload quota</translate> + </td> + <td v-if="defaultUploadQuota"> + {{ defaultUploadQuota * 1000 * 1000 | humanSize }} + </td> + <td v-else> + <translate translate-context="*/*/*">N/A</translate> + </td> + </tr> + <tr> + <td> + <translate translate-context="*/*/*">Federation</translate> + </td> + <td v-if="federationEnabled"> + <i class="check icon"></i> + <translate translate-context="*/*/*/State of feature">Enabled</translate> + </td> + <td v-else> + <i class="x icon"></i> + <translate translate-context="*/*/*/State of feature">Disabled</translate> + </td> + </tr> + <tr> + <td> + <translate translate-context="*/*/*">Anonymous access</translate> + </td> + <td v-if="anonymousCanListen"> + <i class="check icon"></i> + <translate translate-context="*/*/*/State of feature">Enabled</translate> + </td> + <td v-else> + <i class="x icon"></i> + <translate translate-context="*/*/*/State of feature">Disabled</translate> + </td> + </tr> + <tr> + <td> + <translate translate-context="*/*/*">Allow-list</translate> + </td> + <td v-if="allowListEnabled"> + <i class="check icon"></i> + <translate translate-context="*/*/*/State of feature">Enabled</translate> + </td> + <td v-else> + <i class="x icon"></i> + <translate translate-context="*/*/*/State of feature">Disabled</translate> + </td> + </tr> + <tr v-if="allowListDomains"> + <td> + <translate translate-context="*/*/*">Allowed domains</translate> + </td> + <td> + <translate :translate-n="allowListDomains.length" translate-plural="%{ count } allowed domains" :translate-params="{count: allowListDomains.length}" translate-context="*/*/*">%{ count } allowed domains</translate> + <br> + <a @click.prevent="showAllowedDomains = !showAllowedDomains"> + <translate v-if="showAllowedDomains" key="1" translate-context="*/*/*/Verb">Hide</translate> + <translate v-else key="2" translate-context="*/*/*/Verb">Show</translate> + </a> + <ul class="ui list" v-if="showAllowedDomains"> + <li v-for="domain in allowListDomains" :key="domain"> + <a :href="`https://${domain}`" target="_blank" rel="noopener">{{ domain }}</a> + </li> + </ul> + </td> + </tr> + </tbody> + </table> + + <template v-if="stats"> + <h3 class="header"> + <translate translate-context="Content/Home/Header">Statistics</translate> + </h3> + <p> + <i class="user grey icon"></i><translate translate-context="Content/Home/Stat" :translate-params="{count: stats.users.toLocaleString($store.state.ui.momentLocale) }" :translate-n="stats.users" translate-plural="%{ count } active users">%{ count } active user</translate> + </p> + <p> + <i class="music grey icon"></i><translate translate-context="Content/Home/Stat" :translate-params="{count: parseInt(stats.hours).toLocaleString($store.state.ui.momentLocale)}" :translate-n="parseInt(stats.hours)" translate-plural="%{ count } hours of music">%{ count } hour of music</translate> + </p> + <p v-if="stats.artists"> + <i class="users grey icon"></i><translate translate-context="Content/Home/Stat" :translate-params="{count: stats.artists.toLocaleString($store.state.ui.momentLocale) }" :translate-n="stats.artists" translate-plural="%{ count } artists">%{ count } artists</translate> + </p> + <p v-if="stats.albums"> + <i class="headphones grey icon"></i><translate translate-context="Content/Home/Stat" :translate-params="{count: stats.albums.toLocaleString($store.state.ui.momentLocale) }" :translate-n="stats.albums" translate-plural="%{ count } albums">%{ count } albums</translate> + </p> + <p v-if="stats.tracks"> + <i class="file grey icon"></i><translate translate-context="Content/Home/Stat" :translate-params="{count: stats.tracks.toLocaleString($store.state.ui.momentLocale) }" :translate-n="stats.tracks" translate-plural="%{ count } tracks">%{ count } tracks</translate> + </p> + <p v-if="stats.listenings"> + <i class="play grey icon"></i><translate translate-context="Content/Home/Stat" :translate-params="{count: stats.listenings.toLocaleString($store.state.ui.momentLocale) }" :translate-n="stats.listenings" translate-plural="%{ count } listenings">%{ count } listenings</translate> + </p> + </template> + </div> + </div> + </div> </div> </section> </main> @@ -40,37 +199,122 @@ <script> import { mapState } from "vuex" -import Stats from "@/components/instance/Stats" +import _ from '@/lodash' +import showdown from 'showdown' export default { - components: { - Stats - }, data () { return { - markdown: null + markdown: new showdown.Converter(), + showAllowedDomains: false, } }, - created () { - this.$store.dispatch("instance/fetchSettings") - let self = this - import('showdown').then(module => { - self.markdown = new module.default.Converter() - }) - }, computed: { - ...mapState({ - instance: state => state.instance.settings.instance + + ...mapState({ + nodeinfo: state => state.instance.nodeinfo, }), - labels() { - return { - title: this.$pgettext('Content/About/Title', "About this instance") + podName() { + return _.get(this.nodeinfo, 'metadata.nodeName') || "Funkwhale" + }, + banner () { + return _.get(this.nodeinfo, 'metadata.banner') + }, + shortDescription () { + return _.get(this.nodeinfo, 'metadata.shortDescription') + }, + longDescription () { + return _.get(this.nodeinfo, 'metadata.longDescription') + }, + rules () { + return _.get(this.nodeinfo, 'metadata.rules') + }, + terms () { + return _.get(this.nodeinfo, 'metadata.terms') + }, + stats () { + let data = { + users: _.get(this.nodeinfo, 'usage.users.activeMonth', null), + hours: _.get(this.nodeinfo, 'metadata.library.music.hours', null), + artists: _.get(this.nodeinfo, 'metadata.library.artists.total', null), + albums: _.get(this.nodeinfo, 'metadata.library.albums.total', null), + tracks: _.get(this.nodeinfo, 'metadata.library.tracks.total', null), + listenings: _.get(this.nodeinfo, 'metadata.usage.listenings.total', null), } - } + if (data.users === null || data.artists === null) { + return + } + return data + }, + contactEmail () { + return _.get(this.nodeinfo, 'metadata.contactEmail') + }, + anonymousCanListen () { + return _.get(this.nodeinfo, 'metadata.library.anonymousCanListen') + }, + allowListEnabled () { + return _.get(this.nodeinfo, 'metadata.allowList.enabled') + }, + allowListDomains () { + return _.get(this.nodeinfo, 'metadata.allowList.domains') + }, + version () { + return _.get(this.nodeinfo, 'software.version') + }, + openRegistrations () { + return _.get(this.nodeinfo, 'openRegistrations') + }, + defaultUploadQuota () { + return _.get(this.nodeinfo, 'metadata.defaultUploadQuota') + }, + federationEnabled () { + return _.get(this.nodeinfo, 'metadata.library.federationEnabled') + }, + headerStyle() { + if (!this.banner) { + return "" + } + return ( + "background-image: url(" + + this.$store.getters["instance/absoluteUrl"](this.banner) + + ")" + ) + }, } } </script> <!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped lang="scss"> + +.ui.list .list.icon { + padding: 0; +} + +h1.header, h1 .sub.header { + text-shadow: 0 2px 0 rgba(0,0,0,.8); + color: #fff !important; +} +h1.ui.header { + font-size: 3em; +} +h1.ui.header .sub.header { + font-size: 0.8em; +} +.main.pusher { + margin-top: 0; + min-height: 10em; +} +section.segment.head { + padding: 8em 3em; + background: linear-gradient(90deg, rgba(40,88,125,1) 0%, rgba(64,130,180,1) 100%); + background-repeat: no-repeat; + background-size: cover; +} +#pod { + font-size: 110%; + display: block; +} </style> diff --git a/front/src/components/Home.vue b/front/src/components/Home.vue index 976262cfa751bba2dbd4cacb84c5d0bb75141071..4b291d839f6e89b4a13898aa58b24de4d88a786d 100644 --- a/front/src/components/Home.vue +++ b/front/src/components/Home.vue @@ -1,152 +1,276 @@ <template> <main class="main pusher" v-title="labels.title"> - <section class="ui vertical center aligned stripe segment"> - <div class="ui text container"> - <h1 class="ui huge header"> - <translate translate-context="Content/Home/Title/Verb">Welcome to Funkwhale</translate> + <section :class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle"> + <div class="segment-content"> + <h1 class="ui center aligned large header"> + <translate translate-context="Content/Home/Header" + :translate-params="{podName: podName}"> + Welcome to %{ podName }! + </translate> + <div v-if="shortDescription" class="sub header"> + {{ shortDescription }} + </div> </h1> - <p><translate translate-context="Content/Home/Title">We think listening to music should be simple.</translate></p> - <router-link class="ui icon button" to="/about"> - <i class="info icon"></i> - <translate translate-context="Content/Home/Button.Label/Verb">Learn more about this instance</translate> - </router-link> - <router-link class="ui icon teal button" to="/library"> - <translate translate-context="Content/Home/Button.Label/Verb">Get me to the library</translate> - <i class="right arrow icon"></i> - </router-link> </div> </section> <section class="ui vertical stripe segment"> - <div class="ui middle aligned stackable text container"> - <div class="ui grid"> - <div class="row"> - <div class="eight wide left floated column"> - <h2 class="ui header"> - <translate translate-context="Content/Home/Title">Why funkwhale?</translate> - </h2> - <p><translate translate-context="Content/Home/Paragraph">That's simple: we loved Grooveshark and we want to build something even better.</translate></p> - </div> - <div class="four wide left floated column"> - <img class="ui medium image" src="../assets/logo/logo.png" /> + <div class="ui stackable grid"> + <div class="ten wide column"> + <h3 class="header"> + <translate translate-context="Content/Home/Header">About this Funkwhale pod</translate> + </h3> + <div class="ui raised segment" id="pod"> + <div class="ui stackable grid"> + <div class="eight wide column"> + <p v-if="!truncatedDescription"> + <translate translate-context="Content/Home/Paragraph">No description available.</translate> + </p> + <template v-if="truncatedDescription || rules"> + <div v-if="truncatedDescription" v-html="truncatedDescription"></div> + <div v-if="truncatedDescription" class="ui hidden divider"></div> + <div class="ui relaxed list"> + <div class="item" v-if="truncatedDescription"> + <i class="arrow right grey icon"></i> + <div class="content"> + <router-link class="ui link" :to="{name: 'about'}"> + <translate translate-context="Content/Home/Link">Learn more</translate> + </router-link> + </div> + </div> + <div class="item" v-if="rules"> + <i class="book open grey icon"></i> + <div class="content"> + <router-link class="ui link" v-if="rules" :to="{name: 'about', hash: '#rules'}"> + <translate translate-context="Content/Home/Link">Server rules</translate> + </router-link> + </div> + </div> + </div> + </template> + </div> + <div class="eight wide column"> + <template v-if="stats"> + <h3 class="sub header"> + <translate translate-context="Content/Home/Header">Statistics</translate> + </h3> + <p> + <i class="user grey icon"></i><translate translate-context="Content/Home/Stat" :translate-params="{count: stats.users.toLocaleString($store.state.ui.momentLocale) }" :translate-n="stats.users" translate-plural="%{ count } active users">%{ count } active user</translate> + </p> + <p> + <i class="music grey icon"></i><translate translate-context="Content/Home/Stat" :translate-params="{count: parseInt(stats.hours).toLocaleString($store.state.ui.momentLocale)}" :translate-n="parseInt(stats.hours)" translate-plural="%{ count } hours of music">%{ count } hour of music</translate> + </p> + + </template> + <template v-if="contactEmail"> + <h3 class="sub header"> + <translate translate-context="Content/Home/Header/Name">Contact</translate> + </h3> + <i class="at grey icon"></i> + <a :href="`mailto:${contactEmail}`">{{ contactEmail }}</a> + </template> + + </div> </div> </div> </div> - </div> - <div class="ui middle aligned stackable text container"> - <div class="ui hidden divider"></div> - <h2 class="ui header"> - <translate translate-context="Content/Home/Title">Unlimited music</translate> - </h2> - <p><translate translate-context="Content/Home/Paragraph">Funkwhale is designed to make it easy to listen to music you like, or to discover new artists.</translate></p> - <div class="ui list"> - <div class="item"> - <i class="sound icon"></i> - <div class="content"> - <translate translate-context="Content/Home/List item/Verb">Click once, listen for hours using built-in radios</translate> - </div> - </div> - <div class="item"> - <i class="heart icon"></i> - <div class="content"> - <translate translate-context="Content/Home/List item/Verb">Keep track of your favorite songs</translate> - </div> - </div> - <div class="item"> - <i class="list icon"></i> - <div class="content"> - <translate translate-context="Content/Home/List item">Playlists? We've got them</translate> - </div> - </div> + + <div class="six wide column"> + <img class="ui image" src="../assets/network.png" /> </div> </div> - <div class="ui middle aligned stackable text container"> - <div class="ui hidden divider"></div> - <h2 class="ui header"> - <translate translate-context="Content/Home/Title">A clean library</translate> - </h2> - <p><translate translate-context="Content/Home/Paragraph">Funkwhale takes care of handling your music</translate>.</p> - <div class="ui list"> - <div class="item"> - <i class="tag icon"></i> - <div class="content" v-html="musicbrainzItem"></div> - </div> - <div class="item"> - <i class="plus icon"></i> - <div class="content"> - <translate translate-context="Content/Home/List item">Covers, lyrics... our goal is to have them all ;)</translate> - </div> - </div> + <div class="ui hidden divider"></div> + <div class="ui hidden divider"></div> + <div class="ui stackable grid"> + <div class="four wide column"> + <h3 class="header"> + <translate translate-context="Content/Home/Header">About Funkwhale</translate> + </h3> + <p v-translate translate-context="Content/Home/Paragraph">This pod runs Funkwhale, a community-driven project that lets you listen and share music and audio within a decentralized, open network.</p> + <p v-translate translate-context="Content/Home/Paragraph">Funkwhale is free and developped by a friendly community of volunteers.</p> + <a target="_blank" rel="noopener" href="https://funkwhale.audio"> + <i class="external alternate icon"></i> + <translate translate-context="Content/Home/Link">Visit funkwhale.audio</translate> + </a> </div> - </div> - <div class="ui middle aligned stackable text container"> - <div class="ui hidden divider"></div> - <h2 class="ui header"> - <translate translate-context="Content/Home/Title">Easy to use</translate> - </h2> - <p><translate translate-context="Content/Home/Paragraph">Funkwhale is dead simple to use.</translate></p> - <div class="ui list"> - <div class="item"> - <i class="book icon"></i> - <div class="content"> - <translate translate-context="Content/Home/List item">No add-ons, no plugins... you only need a web library</translate> - </div> - </div> - <div class="item"> - <i class="wizard icon"></i> - <div class="content"> - <translate translate-context="Content/Home/List item">Access your music from a clean interface that focuses on what really matters</translate> - </div> + <div class="four wide column"> + <h3 class="header"> + <translate translate-context="Head/Login/Title">Log In</translate> + </h3> + <login-form button-classes="basic green" :show-signup="false"></login-form> + <div class="ui hidden clearing divider"></div> + </div> + <div class="four wide column"> + <h3 class="header"> + <translate translate-context="*/Signup/Title">Sign up</translate> + </h3> + <template v-if="openRegistrations"> + <p> + <translate translate-context="Content/Home/Paragraph">Sign up now to keep a track of your favorites, create playlists, discover new content and much more!</translate> + </p> + <p v-if="defaultUploadQuota"> + <translate translate-context="Content/Home/Paragraph" :translate-params="{quota: humanSize(defaultUploadQuota * 1000 * 1000)}">Users on this pod also get %{ quota } of free storage to upload their own content!</translate> + </p> + <signup-form button-classes="basic green" :show-login="false"></signup-form> + </template> + <div v-else> + <p translate-context="Content/Home/Paragraph">Registrations are closed on this pod. You can signup on another pod using the link below.</p> + <a target="_blank" rel="noopener" href="https://funkwhale.audio/#get-started"> + <i class="external alternate icon"></i> + <translate translate-context="Content/Home/Link">Find another pod</translate> + </a> </div> </div> - </div> - <div class="ui middle aligned stackable text container"> - <div class="ui hidden divider"></div> - <h2 class="ui header"> - <translate translate-context="Content/Home/Title">Your music, your way</translate> - </h2> - <p><translate translate-context="Content/Home/Paragraph">Funkwhale is free and gives you control over your music.</translate></p> - <div class="ui list"> - <div class="item"> - <i class="smile icon"></i> - <div class="content"> - <translate translate-context="Content/Home/List item">The platform is free and open-source, you can install it and modify it without worries</translate> + + <div class="four wide column"> + <h3 class="header"> + <translate translate-context="Content/Home/Header">Useful links</translate> + </h3> + <div class="ui relaxed list"> + <div class="item"> + <i class="headphones icon"></i> + <div class="content"> + <router-link v-if="anonymousCanListen" class="header" to="/library"> + <translate translate-context="Content/Home/Link">Browse public content</translate> + </router-link> + <div class="description"> + <translate translate-context="Content/Home/Link">Listen to public albums and playlists shared on this pod</translate> + </div> + </div> </div> - </div> - <div class="item"> - <i class="protect icon"></i> - <div class="content"> - <translate translate-context="Content/Home/List item">We do not track you or bother you with ads</translate> + <div class="item"> + <i class="mobile alternate icon"></i> + <div class="content"> + <a class="header" href="https://funkwhale.audio/apps" target="_blank" rel="noopener"> + <translate translate-context="Content/Home/Link">Mobile apps</translate> + </a> + <div class="description"> + <translate translate-context="Content/Home/Link">Use Funkwhale on other devices with our apps</translate> + </div> + </div> </div> - </div> - <div class="item"> - <i class="users icon"></i> - <div class="content"> - <translate translate-context="Content/Home/List item">You can invite friends and family to your instance so they can enjoy your music</translate> + <div class="item"> + <i class="book icon"></i> + <div class="content"> + <a class="header" href="https://docs.funkwhale.audio/users/index.html" target="_blank" rel="noopener"> + <translate translate-context="Content/Home/Link">User guides</translate> + </a> + <div class="description"> + <translate translate-context="Content/Home/Link">Discover everything you need to know about Funkwhale and its features</translate> + </div> + </div> </div> </div> </div> </div> </section> + <section v-if="anonymousCanListen" class="ui vertical stripe segment"> + <album-widget :filters="{playable: true, ordering: '-creation_date'}" :limit="10"> + <template slot="title"><translate translate-context="Content/Home/Title">Recently added albums</translate></template> + <router-link to="/library"> + <translate translate-context="Content/Home/Link">View more…</translate> + <div class="ui hidden divider"></div> + </router-link> + </album-widget> + </section> </main> </template> <script> +import $ from 'jquery' +import _ from '@/lodash' +import {mapState} from 'vuex' +import showdown from 'showdown' +import AlbumWidget from "@/components/audio/album/Widget" +import LoginForm from "@/components/auth/LoginForm" +import SignupForm from "@/components/auth/SignupForm" +import {humanSize } from '@/filters' + export default { - data() { + components: { + AlbumWidget, + LoginForm, + SignupForm, + }, + data () { return { - musicbrainzUrl: "https://musicbrainz.org/" + markdown: new showdown.Converter(), + excerptLength: 2, // html nodes, + humanSize } }, computed: { + ...mapState({ + nodeinfo: state => state.instance.nodeinfo, + }), labels() { return { title: this.$pgettext('Head/Home/Title', "Welcome") } }, - musicbrainzItem () { - let msg = this.$pgettext('Content/Home/List item/Verb', 'Get quality metadata about your music thanks to <a href="%{ url }" target="_blank">MusicBrainz</a>') - return this.$gettextInterpolate(msg, {url: this.musicbrainzUrl}) - } + podName() { + return _.get(this.nodeinfo, 'metadata.nodeName') || "Funkwhale" + }, + banner () { + return _.get(this.nodeinfo, 'metadata.banner') + }, + shortDescription () { + return _.get(this.nodeinfo, 'metadata.shortDescription') + }, + longDescription () { + return _.get(this.nodeinfo, 'metadata.longDescription') + }, + rules () { + return _.get(this.nodeinfo, 'metadata.rules') + }, + truncatedDescription () { + if (!this.longDescription) { + return + } + let doc = this.markdown.makeHtml(this.longDescription) + let nodes = $.parseHTML(doc) + let excerptParts = [] + let handled = 0 + nodes.forEach((n) => { + let content = n.innerHTML || n.nodeValue + if (handled < this.excerptLength && content.trim()) { + excerptParts.push(n) + handled += 1 + } + }) + return excerptParts.map((p) => { return p.outerHTML }).join('') + }, + stats () { + let data = { + users: _.get(this.nodeinfo, 'usage.users.activeMonth', null), + hours: _.get(this.nodeinfo, 'metadata.library.music.hours', null), + } + if (data.users === null || data.artists === null) { + return + } + return data + }, + contactEmail () { + return _.get(this.nodeinfo, 'metadata.contactEmail') + }, + defaultUploadQuota () { + return _.get(this.nodeinfo, 'metadata.defaultUploadQuota') + }, + anonymousCanListen () { + return _.get(this.nodeinfo, 'metadata.library.anonymousCanListen') + }, + openRegistrations () { + return _.get(this.nodeinfo, 'openRegistrations') + }, + headerStyle() { + if (!this.banner) { + return "" + } + return ( + "background-image: url(" + + this.$store.getters["instance/absoluteUrl"](this.banner) + + ")" + ) + }, }, watch: { '$store.state.auth.authenticated': { @@ -164,11 +288,34 @@ export default { </script> <!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped> -.stripe p { - font-size: 120%; -} +<style scoped lang="scss"> + .ui.list .list.icon { padding: 0; } + +h1.header, h1 .sub.header { + text-shadow: 0 2px 0 rgba(0,0,0,.8); + color: #fff !important; +} +h1.ui.header { + font-size: 3em; +} +h1.ui.header .sub.header { + font-size: 0.8em; +} +.main.pusher { + margin-top: 0; + min-height: 10em; +} +section.segment.head { + padding: 8em 3em; + background: linear-gradient(90deg, rgba(40,88,125,1) 0%, rgba(64,130,180,1) 100%); + background-repeat: no-repeat; + background-size: cover; +} +#pod { + font-size: 110%; + display: block; +} </style> diff --git a/front/src/components/audio/album/Widget.vue b/front/src/components/audio/album/Widget.vue index 609ef6ebcc2cf5d114f32f27732cc4ed9a75e88b..c9e395f3c7022d9741f4f894ebc18212efcc4382 100644 --- a/front/src/components/audio/album/Widget.vue +++ b/front/src/components/audio/album/Widget.vue @@ -4,6 +4,7 @@ <slot name="title"></slot> <span v-if="showCount" class="ui tiny circular label">{{ count }}</span> </h3> + <slot></slot> <button v-if="controls" :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle left', 'icon']"></i></button> <button v-if="controls" :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle right', 'icon']"></i></button> <button v-if="controls" @click="fetchData('albums/')" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button> @@ -48,6 +49,7 @@ export default { filters: {type: Object, required: true}, controls: {type: Boolean, default: true}, showCount: {type: Boolean, default: false}, + limit: {type: Number, default: 12}, }, components: { PlayButton @@ -55,7 +57,6 @@ export default { data () { return { albums: [], - limit: 12, count: 0, isLoading: false, errors: null, diff --git a/front/src/components/auth/Login.vue b/front/src/components/auth/Login.vue deleted file mode 100644 index 0fb39165469e4c8a4a98db0a62848b6dbb836ec0..0000000000000000000000000000000000000000 --- a/front/src/components/auth/Login.vue +++ /dev/null @@ -1,122 +0,0 @@ -<template> - <main class="main pusher" v-title="labels.title"> - <section class="ui vertical stripe segment"> - <div class="ui small text container"> - <h2><translate translate-context="Content/Login/Title/Verb">Log in to your Funkwhale account</translate></h2> - <form class="ui form" @submit.prevent="submit()"> - <div v-if="error" class="ui negative message"> - <div class="header"><translate translate-context="Content/Login/Error message.Title">We cannot log you in</translate></div> - <ul class="list"> - <li v-if="error == 'invalid_credentials'"><translate translate-context="Content/Login/Error message.List item/Call to action">Please double-check your username/password couple is correct</translate></li> - <li v-else>{{ error }}</li> - </ul> - </div> - <div class="field"> - <label> - <translate translate-context="Content/Login/Input.Label/Noun">Username or email</translate> | - <router-link :to="{path: '/signup'}"> - <translate translate-context="*/Signup/Link/Verb">Create an account</translate> - </router-link> - </label> - <input - ref="username" - tabindex="1" - required - name="username" - type="text" - autofocus - :placeholder="labels.usernamePlaceholder" - v-model="credentials.username" - > - </div> - <div class="field"> - <label> - <translate translate-context="Content/*/Input.Label">Password</translate> | - <router-link :to="{name: 'auth.password-reset', query: {email: credentials.username}}"> - <translate translate-context="*/Login/*/Verb">Reset your password</translate> - </router-link> - </label> - <password-input :index="2" required v-model="credentials.password" /> - - </div> - <button tabindex="3" :class="['ui', {'loading': isLoading}, 'right', 'floated', 'green', 'button']" type="submit"> - <translate translate-context="*/Login/*/Verb">Login</translate> - </button> - </form> - </div> - </section> - </main> -</template> - -<script> -import PasswordInput from "@/components/forms/PasswordInput" - -export default { - props: { - next: { type: String, default: "/library" } - }, - components: { - PasswordInput - }, - data() { - return { - // We need to initialize the component with any - // properties that will be used in it - credentials: { - username: "", - password: "" - }, - error: "", - isLoading: false - } - }, - created () { - if (this.$store.state.auth.authenticated) { - this.$router.push(this.next) - } - }, - mounted() { - this.$refs.username.focus() - }, - computed: { - labels() { - let usernamePlaceholder = this.$pgettext('Content/Login/Input.Placeholder', "Enter your username or email") - let title = this.$pgettext('Head/Login/Title', "Log In") - return { - usernamePlaceholder, - title - } - } - }, - methods: { - submit() { - var self = this - self.isLoading = true - this.error = "" - var credentials = { - username: this.credentials.username, - password: this.credentials.password - } - this.$store - .dispatch("auth/login", { - credentials, - next: this.next, - onError: error => { - if (error.response.status === 400) { - self.error = "invalid_credentials" - } else { - self.error = error.backendErrors[0] - } - } - }) - .then(e => { - self.isLoading = false - }) - } - } -} -</script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped> -</style> diff --git a/front/src/components/auth/LoginForm.vue b/front/src/components/auth/LoginForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..75ecf96f140940dde93d4d386ce2fc11db813d13 --- /dev/null +++ b/front/src/components/auth/LoginForm.vue @@ -0,0 +1,118 @@ +<template> + <form class="ui form" @submit.prevent="submit()"> + <div v-if="error" class="ui negative message"> + <div class="header"><translate translate-context="Content/Login/Error message.Title">We cannot log you in</translate></div> + <ul class="list"> + <li v-if="error == 'invalid_credentials'"><translate translate-context="Content/Login/Error message.List item/Call to action">Please double-check your username/password couple is correct</translate></li> + <li v-else>{{ error }}</li> + </ul> + </div> + <div class="field"> + <label> + <translate translate-context="Content/Login/Input.Label/Noun">Username or email</translate> + <template v-if="showSignup"> + | + <router-link :to="{path: '/signup'}"> + <translate translate-context="*/Signup/Link/Verb">Create an account</translate> + </router-link> + </template> + </label> + <input + ref="username" + tabindex="1" + required + name="username" + type="text" + autofocus + :placeholder="labels.usernamePlaceholder" + v-model="credentials.username" + > + </div> + <div class="field"> + <label> + <translate translate-context="Content/*/Input.Label">Password</translate> | + <router-link :to="{name: 'auth.password-reset', query: {email: credentials.username}}"> + <translate translate-context="*/Login/*/Verb">Reset your password</translate> + </router-link> + </label> + <password-input :index="2" required v-model="credentials.password" /> + + </div> + <button tabindex="3" :class="['ui', {'loading': isLoading}, 'right', 'floated', buttonClasses, 'button']" type="submit"> + <translate translate-context="*/Login/*/Verb">Login</translate> + </button> + </form> +</template> + +<script> +import PasswordInput from "@/components/forms/PasswordInput" + +export default { + props: { + next: { type: String, default: "/library" }, + buttonClasses: { type: String, default: "green" }, + showSignup: { type: Boolean, default: true}, + }, + components: { + PasswordInput + }, + data() { + return { + // We need to initialize the component with any + // properties that will be used in it + credentials: { + username: "", + password: "" + }, + error: "", + isLoading: false + } + }, + created () { + if (this.$store.state.auth.authenticated) { + this.$router.push(this.next) + } + }, + mounted() { + this.$refs.username.focus() + }, + computed: { + labels() { + let usernamePlaceholder = this.$pgettext('Content/Login/Input.Placeholder', "Enter your username or email") + return { + usernamePlaceholder, + } + } + }, + methods: { + submit() { + var self = this + self.isLoading = true + this.error = "" + var credentials = { + username: this.credentials.username, + password: this.credentials.password + } + this.$store + .dispatch("auth/login", { + credentials, + next: this.next, + onError: error => { + if (error.response.status === 400) { + self.error = "invalid_credentials" + } else { + self.error = error.backendErrors[0] + } + } + }) + .then(e => { + self.isLoading = false + }) + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/components/auth/Signup.vue b/front/src/components/auth/Signup.vue deleted file mode 100644 index c84a5095b0502cb56d2b7c4f47734b7fd7da6e47..0000000000000000000000000000000000000000 --- a/front/src/components/auth/Signup.vue +++ /dev/null @@ -1,146 +0,0 @@ -<template> - <main class="main pusher" v-title="labels.title"> - <section class="ui vertical stripe segment"> - <div class="ui small text container"> - <h2><translate translate-context="Content/Signup/Title">Create a funkwhale account</translate></h2> - <form - :class="['ui', {'loading': isLoadingInstanceSetting}, 'form']" - @submit.prevent="submit()"> - <p class="ui message" v-if="!$store.state.instance.settings.users.registration_enabled.value"> - <translate translate-context="Content/Signup/Form/Paragraph">Public registrations are not possible on this instance. You will need an invitation code to sign up.</translate> - </p> - - <div v-if="errors.length > 0" class="ui negative message"> - <div class="header"><translate translate-context="Content/Signup/Form/Paragraph">Your account cannot be created.</translate></div> - <ul class="list"> - <li v-for="error in errors">{{ error }}</li> - </ul> - </div> - <div class="field"> - <label><translate translate-context="Content/*/*">Username</translate></label> - <input - ref="username" - name="username" - required - type="text" - autofocus - :placeholder="labels.usernamePlaceholder" - v-model="username"> - </div> - <div class="field"> - <label><translate translate-context="Content/*/*/Noun">Email</translate></label> - <input - ref="email" - name="email" - required - type="email" - :placeholder="labels.emailPlaceholder" - v-model="email"> - </div> - <div class="field"> - <label><translate translate-context="Content/*/Input.Label">Password</translate></label> - <password-input v-model="password" /> - </div> - <div class="field" v-if="!$store.state.instance.settings.users.registration_enabled.value"> - <label><translate translate-context="Content/*/Input.Label">Invitation code</translate></label> - <input - required - type="text" - name="invitation" - :placeholder="labels.placeholder" - v-model="invitation"> - </div> - <button :class="['ui', 'green', {'loading': isLoading}, 'button']" type="submit"> - <translate translate-context="Content/Signup/Button.Label">Create my account</translate> - </button> - </form> - </div> - </section> - </main> -</template> - -<script> -import axios from "axios" -import logger from "@/logging" - -import PasswordInput from "@/components/forms/PasswordInput" - -export default { - props: { - defaultInvitation: { type: String, required: false, default: null }, - next: { type: String, default: "/" } - }, - components: { - PasswordInput - }, - data() { - return { - username: "", - email: "", - password: "", - isLoadingInstanceSetting: true, - errors: [], - isLoading: false, - invitation: this.defaultInvitation - } - }, - created() { - let self = this - this.$store.dispatch("instance/fetchSettings", { - callback: function() { - self.isLoadingInstanceSetting = false - } - }) - }, - computed: { - labels() { - let title = this.$pgettext("*/Signup/Title", "Sign Up") - let placeholder = this.$pgettext( - "Content/Signup/Form/Placeholder", - "Enter your invitation code (case insensitive)" - ) - let usernamePlaceholder = this.$pgettext("Content/Signup/Form/Placeholder", "Enter your username") - let emailPlaceholder = this.$pgettext("Content/Signup/Form/Placeholder", "Enter your email") - return { - title, - usernamePlaceholder, - emailPlaceholder, - placeholder - } - } - }, - methods: { - submit() { - var self = this - self.isLoading = true - this.errors = [] - var payload = { - username: this.username, - password1: this.password, - password2: this.password, - email: this.email, - invitation: this.invitation - } - return axios.post("auth/registration/", payload).then( - response => { - logger.default.info("Successfully created account") - self.$router.push({ - name: "profile", - params: { - username: this.username - } - }) - }, - error => { - self.errors = error.backendErrors - self.isLoading = false - } - ) - } - } -} -</script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped> -</style> diff --git a/front/src/components/auth/SignupForm.vue b/front/src/components/auth/SignupForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..46752f8408147e731e0edddf8d67e70627c3b4fa --- /dev/null +++ b/front/src/components/auth/SignupForm.vue @@ -0,0 +1,138 @@ +<template> + <form + :class="['ui', {'loading': isLoadingInstanceSetting}, 'form']" + @submit.prevent="submit()"> + <p class="ui message" v-if="!$store.state.instance.settings.users.registration_enabled.value"> + <translate translate-context="Content/Signup/Form/Paragraph">Public registrations are not possible on this instance. You will need an invitation code to sign up.</translate> + </p> + + <div v-if="errors.length > 0" class="ui negative message"> + <div class="header"><translate translate-context="Content/Signup/Form/Paragraph">Your account cannot be created.</translate></div> + <ul class="list"> + <li v-for="error in errors">{{ error }}</li> + </ul> + </div> + <div class="field"> + <label><translate translate-context="Content/*/*">Username</translate></label> + <input + ref="username" + name="username" + required + type="text" + autofocus + :placeholder="labels.usernamePlaceholder" + v-model="username"> + </div> + <div class="field"> + <label><translate translate-context="Content/*/*/Noun">Email</translate></label> + <input + ref="email" + name="email" + required + type="email" + :placeholder="labels.emailPlaceholder" + v-model="email"> + </div> + <div class="field"> + <label><translate translate-context="Content/*/Input.Label">Password</translate></label> + <password-input v-model="password" /> + </div> + <div class="field" v-if="!$store.state.instance.settings.users.registration_enabled.value"> + <label><translate translate-context="Content/*/Input.Label">Invitation code</translate></label> + <input + required + type="text" + name="invitation" + :placeholder="labels.placeholder" + v-model="invitation"> + </div> + <button :class="['ui', buttonClasses, {'loading': isLoading}, ' right floated button']" type="submit"> + <translate translate-context="Content/Signup/Button.Label">Create my account</translate> + </button> + </form> +</template> + +<script> +import axios from "axios" +import logger from "@/logging" + +import PasswordInput from "@/components/forms/PasswordInput" + +export default { + props: { + defaultInvitation: { type: String, required: false, default: null }, + next: { type: String, default: "/" }, + buttonClasses: { type: String, default: "green" }, + }, + components: { + PasswordInput + }, + data() { + return { + username: "", + email: "", + password: "", + isLoadingInstanceSetting: true, + errors: [], + isLoading: false, + invitation: this.defaultInvitation + } + }, + created() { + let self = this + this.$store.dispatch("instance/fetchSettings", { + callback: function() { + self.isLoadingInstanceSetting = false + } + }) + }, + computed: { + labels() { + let placeholder = this.$pgettext( + "Content/Signup/Form/Placeholder", + "Enter your invitation code (case insensitive)" + ) + let usernamePlaceholder = this.$pgettext("Content/Signup/Form/Placeholder", "Enter your username") + let emailPlaceholder = this.$pgettext("Content/Signup/Form/Placeholder", "Enter your email") + return { + usernamePlaceholder, + emailPlaceholder, + placeholder + } + } + }, + methods: { + submit() { + var self = this + self.isLoading = true + this.errors = [] + var payload = { + username: this.username, + password1: this.password, + password2: this.password, + email: this.email, + invitation: this.invitation + } + return axios.post("auth/registration/", payload).then( + response => { + logger.default.info("Successfully created account") + self.$router.push({ + name: "profile", + params: { + username: this.username + } + }) + }, + error => { + self.errors = error.backendErrors + self.isLoading = false + } + ) + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/components/instance/Stats.vue b/front/src/components/instance/Stats.vue deleted file mode 100644 index 85ce14fa07bfc67c79f7627da92525506dff093c..0000000000000000000000000000000000000000 --- a/front/src/components/instance/Stats.vue +++ /dev/null @@ -1,101 +0,0 @@ -<template> - <div> - <div v-if="stats && stats.trackFavorites !== undefined" class="ui stackable two column grid"> - <div class="column"> - <h3 class="ui left aligned header"> - <translate translate-context="Content/About/Title/Noun">User activity</translate> - </h3> - <div v-if="stats" class="ui mini horizontal statistics"> - <div class="statistic"> - <div class="value"> - <i class="green user icon"></i> - {{ stats.users.toLocaleString($store.state.ui.momentLocale) }} - </div> - <div class="label"><translate translate-context="Content/About/Paragraph/Unit">users</translate></div> - </div> - <div class="statistic"> - <div class="value"> - <i class="orange sound icon"></i> {{ stats.listenings.toLocaleString($store.state.ui.momentLocale) }} - </div> - <div class="label"><translate translate-context="Content/About/Paragraph/Unit">tracks listened</translate></div> - </div> - <div class="statistic"> - <div class="value"> - <i class="pink heart icon"></i> {{ stats.trackFavorites.toLocaleString($store.state.ui.momentLocale) }} - </div> - <div class="label"><translate translate-context="Content/About/Paragraph/Unit">Tracks favorited</translate></div> - </div> - </div> - </div> - <div class="column"> - <h3 class="ui left aligned header"><translate translate-context="*/*/*">Library</translate></h3> - <div class="ui mini horizontal statistics"> - <div class="statistic"> - <div class="value"> - {{ stats.musicDuration.toLocaleString($store.state.ui.momentLocale) }} - </div> - <div class="label"><translate translate-context="Content/About/Paragraph/Unit">Hours of music</translate></div> - </div> - <div class="statistic"> - <div class="value"> - {{ stats.artists.toLocaleString($store.state.ui.momentLocale) }} - </div> - <div class="label"><translate translate-context="*/*/*/Noun">Artists</translate></div> - </div> - <div class="statistic"> - <div class="value"> - {{ stats.albums.toLocaleString($store.state.ui.momentLocale) }} - </div> - <div class="label"><translate translate-context="*/*/*">Albums</translate></div> - </div> - <div class="statistic"> - <div class="value"> - {{ stats.tracks.toLocaleString($store.state.ui.momentLocale) }} - </div> - <div class="label"><translate translate-context="*/*/*/Noun">Tracks</translate></div> - </div> - </div> - </div> - </div> - </div> -</template> - -<script> -import _ from '@/lodash' -import axios from 'axios' -import logger from '@/logging' - -export default { - data () { - return { - stats: null - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - var self = this - this.isLoading = true - logger.default.debug('Fetching instance stats...') - axios.get('instance/nodeinfo/2.0/').then((response) => { - let d = response.data - self.stats = {} - self.stats.users = _.get(d, 'usage.users.total') - self.stats.listenings = _.get(d, 'metadata.usage.listenings.total') - self.stats.trackFavorites = _.get(d, 'metadata.usage.favorites.tracks.total') - self.stats.musicDuration = Math.round(_.get(d, 'metadata.library.music.hours')) - self.stats.artists = _.get(d, 'metadata.library.artists.total') - self.stats.albums = _.get(d, 'metadata.library.albums.total') - self.stats.tracks = _.get(d, 'metadata.library.tracks.total') - self.isLoading = false - }) - }, - } -} -</script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped> -</style> diff --git a/front/src/router/index.js b/front/src/router/index.js index 0aedb20e3feaf2c02bf3cd90b28bd2f8e3ffba8a..d8bd0001f497e8835a6972ff68d0ba0b0d904eee 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -8,6 +8,17 @@ export default new Router({ mode: "history", linkActiveClass: "active", base: process.env.VUE_APP_ROUTER_BASE_URL || "/", + scrollBehavior(to, from, savedPosition) { + return new Promise(resolve => { + setTimeout(() => { + if (to.hash) { + resolve({ selector: to.hash }); + } + let pos = savedPosition || { x: 0, y: 0 }; + resolve(pos); + }, 100); + }); + }, routes: [ { path: "/", @@ -18,7 +29,10 @@ export default new Router({ { path: "/front", name: "front", - redirect: "/" + redirect: to => { + const { hash, params, query } = to + return { name: 'index', hash, query } + } }, { path: "/about", @@ -30,7 +44,7 @@ export default new Router({ path: "/login", name: "login", component: () => - import(/* webpackChunkName: "core" */ "@/components/auth/Login"), + import(/* webpackChunkName: "core" */ "@/views/auth/Login"), props: route => ({ next: route.query.next || "/library" }) }, { @@ -87,7 +101,7 @@ export default new Router({ path: "/signup", name: "signup", component: () => - import(/* webpackChunkName: "core" */ "@/components/auth/Signup"), + import(/* webpackChunkName: "core" */ "@/views/auth/Signup"), props: route => ({ defaultInvitation: route.query.invitation }) diff --git a/front/src/store/instance.js b/front/src/store/instance.js index 6af36e8090e3ba276c7fabaf870ebd251f086a15..efe40736493df800ac5b8aeb9f43c000356899de 100644 --- a/front/src/store/instance.js +++ b/front/src/store/instance.js @@ -17,6 +17,7 @@ export default { instanceUrl: process.env.VUE_APP_INSTANCE_URL, events: [], knownInstances: [], + nodeinfo: null, settings: { instance: { name: { @@ -41,7 +42,7 @@ export default { enabled: { value: true } - } + }, } }, mutations: { @@ -57,6 +58,9 @@ export default { events: (state, value) => { state.events = value }, + nodeinfo: (state, value) => { + state.nodeinfo = value + }, frontSettings: (state, value) => { state.frontSettings = value }, diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss index 76a4b2357fc7bcf7c6f79023dac813ca97f7c3e0..b98584f7d21e510a8ec9476fc1d25facbef2ab26 100644 --- a/front/src/style/_main.scss +++ b/front/src/style/_main.scss @@ -388,6 +388,9 @@ input + .help { padding-left: 0; padding-right: 0; } +.column .ui.text.container { + max-width: 100% !important; +} @import "./themes/_light.scss"; @import "./themes/_dark.scss"; diff --git a/front/src/style/themes/_dark.scss b/front/src/style/themes/_dark.scss index 56bd244ee7f6980d9764c71097043056bd8b70a0..a34b60cb5399b72ef5e55518d92609ef2b23f13f 100644 --- a/front/src/style/themes/_dark.scss +++ b/front/src/style/themes/_dark.scss @@ -123,6 +123,9 @@ $link-color: rgb(255, 144, 0); .ui.segment:not(.basic) { background-color: $light-background-color; } + .link { + color: $link-color; + } .ui.list, .ui.dropdown { .item, @@ -136,6 +139,9 @@ $link-color: rgb(255, 144, 0); color: $background-color; } } + .segment .ui.list .item { + background-color: transparent; + } .ui.divided.items > .item:not(:first-child) { border-top: 1px solid $border-color; } @@ -251,9 +257,12 @@ $link-color: rgb(255, 144, 0); } } } + .ui.list > .item .description { + color: $text-color; + } .ui.link.list.list a.item:hover, - .ui.link.list.list .item a:not(.ui):not(.button):hover { - color: $link-color; + .ui.link.list.list .item a:not(.ui):not(.button):hover, .ui.list > .item a.header { + color: $link-color !important; } [data-tooltip]::after { background-color: $light-background-color; diff --git a/front/src/views/auth/Login.vue b/front/src/views/auth/Login.vue new file mode 100644 index 0000000000000000000000000000000000000000..22285bc9cc7952428e6b5d3354d7da766e8f4212 --- /dev/null +++ b/front/src/views/auth/Login.vue @@ -0,0 +1,40 @@ +<template> + <main class="main pusher" v-title="labels.title"> + <section class="ui vertical stripe segment"> + <div class="ui small text container"> + <h2><translate translate-context="Content/Login/Title/Verb">Log in to your Funkwhale account</translate></h2> + <login-form :next="next"></login-form> + </div> + </section> + </main> +</template> + +<script> +import LoginForm from "@/components/auth/LoginForm" + +export default { + props: { + next: { type: String, default: "/library" } + }, + components: { + LoginForm + }, + created () { + if (this.$store.state.auth.authenticated) { + this.$router.push(this.next) + } + }, + computed: { + labels() { + let title = this.$pgettext('Head/Login/Title', "Log In") + return { + title + } + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/views/auth/Signup.vue b/front/src/views/auth/Signup.vue new file mode 100644 index 0000000000000000000000000000000000000000..37b918c72bd4c08b3949c3698f74055211152008 --- /dev/null +++ b/front/src/views/auth/Signup.vue @@ -0,0 +1,48 @@ +<template> + <main class="main pusher" v-title="labels.title"> + <section class="ui vertical stripe segment"> + <div class="ui small text container"> + <h2><translate translate-context="Content/Signup/Title">Create a funkwhale account</translate></h2> + <signup-form :default-invitation="defaultInvitation" :next="next"></signup-form> + </div> + </section> + </main> +</template> + +<script> + +import SignupForm from "@/components/auth/SignupForm" + +export default { + props: { + defaultInvitation: { type: String, required: false, default: null }, + next: { type: String, default: "/" } + }, + components: { + SignupForm + }, + data() { + return { + username: "", + email: "", + password: "", + isLoadingInstanceSetting: true, + errors: [], + isLoading: false, + invitation: this.defaultInvitation + } + }, + computed: { + labels() { + let title = this.$pgettext("*/Signup/Title", "Sign Up") + return { + title + } + } + }, +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style>