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
  • 2451-delete-no-user-query
  • 2452-fetch-third-party-metadata
  • 2469-Fix-search-bar-in-ManageUploads
  • 2471-display-empty-playlist-in-playlist-widget
  • 2490-experiment-use-rstore
  • 2490-experimental-use-simple-data-store
  • 2490-fix-search-modal
  • 2490-search-modal
  • 2492-only-deliver-to-reachable-domains
  • 2501-fix-compatibility-with-older-browsers
  • 2502-drop-uno-and-jquery
  • 2506-fix-frontend-regressions
  • 2533-allow-followers-in-user-activiy-privacy-level
  • 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
  • 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 1686 additions and 2143 deletions
## Playlist libraries to share audio files
### The Issue
- As a user I want to share a list of tracks privately to my friends
- As a user I want to have a single container to curate my content (not playlist and libraries, only playlists)
### Proposed Solution
The users can request access to the playlist content to the playlist owner
### Feature Behavior
Users will be able to click on a "Request access to playlist audios files" button. This is a `LibraryFollow` request of the `playlist.library`. Not to be confused with the playlist follow request (see #-followup)
#### Backend
##### Data model
`Playlist` one_to_one with `Library` through `library` field
`Upload` many_to_one with `Library` through `library` (reverse is `library.uploads`)
`Upload` has also a many_to_many with `Library` through `playlist_libraries` (the same upload can be share various time through various playlists). Reverse relation is `library.playlist_uploads`
We could migrate from O2M to M2M, but this is super complicated since : - it adds a lot of extra logic (you can't query the m2m if the instance is not save -> this generated problem to validate incoming AP objects) - having a built-in lib and playlist libs make verifications easier (only three built-in lib, playlist_lib are always private)
##### Workflow
Playlist activity -> library_scan(get the uploads) -> playlist_scan (set the upload.playlist_relation and create plts)
##### Federation
Since `Playlist` is the main object here, we use the `Playlist` activities to send the `Library` information on ActivitiPub.
There is no other reason to share the playlit.library to remote.
##### Migrations
1. Remote library are not changed
2. Local lib are not deleted but are assigned to a playlist
3. Libraries Follows are not touched
4. Remote want fetch local libs as always but they will need to update the data or fail (migrating uploads from `library` to `playlist_library`)
##### Done
- [x] `PlaylistViewSet` `add` `clear` `remove` update the uploads.playlist_libraries relationships
- [x] `PlaylistViewSet` `add` `clear` `remove` -> `schedule_scan` -> Update activity to remote -> playlist.library scan on remote
- [x] library and playlist scan delay are long (24h), force on ap update
- [x] make sure only owned upload are added to the playlist.library
- [x] update the "drop library" migrations to use the playlist.library instead of user follow
- [x] make sure user get the new libraries created after library drop
- [x] update the federation api : when we receive a fetch for a library the upload serializer need to know which lib (playlist lib or user lib)
- [x] Support library.playlist_uploads in library scan -> add playlist_uploads in items in library federation viewset
- [x] investigate library scan bug : don't delete old content of the lib (local cache?): we need to empty the playlist before the scan(not ideal but less work)
- [x] check actor has only have three built-in libs and upload.playlist_libraries is private after migration
- [x] Playlist discovery : fetch federation endpoint for playlists
- [ ] Seem like the federation fetch (either with fetch endpoint or retreive_ap_obj) is deleting the `privacy_level` since `audience` can only be public or null. Avoid `privacy_level` to be updated if it a local playlist.
### Follow up
- [ ] Add the frontend playlist button in the new ui
- [ ] Playlist discovery : display playlist fid in the frontend
- [ ] Document : The user that want to federate need to activate remote activities in it's user settings. Even if the library is public the playlist activities will not be sended to remote -> We need to implement a followers activity setting (#2362)
- [ ] allow users to change the upload to another built-in lib, make sure the upload is not delete (we would loose the playlist_library relation) but only updated
- [ ] Playlist discovery : add the playlist to my playlist collection = follow request to playlist
- [ ] Playlist Track activity (to avoid having to refetch the whole playlist)
- [ ] Add a popover button to force playlist scan ? or make playlist scan delay shorter ?
......@@ -10,7 +10,9 @@ Has an admin I can add plugins that support downloading tracks from third party
## Backend
When a track queryset is called with `with_playable_uploads` if no upload is found we trigger `plugins.TRIGGER_THIRD_PARTY_UPLOAD`.
When a radio or playlist queryset is called if no upload is found we trigger `plugins.TRIGGER_THIRD_PARTY_UPLOAD`.
RadioViewSet.tracks and PlaylistViewSet.tracks are concerned. These endpoints can be called a lot, `THIRD_PARTY_UPLOAD_MAX_UPLOADS` variable allows to limits the amount af requests that are sended to the tird party service.
`handle_stream` should filter the upload queryset to display manual upload before plugin upload
......@@ -21,9 +23,12 @@ Plugins registering `TRIGGER_THIRD_PARTY_UPLOAD` should :
- trigger celery task. If not the queryset will take a long time to complete.
- create an upload with an associated file
- delete the upload if no file is succefully downloaded
- check if an upload has already been triggered to avoid overloading Celery
An example can be found in `funkwhale_api.contrib.archivedl`
To enable the archive-dl plugin : `FUNKWHALE_PLUGINS=funkwhale_api.contrib.archivedl`
## Follow up
-The frontend should update the track object if `TRIGGER_THIRD_PARTY_UPLOAD`
......@@ -32,3 +37,5 @@ An example can be found in `funkwhale_api.contrib.archivedl`
- trigger a channels group send so the frontend can update track qs when/if the upload is ready
- Third party track stream (do not download the file, only pass a stream)
- Allow `THIRD_PARTY_UPLOAD_MAX_UPLOADS` to be set at the plugin level -> allow admin to set plugin conf in ui -> create PluginAdminViewSet
# Browser support targeting 95% coverage while enabling modern features
# This targets browsers that support ES2020+ and modern CSS features
# Cover 95% of global usage
> 1%
last 2 versions
not dead
# Exclude problematic browsers
not ie 11
not op_mini all
not android <= 4.4
not samsung <= 4
# Ensure modern browser support for ES2020+ features
chrome >= 87
firefox >= 78
safari >= 14
edge >= 88
# Mobile browsers
ios >= 14
and_chr >= 87
and_ff >= 78
......@@ -6,8 +6,7 @@ module.exports = {
extends: [
'plugin:@intlify/vue-i18n/recommended',
'plugin:vue/vue3-recommended',
'@vue/typescript/recommended',
'@vue/standard'
'@vue/typescript/recommended'
],
globals: {
SharedArrayBuffer: 'readonly',
......@@ -20,8 +19,14 @@ module.exports = {
ecmaVersion: 2020
},
plugins: [
'html',
'vue'
],
ignorePatterns: [
'src/locales/*.json',
'dist/',
'stats.html'
],
rules: {
// NOTE: Nicer for the eye
'operator-linebreak': ['error', 'before'],
......@@ -55,7 +60,10 @@ module.exports = {
'@typescript-eslint/no-this-alias': 'off',
// TODO (wvffle): Remove after API Client
'@typescript-eslint/no-explicit-any': 'off'
'@typescript-eslint/no-explicit-any': 'off',
// Configure TypeScript style
'comma-dangle': ['error', 'never']
},
overrides: [
{
......
nodeLinker: node-modules
FROM --platform=$BUILDPLATFORM node:18-alpine AS builder
FROM --platform=$BUILDPLATFORM node:22-alpine AS builder
RUN apk add --no-cache jq bash coreutils python3 build-base
......
FROM node:18-alpine
FROM node:22-alpine
# needed to compile translations
RUN apk add --no-cache jq bash coreutils python3
RUN apk add --no-cache jq bash coreutils git python3
EXPOSE 8080
WORKDIR /app/
COPY scripts/ ./scripts/
# Create node_modules directory to prevent it from being hidden by volume mounts
RUN mkdir -p node_modules
ADD scripts/ ./scripts/
ADD package.json yarn.lock ./
RUN yarn install
RUN yarn
COPY . .
VOLUME /app
VOLUME /app/node_modules
EXPOSE 8080
CMD ["yarn", "serve"]
CMD ["yarn", "dev", "--host"]
# Funkwhale Frontend
Please follow the instructions in [Set up your development environment — funkwhale 1.4.0 documentation](https://docs.funkwhale.audio/developer/setup/index.html).
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="generator" content="Funkwhale">
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta name="generator" content="Funkwhale" />
<title>Funkwhale</title>
<meta name="description" content="Your free and federated audio platform">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png?v=1">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png?v=1">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png?v=1">
<link rel="mask-icon" href="/safari-pinned-tab.svg?v=1" color="#009fe3">
<link rel="shortcut icon" href="/favicon.ico?v=1">
<meta name="apple-mobile-web-app-title" content="Funkwhale">
<meta name="application-name" content="Funkwhale">
<meta name="msapplication-TileColor" content="#009fe3">
<meta name="theme-color" content="#f2711c">
<meta name="description" content="Your free and federated audio platform" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png?v=1" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png?v=1" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png?v=1" />
<link rel="mask-icon" href="/safari-pinned-tab.svg?v=1" color="#009fe3" />
<link rel="shortcut icon" href="/favicon.ico?v=1" />
<meta name="apple-mobile-web-app-title" content="Funkwhale" />
<meta name="application-name" content="Funkwhale" />
<meta name="msapplication-TileColor" content="#009fe3" />
<meta name="theme-color" content="#f2711c" />
<style>
#fake-app {
width: 100vw;
......@@ -68,7 +67,7 @@
</style>
</head>
<body id="body">
<body id="body" style="margin:0">
<div id="fake-app">
<div id="fake-sidebar">
<div id="orange-square"></div>
......@@ -97,5 +96,4 @@
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
{
"name": "front",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "Funkwhale front-end",
"author": "Funkwhale Collective <contact@funkwhale.audio>",
"scripts": {
"dev": "vite",
"dev:docs": "VP_DOCS=true vitepress dev ui-docs",
"build": "vite build --mode development",
"build:deployment": "vite build",
"build:docs": "VP_DOCS=true vitepress build ui-docs",
"serve:docs": "VP_DOCS=true vitepress serve ui-docs",
"serve": "vite preview",
"test": "vitest run",
"test:unit": "vitest run --coverage",
"test:generate-mock-server": "msw-auto-mock ../docs/schema.yml -o test/msw-server.ts --node",
"lint": "eslint --cache --cache-strategy content --ext .ts,.js,.vue,.json,.html src test cypress public/embed.html",
"lint:tsc": "vue-tsc --noEmit --incremental && tsc --noEmit --incremental -p cypress",
"fix-fomantic-css": "scripts/fix-fomantic-css.sh",
"postinstall": "yarn run fix-fomantic-css"
"lint": "yarn lint:es && yarn lint:tsc",
"lint:es": "eslint --max-warnings 0 --cache --cache-strategy content --ext .ts,.js,.vue,.json,.html,.cjs . cypress public/embed.html src test ui-docs",
"lint:tsc": "vue-tsc --noEmit --incremental && tsc --noEmit --incremental --project tsconfig.json",
"generate-types-from-local-schema": "yarn run openapi-typescript ../api/funkwhale_api/common/schema.yml -o src/generated/types.ts",
"generate-types-from-remote-schema": "yarn run openapi-typescript https://docs.funkwhale.audio/develop/swagger/schema.yml -o src/generated/types.ts",
"fmt:es": "yarn lint:es --fix",
"fmt:html": "node --experimental-strip-types node_modules/prettier/bin/prettier.cjs index.html public/embed.html --write"
},
"dependencies": {
"@funkwhale/ui": "0.2.2",
"@sentry/tracing": "7.47.0",
"@sentry/vue": "7.47.0",
"@tauri-apps/api": "2.0.0-beta.1",
"@types/jsmediatags": "3.9.6",
"@vue/runtime-core": "3.3.11",
"@vueuse/core": "10.3.0",
"@vueuse/integrations": "10.3.0",
"@vueuse/math": "10.3.0",
"@vueuse/router": "10.3.0",
"@vueuse/components": "10.6.1",
"@vueuse/core": "10.6.1",
"@vueuse/integrations": "10.6.1",
"@vueuse/math": "10.6.1",
"@vueuse/router": "10.6.1",
"axios": "1.7.2",
"axios-auth-refresh": "3.3.6",
"butterchurn": "3.0.0-beta.4",
"butterchurn-presets": "3.0.0-beta.4",
"diff": "5.1.0",
"dompurify": "3.0.8",
"dompurify": "3.2.4",
"focus-trap": "7.2.0",
"fomantic-ui-css": "2.9.3",
"idb-keyval": "6.2.1",
"jsmediatags": "3.9.7",
"lodash-es": "4.17.21",
"lru-cache": "10.2.0",
"magic-regexp": "0.8.0",
"moment": "2.29.4",
"music-metadata-browser": "2.5.10",
"nanoid": "5.0.4",
"pinia": "2.1.7",
"showdown": "2.1.0",
"stacktrace-js": "2.0.2",
"standardized-audio-context": "25.3.60",
"string-similarity-js": "2.1.4",
"text-clipper": "2.2.0",
"transliteration": "2.3.5",
"type-fest": "4.30.1",
"universal-cookie": "4.0.4",
"vite-plugin-pwa": "0.14.4",
"vue": "3.3.11",
"vue": "3.5.13",
"vue-dompurify-html": "5.2.0",
"vue-gettext": "2.1.12",
"vue-i18n": "9.9.1",
"vue-router": "4.2.5",
......@@ -61,12 +76,12 @@
},
"devDependencies": {
"@faker-js/faker": "8.4.1",
"@iconify/vue": "4.1.1",
"@intlify/eslint-plugin-vue-i18n": "2.0.0",
"@intlify/unplugin-vue-i18n": "2.0.0",
"@tauri-apps/cli": "^2.0.2",
"@types/diff": "5.0.9",
"@types/dompurify": "3.0.5",
"@types/jquery": "3.5.29",
"@types/lodash-es": "4.17.12",
"@types/moxios": "0.4.17",
"@types/qs": "6.9.10",
......@@ -74,18 +89,20 @@
"@types/showdown": "2.0.6",
"@types/vue-virtual-scroller": "npm:@earltp/vue-virtual-scroller",
"@typescript-eslint/eslint-plugin": "7.1.0",
"@vitejs/plugin-vue": "5.0.3",
"@vitejs/plugin-vue": "5.1.4",
"@vitest/coverage-v8": "1.3.1",
"@vue-macros/volar": "0.13.3",
"@vue-macros/common": "1.15.1",
"@vue-macros/volar": "0.17.2",
"@vue/compiler-sfc": "3.3.11",
"@vue/eslint-config-standard": "8.0.1",
"@vue/eslint-config-typescript": "12.0.0",
"@vue/test-utils": "2.2.7",
"@vue/tsconfig": "0.5.1",
"@vue/test-utils": "2.4.1",
"@vue/tsconfig": "0.6.0",
"autoprefixer": "10.4.21",
"cypress": "13.6.4",
"eslint": "8.57.0",
"eslint-config-standard": "17.1.0",
"eslint-plugin-html": "8.0.0",
"eslint-plugin-html": "8.1.2",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-n": "16.6.2",
"eslint-plugin-node": "11.1.0",
......@@ -95,20 +112,27 @@
"jsonc-eslint-parser": "2.4.0",
"msw": "2.2.1",
"msw-auto-mock": "0.18.0",
"openapi-typescript": "7.6.0",
"patch-package": "8.0.0",
"postcss": "8.5.6",
"rollup-plugin-visualizer": "5.9.0",
"sass": "1.57.1",
"sass": "1.68.0",
"sinon": "15.0.2",
"standardized-audio-context-mock": "9.6.32",
"typescript": "5.3.3",
"unplugin-vue-macros": "2.4.6",
"unplugin-vue-macros": "2.14.5",
"utility-types": "3.10.0",
"vite": "5.2.12",
"vite-plugin-node-polyfills": "0.17.0",
"vite-plugin-pwa": "0.14.4",
"vite-plugin-vue-devtools": "^7.5.2",
"vitepress": "1.5.0",
"vitest": "1.3.1",
"vue-tsc": "1.8.27",
"vue-tsc": "3.0.5",
"workbox-core": "6.5.4",
"workbox-precaching": "6.5.4",
"workbox-routing": "6.5.4",
"workbox-strategies": "6.5.4"
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
export default {
plugins: process.env.NODE_ENV === 'development' ? {
// Skip autoprefixer in development - modern dev browsers don't need prefixes
} : {
autoprefixer: {
overrideBrowserslist: [
'> 1%',
'last 2 versions',
'not dead',
'not ie 11',
'not op_mini all',
'chrome >= 87',
'firefox >= 78',
'safari >= 14',
'edge >= 88',
'ios >= 14'
]
}
}
}
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="generator" content="Funkwhale">
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta name="generator" content="Funkwhale" />
<link rel="icon" href="/favicon.ico">
<link rel="icon" href="/favicon.ico" />
<title>Funkwhale Widget</title>
<link rel="stylesheet" href="/embed.css">
<link rel="stylesheet" href="/embed.css" />
<script type="module">
import { createApp, reactive, nextTick } from 'https://unpkg.com/petite-vue@0.4.1?module'
......@@ -25,7 +24,7 @@
const id = params.get('id')
// Error
let error = reactive({ value: false })
const error = reactive({ value: false })
if (!SUPPORTED_TYPES.includes(type)) {
error.value = `The embed widget doesn't support this media type: ${type}.`
}
......@@ -38,7 +37,7 @@
try {
baseUrl = new URL(baseUrl).origin
} catch (err) {
console.error(err)
// console.error(err)
error.value = `The embed widget couldn't read the provided instance URL: ${baseUrl}.`
}
......@@ -147,7 +146,7 @@
// NOTE: If we already have some tracks, let's fail silently
if (tracks.length > 0) {
console.error(error.value)
// console.error(error.value)
error.value = false
}
......@@ -181,7 +180,7 @@
// NOTE: Fetch tracks only if there is no error
if (error.value === false) {
fetchTracks().catch(err => {
console.error(err)
// console.error(err)
error.value = `An unknown error occurred while loading this ${type}.`
})
}
......@@ -388,29 +387,43 @@
</head>
<template id="logo-template">
<a
title="Funkwhale"
href="https://funkwhale.audio"
target="_blank"
rel="noopener noreferrer"
class="logo-link"
tabindex="-1"
>
<a title="Funkwhale" href="https://funkwhale.audio" target="_blank" rel="noopener noreferrer" class="logo-link" tabindex="-1">
<img src="/logo-white.svg" />
</a>
</template>
<template id="icon-template">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="icon" fill="currentColor" viewBox="0 0 16 16">
<path v-if="icon === 'pause'" d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z" />
<path v-else-if="icon === 'play'" d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z" />
<path v-else-if="icon === 'prev'" d="M4 4a.5.5 0 0 1 1 0v3.248l6.267-3.636c.54-.313 1.232.066 1.232.696v7.384c0 .63-.692 1.01-1.232.697L5 8.753V12a.5.5 0 0 1-1 0V4z" />
<path v-else-if="icon === 'next'" d="M12.5 4a.5.5 0 0 0-1 0v3.248L5.233 3.612C4.693 3.3 4 3.678 4 4.308v7.384c0 .63.692 1.01 1.233.697L11.5 8.753V12a.5.5 0 0 0 1 0V4z" />
<path v-else-if="icon === 'mute'" d="M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06zm7.137 2.096a.5.5 0 0 1 0 .708L12.207 8l1.647 1.646a.5.5 0 0 1-.708.708L11.5 8.707l-1.646 1.647a.5.5 0 0 1-.708-.708L10.793 8 9.146 6.354a.5.5 0 1 1 .708-.708L11.5 7.293l1.646-1.647a.5.5 0 0 1 .708 0z" />
<path
v-if="icon === 'pause'"
d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"
/>
<path
v-else-if="icon === 'play'"
d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z"
/>
<path
v-else-if="icon === 'prev'"
d="M4 4a.5.5 0 0 1 1 0v3.248l6.267-3.636c.54-.313 1.232.066 1.232.696v7.384c0 .63-.692 1.01-1.232.697L5 8.753V12a.5.5 0 0 1-1 0V4z"
/>
<path
v-else-if="icon === 'next'"
d="M12.5 4a.5.5 0 0 0-1 0v3.248L5.233 3.612C4.693 3.3 4 3.678 4 4.308v7.384c0 .63.692 1.01 1.233.697L11.5 8.753V12a.5.5 0 0 0 1 0V4z"
/>
<path
v-else-if="icon === 'mute'"
d="M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06zm7.137 2.096a.5.5 0 0 1 0 .708L12.207 8l1.647 1.646a.5.5 0 0 1-.708.708L11.5 8.707l-1.646 1.647a.5.5 0 0 1-.708-.708L10.793 8 9.146 6.354a.5.5 0 1 1 .708-.708L11.5 7.293l1.646-1.647a.5.5 0 0 1 .708 0z"
/>
<g v-else-if="icon === 'volume'">
<path d="M11.536 14.01A8.473 8.473 0 0 0 14.026 8a8.473 8.473 0 0 0-2.49-6.01l-.708.707A7.476 7.476 0 0 1 13.025 8c0 2.071-.84 3.946-2.197 5.303l.708.707z" />
<path d="M10.121 12.596A6.48 6.48 0 0 0 12.025 8a6.48 6.48 0 0 0-1.904-4.596l-.707.707A5.483 5.483 0 0 1 11.025 8a5.483 5.483 0 0 1-1.61 3.89l.706.706z" />
<path d="M8.707 11.182A4.486 4.486 0 0 0 10.025 8a4.486 4.486 0 0 0-1.318-3.182L8 5.525A3.489 3.489 0 0 1 9.025 8 3.49 3.49 0 0 1 8 10.475l.707.707zM6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06z" />
<path
d="M11.536 14.01A8.473 8.473 0 0 0 14.026 8a8.473 8.473 0 0 0-2.49-6.01l-.708.707A7.476 7.476 0 0 1 13.025 8c0 2.071-.84 3.946-2.197 5.303l.708.707z"
/>
<path
d="M10.121 12.596A6.48 6.48 0 0 0 12.025 8a6.48 6.48 0 0 0-1.904-4.596l-.707.707A5.483 5.483 0 0 1 11.025 8a5.483 5.483 0 0 1-1.61 3.89l.706.706z"
/>
<path
d="M8.707 11.182A4.486 4.486 0 0 0 10.025 8a4.486 4.486 0 0 0-1.318-3.182L8 5.525A3.489 3.489 0 0 1 9.025 8 3.49 3.49 0 0 1 8 10.475l.707.707zM6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06z"
/>
</g>
</svg>
</template>
......@@ -447,25 +460,14 @@
<span v-scope="Icon({ icon: 'next' })"></span>
</button>
<input
v-model.number="player.seek"
v-range="player.seek"
@input="player.seekTime"
type="range"
step="0.1"
/>
<input v-model.number="player.seek" v-range="player.seek" @input="player.seekTime" type="range" step="0.1" />
<button @click="volume.mute">
<span v-if="volume.level === 0" v-scope="Icon({ icon: 'mute' })"></span>
<span v-else v-scope="Icon({ icon: 'volume' })"></span>
</button>
<input
v-model.number="volume.level"
v-range="volume.level"
type="range"
step="0.1"
/>
<input v-model.number="volume.level" v-range="volume.level" type="range" step="0.1" />
</div>
<span v-scope="Logo()" class="logo-wrapper"></span>
......@@ -483,21 +485,11 @@
@keyup.enter="player.play(index)"
tabindex="0"
>
<td>
{{ index + 1 }}
</td>
<td :title="track.title">
{{ track.title }}
</td>
<td :title="track.artist.name">
{{ track.artist.name }}
</td>
<td :title="track.album?.title">
{{ track.album?.title }}
</td>
<td>
{{ formatDuration(track.sources?.[0].duration ?? 0) }}
</td>
<td> {{ index + 1 }} </td>
<td :title="track.title"> {{ track.title }} </td>
<td :title="track.artist.name"> {{ track.artist.name }} </td>
<td :title="track.album?.title"> {{ track.album?.title }} </td>
<td> {{ formatDuration(track.sources?.[0].duration ?? 0) }} </td>
</tr>
</table>
</div>
......@@ -508,10 +500,9 @@
:key="source.mimetype + source.listen_url"
:type="source.mimetype"
:src="source.listen_url"
>
/>
</audio>
</template>
</main>
</body>
</html>
#!/usr/bin/env python3
"""
This scripts handles all the heavy-lifting of parsing CSS files from ``fomantic-ui-css`` and:
1. Replace hardcoded values by their CSS vars counterparts, for easier theming
2. Strip unused styles and icons to reduce the final size of CSS
Updated files are not modified in place, but instead copied to another directory (``fomantic-ui-css/tweaked``), in order
to allow easy comparison detection of changes.
If you change this file, you'll need to run ``yarn run fix-fomantic-css`` manually for the changes
to be picked up. If the ``NOSTRIP`` environment variable is set, the second step will be skipped.
"""
import argparse
import os
STRIP_UNUSED = "NOSTRIP" not in os.environ
# Perform a blind replacement of some strings in all fomantic CSS files
GLOBAL_REPLACES = [
# some selectors are repeated in the stylesheet, for some reason
(".ui.ui.ui.ui", ".ui"),
(".ui.ui.ui", ".ui"),
(".ui.ui", ".ui"),
(".icon.icon.icon.icon", ".icon"),
(".icon.icon.icon", ".icon"),
(".icon.icon", ".icon"),
# actually useful stuff
("'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif", "var(--font-family)"),
(".orange", ".vibrant"),
("#F2711C", "var(--vibrant-color)"),
("#FF851B", "var(--vibrant-color)"),
("#f26202", "var(--vibrant-hover-color)"),
("#e76b00", "var(--vibrant-hover-color)"),
("#cf590c", "var(--vibrant-active-color)"),
("#f56100", "var(--vibrant-active-color)"),
("#e76b00", "var(--vibrant-active-color)"),
("#e55b00", "var(--vibrant-focus-color)"),
("#f17000", "var(--vibrant-focus-color)"),
(".teal", ".accent"),
("#00B5AD", "var(--accent-color)"),
("#009c95", "var(--accent-hover-color)"),
("#00827c", "var(--accent-active-color)"),
("#008c86", "var(--accent-focus-color)"),
(".green", ".success"),
("#21BA45", "var(--success-color)"),
("#2ECC40", "var(--success-color)"),
("#16ab39", "var(--success-hover-color)"),
("#1ea92e", "var(--success-hover-color)"),
("#198f35", "var(--success-active-color)"),
("#25a233", "var(--success-active-color)"),
("#0ea432", "var(--success-focus-color)"),
("#19b82b", "var(--success-focus-color)"),
(".blue", ".primary"),
("#2185D0", "var(--primary-color)"),
("#54C8FF", "var(--primary-color)"),
("#54C8FF", "var(--primary-color)"),
("#1678c2", "var(--primary-hover-color)"),
("#21b8ff", "var(--primary-hover-color)"),
("#1a69a4", "var(--primary-active-color)"),
("#0d71bb", "var(--primary-focus-color)"),
("#2bbbff", "var(--primary-focus-color)"),
(".yellow", ".warning"),
("#FBBD08", "var(--warning-color)"),
("#FFE21F", "var(--warning-color)"),
("#eaae00", "var(--warning-hover-color)"),
("#ebcd00", "var(--warning-hover-color)"),
("#cd9903", "var(--warning-active-color)"),
("#ebcd00", "var(--warning-active-color)"),
("#daa300", "var(--warning-focus-color)"),
("#f5d500", "var(--warning-focus-color)"),
(".red.", ".danger."),
("#DB2828", "var(--danger-color)"),
("#FF695E", "var(--danger-color)"),
("#d01919", "var(--danger-hover-color)"),
("#ff392b", "var(--danger-hover-color)"),
("#b21e1e", "var(--danger-active-color)"),
("#ca1010", "var(--danger-focus-color)"),
("#ff4335", "var(--danger-focus-color)"),
]
def discard_unused_icons(rule):
"""
Add an icon to this list if you want to use it in the app.
"""
used_icons = [
".angle",
".arrow",
".at",
".ban",
".bell",
".book",
".bookmark",
".check",
".clock",
".close",
".cloud",
".code",
".comment",
".copy",
".copyright",
".danger",
".database",
".delete",
".disc",
".down angle",
".download",
".dropdown",
".edit",
".ellipsis",
".eraser",
".external",
".eye",
".feed",
".file",
".folder",
".forward",
".globe",
".hashtag",
".headphones",
".heart",
".home",
".hourglass",
".info",
".layer",
".lines",
".link",
".list",
".loading",
".lock",
".minus",
".mobile",
".music",
".paper",
".pause",
".pencil",
".play",
".plus",
".podcast",
".question",
".question ",
".random",
".redo",
".refresh",
".repeat",
".rss",
".search",
".server",
".share",
".shield",
".sidebar",
".sign",
".spinner",
".step",
".stream",
".track",
".trash",
".undo",
".upload",
".user",
".users",
".volume",
".wikipedia",
".wrench",
".x",
".key",
".cog",
".life.ring",
".language",
".palette",
".sun",
".moon",
".gitlab",
".chevron",
".right",
".left",
".compress",
".expand",
".image",
]
if ":before" not in rule["lines"][0]:
return False
return not match(rule, used_icons)
"""
Below is the main configuration object that is used for fine-grained replacement of properties
in component files. It also handles removal of unused selectors.
Example config for a component:
REPLACEMENTS = {
# applies to fomantic-ui-css/components/component-name.css
"component-name": {
# Discard any CSS rule matching one of the selectors listed below
# matching is done using a simple string search, so ``.pink`` will remove
# rules applied to ``.pink``, ``.pink.button`` and `.pinkdark`
"skip": [
".unused.variation",
".pink",
],
# replace some CSS properties values in specific selectors
(".inverted", ".dark"): [
("background", "var(--inverted-background)"),
("color", "var(--inverted-color)"),
],
(".active"): [
("font-size", "var(--active-font-size)"),
],
}
}
Given the previous config, the following style sheet:
.. code-block:: css
.unused.variation {
color: yellow;
}
.primary {
color: white;
}
.primary.pink {
color: pink;
}
.inverted.primary {
background: black;
color: white;
border-top: 1px solid red;
}
.inverted.primary.active {
font-size: 12px;
}
Would be converted to:
.. code-block:: css
.primary {
color: white;
}
.inverted.primary {
background: var(--inverted-background);
color: var(--inverted-color);
border-top: 1px solid red;
}
.inverted.primary.active {
font-size: var(--active-font-size);
}
"""
REPLACEMENTS = {
"site": {
("a",): [
("color", "var(--link-color)"),
("text-decoration", "var(--link-text-decoration)"),
],
("a:hover",): [
("color", "var(--link-hover-color)"),
("text-decoration", "var(--link-hover-text-decoration)"),
],
("body",): [
("background", "var(--site-background)"),
("color", "var(--text-color)"),
],
(
"::-webkit-selection",
"::-moz-selection",
"::selection",
): [
("color", "var(--text-selection-color)"),
("background-color", "var(--text-selection-background)"),
],
(
"textarea::-webkit-selection",
"input::-webkit-selection",
"textarea::-moz-selection",
"input::-moz-selection",
"textarea::selection",
"input::selection",
): [
("color", "var(--input-selection-color)"),
("background-color", "var(--input-selection-background)"),
],
},
"button": {
"skip": [
".vertical",
".animated",
".active",
".olive",
".brown",
".teal",
".violet",
".purple",
".brown",
".grey",
".black",
".positive",
".negative",
".secondary",
".tertiary",
".facebook",
".twitter",
".google.plus",
".vk",
".linkedin",
".instagram",
".youtube",
".whatsapp",
".telegram",
],
(".ui.orange.button", ".ui.orange.button:hover"): [
("background-color", "var(--button-orange-background)")
],
(".ui.basic.button",): [
("background", "var(--button-basic-background)"),
("color", "var(--button-basic-color)"),
("box-shadow", "var(--button-basic-box-shadow)"),
],
(".ui.basic.button:hover",): [
("background", "var(--button-basic-hover-background)"),
("color", "var(--button-basic-hover-color)"),
("box-shadow", "var(--button-basic-hover-box-shadow)"),
],
(".ui.basic.button:focus",): [
("background", "var(--button-basic-hover-background)"),
("color", "var(--button-basic-hover-color)"),
("box-shadow", "var(--button-basic-hover-box-shadow)"),
],
},
"card": {
"skip": [
".inverted",
".olive",
".brown",
".teal",
".violet",
".purple",
".brown",
".grey",
".pink",
".black",
".vibrant",
".success",
".warning",
".danger",
".primary",
".secondary",
".horizontal",
".raised",
]
},
"checkbox": {
(
".ui.toggle.checkbox label",
".ui.toggle.checkbox input:checked ~ label",
'.ui.checkbox input[type="checkbox"]',
".ui.checkbox input:focus ~ label",
".ui.toggle.checkbox input:focus:checked ~ label",
".ui.checkbox input:active ~ label",
): [
("color", "var(--form-label-color)"),
],
(".ui.toggle.checkbox label:before",): [
("background", "var(--input-background)"),
],
},
"divider": {
(".ui.divider:not(.vertical):not(.horizontal)",): [
("border-top", "var(--divider)"),
("border-bottom", "var(--divider)"),
],
(".ui.divider",): [
("color", "var(--text-color)"),
],
},
"dimmer": {
(".ui.inverted.dimmer",): [
("background-color", "var(--dimmer-background)"),
("color", "var(--dropdown-color)"),
],
},
"dropdown": {
"skip": [
".error",
".info",
".success",
".warning",
],
(
".ui.selection.dropdown",
".ui.selection.visible.dropdown > .text:not(.default)",
".ui.dropdown .menu",
): [
("background", "var(--dropdown-background)"),
("color", "var(--dropdown-color)"),
],
(".ui.dropdown .menu > .item",): [
("color", "var(--dropdown-item-color)"),
],
(".ui.dropdown .menu > .item:hover",): [
("color", "var(--dropdown-item-hover-color)"),
("background", "var(--dropdown-item-hover-background)"),
],
(".ui.dropdown .menu .selected.item",): [
("color", "var(--dropdown-item-selected-color)"),
("background", "var(--dropdown-item-selected-background)"),
],
(".ui.dropdown .menu > .header:not(.ui)",): [
("color", "var(--dropdown-header-color)"),
],
(".ui.dropdown .menu > .divider",): [
("border-top", "var(--divider)"),
],
},
"form": {
"skip": [
".inverted",
".success",
".warning",
".error",
".info",
],
('.ui.form input[type="text"]', ".ui.form select", ".ui.input textarea"): [
("background", "var(--input-background)"),
("color", "var(--input-color)"),
],
(
'.ui.form input[type="text"]:focus',
".ui.form select:focus",
".ui.form textarea:focus",
): [
("background", "var(--input-focus-background)"),
("color", "var(--input-focus-color)"),
],
(
".ui.form ::-webkit-input-placeholder",
".ui.form :-ms-input-placeholder",
".ui.form ::-moz-placeholder",
): [
("color", "var(--input-placeholder-color)"),
],
(
".ui.form :focus::-webkit-input-placeholder",
".ui.form :focus:-ms-input-placeholder",
".ui.form :focus::-moz-placeholder",
): [
("color", "var(--input-focus-placeholder-color)"),
],
(
".ui.form .field > label",
".ui.form .inline.fields .field > label",
): [
("color", "var(--form-label-color)"),
],
},
"grid": {
"skip": [
"wide tablet",
"screen",
"mobile only",
"tablet only",
"computer only",
"computer reversed",
"tablet reversed",
"wide computer",
"wide mobile",
"wide tablet",
"vertically",
".celled",
".doubling",
".olive",
".brown",
".teal",
".violet",
".purple",
".brown",
".grey",
".black",
".positive",
".negative",
".secondary",
".tertiary",
".danger",
".vibrant",
".warning",
".primary",
".success",
".justified",
".centered",
]
},
"icon": {"skip": discard_unused_icons},
"input": {
(".ui.input > input",): [
("background", "var(--input-background)"),
("color", "var(--input-color)"),
],
(".ui.input > input:focus",): [
("background", "var(--input-focus-background)"),
("color", "var(--input-focus-color)"),
],
(
".ui.input > input::-webkit-input-placeholder",
".ui.input > input::-moz-placeholder",
".ui.input > input:-ms-input-placeholder",
): [
("color", "var(--input-placeholder-color)"),
],
(
".ui.input > input:focus::-webkit-input-placeholder",
".ui.input > input:focus::-moz-placeholder",
".ui.input > input:focus:-ms-input-placeholder",
): [
("color", "var(--input-focus-placeholder-color)"),
],
},
"item": {
(".ui.divided.items > .item",): [
("border-top", "var(--divider)"),
],
(".ui.items > .item > .content",): [
("color", "var(--text-color)"),
],
(".ui.items > .item .extra",): [
("color", "var(--really-discrete-text-color)"),
],
},
"header": {
"skip": [
".inverted",
".block",
".olive",
".brown",
".teal",
".violet",
".purple",
".brown",
".grey",
".black",
".pink",
],
(".ui.header",): [
("color", "var(--header-color)"),
],
(".ui.header .sub.header",): [
("color", "var(--header-color)"),
],
},
"label": {
"skip": [
".olive",
".brown",
".teal",
".violet",
".purple",
".brown",
".grey",
".black",
".positive",
".negative",
".secondary",
".tertiary",
".facebook",
".twitter",
".google.plus",
".vk",
".linkedin",
".instagram",
".youtube",
".whatsapp",
".telegram",
".corner",
"ribbon",
"pointing",
"attached",
],
},
"list": {
"skip": [
".mini",
".tiny",
".small",
".large",
".big",
".huge",
".massive",
".celled",
".horizontal",
".bulleted",
".ordered",
".suffixed",
".inverted",
".fitted",
"aligned",
],
(".ui.list .list > .item a.header", ".ui.list .list > a.item"): [
("color", "var(--link-color)"),
("text-decoration", "var(--link-text-decoration)"),
],
("a:hover", ".ui.list .list > a.item:hover"): [
("color", "var(--link-hover-color)"),
("text-decoration", "var(--link-hover-text-decoration)"),
],
},
"loader": {
"skip": [
".olive",
".brown",
".teal",
".violet",
".purple",
".brown",
".grey",
".black",
".pink",
".primary",
".vibrant",
".warning",
".success",
".danger",
".elastic",
],
(".ui.inverted.dimmer > .ui.loader",): [
("color", "var(--dimmer-color)"),
],
},
"message": {
"skip": [
".olive",
".brown",
".teal",
".violet",
".purple",
".brown",
".grey",
".black",
".pink",
".vibrant",
".primary",
".secondary",
".floating",
],
},
"menu": {
"skip": [
".inverted.pointing",
".olive",
".brown",
".teal",
".violet",
".purple",
".brown",
".grey",
".black",
".vertical.tabular",
".primary.menu",
".pink.menu",
".vibrant.menu",
".warning.menu",
".success.menu",
".danger.menu",
".fitted",
"fixed",
],
(".ui.menu .item",): [
("color", "var(--menu-item-color)"),
],
(".ui.vertical.inverted.menu .menu .item", ".ui.inverted.menu .item"): [
("color", "var(--inverted-menu-item-color)"),
],
(".inverted-ui.menu .active.item",): [
("color", "var(--menu-inverted-active-item-color)"),
],
(".ui.secondary.pointing.menu .active.item",): [
("color", "var(--secondary-menu-active-item-color)"),
],
(
".ui.secondary.pointing.menu a.item:hover",
".ui.secondary.pointing.menu .active.item:hover",
): [
("color", "var(--secondary-menu-hover-item-color)"),
],
(".ui.menu .ui.dropdown .menu > .item",): [
("color", "var(--dropdown-item-color) !important"),
],
(".ui.menu .ui.dropdown .menu > .item:hover",): [
("color", "var(--dropdown-item-hover-color) !important"),
("background", "var(--dropdown-item-hover-background) !important"),
],
(".ui.menu .dropdown.item .menu",): [
("color", "var(--dropdown--color)"),
("background", "var(--dropdown-background)"),
],
(".ui.menu .ui.dropdown .menu > .active.item",): [
("color", "var(--dropdown-item-selected-color)"),
("background", "var(--dropdown-item-selected-background) !important"),
],
},
"modal": {
(".ui.modal", ".ui.modal > .actions", ".ui.modal > .content"): [
("background", "var(--modal-background)"),
("border-bottom", "var(--divider)"),
("border-top", "var(--divider)"),
],
(".ui.modal > .close.inside",): [
("color", "var(--text-color)"),
],
(".ui.modal > .header",): [
("color", "var(--header-color)"),
("background", "var(--modal-background)"),
("border-bottom", "var(--divider)"),
("border-top", "var(--divider)"),
],
},
"search": {
(
".ui.search > .results",
".ui.search > .results .result",
".ui.category.search > .results .category .results",
".ui.category.search > .results .category",
".ui.category.search > .results .category > .name",
".ui.search > .results > .message .header",
".ui.search > .results > .message .description",
): [
("background", "var(--dropdown-background)"),
("color", "var(--dropdown-item-color)"),
],
(
".ui.search > .results .result .title",
".ui.search > .results .result .description",
): [
("color", "var(--dropdown-item-color)"),
],
(".ui.search > .results .result:hover",): [
("color", "var(--dropdown-item-hover-color)"),
("background", "var(--dropdown-item-hover-background)"),
],
},
"segment": {
"skip": [
".stacked",
".horizontal.segment",
".inverted.segment",
".circular",
".piled",
],
},
"sidebar": {
(".ui.left.visible.sidebar",): [
("box-shadow", "var(--sidebar-box-shadow)"),
]
},
"statistic": {
(".ui.statistic > .value", ".ui.statistic > .label"): [
("color", "var(--text-color)"),
],
},
"progress": {
(".ui.progress.success > .label",): [
("color", "var(--text-color)"),
],
},
"table": {
"skip": [
".marked",
".olive",
".brown",
".teal",
".violet",
".purple",
".brown",
".grey",
".black",
".padded",
".column.table",
".inverted",
".definition",
".error",
".negative",
".structured",
"tablet stackable",
],
(
".ui.table",
".ui.table > thead > tr > th",
): [
("color", "var(--text-color)"),
("background", "var(--table-background)"),
],
(".ui.table > tr > td", ".ui.table > tbody + tbody tr:first-child > td"): [
("border-top", "var(--table-border)"),
],
},
}
def match(rule, skip):
if hasattr(skip, "__call__"):
return skip(rule)
for s in skip:
for rs in rule["selectors"]:
if s in rs:
return True
return False
def rules_from_media_query(rule):
internal = rule["lines"][1:-1]
return parse_rules("\n".join(internal))
def wraps(rule, internal_rules):
return {
"lines": [rule["lines"][0]]
+ [line for r in internal_rules for line in r["lines"]]
+ ["}"]
}
def set_vars(component_name, rules):
"""
Given rules parsed via ``parse_rules``, replace properties values when needed
using ``REPLACEMENTS`` and ``GLOBAL_REPLACES``.
Also remove unused styles if STRIP_UNUSED is set to True.
"""
final_rules = []
try:
conf = REPLACEMENTS[component_name]
except KeyError:
return rules
selectors = list(conf.keys()) + list()
skip = None
if STRIP_UNUSED:
skip = conf.get("skip", [])
try:
skip = set(skip)
except TypeError:
pass
for rule in rules:
if rule["lines"][0].startswith("@media"):
# manual handling of media queries, because our parser is really
# simplistic
internal_rules = rules_from_media_query(rule)
internal_rules = set_vars(component_name, internal_rules)
rule = wraps(rule, internal_rules)
if len(rule["lines"]) > 2:
final_rules.append(rule)
continue
if skip and match(rule, skip):
# discard rule entirely
continue
matching = []
for s in selectors:
if set(s) & set(rule["selectors"]):
matching.append(s)
if not matching:
# no replacements to apply, keep rule as is
final_rules.append(rule)
continue
new_rule = {"lines": []}
for m in matching:
# the block match one of our replacement rules, so we loop on each line
# and replace values if needed.
replacements = conf[m]
for line in rule["lines"]:
for property, new_value in replacements:
if line.strip().startswith(f"{property}:"):
new_property = f"{property}: {new_value};"
indentation = " " * (len(line) - len(line.lstrip(" ")))
line = indentation + new_property
break
new_rule["lines"].append(line)
final_rules.append(new_rule)
return final_rules
def parse_rules(text):
"""
Really basic CSS parsers that stores selectors and corresponding properties. Only works
because the source files have coma-separated selectors (one per line), and one
property/value per line.
Returns a list of dictionaries, each dictionarry containing the selectors and
lines of of each block.
"""
rules = []
current_rule = None
opened_brackets = 0
current_selector = []
for line in text.splitlines():
if not current_rule and line.endswith(","):
current_selector.append(line.rstrip(",").strip())
elif line.endswith(" {"):
# for media queries
opened_brackets += 1
if not current_rule:
current_selector.append(line.rstrip("{").strip())
current_rule = {
"lines": [",\n".join(current_selector) + " {"],
"selectors": current_selector,
}
else:
current_rule["lines"].append(line)
elif current_rule:
current_rule["lines"].append(line)
if line.strip() == "}":
opened_brackets -= 1
if not opened_brackets:
# move on to next rule
rules.append(current_rule)
current_rule = None
current_selector = []
return rules
def serialize_rules(rules):
"""
Convert rules back to valid CSS.
"""
lines = []
for rule in rules:
for line in rule["lines"]:
lines.append(line)
return "\n".join(lines)
def iter_components(dir):
for dname, dirs, files in os.walk(dir):
for fname in files:
if fname.endswith(".min.css"):
continue
if fname.endswith(".js"):
continue
if "semantic" in fname:
continue
if fname.endswith(".css"):
yield os.path.join(dname, fname)
def replace_vars(source, dest):
components = list(sorted(iter_components(os.path.join(source, "components"))))
for c in components:
with open(c) as f:
text = f.read()
for s, r in GLOBAL_REPLACES:
text = text.replace(s, r)
text = text.replace(s.lower(), r)
text = text.replace(s.upper(), r)
rules = parse_rules(text)
name = c.split("/")[-1].split(".")[0]
updated_rules = set_vars(name, rules)
text = serialize_rules(updated_rules)
with open(os.path.join(dest, f"{name}.css"), "w") as f:
f.write(text)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Replace hardcoded values by CSS vars and strip unused rules"
)
parser.add_argument(
"source", help="Source path of the fomantic-ui-less distribution to fix"
)
parser.add_argument(
"dest", help="Destination directory where fixed files should be written"
)
args = parser.parse_args()
replace_vars(source=args.source, dest=args.dest)
#!/usr/bin/env bash
set -eux
cd "$(dirname "$0")/.." # change into base directory
FOMANTIC_SRC_PATH="node_modules/fomantic-ui-css"
find "$FOMANTIC_SRC_PATH/components" -name "*.min.css" -delete
mkdir -p "$FOMANTIC_SRC_PATH/tweaked"
echo 'Removing google font…'
sed -i '/@import url(/d' "$FOMANTIC_SRC_PATH/components/site.css"
echo "Replacing hardcoded values by CSS vars…"
scripts/fix-fomantic-css.py "$FOMANTIC_SRC_PATH" "$FOMANTIC_SRC_PATH/tweaked"
echo 'Fixing jQuery import…'
# shellcheck disable=SC2046
sed -i '1s/^import jQuery from "jquery"//' $(find "$FOMANTIC_SRC_PATH" -name '*.js')
# shellcheck disable=SC2046
sed -i '1s/^/import jQuery from "jquery"\n/' $(find "$FOMANTIC_SRC_PATH" -name '*.js')
<script setup lang="ts">
import type { QueueTrack } from '~/composables/audio/queue'
import { watchEffect, computed, onMounted, nextTick } from 'vue'
import { useIntervalFn, useStyleTag, useToggle, useWindowSize } from '@vueuse/core'
import { computed, nextTick, onMounted, watchEffect, defineAsyncComponent } from 'vue'
import { useQueue } from '~/composables/audio/queue'
import { type QueueTrack, useQueue } from '~/composables/audio/queue'
import { useStore } from '~/store'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
import useLogger from '~/composables/useLogger'
import { useStyleTag, useIntervalFn } from '@vueuse/core'
import { color } from '~/composables/color'
import { generateTrackCreditStringFromQueue } from '~/utils/utils'
const ChannelUploadModal = defineAsyncComponent(() => import('~/components/channels/UploadModal.vue'))
const PlaylistModal = defineAsyncComponent(() => import('~/components/playlists/PlaylistModal.vue'))
const FilterModal = defineAsyncComponent(() => import('~/components/moderation/FilterModal.vue'))
const ReportModal = defineAsyncComponent(() => import('~/components/moderation/ReportModal.vue'))
const ServiceMessages = defineAsyncComponent(() => import('~/components/ServiceMessages.vue'))
const ShortcutsModal = defineAsyncComponent(() => import('~/components/ShortcutsModal.vue'))
const AudioPlayer = defineAsyncComponent(() => import('~/components/audio/Player.vue'))
const Sidebar = defineAsyncComponent(() => import('~/components/Sidebar.vue'))
const Queue = defineAsyncComponent(() => import('~/components/Queue.vue'))
import PlaylistModal from '~/components/playlists/PlaylistModal.vue'
import FilterModal from '~/components/moderation/FilterModal.vue'
import ReportModal from '~/components/moderation/ReportModal.vue'
import ServiceMessages from '~/components/ServiceMessages.vue'
import AudioPlayer from '~/components/audio/Player.vue'
import Queue from '~/components/Queue.vue'
import Sidebar from '~/ui/components/Sidebar.vue'
import ShortcutsModal from '~/ui/modals/Shortcuts.vue'
import LanguagesModal from '~/ui/modals/Language.vue'
import SearchModal from '~/ui/modals/Search.vue'
import UploadModal from '~/ui/modals/Upload.vue'
import Loader from '~/components/ui/Loader.vue'
// Fake content
onMounted(async () => {
await nextTick()
document.getElementById('fake-app')?.remove()
})
const logger = useLogger()
logger.debug('App setup()')
......@@ -28,7 +34,7 @@ logger.debug('App setup()')
const store = useStore()
// Tracks
const { currentTrack, tracks } = useQueue()
const { currentTrack } = useQueue()
const getTrackInformationText = (track: QueueTrack | undefined) => {
if (!track) {
return null
......@@ -50,10 +56,6 @@ watchEffect(() => {
})
// Styles
const customStylesheets = computed(() => {
return store.state.instance.frontSettings.additionalStylesheets ?? []
})
useStyleTag(computed(() => store.state.instance.settings.ui.custom_css.value))
// Fake content
......@@ -68,63 +70,74 @@ useIntervalFn(() => {
store.commit('ui/computeLastDate')
}, 1000 * 60)
// Shortcuts
const [showShortcutsModal, toggleShortcutsModal] = useToggle(false)
onKeyboardShortcut('h', () => toggleShortcutsModal())
const { width } = useWindowSize()
// Fetch user data on startup
// NOTE: We're not checking if we're authenticated in the store,
// because we want to learn if we are authenticated at all
store.dispatch('auth/fetchUser')
</script>
<template>
<div
:key="store.state.instance.instanceUrl"
:class="{
'has-bottom-player': tracks.length > 0,
'queue-focused': store.state.ui.queueFocused
}"
<div class="funkwhale responsive">
<Sidebar />
<RouterView
v-slot="{ Component }"
v-bind="color({}, ['default', 'solid'])()"
:class="$style.layout"
>
<!-- here, we display custom stylesheets, if any -->
<link
v-for="url in customStylesheets"
:key="url"
rel="stylesheet"
property="stylesheet"
:href="url"
<Transition
v-if="Component"
name="main"
mode="out-in"
>
<sidebar
:width="width"
@show:shortcuts-modal="toggleShortcutsModal"
/>
<service-messages />
<transition name="queue">
<queue v-show="store.state.ui.queueFocused" />
</transition>
<router-view v-slot="{ Component }">
<template v-if="Component">
<keep-alive :max="1">
<KeepAlive :max="10">
<Suspense>
<component :is="Component" />
<template #fallback>
<!-- TODO (wvffle): Add loader -->
{{ $t('App.loading') }}
<Loader />
</template>
</Suspense>
</keep-alive>
</template>
</router-view>
<audio-player />
<playlist-modal v-if="store.state.auth.authenticated" />
<channel-upload-modal v-if="store.state.auth.authenticated" />
<filter-modal v-if="store.state.auth.authenticated" />
<report-modal />
<shortcuts-modal v-model:show="showShortcutsModal" />
</KeepAlive>
</Transition>
<transition name="queue">
<Queue v-show="store.state.ui.queueFocused" />
</transition>
</RouterView>
</div>
<AudioPlayer
class="funkwhale"
v-bind="color({}, ['default', 'solid'])()"
/>
<ServiceMessages />
<LanguagesModal />
<ShortcutsModal />
<PlaylistModal v-if="store.state.auth.authenticated" />
<FilterModal v-if="store.state.auth.authenticated" />
<ReportModal />
<UploadModal v-if="store.state.auth.authenticated" />
<SearchModal />
</template>
<style scoped lang="scss">
.responsive {
display: grid !important;
grid-template-rows: min-content;
min-height: 100vh;
@media screen and (min-width: 1024px) {
grid-template-columns: 300px 1fr;
grid-template-rows: 100% 0 0;
}
}
</style>
<style>
/* Make inert pages (behind modals) unscrollable */
body:has(#app[inert="true"]) {
overflow:hidden;
}
</style>
<style module>
.layout {
padding: 32px;
}
</style>
......@@ -5,8 +5,16 @@ import { get } from 'lodash-es'
import { humanSize } from '~/utils/filters'
import { computed } from 'vue'
import type { components } from '~/generated/types.ts'
import SignupForm from '~/components/auth/SignupForm.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 nodeinfo = computed(() => store.state.instance.nodeinfo)
......@@ -16,7 +24,8 @@ const labels = computed(() => ({
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 shortDescription = computed(() => get(nodeinfo.value, 'metadata.shortDescription'))
......@@ -28,10 +37,22 @@ const stats = computed(() => {
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 defaultUploadQuota = computed(() => humanSize(get(nodeinfo.value, 'metadata.defaultUploadQuota', 0) * 1000 * 1000))
const headerStyle = computed(() => {
......@@ -43,64 +64,76 @@ const headerStyle = computed(() => {
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>
<template>
<main
<Layout
v-title="labels.title"
class="main pusher page-about"
>
<div class="ui container">
<div class="ui horizontally fitted basic stripe segment">
<div class="ui horizontally fitted basic very padded segment">
<div class="ui center aligned text container">
<div class="ui text container">
<div class="ui equal width compact stackable grid">
<div class="column" />
<div class="ten wide column">
<div class="ui vertically fitted basic segment">
<router-link to="/">
stack
main
style="align-items: center;"
>
<!-- About funkwhale -->
<Link
to="/"
width="full"
align-text="stretch"
style="width:min(480px, 100%)"
>
<logo-text />
</router-link>
</div>
</div>
<div class="column" />
</div>
</Link>
<h2 class="header">
{{ $t('components.About.header.funkwhale') }}
{{ t('components.About.header.funkwhale') }}
</h2>
<p>
{{ $t('components.About.description.funkwhale') }}
{{ t('components.About.description.funkwhale') }}
</p>
</div>
</div>
</div>
<div class="ui hidden divider" />
<div class="ui vertically fitted basic stripe segment">
<div class="ui two stackable cards">
<div class="ui card">
<div
v-if="!$store.state.auth.authenticated"
class="signup-form content"
<Layout
flex
style="justify-content: center;"
>
<Card
v-if="!store.state.auth.authenticated"
:title="t('components.About.header.signup')"
width="256px"
>
<h3 class="header">
{{ $t('components.About.header.signup') }}
</h3>
<template v-if="openRegistrations">
<p>
{{ $t('components.About.description.signup') }}
{{ t('components.About.description.signup') }}
</p>
<p v-if="defaultUploadQuota">
{{ $t('components.About.description.quota', {quota: defaultUploadQuota}) }}
{{ t('components.About.description.quota', {quota: defaultUploadQuota}) }}
</p>
<signup-form
button-classes="success"
:show-login="false"
:show-login="true"
/>
</template>
<div v-else>
<p>
{{ $t('components.About.help.closedRegistrations') }}
{{ t('components.About.help.closedRegistrations') }}
</p>
<a
......@@ -108,36 +141,58 @@ const headerStyle = computed(() => {
rel="noopener"
href="https://funkwhale.audio/#get-started"
>
{{ $t('components.About.link.findOtherPod') }}
{{ t('components.About.link.findOtherPod') }}
&nbsp;<i class="external alternate icon" />
</a>
</div>
</div>
<div
v-else
v-if="!(store.state.auth.authenticated || openRegistrations)"
class="signup-form content"
>
<h3 class="header">
{{ $t('components.About.header.signup') }}
{{ t('components.About.header.signup') }}
<div class="ui positive message">
<div class="header">
{{ $t('components.About.message.loggedIn') }}
{{ t('components.About.message.loggedIn') }}
</div>
<p>
{{ $t('components.About.message.greeting', {username: $store.state.auth.username}) }}
{{ t('components.About.message.greeting', {username: store.state.auth.username}) }}
</p>
</div>
</h3>
</div>
</div>
<div class="ui card">
</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
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
:style="headerStyle"
>
<h1>
<i class="music icon" />
{{ podName }}
</h1>
</section>
<div class="content pod-description">
......@@ -145,7 +200,7 @@ const headerStyle = computed(() => {
id="description"
class="ui header"
>
{{ $t('components.About.header.aboutPod') }}
{{ t('components.About.header.aboutPod') }}
</h3>
<div
v-if="shortDescription"
......@@ -154,7 +209,7 @@ const headerStyle = computed(() => {
{{ shortDescription }}
</div>
<p v-else>
{{ $t('components.About.placeholder.noDescription') }}
{{ t('components.About.placeholder.noDescription') }}
</p>
<template v-if="stats">
......@@ -162,100 +217,401 @@ const headerStyle = computed(() => {
<div class="two column row">
<div class="column">
<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>
{{ $t('components.About.stat.activeUsers', stats.users) }}
{{ stats.users ? t('components.About.stat.activeUsers', stats.users) : "" }}
</span>
</div>
<div class="column">
<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>
{{ $t('components.About.stat.hoursOfMusic', stats.hours) }}
{{ stats.hours ? t('components.About.stat.hoursOfMusic', stats.hours) : "" }}
</span>
</div>
</div>
</div>
</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
to="/about/pod"
class="ui fluid basic secondary button"
class="item"
>
{{ $t('components.About.link.learnMore') }}
{{ t('components.AboutPod.link.about') }}
</router-link>
</div>
</div>
</div>
<!-- TODO (wvffle): Remove style when migrate away from fomantic -->
<div
class="ui three stackable cards"
style="z-index: 1; position: relative;"
<router-link
to="/about/pod#rules"
class="item"
>
{{ t('components.AboutPod.link.rules') }}
</router-link>
<router-link
to="/"
class="ui card"
to="/about/pod#terms"
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
id="description"
id="rules"
class="ui header"
>
{{ $t('components.About.header.publicContent') }}
{{ t('components.AboutPod.header.rules') }}
</h3>
<p>
{{ $t('components.About.description.publicContent') }}
<sanitized-html
v-if="rules"
:html="rules"
/>
<p v-else>
{{ t('components.AboutPod.placeholder.noRules') }}
</p>
</div>
</router-link>
<a
href="https://funkwhale.audio/#get-started"
class="ui card"
target="_blank"
>
<div class="content">
<h3
id="description"
id="terms"
class="ui header"
>
{{ $t('components.About.link.findOtherPod') }}
&nbsp;<i class="external alternate icon" />
{{ t('components.AboutPod.header.terms') }}
</h3>
<p>
{{ $t('components.About.description.publicContent') }}
<sanitized-html
v-if="terms"
:html="terms"
/>
<p v-else>
{{ t('components.AboutPod.placeholder.noTerms') }}
</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>
</a>
<a
href="https://funkwhale.audio/apps"
class="ui card"
target="_blank"
<div class="column">
<table class="ui very basic table unstackable">
<tbody>
<tr>
<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
id="description"
class="ui header"
id="statistics"
class="header"
>
{{ $t('components.About.header.findApp') }}
&nbsp;<i class="external alternate icon" />
{{ t('components.AboutPod.header.statistics') }}
</h3>
<p>
{{ $t('components.About.description.findApp') }}
</p>
<div class="statistics-container">
<div
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>
</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 class="ui fluid horizontally fitted basic clearing segment container">
<router-link
to="/about/pod"
class="ui right floated basic secondary button"
<div
v-if="stats.data.albums"
class="statistics-statistic"
>
{{ $t('components.About.header.aboutPod') }}
<i class="icon arrow right" />
</router-link>
<span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.data.albums.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br>
{{ t('components.AboutPod.stat.albumsCount', stats.data.albums) }}
</span>
</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
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>
</main>
</Layout>
</template>
......@@ -5,7 +5,6 @@ import { get } from 'lodash-es'
import { computed } from 'vue'
import useMarkdown from '~/composables/useMarkdown'
import type { NodeInfo } from '~/store/instance'
import { useI18n } from 'vue-i18n'
const store = useStore()
......@@ -39,24 +38,14 @@ const federationEnabled = computed(() => {
const onDesktop = computed(() => window.innerWidth > 800)
const stats = computed(() => {
const info = nodeinfo.value ?? {} as NodeInfo
const data = {
users: get(info, 'usage.users.activeMonth', null),
hours: get(info, 'metadata.content.local.hoursOfContent', null),
artists: get(info, 'metadata.content.local.artists.total', null),
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 stats = computed(() => ({
users: nodeinfo.value?.usage.users.activeMonth,
hours: nodeinfo.value?.metadata.content.local.hoursOfContent,
artists: nodeinfo.value?.metadata.content.local.artists,
albums: nodeinfo.value?.metadata.content.local.releases, // TODO: Check where to get 'metadata.content.local.albums.total'
tracks: nodeinfo.value?.metadata.content.local.recordings, // TODO: 'metadata.content.local.tracks.total'
listenings: nodeinfo.value?.metadata.usage?.listenings.total
}))
const headerStyle = computed(() => {
if (!banner.value) {
......@@ -72,7 +61,7 @@ const headerStyle = computed(() => {
<template>
<main
v-title="labels.title"
class="main pusher page-about"
class="main page-about"
>
<div
class="ui"
......@@ -99,32 +88,32 @@ const headerStyle = computed(() => {
to="/about/pod"
class="item"
>
{{ $t('components.AboutPod.link.about') }}
{{ t('components.AboutPod.link.about') }}
</router-link>
<router-link
to="/about/pod#rules"
class="item"
>
{{ $t('components.AboutPod.link.rules') }}
{{ t('components.AboutPod.link.rules') }}
</router-link>
<router-link
to="/about/pod#terms"
class="item"
>
{{ $t('components.AboutPod.link.terms') }}
{{ t('components.AboutPod.link.terms') }}
</router-link>
<router-link
to="/about/pod#features"
class="item"
>
{{ $t('components.AboutPod.link.features') }}
{{ t('components.AboutPod.link.features') }}
</router-link>
<router-link
v-if="stats"
to="/about/pod#statistics"
class="item"
>
{{ $t('components.AboutPod.link.statistics') }}
{{ t('components.AboutPod.link.statistics') }}
</router-link>
</div>
</div>
......@@ -134,49 +123,49 @@ const headerStyle = computed(() => {
id="description about-this-pod"
class="ui header"
>
{{ $t('components.AboutPod.header.about') }}
{{ t('components.AboutPod.header.about') }}
</h2>
<sanitized-html
v-if="longDescription"
:html="longDescription"
/>
<p v-else>
{{ $t('components.AboutPod.placeholder.noDescription') }}
{{ t('components.AboutPod.placeholder.noDescription') }}
</p>
<h3
id="rules"
class="ui header"
>
{{ $t('components.AboutPod.header.rules') }}
{{ t('components.AboutPod.header.rules') }}
</h3>
<sanitized-html
v-if="rules"
:html="rules"
/>
<p v-else>
{{ $t('components.AboutPod.placeholder.noRules') }}
{{ t('components.AboutPod.placeholder.noRules') }}
</p>
<h3
id="terms"
class="ui header"
>
{{ $t('components.AboutPod.header.terms') }}
{{ t('components.AboutPod.header.terms') }}
</h3>
<sanitized-html
v-if="terms"
:html="terms"
/>
<p v-else>
{{ $t('components.AboutPod.placeholder.noTerms') }}
{{ t('components.AboutPod.placeholder.noTerms') }}
</p>
<h3
id="features"
class="header"
>
{{ $t('components.AboutPod.header.features') }}
{{ t('components.AboutPod.header.features') }}
</h3>
<div class="features-container ui two column stackable grid">
<div class="column">
......@@ -184,7 +173,7 @@ const headerStyle = computed(() => {
<tbody>
<tr>
<td>
{{ $t('components.AboutPod.feature.version') }}
{{ t('components.AboutPod.feature.version') }}
</td>
<td
v-if="version"
......@@ -199,13 +188,13 @@ const headerStyle = computed(() => {
class="right aligned"
>
<span class="features-status ui text">
{{ $t('components.AboutPod.notApplicable') }}
{{ t('components.AboutPod.notApplicable') }}
</span>
</td>
</tr>
<tr>
<td>
{{ $t('components.AboutPod.feature.federation') }}
{{ t('components.AboutPod.feature.federation') }}
</td>
<td
v-if="federationEnabled"
......@@ -213,7 +202,7 @@ const headerStyle = computed(() => {
>
<span class="features-status ui text">
<i class="check icon" />
{{ $t('components.AboutPod.feature.status.enabled') }}
{{ t('components.AboutPod.feature.status.enabled') }}
</span>
</td>
<td
......@@ -222,13 +211,13 @@ const headerStyle = computed(() => {
>
<span class="features-status ui text">
<i class="x icon" />
{{ $t('components.AboutPod.feature.status.disabled') }}
{{ t('components.AboutPod.feature.status.disabled') }}
</span>
</td>
</tr>
<tr>
<td>
{{ $t('components.AboutPod.feature.allowList') }}
{{ t('components.AboutPod.feature.allowList') }}
</td>
<td
v-if="allowListEnabled"
......@@ -236,7 +225,7 @@ const headerStyle = computed(() => {
>
<span class="features-status ui text">
<i class="check icon" />
{{ $t('components.AboutPod.feature.status.enabled') }}
{{ t('components.AboutPod.feature.status.enabled') }}
</span>
</td>
<td
......@@ -245,7 +234,7 @@ const headerStyle = computed(() => {
>
<span class="features-status ui text">
<i class="x icon" />
{{ $t('components.AboutPod.feature.status.disabled') }}
{{ t('components.AboutPod.feature.status.disabled') }}
</span>
</td>
</tr>
......@@ -257,7 +246,7 @@ const headerStyle = computed(() => {
<tbody>
<tr>
<td>
{{ $t('components.AboutPod.feature.anonymousAccess') }}
{{ t('components.AboutPod.feature.anonymousAccess') }}
</td>
<td
v-if="anonymousCanListen"
......@@ -265,7 +254,7 @@ const headerStyle = computed(() => {
>
<span class="features-status ui text">
<i class="check icon" />
{{ $t('components.AboutPod.feature.status.enabled') }}
{{ t('components.AboutPod.feature.status.enabled') }}
</span>
</td>
<td
......@@ -274,13 +263,13 @@ const headerStyle = computed(() => {
>
<span class="features-status ui text">
<i class="x icon" />
{{ $t('components.AboutPod.feature.status.disabled') }}
{{ t('components.AboutPod.feature.status.disabled') }}
</span>
</td>
</tr>
<tr>
<td>
{{ $t('components.AboutPod.feature.registrations') }}
{{ t('components.AboutPod.feature.registrations') }}
</td>
<td
v-if="openRegistrations"
......@@ -288,7 +277,7 @@ const headerStyle = computed(() => {
>
<span class="features-status ui text">
<i class="check icon" />
{{ $t('components.AboutPod.feature.status.open') }}
{{ t('components.AboutPod.feature.status.open') }}
</span>
</td>
<td
......@@ -297,13 +286,13 @@ const headerStyle = computed(() => {
>
<span class="features-status ui text">
<i class="x icon" />
{{ $t('components.AboutPod.feature.status.closed') }}
{{ t('components.AboutPod.feature.status.closed') }}
</span>
</td>
</tr>
<tr>
<td>
{{ $t('components.AboutPod.feature.quota') }}
{{ t('components.AboutPod.feature.quota') }}
</td>
<td
v-if="defaultUploadQuota"
......@@ -318,7 +307,7 @@ const headerStyle = computed(() => {
class="right aligned"
>
<span class="features-status ui text">
{{ $t('components.AboutPod.notApplicable') }}
{{ t('components.AboutPod.notApplicable') }}
</span>
</td>
</tr>
......@@ -332,7 +321,7 @@ const headerStyle = computed(() => {
id="statistics"
class="header"
>
{{ $t('components.AboutPod.header.statistics') }}
{{ t('components.AboutPod.header.statistics') }}
</h3>
<div class="statistics-container">
<div
......@@ -340,9 +329,9 @@ const headerStyle = computed(() => {
class="statistics-statistic"
>
<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>
{{ $t('components.AboutPod.stat.hoursOfMusic', stats.hours) }}
{{ t('components.AboutPod.stat.hoursOfMusic', stats.hours) }}
</span>
</div>
<div
......@@ -350,9 +339,9 @@ const headerStyle = computed(() => {
class="statistics-statistic"
>
<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>
{{ $t('components.AboutPod.stat.artistsCount', stats.artists) }}
{{ t('components.AboutPod.stat.artistsCount', stats.artists) }}
</span>
</div>
<div
......@@ -360,9 +349,9 @@ const headerStyle = computed(() => {
class="statistics-statistic"
>
<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>
{{ $t('components.AboutPod.stat.albumsCount', stats.albums) }}
{{ t('components.AboutPod.stat.albumsCount', stats.albums) }}
</span>
</div>
<div
......@@ -370,9 +359,9 @@ const headerStyle = computed(() => {
class="statistics-statistic"
>
<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>
{{ $t('components.AboutPod.stat.tracksCount', stats.tracks) }}
{{ t('components.AboutPod.stat.tracksCount', stats.tracks) }}
</span>
</div>
<div
......@@ -380,9 +369,9 @@ const headerStyle = computed(() => {
class="statistics-statistic"
>
<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>
{{ $t('components.AboutPod.stat.activeUsers', stats.users) }}
{{ t('components.AboutPod.stat.activeUsers', stats.users) }}
</span>
</div>
<div
......@@ -390,9 +379,9 @@ const headerStyle = computed(() => {
class="statistics-statistic"
>
<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>
{{ $t('components.AboutPod.stat.listeningsCount', stats.listenings) }}
{{ t('components.AboutPod.stat.listeningsCount', stats.listenings) }}
</span>
</div>
</div>
......@@ -403,13 +392,13 @@ const headerStyle = computed(() => {
id="contact"
class="ui header"
>
{{ $t('components.AboutPod.header.contact') }}
{{ t('components.AboutPod.header.contact') }}
</h3>
<a
v-if="contactEmail"
:href="`mailto:${contactEmail}`"
>
{{ $t('components.AboutPod.message.contact', { contactEmail }) }}
{{ t('components.AboutPod.message.contact', { contactEmail }) }}
</a>
</template>
......@@ -420,7 +409,7 @@ const headerStyle = computed(() => {
class="ui left floated basic secondary button"
>
<i class="icon arrow left" />
{{ $t('components.AboutPod.link.introduction') }}
{{ t('components.AboutPod.link.introduction') }}
</router-link>
</div>
</div>
......
<script setup lang="ts">
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 LoginForm from '~/components/auth/LoginForm.vue'
import SignupForm from '~/components/auth/SignupForm.vue'
......@@ -13,6 +13,13 @@ import { whenever } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
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 labels = computed(() => ({
title: t('components.Home.title')
......@@ -43,15 +50,11 @@ const stats = computed(() => {
return { users, hours }
})
const headerStyle = computed(() => {
if (!banner.value) {
return ''
}
return {
backgroundImage: `url(${store.getters['instance/absoluteUrl'](banner.value)})`
}
})
const backgroundImage = computed(() =>
banner.value
? `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%)'
)
// TODO (wvffle): Check if needed
const router = useRouter()
......@@ -62,43 +65,43 @@ whenever(() => store.state.auth.authenticated, () => {
</script>
<template>
<main
<Layout
v-title="labels.title"
class="main pusher page-home"
>
<section
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
:style="headerStyle"
stack
main
>
<div class="segment-content">
<h1 class="ui center aligned large header">
<span>
{{ $t('components.Home.header.welcome', {podName: podName}) }}
</span>
<div
v-if="shortDescription"
class="sub header"
<Header
page-heading
:class="$style.banner"
:h1="t('components.Home.header.welcome', {podName: podName})"
>
<p :class="$style.description">
{{ shortDescription }}
</p>
<div>
<img
:class="$style.logo"
src="../assets/network.png"
alt=""
>
</div>
</h1>
</div>
</section>
<section class="ui vertical stripe segment">
<div class="ui stackable grid">
<div class="ten wide column">
<h2 class="header">
{{ $t('components.Home.header.about') }}
</h2>
<div
id="pod"
class="ui raised segment"
<Spacer />
<Spacer />
<Spacer />
<Section
align-left
:columns-per-item="3"
:h2="t('components.Home.header.about')"
>
<Layout
flex
:class="$style['long-description']"
>
<div class="ui stackable grid">
<div class="eight wide column">
<div>
<p v-if="!longDescription">
{{ $t('components.Home.placeholder.noDescription') }}
{{ t('components.Home.placeholder.noDescription') }}
</p>
<!-- TODO: Use new Ui elements once we can test with data -->
<template v-if="longDescription || rules">
<sanitized-html
v-if="longDescription"
......@@ -120,7 +123,7 @@ whenever(() => store.state.auth.authenticated, () => {
class="ui link"
:to="{name: 'about'}"
>
{{ $t('components.Home.link.learnMore') }}
{{ t('components.Home.link.learnMore') }}
</router-link>
</div>
</div>
......@@ -135,89 +138,76 @@ whenever(() => store.state.auth.authenticated, () => {
class="ui link"
:to="{name: 'about', hash: '#rules'}"
>
{{ $t('components.Home.link.rules') }}
{{ t('components.Home.link.rules') }}
</router-link>
</div>
</div>
</div>
</template>
</div>
<div class="eight wide column">
<template v-if="stats">
<h3 class="sub header">
{{ $t('components.Home.header.statistics') }}
</h3>
<p>
<i class="user icon" />
{{ $t('components.Home.stat.activeUsers', stats.users) }}
</p>
<p>
<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>
</Layout>
<Card
v-if="stats"
:title="t('components.Home.header.statistics')"
caption
style="--grid-column: -5 /-1;"
>
<div>
<i class="bi bi-people-fill" />
{{ t('components.Home.stat.activeUsers', stats.users) }}
</div>
<div>
<i class="bi bi-music-note-list" />
{{ t('components.Home.stat.hoursOfMusic', stats.hours) }}
</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">
<img
class="ui image"
src="../assets/network.png"
alt=""
<Section
align-left
:columns-per-item="3"
style="row-gap: 64px;"
>
</div>
</div>
<div class="ui hidden divider" />
<div class="ui hidden divider" />
<div class="ui stackable grid">
<div class="four wide column">
<h3 class="header">
{{ $t('components.Home.header.aboutFunkwhale') }}
</h3>
<Section
:h2="t('components.Home.header.aboutFunkwhale')"
:class="$style.about"
>
<div>
<p>
{{ $t('components.Home.description.funkwhale.paragraph1') }}
{{ t('components.Home.description.funkwhale.paragraph1') }}
</p>
<p>
{{ $t('components.Home.description.funkwhale.paragraph2') }}
{{ t('components.Home.description.funkwhale.paragraph2') }}
</p>
<a
target="_blank"
rel="noopener"
href="https://funkwhale.audio"
<Link
to="https://funkwhale.audio"
icon="bi-box-arrow-up-right"
>
<i class="external alternate icon" />
{{ $t('components.Home.link.funkwhale') }}
</a>
{{ t('components.Home.link.funkwhale') }}
</Link>
</div>
<div class="four wide column">
<h3 class="header">
{{ $t('components.Home.header.login') }}
</h3>
<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>
</Section>
<Section
:h2="t('components.Home.header.signup')"
:class="$style.signup"
>
<template v-if="openRegistrations">
<p>
{{ $t('components.Home.description.signup') }}
{{ t('components.Home.description.signup') }}
</p>
<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>
<signup-form
button-classes="success"
......@@ -226,100 +216,156 @@ whenever(() => store.state.auth.authenticated, () => {
</template>
<div v-else>
<p>
{{ $t('components.Home.help.registrationsClosed') }}
{{ t('components.Home.help.registrationsClosed') }}
</p>
<a
target="_blank"
rel="noopener"
href="https://funkwhale.audio/#get-started"
<Link
to="https://funkwhale.audio/#get-started"
icon="bi-box-arrow-up-right"
>
<i class="external alternate icon" />
{{ $t('components.Home.link.findOtherPod') }}
</a>
</div>
{{ t('components.Home.link.findOtherPod') }}
</Link>
</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">
<h3 class="header">
{{ $t('components.Home.header.links') }}
</h3>
<div class="ui relaxed list">
<div class="item">
<i class="headphones icon" />
<div class="content">
<router-link
<Section :h2="t('components.Home.header.links')">
<Card
v-if="anonymousCanListen"
class="header"
tiny
:title="t('components.Home.link.publicContent.label')"
icon="bi-headphones"
to="/library"
>
{{ $t('components.Home.link.publicContent.label') }}
</router-link>
<div class="description">
{{ $t('components.Home.link.publicContent.description') }}
</div>
</div>
</div>
<div class="item">
<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"
<p>
{{ t('components.Home.link.publicContent.description') }}
</p>
</Card>
<Card
:title="t('components.Home.link.mobileApps.label') "
icon="bi-phone-fill large"
to="https://funkwhale.audio/apps"
>
{{ $t('components.Home.link.userGuides.label') }}
</a>
<div class="description">
{{ $t('components.Home.link.userGuides.description') }}
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section
v-if="anonymousCanListen"
class="ui vertical stripe segment"
<p> {{ t('components.Home.link.mobileApps.description') }} </p>
</Card>
<Card
:title=" t('components.Home.link.userGuides.label') "
icon="bi-book-half large"
to="https://docs.funkwhale.audio/users/index.html"
>
<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
:filters="{playable: true, ordering: '-creation_date'}"
:limit="10"
>
<template #title>
{{ $t('components.Home.header.newAlbums') }}
{{ t('components.Home.header.newAlbums') }}
</template>
<router-link to="/library">
{{ $t('components.Home.link.viewMore') }}
{{ t('components.Home.link.viewMore') }}
<div class="ui hidden divider" />
</router-link>
</album-widget>
<div class="ui hidden section divider" />
<h3 class="ui header">
{{ $t('components.Home.header.newChannels') }}
{{ t('components.Home.header.newChannels') }}
</h3>
<channels-widget
:show-modification-date="true"
:limit="10"
:filters="{ordering: '-creation_date', external: 'false'}"
/>
</section>
</main>
</Section>
<Spacer />
<Spacer />
</Layout>
</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 {
}
withDefaults(defineProps<Props>(), {
fill: '#222222'
fill: 'var(--color)'
})
</script>
......
......@@ -12,7 +12,7 @@ const labels = computed(() => ({
<template>
<main
class="main pusher"
class="main"
:v-title="labels.title"
>
<section class="ui vertical stripe segment">
......@@ -20,11 +20,11 @@ const labels = computed(() => ({
<h1 class="ui huge header">
<i class="warning icon" />
<div class="content">
{{ $t('components.PageNotFound.header.pageNotFound') }}
{{ t('components.PageNotFound.header.pageNotFound') }}
</div>
</h1>
<p>
{{ $t('components.PageNotFound.message.pageNotFound') }}
{{ t('components.PageNotFound.message.pageNotFound') }}
</p>
<a :href="path">{{ path }}</a>
<div class="ui hidden divider" />
......@@ -32,7 +32,7 @@ const labels = computed(() => ({
class="ui icon labeled right button"
to="/"
>
{{ $t('components.PageNotFound.link.home') }}
{{ t('components.PageNotFound.link.home') }}
<i class="right arrow icon" />
</router-link>
</div>
......