Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • 1.4.1-upgrade-release
  • 1121-download
  • 1218-smartplaylist_backend
  • 1373-login-form-move-reset-your-password-link
  • 1381-progress-bars
  • 1481
  • 1518-update-django-allauth
  • 1645
  • 1675-widget-improperly-configured-missing-resource-id
  • 1675-widget-improperly-configured-missing-resource-id-2
  • 1704-required-props-are-not-always-passed
  • 1716-add-frontend-tests-again
  • 1749-smtp-uri-configuration
  • 1930-first-upload-in-a-batch-always-fails
  • 1976-update-documentation-links-in-readme-files
  • 2054-player-layout
  • 2063-funkwhale-connection-interrupted-every-so-often-requires-network-reset-page-refresh
  • 2091-iii-6-improve-visuals-layout
  • 2151-refused-to-load-spa-manifest-json-2
  • 2154-add-to-playlist-pop-up-hidden-by-now-playing-screen
  • 2155-can-t-see-the-episode-list-of-a-podcast-as-an-anonymous-user-with-anonymous-access-enabled
  • 2156-add-management-command-to-change-file-ref-for-in-place-imported-files-to-s3
  • 2192-clear-queue-bug-when-random-shuffle-is-enabled
  • 2205-channel-page-pagination-link-dont-working
  • 2215-custom-logger-does-not-work-at-all-with-webkit-and-blink-based-browsers
  • 2228-troi-real-world-review
  • 2274-implement-new-upload-api
  • 2303-allow-users-to-own-tagged-items
  • 2395-far-right-filter
  • 2405-front-buttont-trigger-third-party-hook
  • 2408-troi-create-missing-tracks
  • 2416-revert-library-drop
  • 2422-trigger-libraries-follow-on-user-follow
  • 2429-fix-popover-auto-close
  • 2448-complete-tags
  • 2452-fetch-third-party-metadata
  • 2469-Fix-search-bar-in-ManageUploads
  • 2476-deep-upload-links
  • 2490-experiment-use-rstore
  • 2490-experimental-use-simple-data-store
  • 2490-fix-search-modal
  • 2490-search-modal
  • 2501-fix-compatibility-with-older-browsers
  • 2502-drop-uno-and-jquery
  • 2533-allow-followers-in-user-activiy-privacy-level
  • 2539-drop-ansible-installation-method-in-favor-of-docker
  • 2560-default-modal-width
  • 623-test
  • 653-enable-starting-embedded-player-at-a-specific-position-in-track
  • activitypub-overview
  • album-sliders
  • arne/2091-improve-visuals
  • back-option-for-edits
  • chore/2406-compose-modularity-scope
  • develop
  • develop-password-reset
  • env-file-cleanup
  • feat/2091-improve-visuals
  • feature/2481-vui-translations
  • fix-amd64-docker-build-gfortran
  • fix-front-node-version
  • fix-gitpod
  • fix-plugins-dev-setup
  • fix-rate-limit-serializer
  • fix-schema-channel-metadata-choices
  • flupsi/2803-improve-visuals
  • flupsi/2804-new-upload-process
  • funkwhale-fix_pwa_manifest
  • funkwhale-petitminion-2136-bug-fix-prune-skipped-upload
  • funkwhale-ui-buttons
  • georg/add-typescript
  • gitpod/test-1866
  • global-button-experiment
  • global-buttons
  • juniorjpdj/pkg-repo
  • manage-py-reference
  • merge-review
  • minimal-python-version
  • petitminion-develop-patch-84496
  • pin-mutagen-to-1.46
  • pipenv
  • plugins
  • plugins-v2
  • plugins-v3
  • pre-release/1.3.0
  • prune_skipped_uploads_docs
  • refactor/homepage
  • renovate/front-all-dependencies
  • renovate/front-major-all-dependencies
  • schema-updates
  • small-gitpod-improvements
  • spectacular_schema
  • stable
  • tempArne
  • ui-buttons
  • update-frontend-dependencies
  • upload-process-spec
  • user-concept-docs
  • v2-artists
  • vite-ws-ssl-compatible
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.14.1
  • 0.14.2
  • 0.15
  • 0.16
  • 0.16.1
  • 0.16.2
  • 0.16.3
  • 0.17
  • 0.18
  • 0.18.1
  • 0.18.2
  • 0.18.3
  • 0.19.0
  • 0.19.0-rc1
  • 0.19.0-rc2
  • 0.19.1
  • 0.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.20.0
  • 0.20.0-rc1
  • 0.20.1
  • 0.21
  • 0.21-rc1
  • 0.21-rc2
  • 0.21.1
  • 0.21.2
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
  • 1.0
  • 1.0-rc1
  • 1.0.1
  • 1.1
  • 1.1-rc1
  • 1.1-rc2
  • 1.1.1
  • 1.1.2
  • 1.1.3
  • 1.1.4
  • 1.2.0
  • 1.2.0-rc1
  • 1.2.0-rc2
  • 1.2.0-testing
  • 1.2.0-testing2
  • 1.2.0-testing3
  • 1.2.0-testing4
  • 1.2.1
  • 1.2.10
  • 1.2.2
  • 1.2.3
  • 1.2.4
  • 1.2.5
  • 1.2.6
  • 1.2.6-1
  • 1.2.7
  • 1.2.8
  • 1.2.9
  • 1.3.0
  • 1.3.0-rc1
  • 1.3.0-rc2
  • 1.3.0-rc3
  • 1.3.0-rc4
  • 1.3.0-rc5
  • 1.3.0-rc6
  • 1.3.1
  • 1.3.2
  • 1.3.3
  • 1.3.4
  • 1.4.0
  • 1.4.0-rc1
  • 1.4.0-rc2
  • 1.4.1
  • 2.0.0-alpha.1
  • 2.0.0-alpha.2
200 results

Target

Select target project
  • funkwhale/funkwhale
  • Luclu7/funkwhale
  • mbothorel/funkwhale
  • EorlBruder/funkwhale
  • tcit/funkwhale
  • JocelynDelalande/funkwhale
  • eneiluj/funkwhale
  • reg/funkwhale
  • ButterflyOfFire/funkwhale
  • m4sk1n/funkwhale
  • wxcafe/funkwhale
  • andybalaam/funkwhale
  • jcgruenhage/funkwhale
  • pblayo/funkwhale
  • joshuaboniface/funkwhale
  • n3ddy/funkwhale
  • gegeweb/funkwhale
  • tohojo/funkwhale
  • emillumine/funkwhale
  • Te-k/funkwhale
  • asaintgenis/funkwhale
  • anoadragon453/funkwhale
  • Sakada/funkwhale
  • ilianaw/funkwhale
  • l4p1n/funkwhale
  • pnizet/funkwhale
  • dante383/funkwhale
  • interfect/funkwhale
  • akhardya/funkwhale
  • svfusion/funkwhale
  • noplanman/funkwhale
  • nykopol/funkwhale
  • roipoussiere/funkwhale
  • Von/funkwhale
  • aurieh/funkwhale
  • icaria36/funkwhale
  • floreal/funkwhale
  • paulwalko/funkwhale
  • comradekingu/funkwhale
  • FurryJulie/funkwhale
  • Legolars99/funkwhale
  • Vierkantor/funkwhale
  • zachhats/funkwhale
  • heyjake/funkwhale
  • sn0w/funkwhale
  • jvoisin/funkwhale
  • gordon/funkwhale
  • Alexander/funkwhale
  • bignose/funkwhale
  • qasim.ali/funkwhale
  • fakegit/funkwhale
  • Kxze/funkwhale
  • stenstad/funkwhale
  • creak/funkwhale
  • Kaze/funkwhale
  • Tixie/funkwhale
  • IISergII/funkwhale
  • lfuelling/funkwhale
  • nhaddag/funkwhale
  • yoasif/funkwhale
  • ifischer/funkwhale
  • keslerm/funkwhale
  • flupe/funkwhale
  • petitminion/funkwhale
  • ariasuni/funkwhale
  • ollie/funkwhale
  • ngaumont/funkwhale
  • techknowlogick/funkwhale
  • Shleeble/funkwhale
  • theflyingfrog/funkwhale
  • jonatron/funkwhale
  • neobrain/funkwhale
  • eorn/funkwhale
  • KokaKiwi/funkwhale
  • u1-liquid/funkwhale
  • marzzzello/funkwhale
  • sirenwatcher/funkwhale
  • newer027/funkwhale
  • codl/funkwhale
  • Zwordi/funkwhale
  • gisforgabriel/funkwhale
  • iuriatan/funkwhale
  • simon/funkwhale
  • bheesham/funkwhale
  • zeoses/funkwhale
  • accraze/funkwhale
  • meliurwen/funkwhale
  • divadsn/funkwhale
  • Etua/funkwhale
  • sdrik/funkwhale
  • Soran/funkwhale
  • kuba-orlik/funkwhale
  • cristianvogel/funkwhale
  • Forceu/funkwhale
  • jeff/funkwhale
  • der_scheibenhacker/funkwhale
  • owlnical/funkwhale
  • jovuit/funkwhale
  • SilverFox15/funkwhale
  • phw/funkwhale
  • mayhem/funkwhale
  • sridhar/funkwhale
  • stromlin/funkwhale
  • rrrnld/funkwhale
  • nitaibezerra/funkwhale
  • jaller94/funkwhale
  • pcouy/funkwhale
  • eduxstad/funkwhale
  • codingHahn/funkwhale
  • captain/funkwhale
  • polyedre/funkwhale
  • leishenailong/funkwhale
  • ccritter/funkwhale
  • lnceballosz/funkwhale
  • fpiesche/funkwhale
  • Fanyx/funkwhale
  • markusblogde/funkwhale
  • Firobe/funkwhale
  • devilcius/funkwhale
  • freaktechnik/funkwhale
  • blopware/funkwhale
  • cone/funkwhale
  • thanksd/funkwhale
  • vachan-maker/funkwhale
  • bbenti/funkwhale
  • tarator/funkwhale
  • prplecake/funkwhale
  • DMarzal/funkwhale
  • lullis/funkwhale
  • hanacgr/funkwhale
  • albjeremias/funkwhale
  • xeruf/funkwhale
  • llelite/funkwhale
  • RoiArthurB/funkwhale
  • cloo/funkwhale
  • nztvar/funkwhale
  • Keunes/funkwhale
  • petitminion/funkwhale-petitminion
  • m-idler/funkwhale
  • SkyLeite/funkwhale
140 results
Select Git revision
  • 1121-download
  • 1218-smartplaylist_backend
  • 1288-user-me-can-be-created-but-cannot-be-edited
  • 1381-progress-bars
  • 1392-update-actor-cache-task
  • 1434-update-pyld
  • 1481
  • 1515-update-click
  • 1518-update-django-allauth
  • 1645
  • 1674-recently-added-radio-repair
  • 1711-ping_remote_instance
  • 1714-resolve-timeouts-on-domain-nodeinfo-fetch
  • 1717-stop-player-when-stop-radio
  • 1798-bulk-fetch-actor-data
  • 2010-fix_i18n_globally
  • 2136-bug-fix-prune-skipped-upload
  • 2275-quality-filter-backend
  • 2322-troi-frontend
  • 623-test
  • 639-ThirdPartyStream-poc
  • 653-enable-starting-embedded-player-at-a-specific-position-in-track
  • 762-domain_follow
  • album-sliders
  • back-option-for-edits
  • develop
  • feat/2091-improve-visuals
  • funkwhale-activityPub-overview
  • generate-swagger
  • master
  • pipenv
  • plugins
  • plugins-v2
  • plugins-v3
  • poetry
  • renovate/configure
  • spec-domain-follow
  • spec_test_for_issue_1
  • stable
  • test_typesens
  • testbranch
  • troi-recommendation-system-with-typenses
  • update-boto3
  • update-frontend-dependencies
  • update-uvicorn
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.14.1
  • 0.14.2
  • 0.15
  • 0.16
  • 0.16.1
  • 0.16.2
  • 0.16.3
  • 0.17
  • 0.18
  • 0.18.1
  • 0.18.2
  • 0.18.3
  • 0.19.0
  • 0.19.0-rc1
  • 0.19.0-rc2
  • 0.19.1
  • 0.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.20.0
  • 0.20.0-rc1
  • 0.20.1
  • 0.21
  • 0.21-rc1
  • 0.21-rc2
  • 0.21.1
  • 0.21.2
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
  • 1.0
  • 1.0-rc1
  • 1.0.1
  • 1.1
  • 1.1-rc1
  • 1.1-rc2
  • 1.1.1
  • 1.1.2
  • 1.1.3
  • 1.1.4
  • 1.2.0
  • 1.2.0-rc1
  • 1.2.0-rc2
  • 1.2.0-testing
  • 1.2.0-testing2
  • 1.2.0-testing3
  • 1.2.0-testing4
  • 1.2.1
118 results
Show changes
Showing
with 1888 additions and 1599 deletions
...@@ -5,8 +5,16 @@ import { get } from 'lodash-es' ...@@ -5,8 +5,16 @@ import { get } from 'lodash-es'
import { humanSize } from '~/utils/filters' import { humanSize } from '~/utils/filters'
import { computed } from 'vue' import { computed } from 'vue'
import type { components } from '~/generated/types.ts'
import SignupForm from '~/components/auth/SignupForm.vue' import SignupForm from '~/components/auth/SignupForm.vue'
import LogoText from '~/components/LogoText.vue' import LogoText from '~/components/LogoText.vue'
import useMarkdown from '~/composables/useMarkdown'
import Link from '~/components/ui/Link.vue'
import Card from '~/components/ui/Card.vue'
import Button from '~/components/ui/Button.vue'
import Layout from '~/components/ui/Layout.vue'
const store = useStore() const store = useStore()
const nodeinfo = computed(() => store.state.instance.nodeinfo) const nodeinfo = computed(() => store.state.instance.nodeinfo)
...@@ -16,7 +24,8 @@ const labels = computed(() => ({ ...@@ -16,7 +24,8 @@ const labels = computed(() => ({
title: t('components.About.title') title: t('components.About.title')
})) }))
const podName = computed(() => get(nodeinfo.value, 'metadata.nodeName') ?? 'Funkwhale') const podName = computed(() => (n => n === '' ? 'No name' : n ?? 'Funkwhale')(get(nodeinfo.value, 'metadata.nodeName')))
const banner = computed(() => get(nodeinfo.value, 'metadata.banner')) const banner = computed(() => get(nodeinfo.value, 'metadata.banner'))
const shortDescription = computed(() => get(nodeinfo.value, 'metadata.shortDescription')) const shortDescription = computed(() => get(nodeinfo.value, 'metadata.shortDescription'))
...@@ -28,10 +37,22 @@ const stats = computed(() => { ...@@ -28,10 +37,22 @@ const stats = computed(() => {
return null return null
} }
return { users, hours } const info = nodeinfo.value ?? {} as components['schemas']['NodeInfo21']
const data = {
users: info.usage.users.activeMonth || null,
hours: info.metadata.content.local.hoursOfContent || null,
artists: info.metadata.content.local.artists || null,
albums: info.metadata.content.local.releases || null,
tracks: info.metadata.content.local.recordings || null,
listenings: info.metadata.usage?.listenings.total || null
}
return { users, hours, data }
}) })
const openRegistrations = computed(() => get(nodeinfo.value, 'openRegistrations')) const openRegistrations = computed(() => get(nodeinfo.value, 'openRegistrations'))
const defaultUploadQuota = computed(() => humanSize(get(nodeinfo.value, 'metadata.defaultUploadQuota', 0) * 1000 * 1000)) const defaultUploadQuota = computed(() => humanSize(get(nodeinfo.value, 'metadata.defaultUploadQuota', 0) * 1000 * 1000))
const headerStyle = computed(() => { const headerStyle = computed(() => {
...@@ -43,64 +64,76 @@ const headerStyle = computed(() => { ...@@ -43,64 +64,76 @@ const headerStyle = computed(() => {
backgroundImage: `url(${store.getters['instance/absoluteUrl'](banner.value)})` backgroundImage: `url(${store.getters['instance/absoluteUrl'](banner.value)})`
} }
}) })
const longDescription = useMarkdown(() => get(nodeinfo.value, 'metadata.longDescription', ''))
const rules = useMarkdown(() => get(nodeinfo.value, 'metadata.rules', ''))
const terms = useMarkdown(() => get(nodeinfo.value, 'metadata.terms', ''))
const contactEmail = computed(() => get(nodeinfo.value, 'metadata.contactEmail'))
const anonymousCanListen = computed(() => {
const features = get(nodeinfo.value, 'metadata.metadata.feature', []) as string[]
const hasAnonymousCanListen = features.includes('anonymousCanListen')
return hasAnonymousCanListen
})
const allowListEnabled = computed(() => get(nodeinfo.value, 'metadata.allowList.enabled'))
const version = computed(() => get(nodeinfo.value, 'software.version'))
const federationEnabled = computed(() => {
const features = get(nodeinfo.value, 'metadata.metadata.feature', []) as string[]
const hasAnonymousCanListen = features.includes('federation')
return hasAnonymousCanListen
})
</script> </script>
<template> <template>
<main <Layout
v-title="labels.title" v-title="labels.title"
class="main pusher page-about" stack
> main
<div class="ui container"> style="align-items: center;"
<div class="ui horizontally fitted basic stripe segment"> >
<div class="ui horizontally fitted basic very padded segment"> <!-- About funkwhale -->
<div class="ui center aligned text container">
<div class="ui text container"> <Link
<div class="ui equal width compact stackable grid"> to="/"
<div class="column" /> width="full"
<div class="ten wide column"> align-text="stretch"
<div class="ui vertically fitted basic segment"> style="width:min(480px, 100%)"
<router-link to="/"> >
<logo-text /> <logo-text />
</router-link> </Link>
</div>
</div>
<div class="column" />
</div>
<h2 class="header"> <h2 class="header">
{{ $t('components.About.header.funkwhale') }} {{ t('components.About.header.funkwhale') }}
</h2> </h2>
<p> <p>
{{ $t('components.About.description.funkwhale') }} {{ t('components.About.description.funkwhale') }}
</p> </p>
</div>
</div> <Layout
</div> flex
<div class="ui hidden divider" /> style="justify-content: center;"
<div class="ui vertically fitted basic stripe segment"> >
<div class="ui two stackable cards"> <Card
<div class="ui card"> v-if="!store.state.auth.authenticated"
<div :title="t('components.About.header.signup')"
v-if="!$store.state.auth.authenticated" width="256px"
class="signup-form content"
> >
<h3 class="header">
{{ $t('components.About.header.signup') }}
</h3>
<template v-if="openRegistrations"> <template v-if="openRegistrations">
<p> <p>
{{ $t('components.About.description.signup') }} {{ t('components.About.description.signup') }}
</p> </p>
<p v-if="defaultUploadQuota"> <p v-if="defaultUploadQuota">
{{ $t('components.About.description.quota', {quota: defaultUploadQuota}) }} {{ t('components.About.description.quota', {quota: defaultUploadQuota}) }}
</p> </p>
<signup-form <signup-form
button-classes="success" button-classes="success"
:show-login="false" :show-login="true"
/> />
</template> </template>
<div v-else> <div v-else>
<p> <p>
{{ $t('components.About.help.closedRegistrations') }} {{ t('components.About.help.closedRegistrations') }}
</p> </p>
<a <a
...@@ -108,36 +141,58 @@ const headerStyle = computed(() => { ...@@ -108,36 +141,58 @@ const headerStyle = computed(() => {
rel="noopener" rel="noopener"
href="https://funkwhale.audio/#get-started" href="https://funkwhale.audio/#get-started"
> >
{{ $t('components.About.link.findOtherPod') }} {{ t('components.About.link.findOtherPod') }}
&nbsp;<i class="external alternate icon" /> &nbsp;<i class="external alternate icon" />
</a> </a>
</div> </div>
</div>
<div <div
v-else v-if="!(store.state.auth.authenticated || openRegistrations)"
class="signup-form content" class="signup-form content"
> >
<h3 class="header"> <h3 class="header">
{{ $t('components.About.header.signup') }} {{ t('components.About.header.signup') }}
<div class="ui positive message"> <div class="ui positive message">
<div class="header"> <div class="header">
{{ $t('components.About.message.loggedIn') }} {{ t('components.About.message.loggedIn') }}
</div> </div>
<p> <p>
{{ $t('components.About.message.greeting', {username: $store.state.auth.username}) }} {{ t('components.About.message.greeting', {username: store.state.auth.username}) }}
</p> </p>
</div> </div>
</h3> </h3>
</div> </div>
</div> </Card>
<div class="ui card">
<Card
v-else
:title="t('components.About.message.greeting', {username: store.state.auth.username})"
width="256px"
>
<p v-if="defaultUploadQuota">
{{ t('components.About.description.quota', {quota: defaultUploadQuota}) }}
</p>
<template #action>
<Button
full
disabled
>
{{ t('components.About.message.loggedIn') }}
</Button>
</template>
</Card>
<Card
:title="podName"
width="256px"
>
<section <section
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
:style="headerStyle" :style="headerStyle"
> >
<h1> <h1>
<i class="music icon" /> <i class="music icon" />
{{ podName }}
</h1> </h1>
</section> </section>
<div class="content pod-description"> <div class="content pod-description">
...@@ -145,7 +200,7 @@ const headerStyle = computed(() => { ...@@ -145,7 +200,7 @@ const headerStyle = computed(() => {
id="description" id="description"
class="ui header" class="ui header"
> >
{{ $t('components.About.header.aboutPod') }} {{ t('components.About.header.aboutPod') }}
</h3> </h3>
<div <div
v-if="shortDescription" v-if="shortDescription"
...@@ -154,7 +209,7 @@ const headerStyle = computed(() => { ...@@ -154,7 +209,7 @@ const headerStyle = computed(() => {
{{ shortDescription }} {{ shortDescription }}
</div> </div>
<p v-else> <p v-else>
{{ $t('components.About.placeholder.noDescription') }} {{ t('components.About.placeholder.noDescription') }}
</p> </p>
<template v-if="stats"> <template v-if="stats">
...@@ -162,100 +217,401 @@ const headerStyle = computed(() => { ...@@ -162,100 +217,401 @@ const headerStyle = computed(() => {
<div class="two column row"> <div class="two column row">
<div class="column"> <div class="column">
<span class="statistics-figure ui text"> <span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.users.toLocaleString($store.state.ui.momentLocale) }}</strong></span> <span class="ui big text"><strong>{{ stats.users?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br> <br>
{{ $t('components.About.stat.activeUsers', stats.users) }} {{ stats.users ? t('components.About.stat.activeUsers', stats.users) : "" }}
</span> </span>
</div> </div>
<div class="column"> <div class="column">
<span class="statistics-figure ui text"> <span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.hours.toLocaleString($store.state.ui.momentLocale) }}</strong></span> <span class="ui big text"><strong>{{ stats.hours ? stats.hours.toLocaleString(store.state.ui.momentLocale) : "" }}</strong></span>
<br> <br>
{{ $t('components.About.stat.hoursOfMusic', stats.hours) }} {{ stats.hours ? t('components.About.stat.hoursOfMusic', stats.hours) : "" }}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
</div>
<template #action>
<Link
align-text="center"
to="/about/pod"
>
{{ t('components.About.link.learnMore') }}
</Link>
</template>
</Card>
</Layout>
<Layout
flex
style="justify-content: center;"
>
<Card
width="256px"
to="/"
:title="t('components.About.header.publicContent')"
icon="bi-box-arrow-up-right"
>
<!-- TODO: Link to Explore page? -->
{{ t('components.About.description.publicContent') }}
</Card>
<Card
width="256px"
:title="t('components.About.link.findOtherPod')"
to="https://funkwhale.audio/#get-started"
icon="bi-box-arrow-up-right"
>
{{ t('components.About.description.publicContent') }}
</Card>
<Card
width="256px"
:title="t('components.About.header.findApp')"
to="https://funkwhale.audio/apps"
icon="bi-box-arrow-up-right"
>
{{ t('components.About.description.findApp') }}
</Card>
</Layout>
<section
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
:style="headerStyle"
>
<h1>
<i class="music icon" />
{{ podName }}
</h1>
</section>
<!-- About Pod -->
<div class="about-pod-info-container">
<div class="about-pod-info-toc">
<div class="ui vertical pointing secondary menu">
<router-link <router-link
to="/about/pod" to="/about/pod"
class="ui fluid basic secondary button" class="item"
> >
{{ $t('components.About.link.learnMore') }} {{ t('components.AboutPod.link.about') }}
</router-link> </router-link>
</div> <router-link
</div> to="/about/pod#rules"
</div> class="item"
<!-- TODO (wvffle): Remove style when migrate away from fomantic -->
<div
class="ui three stackable cards"
style="z-index: 1; position: relative;"
> >
{{ t('components.AboutPod.link.rules') }}
</router-link>
<router-link <router-link
to="/" to="/about/pod#terms"
class="ui card" class="item"
> >
<div class="content"> {{ t('components.AboutPod.link.terms') }}
</router-link>
<router-link
to="/about/pod#features"
class="item"
>
{{ t('components.AboutPod.link.features') }}
</router-link>
<router-link
v-if="stats"
to="/about/pod#statistics"
class="item"
>
{{ t('components.AboutPod.link.statistics') }}
</router-link>
</div>
</div>
<div class="about-pod-info">
<h2
id="description about-this-pod"
class="ui header"
>
{{ t('components.AboutPod.header.about') }}
</h2>
<sanitized-html
v-if="longDescription"
:html="longDescription"
/>
<p v-else>
{{ t('components.AboutPod.placeholder.noDescription') }}
</p>
<h3 <h3
id="description" id="rules"
class="ui header" class="ui header"
> >
{{ $t('components.About.header.publicContent') }} {{ t('components.AboutPod.header.rules') }}
</h3> </h3>
<p> <sanitized-html
{{ $t('components.About.description.publicContent') }} v-if="rules"
:html="rules"
/>
<p v-else>
{{ t('components.AboutPod.placeholder.noRules') }}
</p> </p>
</div>
</router-link>
<a
href="https://funkwhale.audio/#get-started"
class="ui card"
target="_blank"
>
<div class="content">
<h3 <h3
id="description" id="terms"
class="ui header" class="ui header"
> >
{{ $t('components.About.link.findOtherPod') }} {{ t('components.AboutPod.header.terms') }}
&nbsp;<i class="external alternate icon" />
</h3> </h3>
<p> <sanitized-html
{{ $t('components.About.description.publicContent') }} v-if="terms"
:html="terms"
/>
<p v-else>
{{ t('components.AboutPod.placeholder.noTerms') }}
</p> </p>
<h3
id="features"
class="header"
>
{{ t('components.AboutPod.header.features') }}
</h3>
<div class="features-container ui two column stackable grid">
<div class="column">
<table class="ui very basic table unstackable">
<tbody>
<tr>
<td>
{{ t('components.AboutPod.feature.version') }}
</td>
<td
v-if="version"
class="right aligned"
>
<span class="features-status ui text">
{{ version }}
</span>
</td>
<td
v-else
class="right aligned"
>
<span class="features-status ui text">
{{ t('components.AboutPod.notApplicable') }}
</span>
</td>
</tr>
<tr>
<td>
{{ t('components.AboutPod.feature.federation') }}
</td>
<td
v-if="federationEnabled"
class="right aligned"
>
<span class="features-status ui text">
<i class="check icon" />
{{ t('components.AboutPod.feature.status.enabled') }}
</span>
</td>
<td
v-else
class="right aligned"
>
<span class="features-status ui text">
<i class="x icon" />
{{ t('components.AboutPod.feature.status.disabled') }}
</span>
</td>
</tr>
<tr>
<td>
{{ t('components.AboutPod.feature.allowList') }}
</td>
<td
v-if="allowListEnabled"
class="right aligned"
>
<span class="features-status ui text">
<i class="check icon" />
{{ t('components.AboutPod.feature.status.enabled') }}
</span>
</td>
<td
v-else
class="right aligned"
>
<span class="features-status ui text">
<i class="x icon" />
{{ t('components.AboutPod.feature.status.disabled') }}
</span>
</td>
</tr>
</tbody>
</table>
</div> </div>
</a> <div class="column">
<a <table class="ui very basic table unstackable">
href="https://funkwhale.audio/apps" <tbody>
class="ui card" <tr>
target="_blank" <td>
{{ t('components.AboutPod.feature.anonymousAccess') }}
</td>
<td
v-if="anonymousCanListen"
class="right aligned"
>
<span class="features-status ui text">
<i class="check icon" />
{{ t('components.AboutPod.feature.status.enabled') }}
</span>
</td>
<td
v-else
class="right aligned"
>
<span class="features-status ui text">
<i class="x icon" />
{{ t('components.AboutPod.feature.status.disabled') }}
</span>
</td>
</tr>
<tr>
<td>
{{ t('components.AboutPod.feature.registrations') }}
</td>
<td
v-if="openRegistrations"
class="right aligned"
>
<span class="features-status ui text">
<i class="check icon" />
{{ t('components.AboutPod.feature.status.open') }}
</span>
</td>
<td
v-else
class="right aligned"
> >
<div class="content"> <span class="features-status ui text">
<i class="x icon" />
{{ t('components.AboutPod.feature.status.closed') }}
</span>
</td>
</tr>
<tr>
<td>
{{ t('components.AboutPod.feature.quota') }}
</td>
<td
v-if="defaultUploadQuota"
class="right aligned"
>
<span class="features-status ui text">
{{ defaultUploadQuota }}
</span>
</td>
<td
v-else
class="right aligned"
>
<span class="features-status ui text">
{{ t('components.AboutPod.notApplicable') }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<template v-if="stats">
<h3 <h3
id="description" id="statistics"
class="ui header" class="header"
> >
{{ $t('components.About.header.findApp') }} {{ t('components.AboutPod.header.statistics') }}
&nbsp;<i class="external alternate icon" />
</h3> </h3>
<p> <div class="statistics-container">
{{ $t('components.About.description.findApp') }} <div
</p> v-if="stats.hours"
class="statistics-statistic"
>
<span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.hours.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br>
{{ t('components.AboutPod.stat.hoursOfMusic', stats.hours) }}
</span>
</div> </div>
</a> <div
v-if="stats.data.artists"
class="statistics-statistic"
>
<span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.data.artists.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br>
{{ t('components.AboutPod.stat.artistsCount', stats.data.artists) }}
</span>
</div> </div>
<div class="ui fluid horizontally fitted basic clearing segment container"> <div
<router-link v-if="stats.data.albums"
to="/about/pod" class="statistics-statistic"
class="ui right floated basic secondary button"
> >
{{ $t('components.About.header.aboutPod') }} <span class="statistics-figure ui text">
<i class="icon arrow right" /> <span class="ui big text"><strong>{{ stats.data.albums.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
</router-link> <br>
{{ t('components.AboutPod.stat.albumsCount', stats.data.albums) }}
</span>
</div> </div>
<div
v-if="stats.data.tracks"
class="statistics-statistic"
>
<span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.data.tracks.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br>
{{ t('components.AboutPod.stat.tracksCount', stats.data.tracks) }}
</span>
</div> </div>
<div
v-if="stats.users"
class="statistics-statistic"
>
<span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.users.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br>
{{ t('components.AboutPod.stat.activeUsers', stats.users) }}
</span>
</div>
<div
v-if="stats.data.listenings"
class="statistics-statistic"
>
<span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.data.listenings.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br>
{{ t('components.AboutPod.stat.listeningsCount', stats.data.listenings) }}
</span>
</div>
</div>
</template>
<template v-if="contactEmail">
<h3
id="contact"
class="ui header"
>
{{ t('components.AboutPod.header.contact') }}
</h3>
<a
v-if="contactEmail"
:href="`mailto:${contactEmail}`"
>
{{ t('components.AboutPod.message.contact', { contactEmail }) }}
</a>
</template>
<div class="ui hidden divider" />
</div> </div>
</div> </div>
</main> </Layout>
</template> </template>
...@@ -5,7 +5,6 @@ import { get } from 'lodash-es' ...@@ -5,7 +5,6 @@ import { get } from 'lodash-es'
import { computed } from 'vue' import { computed } from 'vue'
import useMarkdown from '~/composables/useMarkdown' import useMarkdown from '~/composables/useMarkdown'
import type { NodeInfo } from '~/store/instance'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const store = useStore() const store = useStore()
...@@ -39,24 +38,14 @@ const federationEnabled = computed(() => { ...@@ -39,24 +38,14 @@ const federationEnabled = computed(() => {
const onDesktop = computed(() => window.innerWidth > 800) const onDesktop = computed(() => window.innerWidth > 800)
const stats = computed(() => { const stats = computed(() => ({
const info = nodeinfo.value ?? {} as NodeInfo users: nodeinfo.value?.usage.users.activeMonth,
hours: nodeinfo.value?.metadata.content.local.hoursOfContent,
const data = { artists: nodeinfo.value?.metadata.content.local.artists,
users: get(info, 'usage.users.activeMonth', null), albums: nodeinfo.value?.metadata.content.local.releases, // TODO: Check where to get 'metadata.content.local.albums.total'
hours: get(info, 'metadata.content.local.hoursOfContent', null), tracks: nodeinfo.value?.metadata.content.local.recordings, // TODO: 'metadata.content.local.tracks.total'
artists: get(info, 'metadata.content.local.artists.total', null), listenings: nodeinfo.value?.metadata.usage?.listenings.total
albums: get(info, 'metadata.content.local.albums.total', null), }))
tracks: get(info, 'metadata.content.local.tracks.total', null),
listenings: get(info, 'metadata.usage.listenings.total', null)
}
if (data.users === null || data.artists === null) {
return data
}
return data
})
const headerStyle = computed(() => { const headerStyle = computed(() => {
if (!banner.value) { if (!banner.value) {
...@@ -72,7 +61,7 @@ const headerStyle = computed(() => { ...@@ -72,7 +61,7 @@ const headerStyle = computed(() => {
<template> <template>
<main <main
v-title="labels.title" v-title="labels.title"
class="main pusher page-about" class="main page-about"
> >
<div <div
class="ui" class="ui"
...@@ -99,32 +88,32 @@ const headerStyle = computed(() => { ...@@ -99,32 +88,32 @@ const headerStyle = computed(() => {
to="/about/pod" to="/about/pod"
class="item" class="item"
> >
{{ $t('components.AboutPod.link.about') }} {{ t('components.AboutPod.link.about') }}
</router-link> </router-link>
<router-link <router-link
to="/about/pod#rules" to="/about/pod#rules"
class="item" class="item"
> >
{{ $t('components.AboutPod.link.rules') }} {{ t('components.AboutPod.link.rules') }}
</router-link> </router-link>
<router-link <router-link
to="/about/pod#terms" to="/about/pod#terms"
class="item" class="item"
> >
{{ $t('components.AboutPod.link.terms') }} {{ t('components.AboutPod.link.terms') }}
</router-link> </router-link>
<router-link <router-link
to="/about/pod#features" to="/about/pod#features"
class="item" class="item"
> >
{{ $t('components.AboutPod.link.features') }} {{ t('components.AboutPod.link.features') }}
</router-link> </router-link>
<router-link <router-link
v-if="stats" v-if="stats"
to="/about/pod#statistics" to="/about/pod#statistics"
class="item" class="item"
> >
{{ $t('components.AboutPod.link.statistics') }} {{ t('components.AboutPod.link.statistics') }}
</router-link> </router-link>
</div> </div>
</div> </div>
...@@ -134,49 +123,49 @@ const headerStyle = computed(() => { ...@@ -134,49 +123,49 @@ const headerStyle = computed(() => {
id="description about-this-pod" id="description about-this-pod"
class="ui header" class="ui header"
> >
{{ $t('components.AboutPod.header.about') }} {{ t('components.AboutPod.header.about') }}
</h2> </h2>
<sanitized-html <sanitized-html
v-if="longDescription" v-if="longDescription"
:html="longDescription" :html="longDescription"
/> />
<p v-else> <p v-else>
{{ $t('components.AboutPod.placeholder.noDescription') }} {{ t('components.AboutPod.placeholder.noDescription') }}
</p> </p>
<h3 <h3
id="rules" id="rules"
class="ui header" class="ui header"
> >
{{ $t('components.AboutPod.header.rules') }} {{ t('components.AboutPod.header.rules') }}
</h3> </h3>
<sanitized-html <sanitized-html
v-if="rules" v-if="rules"
:html="rules" :html="rules"
/> />
<p v-else> <p v-else>
{{ $t('components.AboutPod.placeholder.noRules') }} {{ t('components.AboutPod.placeholder.noRules') }}
</p> </p>
<h3 <h3
id="terms" id="terms"
class="ui header" class="ui header"
> >
{{ $t('components.AboutPod.header.terms') }} {{ t('components.AboutPod.header.terms') }}
</h3> </h3>
<sanitized-html <sanitized-html
v-if="terms" v-if="terms"
:html="terms" :html="terms"
/> />
<p v-else> <p v-else>
{{ $t('components.AboutPod.placeholder.noTerms') }} {{ t('components.AboutPod.placeholder.noTerms') }}
</p> </p>
<h3 <h3
id="features" id="features"
class="header" class="header"
> >
{{ $t('components.AboutPod.header.features') }} {{ t('components.AboutPod.header.features') }}
</h3> </h3>
<div class="features-container ui two column stackable grid"> <div class="features-container ui two column stackable grid">
<div class="column"> <div class="column">
...@@ -184,7 +173,7 @@ const headerStyle = computed(() => { ...@@ -184,7 +173,7 @@ const headerStyle = computed(() => {
<tbody> <tbody>
<tr> <tr>
<td> <td>
{{ $t('components.AboutPod.feature.version') }} {{ t('components.AboutPod.feature.version') }}
</td> </td>
<td <td
v-if="version" v-if="version"
...@@ -199,13 +188,13 @@ const headerStyle = computed(() => { ...@@ -199,13 +188,13 @@ const headerStyle = computed(() => {
class="right aligned" class="right aligned"
> >
<span class="features-status ui text"> <span class="features-status ui text">
{{ $t('components.AboutPod.notApplicable') }} {{ t('components.AboutPod.notApplicable') }}
</span> </span>
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td>
{{ $t('components.AboutPod.feature.federation') }} {{ t('components.AboutPod.feature.federation') }}
</td> </td>
<td <td
v-if="federationEnabled" v-if="federationEnabled"
...@@ -213,7 +202,7 @@ const headerStyle = computed(() => { ...@@ -213,7 +202,7 @@ const headerStyle = computed(() => {
> >
<span class="features-status ui text"> <span class="features-status ui text">
<i class="check icon" /> <i class="check icon" />
{{ $t('components.AboutPod.feature.status.enabled') }} {{ t('components.AboutPod.feature.status.enabled') }}
</span> </span>
</td> </td>
<td <td
...@@ -222,13 +211,13 @@ const headerStyle = computed(() => { ...@@ -222,13 +211,13 @@ const headerStyle = computed(() => {
> >
<span class="features-status ui text"> <span class="features-status ui text">
<i class="x icon" /> <i class="x icon" />
{{ $t('components.AboutPod.feature.status.disabled') }} {{ t('components.AboutPod.feature.status.disabled') }}
</span> </span>
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td>
{{ $t('components.AboutPod.feature.allowList') }} {{ t('components.AboutPod.feature.allowList') }}
</td> </td>
<td <td
v-if="allowListEnabled" v-if="allowListEnabled"
...@@ -236,7 +225,7 @@ const headerStyle = computed(() => { ...@@ -236,7 +225,7 @@ const headerStyle = computed(() => {
> >
<span class="features-status ui text"> <span class="features-status ui text">
<i class="check icon" /> <i class="check icon" />
{{ $t('components.AboutPod.feature.status.enabled') }} {{ t('components.AboutPod.feature.status.enabled') }}
</span> </span>
</td> </td>
<td <td
...@@ -245,7 +234,7 @@ const headerStyle = computed(() => { ...@@ -245,7 +234,7 @@ const headerStyle = computed(() => {
> >
<span class="features-status ui text"> <span class="features-status ui text">
<i class="x icon" /> <i class="x icon" />
{{ $t('components.AboutPod.feature.status.disabled') }} {{ t('components.AboutPod.feature.status.disabled') }}
</span> </span>
</td> </td>
</tr> </tr>
...@@ -257,7 +246,7 @@ const headerStyle = computed(() => { ...@@ -257,7 +246,7 @@ const headerStyle = computed(() => {
<tbody> <tbody>
<tr> <tr>
<td> <td>
{{ $t('components.AboutPod.feature.anonymousAccess') }} {{ t('components.AboutPod.feature.anonymousAccess') }}
</td> </td>
<td <td
v-if="anonymousCanListen" v-if="anonymousCanListen"
...@@ -265,7 +254,7 @@ const headerStyle = computed(() => { ...@@ -265,7 +254,7 @@ const headerStyle = computed(() => {
> >
<span class="features-status ui text"> <span class="features-status ui text">
<i class="check icon" /> <i class="check icon" />
{{ $t('components.AboutPod.feature.status.enabled') }} {{ t('components.AboutPod.feature.status.enabled') }}
</span> </span>
</td> </td>
<td <td
...@@ -274,13 +263,13 @@ const headerStyle = computed(() => { ...@@ -274,13 +263,13 @@ const headerStyle = computed(() => {
> >
<span class="features-status ui text"> <span class="features-status ui text">
<i class="x icon" /> <i class="x icon" />
{{ $t('components.AboutPod.feature.status.disabled') }} {{ t('components.AboutPod.feature.status.disabled') }}
</span> </span>
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td>
{{ $t('components.AboutPod.feature.registrations') }} {{ t('components.AboutPod.feature.registrations') }}
</td> </td>
<td <td
v-if="openRegistrations" v-if="openRegistrations"
...@@ -288,7 +277,7 @@ const headerStyle = computed(() => { ...@@ -288,7 +277,7 @@ const headerStyle = computed(() => {
> >
<span class="features-status ui text"> <span class="features-status ui text">
<i class="check icon" /> <i class="check icon" />
{{ $t('components.AboutPod.feature.status.open') }} {{ t('components.AboutPod.feature.status.open') }}
</span> </span>
</td> </td>
<td <td
...@@ -297,13 +286,13 @@ const headerStyle = computed(() => { ...@@ -297,13 +286,13 @@ const headerStyle = computed(() => {
> >
<span class="features-status ui text"> <span class="features-status ui text">
<i class="x icon" /> <i class="x icon" />
{{ $t('components.AboutPod.feature.status.closed') }} {{ t('components.AboutPod.feature.status.closed') }}
</span> </span>
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td>
{{ $t('components.AboutPod.feature.quota') }} {{ t('components.AboutPod.feature.quota') }}
</td> </td>
<td <td
v-if="defaultUploadQuota" v-if="defaultUploadQuota"
...@@ -318,7 +307,7 @@ const headerStyle = computed(() => { ...@@ -318,7 +307,7 @@ const headerStyle = computed(() => {
class="right aligned" class="right aligned"
> >
<span class="features-status ui text"> <span class="features-status ui text">
{{ $t('components.AboutPod.notApplicable') }} {{ t('components.AboutPod.notApplicable') }}
</span> </span>
</td> </td>
</tr> </tr>
...@@ -332,7 +321,7 @@ const headerStyle = computed(() => { ...@@ -332,7 +321,7 @@ const headerStyle = computed(() => {
id="statistics" id="statistics"
class="header" class="header"
> >
{{ $t('components.AboutPod.header.statistics') }} {{ t('components.AboutPod.header.statistics') }}
</h3> </h3>
<div class="statistics-container"> <div class="statistics-container">
<div <div
...@@ -340,9 +329,9 @@ const headerStyle = computed(() => { ...@@ -340,9 +329,9 @@ const headerStyle = computed(() => {
class="statistics-statistic" class="statistics-statistic"
> >
<span class="statistics-figure ui text"> <span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.hours.toLocaleString($store.state.ui.momentLocale) }}</strong></span> <span class="ui big text"><strong>{{ stats.hours?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br> <br>
{{ $t('components.AboutPod.stat.hoursOfMusic', stats.hours) }} {{ t('components.AboutPod.stat.hoursOfMusic', stats.hours) }}
</span> </span>
</div> </div>
<div <div
...@@ -350,9 +339,9 @@ const headerStyle = computed(() => { ...@@ -350,9 +339,9 @@ const headerStyle = computed(() => {
class="statistics-statistic" class="statistics-statistic"
> >
<span class="statistics-figure ui text"> <span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.artists.toLocaleString($store.state.ui.momentLocale) }}</strong></span> <span class="ui big text"><strong>{{ stats.artists?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br> <br>
{{ $t('components.AboutPod.stat.artistsCount', stats.artists) }} {{ t('components.AboutPod.stat.artistsCount', stats.artists) }}
</span> </span>
</div> </div>
<div <div
...@@ -360,9 +349,9 @@ const headerStyle = computed(() => { ...@@ -360,9 +349,9 @@ const headerStyle = computed(() => {
class="statistics-statistic" class="statistics-statistic"
> >
<span class="statistics-figure ui text"> <span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.albums.toLocaleString($store.state.ui.momentLocale) }}</strong></span> <span class="ui big text"><strong>{{ stats.albums?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br> <br>
{{ $t('components.AboutPod.stat.albumsCount', stats.albums) }} {{ t('components.AboutPod.stat.albumsCount', stats.albums) }}
</span> </span>
</div> </div>
<div <div
...@@ -370,9 +359,9 @@ const headerStyle = computed(() => { ...@@ -370,9 +359,9 @@ const headerStyle = computed(() => {
class="statistics-statistic" class="statistics-statistic"
> >
<span class="statistics-figure ui text"> <span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.tracks.toLocaleString($store.state.ui.momentLocale) }}</strong></span> <span class="ui big text"><strong>{{ stats.tracks?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br> <br>
{{ $t('components.AboutPod.stat.tracksCount', stats.tracks) }} {{ t('components.AboutPod.stat.tracksCount', stats.tracks) }}
</span> </span>
</div> </div>
<div <div
...@@ -380,9 +369,9 @@ const headerStyle = computed(() => { ...@@ -380,9 +369,9 @@ const headerStyle = computed(() => {
class="statistics-statistic" class="statistics-statistic"
> >
<span class="statistics-figure ui text"> <span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.users.toLocaleString($store.state.ui.momentLocale) }}</strong></span> <span class="ui big text"><strong>{{ stats.users.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br> <br>
{{ $t('components.AboutPod.stat.activeUsers', stats.users) }} {{ t('components.AboutPod.stat.activeUsers', stats.users) }}
</span> </span>
</div> </div>
<div <div
...@@ -390,9 +379,9 @@ const headerStyle = computed(() => { ...@@ -390,9 +379,9 @@ const headerStyle = computed(() => {
class="statistics-statistic" class="statistics-statistic"
> >
<span class="statistics-figure ui text"> <span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.listenings.toLocaleString($store.state.ui.momentLocale) }}</strong></span> <span class="ui big text"><strong>{{ stats.listenings.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br> <br>
{{ $t('components.AboutPod.stat.listeningsCount', stats.listenings) }} {{ t('components.AboutPod.stat.listeningsCount', stats.listenings) }}
</span> </span>
</div> </div>
</div> </div>
...@@ -403,13 +392,13 @@ const headerStyle = computed(() => { ...@@ -403,13 +392,13 @@ const headerStyle = computed(() => {
id="contact" id="contact"
class="ui header" class="ui header"
> >
{{ $t('components.AboutPod.header.contact') }} {{ t('components.AboutPod.header.contact') }}
</h3> </h3>
<a <a
v-if="contactEmail" v-if="contactEmail"
:href="`mailto:${contactEmail}`" :href="`mailto:${contactEmail}`"
> >
{{ $t('components.AboutPod.message.contact', { contactEmail }) }} {{ t('components.AboutPod.message.contact', { contactEmail }) }}
</a> </a>
</template> </template>
...@@ -420,7 +409,7 @@ const headerStyle = computed(() => { ...@@ -420,7 +409,7 @@ const headerStyle = computed(() => {
class="ui left floated basic secondary button" class="ui left floated basic secondary button"
> >
<i class="icon arrow left" /> <i class="icon arrow left" />
{{ $t('components.AboutPod.link.introduction') }} {{ t('components.AboutPod.link.introduction') }}
</router-link> </router-link>
</div> </div>
</div> </div>
......
<script setup lang="ts"> <script setup lang="ts">
import { get } from 'lodash-es' import { get } from 'lodash-es'
import AlbumWidget from '~/components/audio/album/Widget.vue' import AlbumWidget from '~/components/album/Widget.vue'
import ChannelsWidget from '~/components/audio/ChannelsWidget.vue' import ChannelsWidget from '~/components/audio/ChannelsWidget.vue'
import LoginForm from '~/components/auth/LoginForm.vue' import LoginForm from '~/components/auth/LoginForm.vue'
import SignupForm from '~/components/auth/SignupForm.vue' import SignupForm from '~/components/auth/SignupForm.vue'
...@@ -13,6 +13,13 @@ import { whenever } from '@vueuse/core' ...@@ -13,6 +13,13 @@ import { whenever } from '@vueuse/core'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import Header from '~/components/ui/Header.vue'
import Layout from '~/components/ui/Layout.vue'
import Card from '~/components/ui/Card.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Section from '~/components/ui/Section.vue'
import Link from '~/components/ui/Link.vue'
const { t } = useI18n() const { t } = useI18n()
const labels = computed(() => ({ const labels = computed(() => ({
title: t('components.Home.title') title: t('components.Home.title')
...@@ -43,15 +50,11 @@ const stats = computed(() => { ...@@ -43,15 +50,11 @@ const stats = computed(() => {
return { users, hours } return { users, hours }
}) })
const headerStyle = computed(() => { const backgroundImage = computed(() =>
if (!banner.value) { banner.value
return '' ? `url(${store.getters['instance/absoluteUrl'](banner.value)})`
} : 'radial-gradient(circle at 80%, rgb(55, 122, 170), transparent), linear-gradient(135deg, rgb(40, 88, 125) 0%, rgb(64, 190, 220) 100%)'
)
return {
backgroundImage: `url(${store.getters['instance/absoluteUrl'](banner.value)})`
}
})
// TODO (wvffle): Check if needed // TODO (wvffle): Check if needed
const router = useRouter() const router = useRouter()
...@@ -62,43 +65,43 @@ whenever(() => store.state.auth.authenticated, () => { ...@@ -62,43 +65,43 @@ whenever(() => store.state.auth.authenticated, () => {
</script> </script>
<template> <template>
<main <Layout
v-title="labels.title" v-title="labels.title"
class="main pusher page-home" stack
> main
<section
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
:style="headerStyle"
> >
<div class="segment-content"> <Header
<h1 class="ui center aligned large header"> page-heading
<span> :class="$style.banner"
{{ $t('components.Home.header.welcome', {podName: podName}) }} :h1="t('components.Home.header.welcome', {podName: podName})"
</span>
<div
v-if="shortDescription"
class="sub header"
> >
<p :class="$style.description">
{{ shortDescription }} {{ shortDescription }}
</p>
<div>
<img
:class="$style.logo"
src="../assets/network.png"
alt=""
>
</div> </div>
</h1> <Spacer />
</div> <Spacer />
</section> <Spacer />
<section class="ui vertical stripe segment"> <Section
<div class="ui stackable grid"> align-left
<div class="ten wide column"> :columns-per-item="3"
<h2 class="header"> :h2="t('components.Home.header.about')"
{{ $t('components.Home.header.about') }} >
</h2> <Layout
<div flex
id="pod" :class="$style['long-description']"
class="ui raised segment"
> >
<div class="ui stackable grid"> <div>
<div class="eight wide column">
<p v-if="!longDescription"> <p v-if="!longDescription">
{{ $t('components.Home.placeholder.noDescription') }} {{ t('components.Home.placeholder.noDescription') }}
</p> </p>
<!-- TODO: Use new Ui elements once we can test with data -->
<template v-if="longDescription || rules"> <template v-if="longDescription || rules">
<sanitized-html <sanitized-html
v-if="longDescription" v-if="longDescription"
...@@ -120,7 +123,7 @@ whenever(() => store.state.auth.authenticated, () => { ...@@ -120,7 +123,7 @@ whenever(() => store.state.auth.authenticated, () => {
class="ui link" class="ui link"
:to="{name: 'about'}" :to="{name: 'about'}"
> >
{{ $t('components.Home.link.learnMore') }} {{ t('components.Home.link.learnMore') }}
</router-link> </router-link>
</div> </div>
</div> </div>
...@@ -135,89 +138,76 @@ whenever(() => store.state.auth.authenticated, () => { ...@@ -135,89 +138,76 @@ whenever(() => store.state.auth.authenticated, () => {
class="ui link" class="ui link"
:to="{name: 'about', hash: '#rules'}" :to="{name: 'about', hash: '#rules'}"
> >
{{ $t('components.Home.link.rules') }} {{ t('components.Home.link.rules') }}
</router-link> </router-link>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
</div> </div>
<div class="eight wide column"> </Layout>
<template v-if="stats"> <Card
<h3 class="sub header"> v-if="stats"
{{ $t('components.Home.header.statistics') }} :title="t('components.Home.header.statistics')"
</h3> caption
<p> style="--grid-column: -5 /-1;"
<i class="user icon" /> >
{{ $t('components.Home.stat.activeUsers', stats.users) }} <div>
</p> <i class="bi bi-people-fill" />
<p> {{ t('components.Home.stat.activeUsers', stats.users) }}
<i class="music icon" />
{{ $t('components.Home.stat.hoursOfMusic', stats.hours) }}
</p>
</template>
<template v-if="contactEmail">
<h3 class="sub header">
{{ $t('components.Home.header.contact') }}
</h3>
<i class="at icon" />
<a :href="`mailto:${contactEmail}`">{{ contactEmail }}</a>
</template>
</div>
</div>
</div> </div>
<div>
<i class="bi bi-music-note-list" />
{{ t('components.Home.stat.hoursOfMusic', stats.hours) }}
</div> </div>
</Card>
<Card
v-if="contactEmail"
:title="t('components.Home.header.contact')"
:to="`mailto:${contactEmail}`"
>
<p>
<i class="bi bi-envelope-at-fill" />
{{ contactEmail }}
</p>
</Card>
</Section>
</Header>
<div class="six wide column"> <Section
<img align-left
class="ui image" :columns-per-item="3"
src="../assets/network.png" style="row-gap: 64px;"
alt=""
> >
</div> <Section
</div> :h2="t('components.Home.header.aboutFunkwhale')"
<div class="ui hidden divider" /> :class="$style.about"
<div class="ui hidden divider" /> >
<div class="ui stackable grid"> <div>
<div class="four wide column">
<h3 class="header">
{{ $t('components.Home.header.aboutFunkwhale') }}
</h3>
<p> <p>
{{ $t('components.Home.description.funkwhale.paragraph1') }} {{ t('components.Home.description.funkwhale.paragraph1') }}
</p> </p>
<p> <p>
{{ $t('components.Home.description.funkwhale.paragraph2') }} {{ t('components.Home.description.funkwhale.paragraph2') }}
</p> </p>
<a <Link
target="_blank" to="https://funkwhale.audio"
rel="noopener" icon="bi-box-arrow-up-right"
href="https://funkwhale.audio"
> >
<i class="external alternate icon" /> {{ t('components.Home.link.funkwhale') }}
{{ $t('components.Home.link.funkwhale') }} </Link>
</a>
</div> </div>
<div class="four wide column"> </Section>
<h3 class="header"> <Section
{{ $t('components.Home.header.login') }} :h2="t('components.Home.header.signup')"
</h3> :class="$style.signup"
<login-form >
button-classes="success"
:show-signup="false"
/>
<div class="ui hidden clearing divider" />
</div>
<div class="four wide column">
<h3 class="header">
{{ $t('components.Home.header.signup') }}
</h3>
<template v-if="openRegistrations"> <template v-if="openRegistrations">
<p> <p>
{{ $t('components.Home.description.signup') }} {{ t('components.Home.description.signup') }}
</p> </p>
<p v-if="defaultUploadQuota"> <p v-if="defaultUploadQuota">
{{ $t('components.Home.description.quota', { quota: humanSize(defaultUploadQuota * 1000 * 1000) }) }} {{ t('components.Home.description.quota', { quota: humanSize(defaultUploadQuota * 1000 * 1000) }) }}
</p> </p>
<signup-form <signup-form
button-classes="success" button-classes="success"
...@@ -226,100 +216,156 @@ whenever(() => store.state.auth.authenticated, () => { ...@@ -226,100 +216,156 @@ whenever(() => store.state.auth.authenticated, () => {
</template> </template>
<div v-else> <div v-else>
<p> <p>
{{ $t('components.Home.help.registrationsClosed') }} {{ t('components.Home.help.registrationsClosed') }}
</p> </p>
<a <Link
target="_blank" to="https://funkwhale.audio/#get-started"
rel="noopener" icon="bi-box-arrow-up-right"
href="https://funkwhale.audio/#get-started"
> >
<i class="external alternate icon" /> {{ t('components.Home.link.findOtherPod') }}
{{ $t('components.Home.link.findOtherPod') }} </Link>
</a>
</div>
</div> </div>
</Section>
<login-form
is-card
primary
solid
:title="t('components.Home.header.login')"
:class="$style.loginCard"
button-classes="success"
:show-signup="false"
/>
</Section>
<div class="four wide column"> <Section :h2="t('components.Home.header.links')">
<h3 class="header"> <Card
{{ $t('components.Home.header.links') }}
</h3>
<div class="ui relaxed list">
<div class="item">
<i class="headphones icon" />
<div class="content">
<router-link
v-if="anonymousCanListen" v-if="anonymousCanListen"
class="header" tiny
:title="t('components.Home.link.publicContent.label')"
icon="bi-headphones"
to="/library" to="/library"
> >
{{ $t('components.Home.link.publicContent.label') }} <p>
</router-link> {{ t('components.Home.link.publicContent.description') }}
<div class="description"> </p>
{{ $t('components.Home.link.publicContent.description') }} </Card>
</div> <Card
</div> :title="t('components.Home.link.mobileApps.label') "
</div> icon="bi-phone-fill large"
<div class="item"> to="https://funkwhale.audio/apps"
<i class="mobile alternate icon" />
<div class="content">
<a
class="header"
href="https://funkwhale.audio/apps"
target="_blank"
rel="noopener"
>
{{ $t('components.Home.link.mobileApps.label') }}
</a>
<div class="description">
{{ $t('components.Home.link.mobileApps.description') }}
</div>
</div>
</div>
<div class="item">
<i class="book icon" />
<div class="content">
<a
class="header"
href="https://docs.funkwhale.audio/users/index.html"
target="_blank"
rel="noopener"
> >
{{ $t('components.Home.link.userGuides.label') }} <p> {{ t('components.Home.link.mobileApps.description') }} </p>
</a> </Card>
<div class="description"> <Card
{{ $t('components.Home.link.userGuides.description') }} :title=" t('components.Home.link.userGuides.label') "
</div> icon="bi-book-half large"
</div> to="https://docs.funkwhale.audio/users/index.html"
</div>
</div>
</div>
</div>
</section>
<section
v-if="anonymousCanListen"
class="ui vertical stripe segment"
> >
<p> {{ t('components.Home.link.userGuides.description') }} </p>
</Card>
</Section>
<Section v-if="anonymousCanListen">
<!-- TODO: Update design here. Cannot do it right now because `anonymousCanListen` is `undefined`-->
<album-widget <album-widget
:filters="{playable: true, ordering: '-creation_date'}" :filters="{playable: true, ordering: '-creation_date'}"
:limit="10" :limit="10"
> >
<template #title> <template #title>
{{ $t('components.Home.header.newAlbums') }} {{ t('components.Home.header.newAlbums') }}
</template> </template>
<router-link to="/library"> <router-link to="/library">
{{ $t('components.Home.link.viewMore') }} {{ t('components.Home.link.viewMore') }}
<div class="ui hidden divider" /> <div class="ui hidden divider" />
</router-link> </router-link>
</album-widget> </album-widget>
<div class="ui hidden section divider" /> <div class="ui hidden section divider" />
<h3 class="ui header"> <h3 class="ui header">
{{ $t('components.Home.header.newChannels') }} {{ t('components.Home.header.newChannels') }}
</h3> </h3>
<channels-widget <channels-widget
:show-modification-date="true" :show-modification-date="true"
:limit="10" :limit="10"
:filters="{ordering: '-creation_date', external: 'false'}" :filters="{ordering: '-creation_date', external: 'false'}"
/> />
</section> </Section>
</main> <Spacer />
<Spacer />
</Layout>
</template> </template>
<style module>
.banner {
position: relative;
color: white;
text-shadow: .5px .5px 4px rgba(0, 0, 0, 0.5);
--logo-width: min(60rem, max(63%, 350px));
padding-top: calc(var(--logo-width) / 1.6 - 14rem);
&::before{
content: "";
position: absolute;
inset: -32px;
background-repeat: no-repeat;
background-size: cover;
background-image: v-bind('backgroundImage');
}
> *{ z-index: 2; }
.description {
font-weight: 700;
max-width: min(220px, calc(100% - var(--logo-width)));
&:empty { display: none; }
}
:has(>.logo) {
position: relative;
> .logo {
width: var(--logo-width);
height: auto;
position: absolute;
bottom: -12rem;
right: max(-32px, calc(5% - 7rem));
z-index: -2;
}
z-index: -2;
}
}
i {
min-width: 24px;
display: inline-block;
}
p {
text-wrap: balance;
}
.about, .signup, .long-description {
grid-column: 1 / -5 !important;
margin-bottom: 58px;
}
.loginCard{
grid-column: -5 / -1 !important;
grid-row: 1 / 4 !important;
margin-bottom: 58px;
}
@media (max-width: 768px) {
.about, .signup, .description, .long-description { grid-column: 1 / -1 !important; }
}
@media (min-width: 1280px) {
.about {
grid-column: 1 / 5 !important;
}
.signup {
grid-column: 5 / -5 !important;
}
}
</style>
...@@ -4,7 +4,7 @@ interface Props { ...@@ -4,7 +4,7 @@ interface Props {
} }
withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
fill: '#222222' fill: 'var(--color)'
}) })
</script> </script>
......
...@@ -12,7 +12,7 @@ const labels = computed(() => ({ ...@@ -12,7 +12,7 @@ const labels = computed(() => ({
<template> <template>
<main <main
class="main pusher" class="main"
:v-title="labels.title" :v-title="labels.title"
> >
<section class="ui vertical stripe segment"> <section class="ui vertical stripe segment">
...@@ -20,11 +20,11 @@ const labels = computed(() => ({ ...@@ -20,11 +20,11 @@ const labels = computed(() => ({
<h1 class="ui huge header"> <h1 class="ui huge header">
<i class="warning icon" /> <i class="warning icon" />
<div class="content"> <div class="content">
{{ $t('components.PageNotFound.header.pageNotFound') }} {{ t('components.PageNotFound.header.pageNotFound') }}
</div> </div>
</h1> </h1>
<p> <p>
{{ $t('components.PageNotFound.message.pageNotFound') }} {{ t('components.PageNotFound.message.pageNotFound') }}
</p> </p>
<a :href="path">{{ path }}</a> <a :href="path">{{ path }}</a>
<div class="ui hidden divider" /> <div class="ui hidden divider" />
...@@ -32,7 +32,7 @@ const labels = computed(() => ({ ...@@ -32,7 +32,7 @@ const labels = computed(() => ({
class="ui icon labeled right button" class="ui icon labeled right button"
to="/" to="/"
> >
{{ $t('components.PageNotFound.link.home') }} {{ t('components.PageNotFound.link.home') }}
<i class="right arrow icon" /> <i class="right arrow icon" />
</router-link> </router-link>
</div> </div>
......
...@@ -5,7 +5,6 @@ import { whenever, watchDebounced, useCurrentElement, useScrollLock, useFullscre ...@@ -5,7 +5,6 @@ import { whenever, watchDebounced, useCurrentElement, useScrollLock, useFullscre
import { nextTick, ref, computed, watchEffect, defineAsyncComponent } from 'vue' import { nextTick, ref, computed, watchEffect, defineAsyncComponent } from 'vue'
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap' import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useStore } from '~/store' import { useStore } from '~/store'
import { usePlayer } from '~/composables/audio/player' import { usePlayer } from '~/composables/audio/player'
...@@ -14,6 +13,8 @@ import { useQueue } from '~/composables/audio/queue' ...@@ -14,6 +13,8 @@ import { useQueue } from '~/composables/audio/queue'
import time from '~/utils/time' import time from '~/utils/time'
import { useI18n } from 'vue-i18n'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue' import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
import PlayerControls from '~/components/audio/PlayerControls.vue' import PlayerControls from '~/components/audio/PlayerControls.vue'
...@@ -21,6 +22,12 @@ import PlayerControls from '~/components/audio/PlayerControls.vue' ...@@ -21,6 +22,12 @@ import PlayerControls from '~/components/audio/PlayerControls.vue'
import VirtualList from '~/components/vui/list/VirtualList.vue' import VirtualList from '~/components/vui/list/VirtualList.vue'
import QueueItem from '~/components/QueueItem.vue' import QueueItem from '~/components/QueueItem.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Link from '~/components/ui/Link.vue'
import Button from '~/components/ui/Button.vue'
import ArtistCreditLabel from '~/components/audio/ArtistCreditLabel.vue'
const MilkDrop = defineAsyncComponent(() => import('~/components/audio/visualizer/MilkDrop.vue')) const MilkDrop = defineAsyncComponent(() => import('~/components/audio/visualizer/MilkDrop.vue'))
const { const {
...@@ -173,7 +180,7 @@ if (!isWebGLSupported) { ...@@ -173,7 +180,7 @@ if (!isWebGLSupported) {
<template> <template>
<section <section
class="main with-background component-queue" class="main opaque component-queue default solid"
:aria-label="labels.queue" :aria-label="labels.queue"
> >
<div <div
...@@ -194,12 +201,12 @@ if (!isWebGLSupported) { ...@@ -194,12 +201,12 @@ if (!isWebGLSupported) {
<img <img
v-if="fullscreen" v-if="fullscreen"
class="cover-shadow" class="cover-shadow"
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)" :src="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
> >
<img <img
ref="cover" ref="cover"
alt="" alt=""
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)" :src="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
> >
</template> </template>
<milk-drop <milk-drop
...@@ -212,47 +219,40 @@ if (!isWebGLSupported) { ...@@ -212,47 +219,40 @@ if (!isWebGLSupported) {
v-if="!fullscreen || !idle" v-if="!fullscreen || !idle"
class="cover-buttons" class="cover-buttons"
> >
<tooltip :content="!isWebGLSupported && $t('components.Queue.message.webglUnsupported')"> <tooltip :content="!isWebGLSupported && t('components.Queue.message.webglUnsupported')">
<button <Button
v-if="coverType === CoverType.COVER_ART" v-if="coverType === CoverType.COVER_ART"
class="ui secondary button"
:aria-label="labels.showVisualizer" :aria-label="labels.showVisualizer"
:title="labels.showVisualizer" :title="labels.showVisualizer"
:disabled="!isWebGLSupported" :disabled="!isWebGLSupported"
icon="bi-display"
@click="coverType = CoverType.MILK_DROP" @click="coverType = CoverType.MILK_DROP"
> />
<i class="icon signal" /> <Button
</button>
<button
v-else-if="coverType === CoverType.MILK_DROP" v-else-if="coverType === CoverType.MILK_DROP"
class="ui secondary button"
:aria-label="labels.showCoverArt" :aria-label="labels.showCoverArt"
:title="labels.showCoverArt" :title="labels.showCoverArt"
:disabled="!isWebGLSupported" :disabled="!isWebGLSupported"
icon="bi-image-fill"
@click="coverType = CoverType.COVER_ART" @click="coverType = CoverType.COVER_ART"
> />
<i class="icon image outline" />
</button>
</tooltip> </tooltip>
<button <Button
v-if="!fullscreen" v-if="!fullscreen"
class="ui secondary button"
:aria-label="labels.fullscreen" :aria-label="labels.fullscreen"
:title="labels.fullscreen" :title="labels.fullscreen"
icon="bi-arrows-fullscreen"
@click="enter" @click="enter"
> />
<i class="icon expand" /> <Button
</button>
<button
v-else v-else
class="ui secondary button" secondary
:aria-label="labels.exitFullscreen" :aria-label="labels.exitFullscreen"
:title="labels.exitFullscreen" :title="labels.exitFullscreen"
icon="bi-fullscreen-exit"
@click="exit" @click="exit"
> />
<i class="icon compress" />
</button>
</div> </div>
</Transition> </Transition>
<Transition name="queue"> <Transition name="queue">
...@@ -267,65 +267,53 @@ if (!isWebGLSupported) { ...@@ -267,65 +267,53 @@ if (!isWebGLSupported) {
v-for="ac in currentTrack.artistCredit" v-for="ac in currentTrack.artistCredit"
:key="ac.artist.id" :key="ac.artist.id"
> >
{{ ac.credit ?? $t('components.Queue.meta.unknownArtist') }} {{ ac.credit ?? t('components.Queue.meta.unknownArtist') }}
<span>{{ ac.joinphrase }}</span> <span>{{ ac.joinphrase }}</span>
</div> </div>
<span class="symbol hyphen middle" /> <span class="symbol hyphen middle" />
{{ currentTrack.albumTitle ?? $t('components.Queue.meta.unknownAlbum') }} {{ currentTrack.albumTitle ?? t('components.Queue.meta.unknownAlbum') }}
</h2> </h2>
</div> </div>
</Transition> </Transition>
</div> </div>
</div> </div>
<h1 class="ui header"> <h1 class="ui header">
<div class="content ellipsis"> <Link
<router-link class="track"
class="small header discrete link track"
:to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}" :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"
> >
{{ currentTrack.title }} {{ currentTrack.title }}
</router-link> </Link>
<div class="sub header ellipsis"> </h1>
<span> <h2>
<template
v-for="ac in currentTrack.artistCredit"
:key="ac.artist.id"
>
<router-link
class="discrete link"
:to="{name: 'library.artists.detail', params: {id: ac.artist.id }}"
@click.stop.prevent=""
>
{{ ac.credit ?? $t('components.Queue.meta.unknownArtist') }}
</router-link>
<span>{{ ac.joinphrase }}</span>
</template>
</span>
<template v-if="currentTrack.albumId !== -1"> <template v-if="currentTrack.albumId !== -1">
<span class="middle slash symbol" /> <Link
<router-link class="album"
class="discrete link album"
:to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}" :to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}"
> >
{{ currentTrack.albumTitle ?? $t('components.Queue.meta.unknownAlbum') }} {{ currentTrack.albumTitle ?? t('components.Queue.meta.unknownAlbum') }}
</router-link> </Link>
</template> </template>
</div> </h2>
</div> <span>
</h1> <ArtistCreditLabel
v-if="currentTrack.artistCredit"
:artist-credit="currentTrack.artistCredit"
/>
</span>
<div <div
v-if="currentTrack && errored" v-if="currentTrack && errored"
class="ui small warning message" class="ui small warning message"
> >
<h3 class="header"> <h3 class="header">
{{ $t('components.Queue.header.failure') }} {{ t('components.Queue.header.failure') }}
</h3> </h3>
<p v-if="hasNext && isPlaying"> <p v-if="hasNext && isPlaying">
{{ $t('components.Queue.message.automaticPlay') }} {{ t('components.Queue.message.automaticPlay') }}
<i class="loading spinner icon" /> <i class="loading spinner icon" />
</p> </p>
<p> <p>
{{ $t('components.Queue.warning.connectivity') }} {{ t('components.Queue.warning.connectivity') }}
</p> </p>
</div> </div>
<div <div
...@@ -333,32 +321,40 @@ if (!isWebGLSupported) { ...@@ -333,32 +321,40 @@ if (!isWebGLSupported) {
class="ui small warning message" class="ui small warning message"
> >
<h3 class="header"> <h3 class="header">
{{ $t('components.Queue.header.noSources') }} {{ t('components.Queue.header.noSources') }}
</h3> </h3>
<p v-if="hasNext && isPlaying"> <p v-if="hasNext && isPlaying">
{{ $t('components.Queue.message.automaticPlay') }} {{ t('components.Queue.message.automaticPlay') }}
<i class="loading spinner icon" /> <i class="loading spinner icon" />
</p> </p>
</div> </div>
<div class="additional-controls desktop-and-below"> <Spacer
:size="16"
class="desktop-and-below"
/>
<Layout
flex
class="additional-controls desktop-and-below"
>
<track-favorite-icon <track-favorite-icon
v-if="$store.state.auth.authenticated" v-if="store.state.auth.authenticated"
:track="currentTrack" :track="currentTrack"
ghost
/> />
<track-playlist-icon <track-playlist-icon
v-if="$store.state.auth.authenticated" v-if="store.state.auth.authenticated"
:track="currentTrack" :track="currentTrack"
ghost
/> />
<button <Button
v-if="$store.state.auth.authenticated" v-if="store.state.auth.authenticated"
:class="['ui', 'really', 'basic', 'circular', 'icon', 'button']" ghost
icon="bi-eye-slash"
:aria-label="labels.addArtistContentFilter" :aria-label="labels.addArtistContentFilter"
:title="labels.addArtistContentFilter" :title="labels.addArtistContentFilter"
@click="hideArtist" @click="hideArtist"
> />
<i :class="['eye slash outline', 'basic', 'icon']" /> </Layout>
</button>
</div>
<div class="progress-wrapper"> <div class="progress-wrapper">
<div class="progress-area"> <div class="progress-area">
<div <div
...@@ -386,28 +382,33 @@ if (!isWebGLSupported) { ...@@ -386,28 +382,33 @@ if (!isWebGLSupported) {
<span class="right floated timer total">{{ time.parse(Math.round(duration)) }}</span> <span class="right floated timer total">{{ time.parse(Math.round(duration)) }}</span>
</template> </template>
<template v-else> <template v-else>
<span class="left floated timer">{{ $t('components.Queue.meta.startTime') }}</span> <span class="left floated timer">{{ t('components.Queue.meta.startTime') }}</span>
<span class="right floated timer">{{ $t('components.Queue.meta.startTime') }}</span> <span class="right floated timer">{{ t('components.Queue.meta.startTime') }}</span>
</template> </template>
</div> </div>
</div> </div>
<player-controls class="desktop-and-below" /> <player-controls class="desktop-and-below queue-controls" />
</template> </template>
</div> </div>
<div id="queue"> <div id="queue">
<div class="ui basic clearing segment"> <div class="ui basic clearing segment">
<h2 class="ui header"> <h2 class="ui header">
<div class="content"> <div class="content">
<button <Button
v-t="'components.Queue.button.close'" ghost
class="ui right floated basic button" icon="bi-chevron-down"
@click="$store.commit('ui/queueFocused', null)" style="float: right; margin-right: 24px;"
@click="store.commit('ui/queueFocused', null)"
/> />
<button <Button
v-t="'components.Queue.button.clear'" destructive
class="ui right floated basic button danger" outline
icon="bi-trash-fill"
style="float: right; margin-right: 16px;"
@click="clear" @click="clear"
/> >
{{ t('components.Queue.button.clear') }}
</Button>
{{ labels.queue }} {{ labels.queue }}
<div class="sub header"> <div class="sub header">
<div> <div>
...@@ -420,7 +421,10 @@ if (!isWebGLSupported) { ...@@ -420,7 +421,10 @@ if (!isWebGLSupported) {
</template> </template>
</i18n-t> </i18n-t>
<span class="middle pipe symbol" /> <span class="middle pipe symbol" />
<span v-t="'components.Queue.meta.end'" /> <span
v-t="'components.Queue.meta.end'"
style="margin-right: 8px;"
/>
<span :title="labels.duration"> <span :title="labels.duration">
{{ endsIn }} {{ endsIn }}
</span> </span>
...@@ -451,30 +455,31 @@ if (!isWebGLSupported) { ...@@ -451,30 +455,31 @@ if (!isWebGLSupported) {
</template> </template>
<template #footer> <template #footer>
<div <div
v-if="$store.state.radios.populating" v-if="store.state.radios.populating"
class="radio-populating" class="radio-populating"
> >
<i class="loading spinner icon" /> <i class="loading spinner icon" />
{{ labels.populating }} {{ labels.populating }}
</div> </div>
<div <div
v-if="$store.state.radios.running" v-if="store.state.radios.running"
class="ui info message radio-message" class="ui info message radio-message"
> >
<div class="content"> <div class="content">
<h3 class="header"> <h3 class="header">
<i class="feed icon" /> <i class="bi bi-boombox-fill" />
{{ $t('components.Queue.header.radio') }} {{ t('components.Queue.header.radio') }}
</h3> </h3>
<p> <p>
{{ $t('components.Queue.message.radio') }} {{ t('components.Queue.message.radio') }}
</p> </p>
<button <Button
class="ui basic primary button" primary
@click="$store.dispatch('radios/stop')" icon="bi-stop-fill"
@click="store.dispatch('radios/stop')"
> >
{{ $t('components.Queue.button.stopRadio') }} {{ t('components.Queue.button.stopRadio') }}
</button> </Button>
</div> </div>
</div> </div>
</template> </template>
......
...@@ -3,6 +3,11 @@ import type { QueueItemSource } from '~/types' ...@@ -3,6 +3,11 @@ import type { QueueItemSource } from '~/types'
import time from '~/utils/time' import time from '~/utils/time'
import { generateTrackCreditStringFromQueue } from '~/utils/utils' import { generateTrackCreditStringFromQueue } from '~/utils/utils'
import { useStore } from '~/store'
import Button from '~/components/ui/Button.vue'
const store = useStore()
interface Events { interface Events {
(e: 'play', index: number): void (e: 'play', index: number): void
...@@ -20,11 +25,11 @@ defineProps<Props>() ...@@ -20,11 +25,11 @@ defineProps<Props>()
<template> <template>
<div <div
class="queue-item" class="queue-item interactive ghost solid default raised "
tabindex="0" tabindex="0"
> >
<div class="handle"> <div class="handle">
<i class="grip lines icon" /> <i class="bi bi-list" />
</div> </div>
<div <div
class="image-cell" class="image-cell"
...@@ -37,7 +42,7 @@ defineProps<Props>() ...@@ -37,7 +42,7 @@ defineProps<Props>()
> >
</div> </div>
<div @click="$emit('play', index)"> <div @click="$emit('play', index)">
<button <div
class="title reset ellipsis" class="title reset ellipsis"
:title="source.title" :title="source.title"
:aria-label="source.labels.selectTrack" :aria-label="source.labels.selectTrack"
...@@ -46,7 +51,7 @@ defineProps<Props>() ...@@ -46,7 +51,7 @@ defineProps<Props>()
<span> <span>
{{ generateTrackCreditStringFromQueue(source) }} {{ generateTrackCreditStringFromQueue(source) }}
</span> </span>
</button> </div>
</div> </div>
<div class="duration-cell"> <div class="duration-cell">
<template v-if="source.sources.length > 0"> <template v-if="source.sources.length > 0">
...@@ -54,26 +59,28 @@ defineProps<Props>() ...@@ -54,26 +59,28 @@ defineProps<Props>()
</template> </template>
</div> </div>
<div class="controls"> <div class="controls">
<button <Button
v-if="$store.state.auth.authenticated" v-if="store.state.auth.authenticated"
:aria-label="source.labels.favorite" :aria-label="source.labels.favorite"
:title="source.labels.favorite" :title="source.labels.favorite"
class="ui really basic circular icon button" :icon="store.getters['favorites/isFavorite'](source.id) ? 'bi-heart-fill' : 'bi-heart'"
@click.stop="$store.dispatch('favorites/toggle', source.id)" round
> ghost
<i square-small
:class="$store.getters['favorites/isFavorite'](source.id) ? 'pink' : ''" style="align-self: center;"
class="heart icon" :class="store.getters['favorites/isFavorite'](source.id) ? 'pink' : ''"
@click.stop="store.dispatch('favorites/toggle', source.id)"
/> />
</button> <Button
<button
:aria-label="source.labels.remove" :aria-label="source.labels.remove"
:title="source.labels.remove" :title="source.labels.remove"
class="ui really tiny basic circular icon button" icon="bi-x"
round
ghost
square-small
style="align-self: center;"
@click.stop="$emit('remove', index)" @click.stop="$emit('remove', index)"
> />
<i class="x icon" />
</button>
</div> </div>
</div> </div>
</template> </template>
...@@ -8,6 +8,11 @@ import { useStore } from '~/store' ...@@ -8,6 +8,11 @@ import { useStore } from '~/store'
import axios from 'axios' import axios from 'axios'
import Layout from '~/components/ui/Layout.vue'
import Button from '~/components/ui/Button.vue'
import Input from '~/components/ui/Input.vue'
import Alert from '~/components/ui/Alert.vue'
import updateQueryString from '~/composables/updateQueryString' import updateQueryString from '~/composables/updateQueryString'
import useLogger from '~/composables/useLogger' import useLogger from '~/composables/useLogger'
...@@ -115,7 +120,7 @@ const createFetch = async () => { ...@@ -115,7 +120,7 @@ const createFetch = async () => {
isLoading.value = true isLoading.value = true
try { try {
const response = await axios.post('federation/fetches/', { object: id.value }) const response = await axios.post('federation/fetches/', { object_uri: id.value })
obj.value = response.data obj.value = response.data
if (response.data.status === 'errored' || response.data.status === 'skipped') { if (response.data.status === 'errored' || response.data.status === 'skipped') {
...@@ -165,41 +170,42 @@ watch(() => props.initialId, () => { ...@@ -165,41 +170,42 @@ watch(() => props.initialId, () => {
</script> </script>
<template> <template>
<div <Layout
v-if="type === 'both'" v-if="type === 'both'"
class="two ui buttons" stack
> >
<button <Button
class="ui left floated labeled icon button" secondary
raised
split
round
icon="bi-rss-fill"
split-icon="bi-globe"
style="align-self: center;"
:split-title="t('components.RemoteSearchForm.button.fediverse')"
@click.prevent="type = 'rss'" @click.prevent="type = 'rss'"
@split-click.prevent="type = 'artists'"
> >
<i class="feed icon" /> {{ t('components.RemoteSearchForm.button.rss') }}
{{ $t('components.RemoteSearchForm.button.rss') }} </Button>
</button> </Layout>
<div class="or" /> <Layout
<button v-else
class="ui right floated right labeled icon button"
@click.prevent="type = 'artists'"
>
<i class="globe icon" />
{{ $t('components.RemoteSearchForm.button.fediverse') }}
</button>
</div>
<div v-else>
<form
id="remote-search" id="remote-search"
form
:class="['ui', {loading: isLoading}, 'form']" :class="['ui', {loading: isLoading}, 'form']"
@submit.stop.prevent="submit" @submit.stop.prevent="submit"
> >
<div <Alert
v-if="errors.length > 0" v-if="errors.length > 0"
red
role="alert" role="alert"
class="ui negative message" title="t('components.RemoteSearchForm.header.fetchFailed')"
>
<ul
v-if="errors.length > 1"
class="list"
> >
<h3 class="header">
{{ $t('components.RemoteSearchForm.header.fetchFailed') }}
</h3>
<ul class="list">
<li <li
v-for="(error, key) in errors" v-for="(error, key) in errors"
:key="key" :key="key"
...@@ -207,43 +213,42 @@ watch(() => props.initialId, () => { ...@@ -207,43 +213,42 @@ watch(() => props.initialId, () => {
{{ error }} {{ error }}
</li> </li>
</ul> </ul>
</div> <p v-else>
<div class="ui required field"> {{ errors[0] }}
<label for="object-id"> </p>
{{ labels.fieldLabel }} </Alert>
</label>
<p v-if="type === 'rss'"> <p v-if="type === 'rss'">
{{ $t('components.RemoteSearchForm.description.rss') }} {{ t('components.RemoteSearchForm.description.rss') }}
</p> </p>
<p v-else-if="type === 'artists'"> <p v-else-if="type === 'artists'">
{{ $t('components.RemoteSearchForm.description.fediverse') }} {{ t('components.RemoteSearchForm.description.fediverse') }}
</p> </p>
<input
<Input
id="object-id" id="object-id"
v-model="id" v-model="id"
type="text" type="text"
name="object-id" name="object-id"
:label="labels.fieldLabel"
:placeholder="labels.fieldPlaceholder" :placeholder="labels.fieldPlaceholder"
style="width: 100%;"
required required
> />
</div>
<button <Button
v-if="showSubmit" v-if="showSubmit"
primary
type="submit" type="submit"
:class="['ui', 'primary', {loading: isLoading}, 'button']" :class="{loading: isLoading}"
:disabled="isLoading || !id || id.length === 0" :disabled="isLoading || !id || id.length === 0"
> >
{{ $t('components.RemoteSearchForm.button.search') }} {{ t('components.RemoteSearchForm.button.search') }}
</button> </Button>
</form> </Layout>
<div <Alert
v-if="!isLoading && obj?.status === 'finished' && !redirectRoute" v-if="!isLoading && obj?.status === 'finished' && !redirectRoute"
role="alert" red
class="ui warning message"
> >
<p> {{ t('components.RemoteSearchForm.warning.unsupported') }}
{{ $t('components.RemoteSearchForm.warning.unsupported') }} </Alert>
</p>
</div>
</div>
</template> </template>
<script setup lang="ts">
import { useStore } from '~/store'
const store = useStore()
</script>
<template> <template>
<div class="ui toast-container"> <div class="ui toast-container">
<message <message
v-for="message in $store.state.ui.messages" v-for="message in store.state.ui.messages"
:key="message.key" :key="message.key"
:message="message" :message="message"
/> />
......
<script setup lang="ts">
import Modal from '~/components/ui/Modal.vue'
import axios from 'axios'
import { uniq } from 'lodash-es'
import { useVModel } from '@vueuse/core'
import { ref, computed, watch, nextTick } from 'vue'
import { useStore } from '~/store'
import { useI18n } from 'vue-i18n'
// TODO: Delete this file?
const { t } = useI18n()
interface Props {
show: boolean
}
const props = defineProps<Props>()
const emit = defineEmits(['update:show'])
const show = useVModel(props, 'show', emit)
const instanceUrl = ref('')
const store = useStore()
const suggestedInstances = computed(() => {
const serverUrl = store.state.instance.frontSettings.defaultServerUrl
return uniq([
store.state.instance.instanceUrl,
...store.state.instance.knownInstances,
serverUrl.endsWith('/') ? serverUrl : serverUrl + '/',
store.getters['instance/defaultInstance']
]).slice(1)
})
watch(() => store.state.instance.instanceUrl, () => store.dispatch('instance/fetchSettings'))
// TODO: replace translation mechanism { $pgettext } with { t }
// const { $pgettext } = useGettext()
const isError = ref(false)
const isLoading = ref(false)
const checkAndSwitch = async (url: string) => {
isError.value = false
isLoading.value = true
try {
const instanceUrl = new URL(url.startsWith('https://') || url.startsWith('http://') ? url : `https://${url}`).origin
await axios.get(instanceUrl + '/api/v1/instance/nodeinfo/2.0/')
show.value = false
store.commit('ui/addMessage', {
content: 'You are now using the Funkwhale instance at %{ url }',
// $pgettext('*/Instance/Message', 'You are now using the Funkwhale instance at %{ url }', { url: instanceUrl }),
date: new Date()
})
await nextTick()
store.dispatch('instance/setUrl', instanceUrl)
} catch (error) {
isError.value = true
}
isLoading.value = false
}
</script>
<template>
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<Modal
v-model="show"
:title="t('views.ChooseInstance.header.chooseInstance')"
@update="isError = false"
>
<h3 class="header">
<!-- TODO: translate -->
<!-- <translate translate-context="Popup/Instance/Title">
</translate> -->
</h3>
<div class="scrolling content">
<div
v-if="isError"
role="alert"
class="ui negative message"
>
<h4 class="header">
<!-- TODO: translate -->
It is not possible to connect to the given URL
<!-- <translate translate-context="Popup/Instance/Error message.Title">
</translate> -->
</h4>
<ul class="list">
<li>
<!-- TODO: translate -->
The server might be down
<!-- <translate translate-context="Popup/Instance/Error message.List item">
</translate> -->
</li>
<li>
<!-- TODO: translate -->
The given address is not a Funkwhale server
<!-- <translate translate-context="Popup/Instance/Error message.List item">
</translate> -->
</li>
</ul>
</div>
<form
class="ui form"
@submit.prevent="checkAndSwitch(instanceUrl)"
>
<p
v-if="store.state.instance.instanceUrl"
v-translate="{url: store.state.instance.instanceUrl, hostname: store.getters['instance/domain'] }"
class="description"
translate-context="Popup/Login/Paragraph"
>
You are currently connected to <a
href="%{ url }"
target="_blank"
>%{ hostname }&nbsp;<i class="external icon" /></a>. If you continue, you will be disconnected from your current instance and all your local data will be deleted.
</p>
<p v-else>
<!-- TODO: translate -->
To continue, please select the Funkwhale instance you want to connect to. Enter the address directly, or select one of the suggested choices.
<!-- <translate translate-context="Popup/Instance/Paragraph">
</translate> -->
</p>
<div class="field">
<label for="instance-picker">
<!-- TODO: translate -->
<!-- <translate translate-context="Popup/Instance/Input.Label/Noun">Instance URL</translate>
-->
</label>
<div class="ui action input">
<input
id="instance-picker"
v-model="instanceUrl"
type="text"
placeholder="https://funkwhale.server"
>
<button
type="submit"
:class="['ui', 'icon', {loading: isLoading}, 'button']"
>
<!-- TODO: translate -->
Submit
<!-- <translate translate-context="*/*/Button.Label/Verb">
</translate> -->
</button>
</div>
</div>
</form>
<div class="ui hidden divider" />
<form
class="ui form"
@submit.prevent=""
>
<div class="field">
<h4>
<!-- TODO: translate -->
Suggested choices
<!-- <translate translate-context="Popup/Instance/List.Label">
</translate> -->
</h4>
<button
v-for="(url, key) in suggestedInstances"
:key="key"
class="ui basic button"
@click="checkAndSwitch(url)"
>
{{ url }}
</button>
</div>
</form>
</div>
<div class="actions">
<button class="ui basic cancel button">
<!-- TODO: translate -->
Cancel
<!-- <translate translate-context="*/*/Button.Label/Verb">
</translate> -->
</button>
</div>
</Modal>
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
</template>
<script setup lang="ts">
import type { RouteRecordName } from 'vue-router'
import { computed, ref, watch, watchEffect, onMounted } from 'vue'
import { setI18nLanguage, SUPPORTED_LOCALES } from '~/init/locale'
import { useCurrentElement } from '@vueuse/core'
import { setupDropdown } from '~/utils/fomantic'
import { useRoute } from 'vue-router'
import { useStore } from '~/store'
import { useI18n } from 'vue-i18n'
import SemanticModal from '~/components/semantic/Modal.vue'
import UserModal from '~/components/common/UserModal.vue'
import SearchBar from '~/components/audio/SearchBar.vue'
import UserMenu from '~/components/common/UserMenu.vue'
import Logo from '~/components/Logo.vue'
import useThemeList from '~/composables/useThemeList'
import useTheme from '~/composables/useTheme'
import { isTauri as checkTauri } from '~/composables/tauri'
interface Props {
width: number
}
defineProps<Props>()
const store = useStore()
const { theme } = useTheme()
const themes = useThemeList()
const { t, locale: i18nLocale } = useI18n()
const route = useRoute()
const isCollapsed = ref(true)
watch(() => route.path, () => (isCollapsed.value = true))
const additionalNotifications = computed(() => store.getters['ui/additionalNotifications'])
const logoUrl = computed(() => store.state.auth.authenticated ? 'library.index' : 'index')
const labels = computed(() => ({
mainMenu: t('components.Sidebar.label.main'),
selectTrack: t('components.Sidebar.label.play'),
pendingFollows: t('components.Sidebar.label.follows'),
pendingReviewEdits: t('components.Sidebar.label.edits'),
pendingReviewReports: t('components.Sidebar.label.reports'),
language: t('components.Sidebar.label.language'),
theme: t('components.Sidebar.label.theme'),
addContent: t('components.Sidebar.label.add'),
administration: t('components.Sidebar.label.administration')
}))
type SidebarMenuTabs = 'explore' | 'myLibrary'
const expanded = ref<SidebarMenuTabs>('explore')
const ROUTE_MAPPINGS: Record<SidebarMenuTabs, RouteRecordName[]> = {
explore: [
'search',
'library.index',
'library.podcasts.browse',
'library.albums.browse',
'library.albums.detail',
'library.artists.browse',
'library.artists.detail',
'library.tracks.detail',
'library.playlists.browse',
'library.playlists.detail',
'library.radios.browse',
'library.radios.detail'
],
myLibrary: [
'library.me',
'library.albums.me',
'library.artists.me',
'library.playlists.me',
'library.radios.me',
'favorites'
]
}
watchEffect(() => {
if (ROUTE_MAPPINGS.explore.includes(route.name as RouteRecordName)) {
expanded.value = 'explore'
return
}
if (ROUTE_MAPPINGS.myLibrary.includes(route.name as RouteRecordName)) {
expanded.value = 'myLibrary'
return
}
expanded.value = store.state.auth.authenticated ? 'myLibrary' : 'explore'
})
const moderationNotifications = computed(() =>
store.state.ui.notifications.pendingReviewEdits
+ store.state.ui.notifications.pendingReviewReports
+ store.state.ui.notifications.pendingReviewRequests
)
const showLanguageModal = ref(false)
const locale = ref(i18nLocale.value)
watch(locale, (locale) => {
setI18nLanguage(locale)
})
const isProduction = import.meta.env.PROD
const isTauri = checkTauri()
const showUserModal = ref(false)
const showThemeModal = ref(false)
const el = useCurrentElement()
watchEffect(() => {
if (store.state.auth.authenticated) {
setupDropdown('.admin-dropdown', el.value)
}
setupDropdown('.user-dropdown', el.value)
})
onMounted(() => {
document.getElementById('fake-sidebar')?.classList.add('loaded')
})
</script>
<template>
<aside :class="['ui', 'vertical', 'left', 'visible', 'wide', {'collapsed': isCollapsed}, 'sidebar', 'component-sidebar']">
<header class="ui basic segment header-wrapper">
<router-link
:title="'Funkwhale'"
:to="{name: logoUrl}"
>
<i class="logo bordered inverted vibrant big icon">
<logo class="logo" />
<span class="visually-hidden">{{ $t('components.Sidebar.link.home') }}</span>
</i>
</router-link>
<nav class="top ui compact right aligned inverted text menu">
<div class="right menu">
<div
v-if="$store.state.auth.availablePermissions['settings'] || $store.state.auth.availablePermissions['moderation']"
class="item"
:title="labels.administration"
>
<div class="item ui inline admin-dropdown dropdown">
<i class="wrench icon" />
<div
v-if="moderationNotifications > 0"
:class="['ui', 'accent', 'mini', 'bottom floating', 'circular', 'label']"
>
{{ moderationNotifications }}
</div>
<div class="menu">
<h3 class="header">
{{ $t('components.Sidebar.header.administration') }}
</h3>
<div class="divider" />
<router-link
v-if="$store.state.auth.availablePermissions['library']"
class="item"
:to="{name: 'manage.library.edits', query: {q: 'is_approved:null'}}"
>
<div
v-if="$store.state.ui.notifications.pendingReviewEdits > 0"
:title="labels.pendingReviewEdits"
:class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']"
>
{{ $store.state.ui.notifications.pendingReviewEdits }}
</div>
{{ $t('components.Sidebar.link.library') }}
</router-link>
<router-link
v-if="$store.state.auth.availablePermissions['moderation']"
class="item"
:to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}"
>
<div
v-if="$store.state.ui.notifications.pendingReviewReports + $store.state.ui.notifications.pendingReviewRequests > 0"
:title="labels.pendingReviewReports"
:class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']"
>
{{ $store.state.ui.notifications.pendingReviewReports + $store.state.ui.notifications.pendingReviewRequests }}
</div>
{{ $t('components.Sidebar.link.moderation') }}
</router-link>
<router-link
v-if="$store.state.auth.availablePermissions['settings']"
class="item"
:to="{name: 'manage.users.users.list'}"
>
{{ $t('components.Sidebar.link.users') }}
</router-link>
<router-link
v-if="$store.state.auth.availablePermissions['settings']"
class="item"
:to="{path: '/manage/settings'}"
>
{{ $t('components.Sidebar.link.settings') }}
</router-link>
</div>
</div>
</div>
</div>
<router-link
v-if="$store.state.auth.authenticated"
class="item"
:to="{name: 'content.index'}"
>
<i class="upload icon" />
<span class="visually-hidden">{{ labels.addContent }}</span>
</router-link>
<template v-if="width > 768">
<div class="item">
<div class="ui user-dropdown dropdown">
<img
v-if="$store.state.auth.authenticated && $store.state.auth.profile?.avatar && $store.state.auth.profile?.avatar.urls.medium_square_crop"
class="ui avatar image"
alt=""
:src="$store.getters['instance/absoluteUrl']($store.state.auth.profile?.avatar.urls.medium_square_crop)"
>
<actor-avatar
v-else-if="$store.state.auth.authenticated"
:actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username,}"
/>
<i
v-else
class="cog icon"
/>
<div
v-if="$store.state.ui.notifications.inbox + additionalNotifications > 0"
:class="['ui', 'accent', 'mini', 'bottom floating', 'circular', 'label']"
>
{{ $store.state.ui.notifications.inbox + additionalNotifications }}
</div>
<user-menu
v-bind="$attrs"
:width="width"
/>
</div>
</div>
</template>
<template v-else>
<a
href=""
class="item"
@click.prevent.exact="showUserModal = !showUserModal"
>
<img
v-if="$store.state.auth.authenticated && $store.state.auth.profile?.avatar?.urls.medium_square_crop"
class="ui avatar image"
alt=""
:src="$store.getters['instance/absoluteUrl']($store.state.auth.profile?.avatar.urls.medium_square_crop)"
>
<actor-avatar
v-else-if="$store.state.auth.authenticated"
:actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username,}"
/>
<i
v-else
class="cog icon"
/>
<div
v-if="$store.state.ui.notifications.inbox + additionalNotifications > 0"
:class="['ui', 'accent', 'mini', 'bottom floating', 'circular', 'label']"
>
{{ $store.state.ui.notifications.inbox + additionalNotifications }}
</div>
</a>
</template>
<user-modal
v-model:show="showUserModal"
@show-theme-modal-event="showThemeModal=true"
@show-language-modal-event="showLanguageModal=true"
/>
<semantic-modal
ref="languageModal"
v-model:show="showLanguageModal"
:fullscreen="false"
>
<i
role="button"
class="left chevron back inside icon"
@click.prevent.exact="showUserModal = !showUserModal"
/>
<div class="header">
<h3 class="title">
{{ labels.language }}
</h3>
</div>
<div class="content">
<fieldset
v-for="(language, key) in SUPPORTED_LOCALES"
:key="key"
>
<input
:id="`${key}`"
v-model="locale"
type="radio"
name="language"
:value="key"
>
<label :for="`${key}`">{{ language }}</label>
</fieldset>
</div>
</semantic-modal>
<semantic-modal
ref="themeModal"
v-model:show="showThemeModal"
:fullscreen="false"
>
<i
role="button"
class="left chevron back inside icon"
@click.prevent.exact="showUserModal = !showUserModal"
/>
<div class="header">
<h3 class="title">
{{ labels.theme }}
</h3>
</div>
<div class="content">
<fieldset
v-for="th in themes"
:key="th.key"
>
<input
:id="th.key"
v-model="theme"
type="radio"
name="theme"
:value="th.key"
>
<label :for="th.key">{{ th.name }}</label>
</fieldset>
</div>
</semantic-modal>
<div class="item collapse-button-wrapper">
<button
:class="['ui', 'basic', 'big', {'vibrant': !isCollapsed}, 'inverted icon', 'collapse', 'button']"
@click="isCollapsed = !isCollapsed"
>
<i class="sidebar icon" />
</button>
</div>
</nav>
</header>
<div class="ui basic search-wrapper segment">
<search-bar @search="isCollapsed = false" />
</div>
<div
v-if="!$store.state.auth.authenticated"
class="ui basic signup segment"
>
<router-link
class="ui fluid tiny primary button"
:to="{name: 'login'}"
>
{{ $t('components.Sidebar.link.login') }}
</router-link>
<div class="ui small hidden divider" />
<router-link
class="ui fluid tiny button"
:to="{path: '/signup'}"
>
{{ $t('components.Sidebar.link.createAccount') }}
</router-link>
</div>
<nav
class="secondary"
role="navigation"
aria-labelledby="navigation-label"
>
<h1
id="navigation-label"
class="visually-hidden"
>
{{ $t('components.Sidebar.header.main') }}
</h1>
<div class="ui small hidden divider" />
<section
:aria-label="labels.mainMenu"
class="ui bottom attached active tab"
>
<nav
class="ui vertical large fluid inverted menu"
role="navigation"
:aria-label="labels.mainMenu"
>
<div :class="[{ collapsed: expanded !== 'explore' }, 'collapsible item']">
<h2
class="header"
role="button"
tabindex="0"
@click="expanded = 'explore'"
@focus="expanded = 'explore'"
>
{{ $t('components.Sidebar.header.explore') }}
<i
v-if="expanded !== 'explore'"
class="angle right icon"
/>
</h2>
<div class="menu">
<router-link
class="item"
:to="{name: 'search'}"
>
<i class="search icon" />
{{ $t('components.Sidebar.link.search') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.index'}"
active-class="_active"
>
<i class="music icon" />
{{ $t('components.Sidebar.link.browse') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.podcasts.browse'}"
>
<i class="podcast icon" />
{{ $t('components.Sidebar.link.podcasts') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.albums.browse'}"
>
<i class="compact disc icon" />
{{ $t('components.Sidebar.link.albums') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.artists.browse'}"
>
<i class="user icon" />
{{ $t('components.Sidebar.link.artists') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.playlists.browse'}"
>
<i class="list icon" />
{{ $t('components.Sidebar.link.playlists') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.radios.browse'}"
>
<i class="feed icon" />
{{ $t('components.Sidebar.link.radios') }}
</router-link>
</div>
</div>
<div
v-if="$store.state.auth.authenticated"
:class="[{ collapsed: expanded !== 'myLibrary' }, 'collapsible item']"
>
<h3
class="header"
role="button"
tabindex="0"
@click="expanded = 'myLibrary'"
@focus="expanded = 'myLibrary'"
>
{{ $t('components.Sidebar.header.library') }}
<i
v-if="expanded !== 'myLibrary'"
class="angle right icon"
/>
</h3>
<div class="menu">
<router-link
class="item"
:to="{name: 'library.me'}"
>
<i class="music icon" />
{{ $t('components.Sidebar.link.browse') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.albums.me'}"
>
<i class="compact disc icon" />
{{ $t('components.Sidebar.link.albums') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.artists.me'}"
>
<i class="user icon" />
{{ $t('components.Sidebar.link.artists') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.playlists.me'}"
>
<i class="list icon" />
{{ $t('components.Sidebar.link.playlists') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.radios.me'}"
>
<i class="feed icon" />
{{ $t('components.Sidebar.link.radios') }}
</router-link>
<router-link
class="item"
:to="{name: 'favorites'}"
>
<i class="heart icon" />
{{ $t('components.Sidebar.link.favorites') }}
</router-link>
</div>
</div>
<router-link
v-if="$store.state.auth.authenticated"
class="header item"
:to="{name: 'subscriptions'}"
>
{{ $t('components.Sidebar.link.channels') }}
</router-link>
<div class="item">
<h3 class="header">
{{ $t('components.Sidebar.header.more') }}
</h3>
<div class="menu">
<router-link
class="item"
to="/about"
active-class="router-link-exact-active active"
>
<i class="info icon" />
{{ $t('components.Sidebar.link.about') }}
</router-link>
</div>
</div>
<div
v-if="!isProduction || isTauri"
class="item"
>
<router-link
to="/instance-chooser"
class="link item"
>
{{ $t('components.Sidebar.link.switchInstance') }}
</router-link>
</div>
</nav>
</section>
</nav>
</aside>
</template>
<style>
[type="radio"] {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
[type="radio"] + label::after {
content: "";
font-size: 1.4em;
}
[type="radio"]:checked + label::after {
margin-left: 10px;
content: "\2713"; /* Checkmark */
font-size: 1.4em;
}
[type="radio"]:checked + label {
font-weight: bold;
}
fieldset {
border: none;
}
.back {
font-size: 1.25em !important;
position: absolute;
top: 0.5rem;
left: 0.5rem;
width: 2.25rem !important;
height: 2.25rem !important;
padding: 0.625rem 0 0 0;
}
</style>
...@@ -6,6 +6,17 @@ import useFormData from '~/composables/useFormData' ...@@ -6,6 +6,17 @@ import useFormData from '~/composables/useFormData'
import { ref, computed, reactive } from 'vue' import { ref, computed, reactive } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import useLogger from '~/composables/useLogger' import useLogger from '~/composables/useLogger'
import { useI18n } from 'vue-i18n'
import Section from '~/components/ui/Section.vue'
import Layout from '~/components/ui/Layout.vue'
import Toggle from '~/components/ui/Toggle.vue'
import Input from '~/components/ui/Input.vue'
import Alert from '~/components/ui/Alert.vue'
import Button from '~/components/ui/Button.vue'
import Spacer from '~/components/ui/Spacer.vue'
const { t } = useI18n()
interface Props { interface Props {
group: SettingsGroup group: SettingsGroup
...@@ -96,42 +107,24 @@ const save = async () => { ...@@ -96,42 +107,24 @@ const save = async () => {
</script> </script>
<template> <template>
<!-- TODO: type the different values in `settings` (use generics) -->
<!-- eslint-disable vue/valid-v-model -->
<Section
align-left
:h2="group.label"
large-section-heading
>
<form <form
:id="group.id" :id="group.id"
class="ui form component-settings-group" class="ui form component-settings-group"
style="grid-column: 1 / -1;"
@submit.prevent="save" @submit.prevent="save"
> >
<div class="ui divider" /> <Spacer :size="16" />
<h3 class="ui header">
{{ group.label }}
</h3>
<div
v-if="errors.length > 0"
role="alert"
class="ui negative message"
>
<h4 class="header">
{{ $t('components.admin.SettingsGroup.header.error') }}
</h4>
<ul class="list">
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</div>
<div
v-if="result"
class="ui positive message"
>
{{ $t('components.admin.SettingsGroup.message.success') }}
</div>
<div <div
v-for="(setting, key) in settings" v-for="(setting, key) in settings"
:key="key" :key="key"
class="ui field" :class="[$style.field, 'ui', 'field']"
> >
<template v-if="setting.field.widget.class !== 'CheckboxInput'"> <template v-if="setting.field.widget.class !== 'CheckboxInput'">
<label :for="setting.identifier">{{ setting.verbose_name }}</label> <label :for="setting.identifier">{{ setting.verbose_name }}</label>
...@@ -144,60 +137,46 @@ const save = async () => { ...@@ -144,60 +137,46 @@ const save = async () => {
v-bind="setting.fieldParams" v-bind="setting.fieldParams"
v-model="values[setting.identifier]" v-model="values[setting.identifier]"
/> />
<!-- eslint-disable vue/valid-v-model -->
<signup-form-builder <signup-form-builder
v-else-if="setting.fieldType === 'formBuilder'" v-else-if="setting.fieldType === 'formBuilder'"
v-model="values[setting.identifier] as Form" v-model="values[setting.identifier] as Form"
:signup-approval-enabled="!!values.moderation__signup_approval_enabled" :signup-approval-enabled="!!values.moderation__signup_approval_enabled"
/> />
<!-- eslint-enable vue/valid-v-model --> <Input
<input
v-else-if="setting.field.widget.class === 'PasswordInput'" v-else-if="setting.field.widget.class === 'PasswordInput'"
:id="setting.identifier" v-model="values[setting.identifier] as string"
v-model="values[setting.identifier]" password
:name="setting.identifier"
type="password" type="password"
class="ui input" class="ui input"
> />
<input <Input
v-else-if="setting.field.widget.class === 'TextInput'" v-else-if="setting.field.widget.class === 'TextInput'"
:id="setting.identifier" v-model="values[setting.identifier] as string"
v-model="values[setting.identifier]"
:name="setting.identifier"
type="text" type="text"
class="ui input" class="ui input"
> />
<input <Input
v-else-if="setting.field.class === 'IntegerField'" v-else-if="setting.field.class === 'IntegerField'"
:id="setting.identifier" v-model.number="values[setting.identifier] as number"
v-model.number="values[setting.identifier]"
:name="setting.identifier"
type="number" type="number"
class="ui input" class="ui input"
> />
<!-- eslint-disable vue/valid-v-model -->
<textarea <textarea
v-else-if="setting.field.widget.class === 'Textarea'" v-else-if="setting.field.widget.class === 'Textarea'"
:id="setting.identifier"
v-model="values[setting.identifier] as string" v-model="values[setting.identifier] as string"
:name="setting.identifier"
type="text" type="text"
class="ui input" class="ui input"
/> />
<!-- eslint-enable vue/valid-v-model --> <!-- eslint-enable vue/valid-v-model -->
<div <div
v-else-if="setting.field.widget.class === 'CheckboxInput'" v-else-if="setting.field.widget.class === 'CheckboxInput'"
class="ui toggle checkbox"
> >
<!-- eslint-disable vue/valid-v-model --> <Toggle
<input
:id="setting.identifier"
v-model="values[setting.identifier] as boolean" v-model="values[setting.identifier] as boolean"
:name="setting.identifier" big
type="checkbox" :label="setting.verbose_name"
> />
<!-- eslint-enable vue/valid-v-model --> <Spacer :size="8" />
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
<p v-if="setting.help_text"> <p v-if="setting.help_text">
{{ setting.help_text }} {{ setting.help_text }}
</p> </p>
...@@ -208,6 +187,7 @@ const save = async () => { ...@@ -208,6 +187,7 @@ const save = async () => {
v-model="values[setting.identifier]" v-model="values[setting.identifier]"
multiple multiple
class="ui search selection dropdown" class="ui search selection dropdown"
style="height: 150px;"
> >
<option <option
v-for="v in setting.additional_data?.choices" v-for="v in setting.additional_data?.choices"
...@@ -232,30 +212,75 @@ const save = async () => { ...@@ -232,30 +212,75 @@ const save = async () => {
</option> </option>
</select> </select>
<div v-else-if="setting.field.widget.class === 'ImageWidget'"> <div v-else-if="setting.field.widget.class === 'ImageWidget'">
<input <!-- TODO: Implement image input -->
<!-- @vue-ignore -->
<Input
:id="setting.identifier" :id="setting.identifier"
:ref="setFileRef(setting.identifier)" :ref="setFileRef(setting.identifier)"
type="file" type="file"
> />
<div v-if="values[setting.identifier]"> <div v-if="values[setting.identifier]">
<div class="ui hidden divider" />
<h3 class="ui header"> <h3 class="ui header">
{{ $t('components.admin.SettingsGroup.header.image') }} {{ t('components.admin.SettingsGroup.header.image') }}
</h3> </h3>
<img <img
v-if="values[setting.identifier]" v-if="values[setting.identifier]"
class="ui image" class="ui image"
alt="" alt=""
:src="$store.getters['instance/absoluteUrl'](values[setting.identifier])" :src="store.getters['instance/absoluteUrl'](values[setting.identifier])"
> >
</div> </div>
</div> </div>
<Spacer />
</div> </div>
<button <Layout flex>
<Spacer grow />
<Button
type="submit" type="submit"
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'success', 'button']" :class="[{'loading': isLoading}]"
primary
>
{{ t('components.admin.SettingsGroup.button.save') }}
</Button>
</Layout>
<Spacer />
<Alert
v-if="errors.length > 0"
red
> >
{{ $t('components.admin.SettingsGroup.button.save') }} <h4 class="header">
</button> {{ t('components.admin.SettingsGroup.header.error', {label: group.label}) }}
</h4>
<ul class="list">
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</Alert>
<Alert
v-if="result"
green
>
{{ t('components.admin.SettingsGroup.message.success') }}
</Alert>
</form> </form>
</Section>
<hr :class="$style.separator">
<Spacer size-64 />
<!-- eslint-enable vue/valid-v-model -->
</template> </template>
<style module>
.field > div {
display: flex;
flex-direction: column;
}
.separator:last-of-type {
display: none;
}
</style>
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
import type { Form } from '~/types' import type { Form } from '~/types'
import SignupForm from '~/components/auth/SignupForm.vue' import SignupForm from '~/components/auth/SignupForm.vue'
import Button from '~/components/ui/Button.vue'
import { useVModel } from '@vueuse/core' import { useVModel } from '@vueuse/core'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
...@@ -65,18 +67,20 @@ const move = (idx: number, increment: number) => { ...@@ -65,18 +67,20 @@ const move = (idx: number, increment: number) => {
<template> <template>
<div> <div>
<div class="ui top attached tabular menu"> <div class="ui top attached tabular menu">
<button <Button
color="primary"
:class="[{active: !isPreviewing}, 'item']" :class="[{active: !isPreviewing}, 'item']"
@click.stop.prevent="isPreviewing = false" @click.stop.prevent="isPreviewing = false"
> >
{{ $t('components.admin.SignupFormBuilder.button.edit') }} {{ t('components.admin.SignupFormBuilder.button.edit') }}
</button> </Button>
<button <Button
color="primary"
:class="[{active: isPreviewing}, 'item']" :class="[{active: isPreviewing}, 'item']"
@click.stop.prevent="isPreviewing = true" @click.stop.prevent="isPreviewing = true"
> >
{{ $t('components.admin.SignupFormBuilder.button.preview') }} {{ t('components.admin.SignupFormBuilder.button.preview') }}
</button> </Button>
</div> </div>
<div <div
v-if="isPreviewing" v-if="isPreviewing"
...@@ -95,10 +99,10 @@ const move = (idx: number, increment: number) => { ...@@ -95,10 +99,10 @@ const move = (idx: number, increment: number) => {
> >
<div class="field"> <div class="field">
<label for="help-text"> <label for="help-text">
{{ $t('components.admin.SignupFormBuilder.label.helpText') }} {{ t('components.admin.SignupFormBuilder.label.helpText') }}
</label> </label>
<p> <p>
{{ $t('components.admin.SignupFormBuilder.help.helpText') }} {{ t('components.admin.SignupFormBuilder.help.helpText') }}
</p> </p>
<content-form <content-form
v-if="value.help_text" v-if="value.help_text"
...@@ -109,24 +113,24 @@ const move = (idx: number, increment: number) => { ...@@ -109,24 +113,24 @@ const move = (idx: number, increment: number) => {
</div> </div>
<div class="field"> <div class="field">
<label> <label>
{{ $t('components.admin.SignupFormBuilder.label.additionalFields') }} {{ t('components.admin.SignupFormBuilder.label.additionalFields') }}
</label> </label>
<p> <p>
{{ $t('components.admin.SignupFormBuilder.help.additionalFields') }} {{ t('components.admin.SignupFormBuilder.help.additionalFields') }}
</p> </p>
<table v-if="value.fields?.length > 0"> <table v-if="value.fields?.length > 0">
<thead> <thead>
<tr> <tr>
<th> <th>
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.label') }} {{ t('components.admin.SignupFormBuilder.table.additionalFields.header.label') }}
</th> </th>
<th> <th>
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.type') }} {{ t('components.admin.SignupFormBuilder.table.additionalFields.header.type') }}
</th> </th>
<th> <th>
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.required') }} {{ t('components.admin.SignupFormBuilder.table.additionalFields.header.required') }}
</th> </th>
<th><span class="visually-hidden">{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.actions') }}</span></th> <th><span class="visually-hidden">{{ t('components.admin.SignupFormBuilder.table.additionalFields.header.actions') }}</span></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
...@@ -144,20 +148,20 @@ const move = (idx: number, increment: number) => { ...@@ -144,20 +148,20 @@ const move = (idx: number, increment: number) => {
<td> <td>
<select v-model="field.input_type"> <select v-model="field.input_type">
<option value="short_text"> <option value="short_text">
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.type.short') }} {{ t('components.admin.SignupFormBuilder.table.additionalFields.type.short') }}
</option> </option>
<option value="long_text"> <option value="long_text">
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.type.long') }} {{ t('components.admin.SignupFormBuilder.table.additionalFields.type.long') }}
</option> </option>
</select> </select>
</td> </td>
<td> <td>
<select v-model="field.required"> <select v-model="field.required">
<option :value="true"> <option :value="true">
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.required.true') }} {{ t('components.admin.SignupFormBuilder.table.additionalFields.required.true') }}
</option> </option>
<option :value="false"> <option :value="false">
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.required.false') }} {{ t('components.admin.SignupFormBuilder.table.additionalFields.required.false') }}
</option> </option>
</select> </select>
</td> </td>
...@@ -187,13 +191,13 @@ const move = (idx: number, increment: number) => { ...@@ -187,13 +191,13 @@ const move = (idx: number, increment: number) => {
</tbody> </tbody>
</table> </table>
<div class="ui hidden divider" /> <div class="ui hidden divider" />
<button <Button
v-if="value.fields?.length < maxFields" v-if="value.fields?.length < maxFields"
class="ui basic button" color="primary"
@click.stop.prevent="addField" @click.stop.prevent="addField"
> >
{{ $t('components.admin.SignupFormBuilder.button.add') }} {{ t('components.admin.SignupFormBuilder.button.add') }}
</button> </Button>
</div> </div>
</div> </div>
<div class="ui hidden divider" /> <div class="ui hidden divider" />
......
<script setup lang="ts">
import { computed } from 'vue'
import { useStore } from '~/store'
import { useI18n } from 'vue-i18n'
import { momentFormat } from '~/utils/filters'
import defaultCover from '~/assets/audio/default-cover.png'
import PlayButton from '~/components/audio/PlayButton.vue'
import Layout from '~/components/ui/Layout.vue'
import Card from '~/components/ui/Card.vue'
import Link from '~/components/ui/Link.vue'
import Spacer from '~/components/ui/Spacer.vue'
import { type Album } from '~/types'
interface Props {
album: Album;
}
const { t } = useI18n()
const props = defineProps<Props>()
const { album } = props
const artistCredit = album.artist_credit || []
const store = useStore()
const imageUrl = computed(() => props.album.cover?.urls.original
? store.getters['instance/absoluteUrl'](props.album.cover?.urls.medium_square_crop)
: defaultCover
)
</script>
<template>
<Card
:title="album.title"
:image="imageUrl"
:tags="album.tags"
:to="{ name: 'library.albums.detail', params: { id: album.id } }"
small
>
<template #topright>
<PlayButton
icon-only
:is-playable="album.is_playable"
:album="album"
/>
</template>
<Layout
flex
gap-4
style="overflow: hidden;"
>
<template
v-for="ac in artistCredit"
:key="ac.artist.id"
>
<Link
align-text="start"
:to="{ name: 'library.artists.detail', params: { id: ac.artist.id } }"
>
{{ ac.credit ?? t('components.Queue.meta.unknownArtist') }}
</Link>
<span style="font-weight: 600;">{{ ac.joinphrase }}</span>
</template>
</Layout>
<template #footer>
<span v-if="album.release_date">
{{ momentFormat(new Date(album.release_date), 'Y') }}
</span>
<i class="bi bi-dot" />
<span>
{{ t('components.audio.album.Card.meta.tracks', album.tracks_count) }}
</span>
<Spacer
h
grow
/>
<PlayButton
:dropdown-only="true"
discrete
:is-playable="album.is_playable"
:album="album"
/>
</template>
</Card>
</template>
<style scoped>
.play-button {
top: 16px;
right: 16px;
}
</style>
...@@ -6,21 +6,27 @@ import { useStore } from '~/store' ...@@ -6,21 +6,27 @@ import { useStore } from '~/store'
import axios from 'axios' import axios from 'axios'
import AlbumCard from '~/components/audio/album/Card.vue'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import AlbumCard from '~/components/album/Card.vue'
import Section from '~/components/ui/Section.vue'
import Loader from '~/components/ui/Loader.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Pagination from '~/components/ui/Pagination.vue'
interface Props { interface Props {
filters: Record<string, string | boolean> filters: Record<string, string | boolean>
showCount?: boolean showCount?: boolean
search?: boolean search?: boolean
limit?: number limit?: number
title?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
showCount: false, showCount: false,
search: false, search: false,
limit: 12 limit: 12,
title: undefined
}) })
const store = useStore() const store = useStore()
...@@ -28,6 +34,7 @@ const store = useStore() ...@@ -28,6 +34,7 @@ const store = useStore()
const query = ref('') const query = ref('')
const albums = reactive([] as Album[]) const albums = reactive([] as Album[])
const count = ref(0) const count = ref(0)
const page = ref(1)
const nextPage = ref() const nextPage = ref()
const isLoading = ref(false) const isLoading = ref(false)
...@@ -38,13 +45,14 @@ const fetchData = async (url = 'albums/') => { ...@@ -38,13 +45,14 @@ const fetchData = async (url = 'albums/') => {
const params = { const params = {
q: query.value, q: query.value,
...props.filters, ...props.filters,
page: page.value,
page_size: props.limit page_size: props.limit
} }
const response = await axios.get(url, { params }) const response = await axios.get(url, { params })
nextPage.value = response.data.next nextPage.value = response.data.next
count.value = response.data.count count.value = response.data.count
albums.push(...response.data.results) albums.splice(0, albums.length, ...response.data.results)
} catch (error) { } catch (error) {
useErrorHandler(error as Error) useErrorHandler(error as Error)
} }
...@@ -52,68 +60,59 @@ const fetchData = async (url = 'albums/') => { ...@@ -52,68 +60,59 @@ const fetchData = async (url = 'albums/') => {
isLoading.value = false isLoading.value = false
} }
setTimeout(fetchData, 1000)
const performSearch = () => { const performSearch = () => {
albums.length = 0 albums.length = 0
fetchData() fetchData()
} }
watch( watch(
() => store.state.moderation.lastUpdate, () => [store.state.moderation.lastUpdate, page.value],
() => fetchData(), () => fetchData(),
{ immediate: true } { immediate: true }
) )
</script> </script>
<template> <template>
<div class="wrapper"> <Section
<h3 align-left
v-if="!!$slots.title" :h2="title"
class="ui header" :columns-per-item="1"
> >
<slot name="title" />
<span
v-if="showCount"
class="ui tiny circular label"
>{{ count }}</span>
</h3>
<slot />
<inline-search-bar <inline-search-bar
v-if="search" v-if="search"
v-model="query" v-model="query"
style="grid-column: 1 / -1;"
@search="performSearch" @search="performSearch"
/> />
<div class="ui hidden divider" /> <Loader
<div class="ui app-cards cards">
<div
v-if="isLoading" v-if="isLoading"
class="ui inverted active dimmer" style="grid-column: 1 / -1;"
> />
<div class="ui loader" /> <template v-if="!isLoading && albums.length > 0">
</div>
<album-card <album-card
v-for="album in albums" v-for="album in albums"
:key="album.id" :key="album.id"
:album="album" :album="album"
/> />
</div> </template>
<slot <slot
v-if="!isLoading && albums.length === 0" v-if="!isLoading && albums.length === 0"
name="empty-state" name="empty-state"
> >
<empty-state <empty-state
:refresh="true" :refresh="true"
style="grid-column: 1 / -1;"
@refresh="fetchData" @refresh="fetchData"
/> />
</slot> </slot>
<template v-if="nextPage"> <Spacer grow />
<div class="ui hidden divider" /> <Pagination
<button v-if="page && albums && count > props.limit"
v-if="nextPage" v-model:page="page"
:class="['ui', 'basic', 'button']" :pages="Math.ceil((count || 0) / props.limit)"
@click="fetchData(nextPage)" style="grid-column: 1 / -1;"
> />
{{ $t('components.audio.album.Widget.button.more') }} </Section>
</button>
</template>
</div>
</template> </template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { components } from '~/generated/types.ts'
import PlayButton from '~/components/audio/PlayButton.vue'
import Card from '~/components/ui/Card.vue'
import Spacer from '~/components/ui/Spacer.vue'
import type { Artist, Album } from '~/types'
const albums = ref([] as Album[])
interface Props {
artist: Artist | components['schemas']['ArtistWithAlbums'];
}
const { t } = useI18n()
const props = defineProps<Props>()
const { artist } = props
if ('albums' in artist && Array.isArray(artist.albums)) {
albums.value = artist.albums
}
</script>
<template>
<Card
:title="artist.name"
class="artist-card"
:tags="artist.tags"
:to="{name: 'library.artists.detail', params: {id: artist.id}}"
small
>
<template #topright>
<PlayButton
icon-only
:is-playable="true"
:artist="artist"
/>
</template>
<template #image>
<img
v-if="artist.cover"
v-lazy="artist.cover.urls.medium_square_crop"
:alt="artist.name"
:class="[artist.content_category === 'podcast' ? 'podcast-image' : 'channel-image']"
>
<i
v-else
class="bi bi-person-circle"
style="font-size: 167px; margin: 16px;"
/>
</template>
<template #footer>
<span v-if="artist.content_category === 'music' && 'tracks_count' in artist">
{{ t('components.audio.artist.Card.meta.tracks', artist.tracks_count) }}
</span>
<span v-else-if="'tracks_count' in artist">
{{ t('components.audio.artist.Card.meta.episodes', artist.tracks_count) }}
</span>
<i
v-if="albums"
class="bi bi-dot"
/>
<span v-if="albums">
{{ t('components.audio.artist.Card.meta.albums', albums.length) }}
</span>
<Spacer style="flex-grow: 1" />
<PlayButton
:dropdown-only="true"
:is-playable="Boolean(albums.find(album => album.is_playable))"
:artist="artist"
discrete
/>
</template>
</Card>
</template>
<style lang="scss" scoped>
.channel-image {
border-radius: 50%;
width: 168px;
height: 168px;
margin: 16px;
}
.podcast-image {
width: 168px;
height: 168px;
margin: 16px;
}
.play-button {
top: 16px;
right: 16px;
}
</style>
<script setup lang="ts"> <script setup lang="ts">
import type { Artist } from '~/types' import type { Artist } from '~/types'
import { reactive, ref, watch } from 'vue' import { reactive, ref, watch, onMounted } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import axios from 'axios' import axios from 'axios'
import ArtistCard from '~/components/audio/artist/Card.vue'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import ArtistCard from '~/components/artist/Card.vue'
import Section from '~/components/ui/Section.vue'
import Pagination from '~/components/ui/Pagination.vue'
import Loader from '~/components/ui/Loader.vue'
interface Props { interface Props {
filters: Record<string, string | boolean> filters: Record<string, string | boolean>
search?: boolean search?: boolean
header?: boolean header?: boolean
limit?: number limit?: number
title?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
search: false, search: false,
header: true, header: true,
limit: 12 limit: 12,
title: undefined
}) })
const store = useStore() const store = useStore()
...@@ -28,6 +33,7 @@ const store = useStore() ...@@ -28,6 +33,7 @@ const store = useStore()
const query = ref('') const query = ref('')
const artists = reactive([] as Artist[]) const artists = reactive([] as Artist[])
const count = ref(0) const count = ref(0)
const page = ref(1)
const nextPage = ref() const nextPage = ref()
const isLoading = ref(false) const isLoading = ref(false)
...@@ -38,13 +44,14 @@ const fetchData = async (url = 'artists/') => { ...@@ -38,13 +44,14 @@ const fetchData = async (url = 'artists/') => {
const params = { const params = {
q: query.value, q: query.value,
...props.filters, ...props.filters,
page: page.value,
page_size: props.limit page_size: props.limit
} }
const response = await axios.get(url, { params }) const response = await axios.get(url, { params })
nextPage.value = response.data.next nextPage.value = response.data.next
count.value = response.data.count count.value = response.data.count
artists.push(...response.data.results) artists.splice(0, artists.length, ...response.data.results)
} catch (error) { } catch (error) {
useErrorHandler(error as Error) useErrorHandler(error as Error)
} }
...@@ -52,64 +59,58 @@ const fetchData = async (url = 'artists/') => { ...@@ -52,64 +59,58 @@ const fetchData = async (url = 'artists/') => {
isLoading.value = false isLoading.value = false
} }
onMounted(() => {
setTimeout(fetchData, 1000)
})
const performSearch = () => { const performSearch = () => {
artists.length = 0 artists.length = 0
fetchData() fetchData()
} }
watch( watch(
() => store.state.moderation.lastUpdate, [() => store.state.moderation.lastUpdate, page],
() => fetchData(), () => fetchData(),
{ immediate: true } { immediate: true }
) )
</script> </script>
<template> <template>
<div class="wrapper"> <Section
<h3 align-left
v-if="header" :columns-per-item="3"
class="ui header" :h2="title"
> >
<slot name="title" /> <Loader
<span class="ui tiny circular label">{{ count }}</span>
</h3>
<inline-search-bar
v-if="search"
v-model="query"
@search="performSearch"
/>
<div class="ui hidden divider" />
<div class="ui five app-cards cards">
<div
v-if="isLoading" v-if="isLoading"
class="ui inverted active dimmer" style="grid-column: 1 / -1;"
>
<div class="ui loader" />
</div>
<artist-card
v-for="artist in artists"
:key="artist.id"
:artist="artist"
/> />
</div>
<slot <slot
v-if="!isLoading && artists.length === 0" v-if="!isLoading && artists.length === 0"
name="empty-state" name="empty-state"
> >
<empty-state <empty-state
style="grid-column: 1 / -1;"
:refresh="true" :refresh="true"
@refresh="fetchData" @refresh="fetchData"
/> />
</slot> </slot>
<template v-if="nextPage"> <inline-search-bar
<div class="ui hidden divider" /> v-if="!isLoading && search"
<button v-model="query"
v-if="nextPage" style="grid-column: 1 / -1;"
:class="['ui', 'basic', 'button']" @search="performSearch"
@click="fetchData(nextPage)" />
> <artist-card
{{ $t('components.audio.artist.Widget.button.more') }} v-for="artist in artists"
</button> :key="artist.id"
</template> :artist="artist"
</div> />
<Pagination
v-if="page && artists && count > limit"
v-model:page="page"
style="grid-column: 1 / -1;"
:pages="Math.ceil((count || 0) / limit)"
/>
</Section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ArtistCredit } from '~/types' import type { ArtistCredit } from '~/types'
import { useStore } from '~/store'
import Layout from '~/components/ui/Layout.vue'
import Pill from '~/components/ui/Pill.vue'
const store = useStore()
interface Props { interface Props {
artistCredit: ArtistCredit[] artistCredit: ArtistCredit[]
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const getRoute = (ac: ArtistCredit) => {
return {
name: ac.artist.channel ? 'channels.detail' : 'library.artists.detail',
params: {
id: ac.artist.id.toString()
}
}
}
</script> </script>
<template> <template>
<div class="artist-label ui image label"> <Layout
flex
gap-8
style="/*2px typographic overshoot compensation+ 4px pill paddings */ margin: 0 -6px;"
>
<template <template
v-for="ac in props.artistCredit" v-for="ac in props.artistCredit"
:key="ac.artist.id" :key="ac.artist.id"
> >
<router-link <router-link
:to="getRoute(ac)" :to="{name: 'library.artists.detail', params: {id: ac.artist.id }}"
class="username"
@click.stop.prevent=""
> >
<Pill v-bind="$attrs">
<template #image>
<img <img
v-if="ac.index === 0 && ac.artist.cover && ac.artist.cover.urls.original" v-if="ac.artist.cover && ac.artist.cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](ac.artist.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](ac.artist.cover.urls.small_square_crop)"
alt="" :alt="ac.artist.name"
:class="[{circular: ac.artist.content_category != 'podcast'}]" @error="(e) => { e.target && ac.artist.cover ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](ac.artist.cover.urls.medium_square_crop) : null }"
> >
<i <i
v-else-if="ac.index === 0" v-else
:class="[ac.artist.content_category != 'podcast' ? 'circular' : 'bordered', 'inverted violet users icon']" class="bi bi-person-circle"
style="font-size: 24px;"
/> />
</template>
{{ ac.credit }} {{ ac.credit }}
</Pill>
</router-link> </router-link>
<span>{{ ac.joinphrase }}</span> <span>{{ ac.joinphrase }}</span>
</template> </template>
</div> </Layout>
</template> </template>
<style lang="scss" scoped>
a.username {
text-decoration: none;
height: 25px;
}
</style>
...@@ -2,6 +2,9 @@ ...@@ -2,6 +2,9 @@
import type { Artist } from '~/types' import type { Artist } from '~/types'
import { computed } from 'vue' import { computed } from 'vue'
import { useStore } from '~/store'
const store = useStore()
interface Props { interface Props {
artist: Artist artist: Artist
...@@ -10,7 +13,7 @@ interface Props { ...@@ -10,7 +13,7 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
const route = computed(() => props.artist.channel const route = computed(() => props.artist.channel
? { name: 'channels.detail', params: { id: props.artist.channel.uuid } } ? { name: 'channels.detail', params: { id: props.artist.channel } }
: { name: 'library.artists.detail', params: { id: props.artist.id } } : { name: 'library.artists.detail', params: { id: props.artist.id } }
) )
</script> </script>
...@@ -22,9 +25,10 @@ const route = computed(() => props.artist.channel ...@@ -22,9 +25,10 @@ const route = computed(() => props.artist.channel
> >
<img <img
v-if="artist.cover && artist.cover.urls.original" v-if="artist.cover && artist.cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](artist.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](artist.cover.urls.small_square_crop)"
alt="" alt=""
:class="[{circular: artist.content_category != 'podcast'}]" :class="[{circular: artist.content_category != 'podcast'}]"
@error="(e) => { e.target && artist.cover ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](artist.cover.urls.medium_square_crop) : null }"
> >
<i <i
v-else v-else
......
...@@ -9,7 +9,9 @@ import { computed } from 'vue' ...@@ -9,7 +9,9 @@ import { computed } from 'vue'
import moment from 'moment' import moment from 'moment'
import PlayButton from '~/components/audio/PlayButton.vue' import PlayButton from '~/components/audio/PlayButton.vue'
import TagsList from '~/components/tags/List.vue' import Card from '~/components/ui/Card.vue'
import Spacer from '~/components/ui/Spacer.vue'
import ActorLink from '~/components/common/ActorLink.vue'
interface Props { interface Props {
object: Channel object: Channel
...@@ -41,64 +43,92 @@ const updatedAgo = computed(() => moment(props.object.artist?.modification_date) ...@@ -41,64 +43,92 @@ const updatedAgo = computed(() => moment(props.object.artist?.modification_date)
</script> </script>
<template> <template>
<div class="card app-card"> <Card
<div :title="object.artist?.name"
v-lazy:background-image="imageUrl" :tags="object.artist?.tags ?? []"
:class="['ui', 'head-image', {'circular': object.artist?.content_category != 'podcast'}, {'padded': object.artist?.content_category === 'podcast'}, 'image', {'default-cover': !object.artist?.cover}]" class="artist-card"
@click="$router.push({name: 'channels.detail', params: {id: urlId}})" :to="{name: 'channels.detail', params: {id: urlId}}"
solid
small
> >
<play-button <template #topright>
:icon-only="true" <PlayButton
:is-playable="true" icon-only
:button-classes="['ui', 'circular', 'large', 'vibrant', 'icon', 'button']"
:artist="object.artist" :artist="object.artist"
:is-playable="true"
/> />
</div> </template>
<div class="content">
<strong> <template #image>
<router-link <img
class="discrete link" v-if="imageUrl"
:to="{name: 'channels.detail', params: {id: urlId}}" v-lazy="imageUrl"
> :alt="object.artist?.name"
{{ object.artist?.name }} :class="[object.artist?.content_category === 'podcast' ? 'podcast-image' : 'channel-image']"
</router-link>
</strong>
<div class="description">
<span
v-if="object.artist?.content_category === 'podcast'"
class="meta ellipsis"
> >
{{ $t('components.audio.ChannelCard.meta.episodes', object.artist.tracks_count) }} <i
</span> v-else
<span v-else> class="bi bi-person-circle"
{{ $t('components.audio.ChannelCard.meta.tracks', object.artist?.tracks_count ?? 0) }} style="font-size: 167px; margin: 16px;"
</span> />
<tags-list </template>
label-classes="tiny"
:truncate-size="20" <template #default>
:limit="2" <Spacer :size="8" />
:show-more="false" <ActorLink
:tags="object.artist?.tags ?? []" :actor="object.attributed_to"
discrete
/> />
</div> </template>
</div>
<div class="extra content"> <template #footer>
<time <time
class="meta ellipsis"
:datetime="object.artist?.modification_date" :datetime="object.artist?.modification_date"
:title="updatedTitle" :title="updatedTitle"
> >
{{ updatedAgo }} {{ updatedAgo }}
</time> </time>
<play-button <i class="bi bi-dot" />
class="right floated basic icon" <span
v-if="object.artist?.content_category === 'podcast'"
>
{{ t('components.audio.ChannelCard.meta.episodes', object.artist.tracks_count) }}
</span>
<span v-else>
{{ t('components.audio.ChannelCard.meta.tracks', object.artist?.tracks_count ?? 0) }}
</span>
<Spacer
h
grow
/>
<PlayButton
:dropdown-only="true" :dropdown-only="true"
:is-playable="true" :is-playable="true"
:dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']"
:artist="object.artist" :artist="object.artist"
:channel="object" :channel="object"
:account="object.attributed_to" :account="object.attributed_to"
discrete
/> />
</div>
</div>
</template> </template>
</Card>
</template>
<style lang="scss" scoped>
.channel-image {
border-radius: 50%;
width: 168px;
height: 168px;
margin: 16px;
}
.podcast-image {
width: 168px;
height: 168px;
margin: 16px;
}
.play-button {
top: 16px;
right: 16px;
}
</style>