Merge branch '872-landing-page' into 'develop'
Resolve "[Epic] New landing page" Closes #872 See merge request funkwhale/funkwhale!899
This commit is contained in:
commit
1fec080970
|
@ -0,0 +1 @@
|
|||
Redesign of the landing and about pages (#872)
|
|
@ -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
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 118 KiB |
|
@ -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>
|
||||
|
|
|
@ -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 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 class="four wide left floated column">
|
||||
<img class="ui medium image" src="../assets/logo/logo.png" />
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div class="six wide column">
|
||||
<img class="ui image" src="../assets/network.png" />
|
||||
</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 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 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 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 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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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
|
||||
},
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
Loading…
Reference in New Issue