kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
feat (front): new ui on start page #2477 NOCHANGELOG
rodzic
d33490c219
commit
1db86d404f
|
@ -135,8 +135,9 @@ flake.lock
|
|||
# Zed
|
||||
.zed/
|
||||
|
||||
# Node version (asdf)
|
||||
# Node version (asdf, mise)
|
||||
.tool-versions
|
||||
mise.toml
|
||||
|
||||
# Lychee link checker
|
||||
.lycheecache
|
||||
|
|
|
@ -95,7 +95,7 @@ review_front:
|
|||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
when: manual
|
||||
|
||||
image: $CI_REGISTRY/funkwhale/ci/node-python:18
|
||||
image: $CI_REGISTRY/funkwhale/ci/node-python:22
|
||||
variables:
|
||||
BASE_URL: /-/$CI_PROJECT_NAME/-/jobs/$CI_JOB_ID/artifacts/front-review/
|
||||
VUE_APP_ROUTER_BASE_URL: /-/$CI_PROJECT_NAME/-/jobs/$CI_JOB_ID/artifacts/front-review/
|
||||
|
@ -202,7 +202,7 @@ lint_front:
|
|||
- if: $CI_COMMIT_BRANCH =~ /(stable|develop)/
|
||||
- changes: [front/**/*]
|
||||
|
||||
image: $CI_REGISTRY/funkwhale/ci/node-python:18
|
||||
image: $CI_REGISTRY/funkwhale/ci/node-python:22
|
||||
cache:
|
||||
- *yarn_cache
|
||||
- *node_cache
|
||||
|
@ -290,7 +290,7 @@ test_front:
|
|||
- if: $CI_COMMIT_BRANCH =~ /(stable|develop)/
|
||||
- changes: [front/**/*]
|
||||
|
||||
image: $CI_REGISTRY/funkwhale/ci/node-python:18
|
||||
image: $CI_REGISTRY/funkwhale/ci/node-python:22
|
||||
cache:
|
||||
- *yarn_cache
|
||||
- *node_cache
|
||||
|
@ -417,7 +417,7 @@ build_front:
|
|||
- if: $CI_COMMIT_BRANCH =~ /(stable|develop)/
|
||||
- changes: [front/**/*]
|
||||
|
||||
image: $CI_REGISTRY/funkwhale/ci/node-python:18
|
||||
image: $CI_REGISTRY/funkwhale/ci/node-python:22
|
||||
variables:
|
||||
<<: *keep_git_files_permissions
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
# 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
|
|
@ -0,0 +1 @@
|
|||
nodeLinker: node-modules
|
|
@ -1,4 +1,4 @@
|
|||
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
|
||||
|
||||
|
|
|
@ -43,7 +43,6 @@
|
|||
"dompurify": "3.2.4",
|
||||
"focus-trap": "7.2.0",
|
||||
"idb-keyval": "6.2.1",
|
||||
"jquery": "3.7.1",
|
||||
"jsmediatags": "3.9.7",
|
||||
"lodash-es": "4.17.21",
|
||||
"lru-cache": "10.2.0",
|
||||
|
@ -55,6 +54,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",
|
||||
|
@ -82,7 +82,6 @@
|
|||
"@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",
|
||||
|
@ -99,6 +98,7 @@
|
|||
"@vue/eslint-config-typescript": "12.0.0",
|
||||
"@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",
|
||||
|
@ -114,13 +114,13 @@
|
|||
"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.68.0",
|
||||
"sinon": "15.0.2",
|
||||
"standardized-audio-context-mock": "9.6.32",
|
||||
"typescript": "5.3.3",
|
||||
"unocss": "0.58.0",
|
||||
"unplugin-vue-macros": "2.6.2",
|
||||
"unplugin-vue-macros": "2.14.5",
|
||||
"utility-types": "3.10.0",
|
||||
"vite": "5.2.12",
|
||||
"vite-plugin-node-polyfills": "0.17.0",
|
||||
|
@ -128,7 +128,7 @@
|
|||
"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",
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
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'
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,243 +65,207 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
<Layout
|
||||
v-title="labels.title"
|
||||
class="main page-home"
|
||||
stack
|
||||
main
|
||||
>
|
||||
<section
|
||||
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
|
||||
:style="headerStyle"
|
||||
<Header
|
||||
page-heading
|
||||
:class="$style.banner"
|
||||
:h1="t('components.Home.header.welcome', {podName: podName})"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
{{ shortDescription }}
|
||||
</div>
|
||||
</h1>
|
||||
<p :class="$style.description">
|
||||
{{ shortDescription }}
|
||||
</p>
|
||||
<div>
|
||||
<img
|
||||
:class="$style.logo"
|
||||
src="../assets/network.png"
|
||||
alt=""
|
||||
>
|
||||
</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"
|
||||
>
|
||||
<div class="ui stackable grid">
|
||||
<div class="eight wide column">
|
||||
<p v-if="!longDescription">
|
||||
{{ t('components.Home.placeholder.noDescription') }}
|
||||
</p>
|
||||
<template v-if="longDescription || rules">
|
||||
<sanitized-html
|
||||
v-if="longDescription"
|
||||
id="renderedDescription"
|
||||
:html="longDescription"
|
||||
/>
|
||||
<div
|
||||
v-if="longDescription"
|
||||
class="ui hidden divider"
|
||||
/>
|
||||
<div class="ui relaxed list">
|
||||
<div
|
||||
v-if="longDescription"
|
||||
class="item"
|
||||
<Spacer />
|
||||
<Spacer />
|
||||
<Spacer />
|
||||
<Section
|
||||
align-left
|
||||
:columns-per-item="3"
|
||||
:h2="t('components.Home.header.about')"
|
||||
>
|
||||
<Layout
|
||||
flex
|
||||
:class="$style['long-description']"
|
||||
>
|
||||
<div>
|
||||
<p v-if="!longDescription">
|
||||
{{ 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"
|
||||
id="renderedDescription"
|
||||
:html="longDescription"
|
||||
/>
|
||||
<div
|
||||
v-if="longDescription"
|
||||
class="ui hidden divider"
|
||||
/>
|
||||
<div class="ui relaxed list">
|
||||
<div
|
||||
v-if="longDescription"
|
||||
class="item"
|
||||
>
|
||||
<i class="arrow right icon" />
|
||||
<div class="content">
|
||||
<router-link
|
||||
class="ui link"
|
||||
:to="{name: 'about'}"
|
||||
>
|
||||
<i class="arrow right icon" />
|
||||
<div class="content">
|
||||
<router-link
|
||||
class="ui link"
|
||||
:to="{name: 'about'}"
|
||||
>
|
||||
{{ t('components.Home.link.learnMore') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="rules"
|
||||
class="item"
|
||||
>
|
||||
<i class="book open icon" />
|
||||
<div class="content">
|
||||
<router-link
|
||||
v-if="rules"
|
||||
class="ui link"
|
||||
:to="{name: 'about', hash: '#rules'}"
|
||||
>
|
||||
{{ t('components.Home.link.rules') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
{{ t('components.Home.link.learnMore') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="rules"
|
||||
class="item"
|
||||
>
|
||||
<i class="book open icon" />
|
||||
<div class="content">
|
||||
<router-link
|
||||
v-if="rules"
|
||||
class="ui link"
|
||||
:to="{name: 'about', hash: '#rules'}"
|
||||
>
|
||||
{{ t('components.Home.link.rules') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</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=""
|
||||
>
|
||||
</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
|
||||
align-left
|
||||
:columns-per-item="3"
|
||||
style="row-gap: 64px;"
|
||||
>
|
||||
<Section
|
||||
:h2="t('components.Home.header.aboutFunkwhale')"
|
||||
:class="$style.about"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
{{ t('components.Home.description.funkwhale.paragraph1') }}
|
||||
</p>
|
||||
<p>
|
||||
{{ 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>
|
||||
</Link>
|
||||
</div>
|
||||
<div class="four wide column">
|
||||
<h3 class="header">
|
||||
{{ t('components.Home.header.login') }}
|
||||
</h3>
|
||||
<login-form
|
||||
</Section>
|
||||
<Section
|
||||
:h2="t('components.Home.header.signup')"
|
||||
:class="$style.signup"
|
||||
>
|
||||
<template v-if="openRegistrations">
|
||||
<p>
|
||||
{{ t('components.Home.description.signup') }}
|
||||
</p>
|
||||
<p v-if="defaultUploadQuota">
|
||||
{{ t('components.Home.description.quota', { quota: humanSize(defaultUploadQuota * 1000 * 1000) }) }}
|
||||
</p>
|
||||
<signup-form
|
||||
button-classes="success"
|
||||
:show-signup="false"
|
||||
:show-login="false"
|
||||
/>
|
||||
<div class="ui hidden clearing divider" />
|
||||
</div>
|
||||
<div class="four wide column">
|
||||
<h3 class="header">
|
||||
{{ t('components.Home.header.signup') }}
|
||||
</h3>
|
||||
<template v-if="openRegistrations">
|
||||
<p>
|
||||
{{ t('components.Home.description.signup') }}
|
||||
</p>
|
||||
<p v-if="defaultUploadQuota">
|
||||
{{ t('components.Home.description.quota', { quota: humanSize(defaultUploadQuota * 1000 * 1000) }) }}
|
||||
</p>
|
||||
<signup-form
|
||||
button-classes="success"
|
||||
:show-login="false"
|
||||
/>
|
||||
</template>
|
||||
<div v-else>
|
||||
<p>
|
||||
{{ t('components.Home.help.registrationsClosed') }}
|
||||
</p>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
href="https://funkwhale.audio/#get-started"
|
||||
>
|
||||
<i class="external alternate icon" />
|
||||
{{ t('components.Home.link.findOtherPod') }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else>
|
||||
<p>
|
||||
{{ t('components.Home.help.registrationsClosed') }}
|
||||
</p>
|
||||
<Link
|
||||
to="https://funkwhale.audio/#get-started"
|
||||
icon="bi-box-arrow-up-right"
|
||||
>
|
||||
{{ 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
|
||||
v-if="anonymousCanListen"
|
||||
class="header"
|
||||
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"
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
<Section :h2="t('components.Home.header.links')">
|
||||
<Card
|
||||
v-if="anonymousCanListen"
|
||||
tiny
|
||||
:title="t('components.Home.link.publicContent.label')"
|
||||
icon="bi-headphones"
|
||||
to="/library"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
|
@ -320,6 +287,85 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
: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>
|
||||
|
|
|
@ -67,7 +67,7 @@ const performSearch = () => {
|
|||
}
|
||||
|
||||
watch(
|
||||
[() => store.state.moderation.lastUpdate, page],
|
||||
() => [store.state.moderation.lastUpdate, page.value],
|
||||
() => fetchData(),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
|
|
@ -33,7 +33,7 @@ const duration = computed(() => props.entry.uploads.find(upload => upload.durati
|
|||
<div class="controls">
|
||||
<play-button
|
||||
class="basic circular icon"
|
||||
:discrete="true"
|
||||
discrete
|
||||
:icon-only="true"
|
||||
:is-playable="true"
|
||||
:button-classes="['ui', 'circular', 'inverted vibrant', 'icon', 'button']"
|
||||
|
|
|
@ -5,6 +5,7 @@ import type { paths } from '~/generated/types'
|
|||
import { slugify } from 'transliteration'
|
||||
import { reactive, computed, ref, watchEffect, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDataStore } from '~/ui/stores/data'
|
||||
|
||||
import axios from 'axios'
|
||||
import AttachmentInput from '~/components/common/AttachmentInput.vue'
|
||||
|
@ -35,6 +36,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const dataStore = useDataStore()
|
||||
|
||||
const newValues = reactive({
|
||||
name: props.object?.artist?.name ?? '',
|
||||
|
@ -261,7 +263,7 @@ defineExpose({
|
|||
:get="model => { newValues.tags = model.currents.map(({ label }) => label) }"
|
||||
:set="model => ({
|
||||
currents: newValues.tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
||||
others: [].map(tag => ({ type: 'custom' as const, label: tag }))
|
||||
others: dataStore.tags().value.map(({ name }) => ({ type: 'custom' as const, label: name }))
|
||||
})"
|
||||
:label="t('components.audio.ChannelForm.label.tags')"
|
||||
/>
|
||||
|
|
|
@ -20,7 +20,7 @@ interface Props extends PlayOptionsProps {
|
|||
dropdownIconClasses?: string[]
|
||||
playIconClass?: string
|
||||
buttonClasses?: string[]
|
||||
discrete?: boolean
|
||||
discrete?: true
|
||||
dropdownOnly?: boolean
|
||||
iconOnly?: boolean
|
||||
playing?: boolean
|
||||
|
@ -52,7 +52,6 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
dropdownIconClasses: () => ['bi-caret-down-fill'],
|
||||
playIconClass: () => 'bi-play-fill',
|
||||
buttonClasses: () => ['button'],
|
||||
discrete: () => false,
|
||||
dropdownOnly: () => false,
|
||||
iconOnly: () => false,
|
||||
isPlayable: () => false,
|
||||
|
|
|
@ -26,9 +26,6 @@ const router = useRouter()
|
|||
const query = ref()
|
||||
|
||||
const enter = () => {
|
||||
// TODO: Find out what jQuery version supports `search`
|
||||
// jQuery(el.value).search('cancel query')
|
||||
|
||||
// Cancel any API search request to backend…
|
||||
return router.push(`/search?q=${query.value}&type=artists`)
|
||||
}
|
||||
|
@ -38,108 +35,7 @@ const blur = () => {
|
|||
}
|
||||
|
||||
onMounted(() => {
|
||||
// TODO: Find out what jQuery version supports `search`
|
||||
// jQuery(el.value).search({
|
||||
// type: 'category',
|
||||
// minCharacters: 3,
|
||||
// showNoResults: true,
|
||||
// error: {
|
||||
// // @ts-expect-error Semantic is broken
|
||||
// noResultsHeader: t('components.audio.SearchBar.header.noResults'),
|
||||
// noResults: t('components.audio.SearchBar.empty.noResults')
|
||||
// },
|
||||
|
||||
// onSelect (result, response) {
|
||||
// jQuery(el.value).search('set value', query.value)
|
||||
// router.push(result.routerUrl)
|
||||
// jQuery(el.value).search('hide results')
|
||||
// return false
|
||||
// },
|
||||
// onSearchQuery (value) {
|
||||
// // query.value = value
|
||||
// emit('search')
|
||||
// },
|
||||
// apiSettings: {
|
||||
// url: store.getters['instance/absoluteUrl']('api/v1/search?query={query}'),
|
||||
// beforeXHR: function (xhrObject) {
|
||||
// if (!store.state.auth.authenticated) {
|
||||
// return xhrObject
|
||||
// }
|
||||
|
||||
// if (store.state.auth.oauth.accessToken) {
|
||||
// xhrObject.setRequestHeader('Authorization', store.getters['auth/header'])
|
||||
// }
|
||||
|
||||
// return xhrObject
|
||||
// },
|
||||
// onResponse: function (initialResponse) {
|
||||
// const id = objectId.value
|
||||
// const results: Partial<Record<CategoryCode, Results>> = {}
|
||||
|
||||
// let resultsEmpty = true
|
||||
// for (const category of categories.value) {
|
||||
// results[category.code] = {
|
||||
// name: category.name,
|
||||
// results: []
|
||||
// }
|
||||
|
||||
// if (category.code === 'federation' && id) {
|
||||
// resultsEmpty = false
|
||||
// results[category.code]?.results.push({
|
||||
// title: t('components.audio.SearchBar.link.fediverse'),
|
||||
// routerUrl: {
|
||||
// name: 'search',
|
||||
// query: { id }
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
// if (category.code === 'podcasts' && id) {
|
||||
// resultsEmpty = false
|
||||
// results[category.code]?.results.push({
|
||||
// title: t('components.audio.SearchBar.link.rss'),
|
||||
// routerUrl: {
|
||||
// name: 'search',
|
||||
// query: { id, type: 'rss' }
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
// if (category.code === 'more') {
|
||||
// results[category.code]?.results.push({
|
||||
// title: t('components.audio.SearchBar.link.more'),
|
||||
// routerUrl: {
|
||||
// name: 'search',
|
||||
// query: { type: 'artists', q: query.value }
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
// if (isCategoryGuard(category)) {
|
||||
// for (const result of initialResponse[category.code]) {
|
||||
// resultsEmpty = false
|
||||
// const id = category.getId(result)
|
||||
// results[category.code]?.results.push({
|
||||
// title: category.getTitle(result),
|
||||
// id,
|
||||
// routerUrl: {
|
||||
// name: category.route,
|
||||
// params: { id }
|
||||
// },
|
||||
// description: category.getDescription(result)
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// return {
|
||||
// results: resultsEmpty
|
||||
// ? {}
|
||||
// : results
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
// Search functionality could be implemented here if needed
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -32,7 +32,7 @@ withDefaults(defineProps<Props>(), {
|
|||
total: 0
|
||||
})
|
||||
|
||||
const { page } = defineModels<{ page: number, }>()
|
||||
const page = defineModel<number>('page', { required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
@ -83,7 +83,7 @@ onMounted(() => {
|
|||
})
|
||||
|
||||
watch(
|
||||
[() => store.state.moderation.lastUpdate, page],
|
||||
() => [store.state.moderation.lastUpdate, page.value],
|
||||
() => fetchData(),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
|
|
@ -12,11 +12,13 @@ import Input from '~/components/ui/Input.vue'
|
|||
import Button from '~/components/ui/Button.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import Card from '~/components/ui/Card.vue'
|
||||
|
||||
interface Props {
|
||||
next?: RouteLocationRaw
|
||||
buttonClasses?: string
|
||||
showSignup?: boolean
|
||||
isCard?: true
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
|
@ -75,15 +77,19 @@ const submit = async () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<Layout
|
||||
<component
|
||||
:is="isCard ? Card : Layout"
|
||||
v-bind="$attrs"
|
||||
form
|
||||
stack
|
||||
gap-32
|
||||
style="max-width: 600px"
|
||||
@submit.prevent="submit()"
|
||||
>
|
||||
<Alert
|
||||
v-if="errors.length > 0"
|
||||
red
|
||||
style="margin: 0px calc(0px - var(--fw-card-padding) - 1px);"
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ t('components.auth.LoginForm.header.loginFailure') }}
|
||||
|
@ -112,7 +118,7 @@ const submit = async () => {
|
|||
</component>
|
||||
</component>
|
||||
</Alert>
|
||||
<Spacer />
|
||||
<Spacer h />
|
||||
<template v-if="domain === store.getters['instance/domain']">
|
||||
<Input
|
||||
id="username-field"
|
||||
|
@ -161,11 +167,25 @@ const submit = async () => {
|
|||
</p>
|
||||
</template>
|
||||
<Button
|
||||
v-if="!isCard"
|
||||
solid
|
||||
primary
|
||||
type="submit"
|
||||
>
|
||||
{{ t('components.auth.LoginForm.button.login') }}
|
||||
</Button>
|
||||
</Layout>
|
||||
<Layout
|
||||
v-if="isCard"
|
||||
flex
|
||||
>
|
||||
<Spacer grow />
|
||||
<Button
|
||||
primary
|
||||
raised
|
||||
type="submit"
|
||||
>
|
||||
{{ t('components.auth.LoginForm.button.login') }}
|
||||
</Button>
|
||||
</Layout>
|
||||
</component>
|
||||
</template>
|
||||
|
|
|
@ -14,15 +14,14 @@ interface Props {
|
|||
admin?: boolean
|
||||
displayName?: boolean
|
||||
truncateLength?: number
|
||||
discrete?: boolean
|
||||
discrete?: true
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
avatar: true,
|
||||
admin: false,
|
||||
displayName: false,
|
||||
truncateLength: 30,
|
||||
discrete: false
|
||||
truncateLength: 30
|
||||
})
|
||||
|
||||
const { displayName, actor, truncateLength, admin, avatar } = toRefs(props)
|
||||
|
|
|
@ -11,15 +11,14 @@ import Link from '~/components/ui/Link.vue'
|
|||
interface Props {
|
||||
user: User
|
||||
avatar?: boolean
|
||||
discrete?: boolean
|
||||
solid?: true
|
||||
}
|
||||
|
||||
const store = useStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
avatar: true,
|
||||
discrete: false
|
||||
avatar: true
|
||||
})
|
||||
|
||||
const userColor = computed(() => intToRGB(hashCode(props.user.username + props.user.id)))
|
||||
|
@ -28,11 +27,11 @@ const defaultAvatarStyle = computed(() => ({ backgroundColor: `#${userColor.valu
|
|||
|
||||
<template>
|
||||
<Link
|
||||
:to="user"
|
||||
to="user"
|
||||
:title="user.full_username"
|
||||
:solid="solid"
|
||||
:round="solid"
|
||||
class="username"
|
||||
:solid="!discrete"
|
||||
:round="!discrete"
|
||||
>
|
||||
<template v-if="avatar">
|
||||
<img
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import type { Track, Album, Artist, Library, ArtistCredit } from '~/types'
|
||||
import type { Track, Library } from '~/types'
|
||||
|
||||
import { momentFormat } from '~/utils/filters'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
@ -7,6 +7,7 @@ import { useI18n } from 'vue-i18n'
|
|||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { sum } from 'lodash-es'
|
||||
import { useStore } from '~/store'
|
||||
import { useDataStore } from '~/ui/stores/data'
|
||||
import { useQueue } from '~/composables/audio/queue'
|
||||
|
||||
import axios from 'axios'
|
||||
|
@ -35,13 +36,15 @@ interface Props {
|
|||
}
|
||||
|
||||
const store = useStore()
|
||||
const dataStore = useDataStore()
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const object = ref<Album | null>(null)
|
||||
const artist = ref<Artist | null>(null)
|
||||
const artistCredit = ref([] as ArtistCredit[])
|
||||
const object = computed(() => dataStore.get("album", props.id).value)
|
||||
const artistCredit = computed(() => object.value?.artist_credit ?? [])
|
||||
|
||||
|
||||
const libraries = ref([] as Library[])
|
||||
const paginateBy = ref(50)
|
||||
|
||||
|
@ -75,20 +78,10 @@ const {
|
|||
const isLoading = ref(false)
|
||||
const fetchData = async () => {
|
||||
isLoading.value = true
|
||||
|
||||
const albumResponse = await axios.get(`albums/${props.id}/`, { params: { refresh: 'true' } })
|
||||
|
||||
artistCredit.value = albumResponse.data.artist_credit
|
||||
|
||||
// fetch the first artist of the album
|
||||
const artistResponse = await axios.get(`artists/${albumResponse.data.artist_credit[0].artist.id}/`)
|
||||
|
||||
artist.value = artistResponse.data
|
||||
|
||||
object.value = albumResponse.data
|
||||
if (object.value) {
|
||||
if (!object.value)
|
||||
return
|
||||
else
|
||||
object.value.tracks = []
|
||||
}
|
||||
|
||||
fetchTracks()
|
||||
isLoading.value = false
|
||||
|
@ -128,7 +121,7 @@ const fetchTracks = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
watch(() => props.id, fetchData, { immediate: true })
|
||||
watch(() => [props.id, object.value], fetchData, { immediate: true })
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
@ -138,7 +131,8 @@ const remove = async () => {
|
|||
try {
|
||||
await axios.delete(`albums/${object.value?.id}`)
|
||||
emit('deleted')
|
||||
router.push({ name: 'library.artists.detail', params: { id: artist.value?.id } })
|
||||
if (artistCredit.value)
|
||||
router.push({ name: 'library.artists.detail', params: { id: artistCredit.value[0].artist.id } })
|
||||
} catch (error) {
|
||||
useErrorHandler(error as Error)
|
||||
}
|
||||
|
@ -154,6 +148,7 @@ const remove = async () => {
|
|||
/>
|
||||
<Header
|
||||
v-if="object"
|
||||
:key="object.title /*Re-render component when title changes after an update*/"
|
||||
:h1="object.title"
|
||||
page-heading
|
||||
>
|
||||
|
@ -272,7 +267,7 @@ const remove = async () => {
|
|||
</Layout>
|
||||
</Header>
|
||||
|
||||
<div style="flex 1;">
|
||||
<div style="flex: 1;">
|
||||
<router-view
|
||||
v-if="object"
|
||||
:key="route.fullPath"
|
||||
|
|
|
@ -11,6 +11,7 @@ import { useI18n } from 'vue-i18n'
|
|||
import { syncRef } from '@vueuse/core'
|
||||
import { sortedUniq } from 'lodash-es'
|
||||
import { useStore } from '~/store'
|
||||
import { useDataStore } from '~/ui/stores/data'
|
||||
import { useModal } from '~/ui/composables/useModal.ts'
|
||||
|
||||
import axios from 'axios'
|
||||
|
@ -46,7 +47,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
|
||||
const page = usePage()
|
||||
|
||||
const tags = useRouteQuery<string[]>('tag', [], { transform: (param: string | string[] | null) => (param === null ? [] : Array.isArray(param) ? param : [param]).filter(p => p.trim() !== '') })
|
||||
const tags = useRouteQuery<string[]>('tag', [], { transform: (param: string[]) => param.filter(p => p.trim() !== '') })
|
||||
|
||||
const q = useRouteQuery('query', '')
|
||||
const query = ref(q.value ?? '')
|
||||
|
@ -101,6 +102,7 @@ const fetchData = async () => {
|
|||
}
|
||||
|
||||
const store = useStore()
|
||||
const dataStore = useDataStore()
|
||||
watch(() => store.state.moderation.lastUpdate, fetchData)
|
||||
watch([page, tags, q, ordering, orderingDirection, () => props.scope], fetchData)
|
||||
fetchData()
|
||||
|
@ -150,13 +152,16 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
|
|||
:placeholder="labels.searchPlaceholder"
|
||||
/>
|
||||
<Pills
|
||||
v-if="typeof tags === 'object'"
|
||||
:get="model => { tags = model.currents.map(({ label }) => label) }"
|
||||
:set="model => ({
|
||||
...model,
|
||||
currents: tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
||||
currents: tags.map(tag => ({ type: 'preset' as const, label: tag })),
|
||||
others: dataStore.tags().value
|
||||
.filter(({ name }) => result?.results?.some((object) => object.tags?.includes(name)) && !tags.includes(name))
|
||||
.map(({ name }) => ({ type: 'preset' as const, label: name })),
|
||||
})"
|
||||
:label="t('components.library.Albums.label.tags')"
|
||||
style="max-width: 150px;"
|
||||
style="max-width: 350px;"
|
||||
/>
|
||||
<Layout
|
||||
stack
|
||||
|
|
|
@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n'
|
|||
import { syncRef } from '@vueuse/core'
|
||||
import { sortedUniq } from 'lodash-es'
|
||||
import { useStore } from '~/store'
|
||||
import { useDataStore } from '~/ui/stores/data'
|
||||
import { useModal } from '~/ui/composables/useModal.ts'
|
||||
|
||||
import axios from 'axios'
|
||||
|
@ -101,6 +102,7 @@ const fetchData = async () => {
|
|||
}
|
||||
|
||||
const store = useStore()
|
||||
const dataStore = useDataStore()
|
||||
watch([() => store.state.moderation.lastUpdate, excludeCompilation], fetchData)
|
||||
watch([page, tags, q, ordering, orderingDirection, () => props.scope], fetchData)
|
||||
fetchData()
|
||||
|
@ -150,13 +152,16 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
|
|||
:placeholder="labels.searchPlaceholder"
|
||||
/>
|
||||
<Pills
|
||||
v-if="typeof tags === 'object'"
|
||||
:get="model => { tags = model.currents.map(({ label }) => label) }"
|
||||
:set="model => ({
|
||||
...model,
|
||||
currents: tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
||||
others: dataStore.tags().value
|
||||
.filter(({ name }) => result?.results?.some((object) => object.tags?.includes(name)) && !tags.includes(name))
|
||||
.map(({ name }) => ({ type: 'preset' as const, label: name })),
|
||||
})"
|
||||
:label="t('components.library.Artists.label.tags')"
|
||||
style="max-width: 150px;"
|
||||
style="max-width: 350px;"
|
||||
/>
|
||||
<Layout
|
||||
stack
|
||||
|
|
|
@ -7,6 +7,7 @@ import { isEqual, clone } from 'lodash-es'
|
|||
import { useI18n } from 'vue-i18n'
|
||||
import { useStore } from '~/store'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useDataStore } from '~/ui/stores/data'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
|
@ -34,14 +35,18 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
licenses: () => []
|
||||
})
|
||||
|
||||
// Since the object may be reactive (self-updating), we need a clone to compare changes
|
||||
const originalObject = Object.assign({}, props.object)
|
||||
|
||||
const { t } = useI18n()
|
||||
const configs = useEditConfigs()
|
||||
const store = useStore()
|
||||
const dataStore = useDataStore()
|
||||
const route = useRoute()
|
||||
|
||||
const config = computed(() => configs[props.objectType])
|
||||
const currentState = computed(() => config.value.fields.reduce((state: ReviewState, field) => {
|
||||
state[field.id] = { value: field.getValue(props.object) }
|
||||
state[field.id] = { value: field.getValue(originalObject) }
|
||||
return state
|
||||
}, {}))
|
||||
|
||||
|
@ -123,6 +128,9 @@ const submit = async () => {
|
|||
})
|
||||
|
||||
submittedMutation.value = response.data
|
||||
|
||||
// Immediately re-fetch the updated object into the store
|
||||
dataStore.get(props.objectType, props.object.id!.toString(), { immediate: true })
|
||||
} catch (error) {
|
||||
errors.value = (error as BackendError).backendErrors
|
||||
}
|
||||
|
@ -255,7 +263,7 @@ const resetField = (fieldId: string) => {
|
|||
:id="fieldConfig.id"
|
||||
v-model="values[fieldConfig.id]"
|
||||
:type="fieldConfig.inputType || 'text'"
|
||||
:required="fieldConfig.required"
|
||||
:required="fieldConfig.required || undefined"
|
||||
:name="fieldConfig.id"
|
||||
:label="fieldConfig.label"
|
||||
/>
|
||||
|
@ -303,17 +311,19 @@ const resetField = (fieldId: string) => {
|
|||
</attachment-input>
|
||||
</template>
|
||||
<template v-else-if="fieldConfig.type === 'tags'">
|
||||
<!-- TODO: Make Tags work -->
|
||||
<Pills
|
||||
:id="fieldConfig.id"
|
||||
ref="tags"
|
||||
v-for="key in [fieldConfig.id]"
|
||||
:key="key"
|
||||
:get="model => { values[fieldConfig.id] = model.currents.map(({ label }) => label) }"
|
||||
:set="model => ({
|
||||
...model,
|
||||
currents: (values[fieldConfig.id] as string[]).map(tag => ({ type: 'custom' as const, label: tag })),
|
||||
currents: (values[fieldConfig.id] as string[]).map(tag =>
|
||||
({ type: dataStore.tags().value.every(({ name })=> name !== tag) ? 'custom' as const : 'preset' as const, label: tag })),
|
||||
others: dataStore.tags().value.map(({ name }) =>
|
||||
({ type: 'preset' as const, label: name })),
|
||||
})"
|
||||
:label="fieldConfig.label"
|
||||
required="fieldConfig.required"
|
||||
:required="fieldConfig.required"
|
||||
>
|
||||
<Button
|
||||
icon="bi-x"
|
||||
|
@ -359,13 +369,12 @@ const resetField = (fieldId: string) => {
|
|||
primary
|
||||
:disabled="isLoading || !mutationPayload"
|
||||
>
|
||||
<span v-if="canEdit">
|
||||
{{ t('components.library.EditForm.button.submit') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t('components.library.EditForm.button.suggest') }}
|
||||
</span>
|
||||
{{ canEdit
|
||||
? t('components.library.EditForm.button.submit')
|
||||
: t('components.library.EditForm.button.suggest')
|
||||
}}
|
||||
</Button>
|
||||
<Spacer />
|
||||
</form>
|
||||
</Layout>
|
||||
</template>
|
||||
|
|
|
@ -1,12 +1,3 @@
|
|||
<template>
|
||||
<div class="main page-library">
|
||||
<RouterView />
|
||||
</div>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
main {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n'
|
|||
import { syncRef } from '@vueuse/core'
|
||||
import { sortedUniq } from 'lodash-es'
|
||||
import { useStore } from '~/store'
|
||||
import { useDataStore } from '~/ui/stores/data'
|
||||
import { useModal } from '~/ui/composables/useModal.ts'
|
||||
|
||||
import axios from 'axios'
|
||||
|
@ -60,11 +61,6 @@ const submittable = ref(false)
|
|||
|
||||
const tags = useRouteQuery<string[]>('tag', [])
|
||||
|
||||
computed(() => ({
|
||||
currents: [].map(tag => ({ type: 'custom' as const, label: tag })),
|
||||
others: tags.value.map(tag => ({ type: 'custom' as const, label: tag }))
|
||||
}))
|
||||
|
||||
const q = useRouteQuery('query', '')
|
||||
const query = ref(q.value)
|
||||
syncRef(q, query, { direction: 'ltr' })
|
||||
|
@ -116,6 +112,7 @@ const fetchData = async () => {
|
|||
}
|
||||
|
||||
const store = useStore()
|
||||
const dataStore = useDataStore()
|
||||
watch(() => store.state.moderation.lastUpdate, fetchData)
|
||||
watch([page, tags, q, ordering, orderingDirection], fetchData)
|
||||
fetchData()
|
||||
|
@ -176,13 +173,16 @@ const { to: upload } = useModal('upload')
|
|||
:placeholder="labels.searchPlaceholder"
|
||||
/>
|
||||
<Pills
|
||||
v-if="typeof tags === 'object'"
|
||||
:get="model => { tags = model.currents.map(({ label }) => label) }"
|
||||
:set="model => ({
|
||||
...model,
|
||||
currents: tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
||||
others: dataStore.tags().value
|
||||
.filter(({ name }) => result?.results?.some((object) => object.tags?.includes(name)) && !tags.includes(name))
|
||||
.map(({ name }) => ({ type: 'preset' as const, label: name })),
|
||||
})"
|
||||
:label="t('components.library.Podcasts.label.tags')"
|
||||
style="max-width: 150px;"
|
||||
style="max-width: 350px;"
|
||||
/>
|
||||
<Layout
|
||||
stack
|
||||
|
|
|
@ -60,7 +60,7 @@ const fetchData = async (url = props.url) => {
|
|||
fetchData()
|
||||
|
||||
watch(
|
||||
[() => store.state.moderation.lastUpdate, page],
|
||||
() => [store.state.moderation.lastUpdate, page.value],
|
||||
() => fetchData(),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
|
|
@ -5,6 +5,8 @@ import { ref, computed } from 'vue'
|
|||
import { useStore } from '~/store'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import UserLink from '~/components/common/UserLink.vue'
|
||||
|
||||
import RadioButton from './Button.vue'
|
||||
import Card from '~/components/ui/Card.vue'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
|
@ -53,11 +55,10 @@ const customRadioId = computed(() => props.customRadio?.id ?? null)
|
|||
</template>
|
||||
|
||||
<template #default>
|
||||
<user-link
|
||||
<UserLink
|
||||
v-if="radio.user"
|
||||
:user="radio.user"
|
||||
:avatar="false"
|
||||
discrete
|
||||
/>
|
||||
<Spacer />
|
||||
<div
|
||||
|
@ -90,9 +91,8 @@ const customRadioId = computed(() => props.customRadio?.id ?? null)
|
|||
:title="radio.name"
|
||||
>
|
||||
<template #default>
|
||||
<user-link
|
||||
<UserLink
|
||||
v-if="radio.user"
|
||||
discrete
|
||||
:user="radio.user"
|
||||
:avatar="false"
|
||||
/>
|
||||
|
|
|
@ -8,6 +8,8 @@ import { type Track, type User } from '~/types'
|
|||
import OptionsButton from '~/components/ui/button/Options.vue'
|
||||
import PlayButton from '~/components/ui/button/Play.vue'
|
||||
|
||||
// TODO (2.0.0+): Move into app namespace because this component uses funkwhale types
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{ play: [track: Track] }>()
|
||||
|
|
|
@ -195,6 +195,7 @@ onUnmounted(() =>
|
|||
<style lang="scss">
|
||||
.funkwhale {
|
||||
&.split-button {
|
||||
cursor: var(--cursor, pointer);
|
||||
|
||||
.button {
|
||||
display: inline-flex; // Ensure consistent display
|
||||
|
@ -214,6 +215,7 @@ onUnmounted(() =>
|
|||
}
|
||||
|
||||
&.button {
|
||||
cursor: var(--cursor, pointer);
|
||||
|
||||
// Layout
|
||||
|
||||
|
|
|
@ -48,7 +48,7 @@ const attributes = computed(() =>
|
|||
stack
|
||||
no-gap
|
||||
:class="[{ [$style.card]: true, [$style['is-category']]: category }, 'card']"
|
||||
v-bind="attributes"
|
||||
v-bind="{...attributes, ...$attrs, class: `${attributes.class} ${$attrs.class}`}"
|
||||
>
|
||||
<!-- Link -->
|
||||
|
||||
|
@ -137,7 +137,8 @@ const attributes = computed(() =>
|
|||
|
||||
<Layout
|
||||
v-if="$slots.default"
|
||||
no-gap
|
||||
:no-gap="Object.entries($attrs).every(
|
||||
([key, value]) => !key.startsWith('gap'))"
|
||||
:class="$style.content"
|
||||
>
|
||||
<slot />
|
||||
|
@ -166,14 +167,14 @@ const attributes = computed(() =>
|
|||
|
||||
<Spacer
|
||||
v-if="!$slots.footer && !$slots.action"
|
||||
:size="'small' in props? 24 : 32"
|
||||
:size="'small' in props && props.small? 18 : 24"
|
||||
/>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.card {
|
||||
--fw-card-padding: v-bind("'small' in props ? '16px' : '24px'");
|
||||
--fw-card-padding: v-bind("'small' in props && props.small ? '16px' : '24px'");
|
||||
|
||||
position: relative;
|
||||
|
||||
|
@ -295,12 +296,17 @@ const attributes = computed(() =>
|
|||
}
|
||||
}
|
||||
|
||||
/* If both card and an action control has a special color, draw foreground color line above that action control */
|
||||
&:global(:is(.primary, .destructive)) > .action :global(:is(.primary, .destructive)) {
|
||||
border-top-color: color-mix(in oklab, var(--color) 50%, transparent) !important;
|
||||
}
|
||||
|
||||
>.action {
|
||||
display: flex;
|
||||
background: color-mix(in oklab, var(--fw-bg-color) 80%, var(--fw-gray-500));
|
||||
border-bottom-left-radius: var(--fw-border-radius);
|
||||
border-bottom-right-radius: var(--fw-border-radius);
|
||||
margin-top:16px;
|
||||
margin-top: var(--fw-card-padding);
|
||||
|
||||
>*:not(.with-padding) {
|
||||
margin: 0;
|
||||
|
|
|
@ -30,14 +30,17 @@ const props = defineProps<{
|
|||
header
|
||||
flex
|
||||
gap-24
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<div v-if="$slots.image">
|
||||
<slot name="image" />
|
||||
</div>
|
||||
<!-- The inferred type of props occasionally overloads the typescript compiler. -->
|
||||
<!-- TODO: Remove @vue-ignore once tsc is re-implemented in Go (and 10x faster) -->
|
||||
<!-- @vue-ignore -->
|
||||
<Layout
|
||||
stack
|
||||
:gap-8="!(props.noGap as boolean)"
|
||||
:no-gap="props.noGap"
|
||||
v-bind="{ [props.noGap ? 'no-gap' : 'gap-8']: true }"
|
||||
style="flex-grow: 1;"
|
||||
>
|
||||
<Layout
|
||||
|
|
|
@ -24,15 +24,15 @@ const { icon, placeholder, ...props } = defineProps<{
|
|||
// https://technology.blog.gov.uk/2020/02/24/why-the-gov-uk-design-system-team-changed-the-input-type-for-numbers/
|
||||
// const isNumeric = restProps.numeric
|
||||
|
||||
const showPassword = ref(false)
|
||||
onKeyboardShortcut('escape', () => showPassword.value = false)
|
||||
const isShowingPassword = ref(false)
|
||||
onKeyboardShortcut('escape', () => isShowingPassword.value = false)
|
||||
|
||||
// TODO: Accept fallback $attrs: `const fallthroughAttrs = useAttrs()`
|
||||
|
||||
// TODO: Implement `copy password` button?
|
||||
|
||||
const attributes = computed(() => ({
|
||||
...(props.password && !showPassword.value ? { type: 'password' } : {}),
|
||||
...(props.password && !isShowingPassword.value ? { type: 'password' } : {}),
|
||||
...(props.search ? { type: 'search' } : {}),
|
||||
...(props.numeric ? { type: 'numeric' } : {})
|
||||
}))
|
||||
|
@ -85,7 +85,7 @@ const model = defineModel<string|number>({ required: true })
|
|||
:autofocus="autofocus || undefined"
|
||||
:placeholder="placeholder"
|
||||
@click.stop
|
||||
@blur="showPassword = false"
|
||||
@blur="isShowingPassword = false"
|
||||
>
|
||||
|
||||
<!-- Left side icon -->
|
||||
|
@ -119,13 +119,15 @@ const model = defineModel<string|number>({ required: true })
|
|||
<!-- Password -->
|
||||
<button
|
||||
v-if="props.password"
|
||||
style="background:transparent; border:none; appearance:none;"
|
||||
v-bind="{...$attrs, ...attributes, ...color(props, ['solid', 'default', 'secondary'])()}"
|
||||
style="background:transparent; border:none; appearance:none; height:calc(100% - 16px); color:var(--color); cursor:pointer;"
|
||||
role="switch"
|
||||
type="button"
|
||||
class="input-right show-password"
|
||||
title="toggle visibility"
|
||||
@click="showPassword = !showPassword"
|
||||
@blur="(e) => { if (e.relatedTarget && 'value' in e.relatedTarget && e.relatedTarget.value === model) showPassword = showPassword; else showPassword = false; }"
|
||||
:title="isShowingPassword ? t('vui.aria.password.hide') : t('vui.aria.password.show')"
|
||||
:aria-label="isShowingPassword ? t('vui.aria.password.hide') : t('vui.aria.password.show')"
|
||||
@click="isShowingPassword = !isShowingPassword"
|
||||
@blur="(e) => { if (e.relatedTarget && 'value' in e.relatedTarget && e.relatedTarget.value === model) isShowingPassword = isShowingPassword; else isShowingPassword = false; }"
|
||||
>
|
||||
<i class="bi bi-eye" />
|
||||
</button>
|
||||
|
|
|
@ -2,17 +2,20 @@
|
|||
import { type ColorProps, type DefaultProps, color } from '~/composables/color'
|
||||
import { watchEffect, ref, nextTick } from 'vue'
|
||||
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import Heading from '~/components/ui/Heading.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
title: string,
|
||||
overPopover?: true,
|
||||
destructive?: true,
|
||||
cancel?: string,
|
||||
cancel?: string | true,
|
||||
icon?: string,
|
||||
autofocus?: true | 'off'
|
||||
} &(ColorProps | DefaultProps)>()
|
||||
|
@ -94,6 +97,7 @@ onKeyboardShortcut('escape', () => { isOpen.value = false })
|
|||
icon="bi-x-lg"
|
||||
ghost
|
||||
align-self="baseline"
|
||||
:aria-label="t('vui.aria.close')"
|
||||
:autofocus="props.autofocus === undefined ? ($slots.actions || cancel ? undefined : true) : props.autofocus !== 'off'"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
|
@ -138,7 +142,7 @@ onKeyboardShortcut('escape', () => { isOpen.value = false })
|
|||
autofocus
|
||||
:on-click="()=>{ isOpen = false }"
|
||||
>
|
||||
{{ cancel }}
|
||||
{{ typeof cancel === 'string' ? cancel : t('vui.cancel') }}
|
||||
</Button>
|
||||
</Layout>
|
||||
</div>
|
||||
|
|
|
@ -4,6 +4,8 @@ import { type RouterLinkProps } from 'vue-router'
|
|||
import Link from '~/components/ui/Link.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
|
||||
// TODO (2.0.0+): Consolidate with `Tab`, `Tabs` components
|
||||
|
||||
type Tab = {
|
||||
title: string,
|
||||
to: RouterLinkProps['to'],
|
||||
|
@ -31,7 +33,7 @@ const tabs = defineModel<Tab[]>({ required: true })
|
|||
stack
|
||||
no-gap
|
||||
>
|
||||
<span :class="$style.fakeTitle">{{ tab.title }}</span>
|
||||
<span aria-hidden="true">{{ tab.title }}</span>
|
||||
<span :class="$style.realTitle">{{ tab.title }}</span>
|
||||
<span
|
||||
v-if="tab.badge"
|
||||
|
@ -45,7 +47,11 @@ const tabs = defineModel<Tab[]>({ required: true })
|
|||
</template>
|
||||
|
||||
<style module>
|
||||
.fakeTitle {
|
||||
.realTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
}
|
||||
[aria-hidden="true"] {
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
opacity: 0;
|
||||
|
@ -53,10 +59,6 @@ const tabs = defineModel<Tab[]>({ required: true })
|
|||
max-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.realTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.tab {
|
||||
--hover-background-color: transparent;
|
||||
--exact-active-background-color: transparent;
|
||||
|
|
|
@ -1,13 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, computed, onMounted } from 'vue'
|
||||
import { type ColorProps, type PastelProps, type VariantProps, type RaisedProps, color } from '~/composables/color'
|
||||
import { type ColorProps, type PastelProps, type VariantProps, type RaisedProps, type DefaultProps, color } from '~/composables/color'
|
||||
|
||||
import { uniqBy } from 'lodash-es'
|
||||
import { stringSimilarity } from "string-similarity-js";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Layout from './Layout.vue'
|
||||
import Button from './Button.vue'
|
||||
import Input from './Input.vue'
|
||||
import Popover from './Popover.vue'
|
||||
import PopoverItem from './popover/PopoverItem.vue'
|
||||
import { uniqBy } from 'lodash-es'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/* Event */
|
||||
|
||||
|
@ -23,7 +28,7 @@ const props = defineProps<{
|
|||
noUnderline?: true,
|
||||
cancel?: string,
|
||||
autofocus?: boolean
|
||||
} & (PastelProps | ColorProps)
|
||||
} & (PastelProps | ColorProps | DefaultProps)
|
||||
& VariantProps
|
||||
& RaisedProps
|
||||
>()
|
||||
|
@ -96,13 +101,14 @@ const pressedKey = (e: KeyboardEvent) => {
|
|||
// confirm or cancel
|
||||
switch (e.key) {
|
||||
case "Enter":
|
||||
confirmed("BestMatch"); break;
|
||||
case "Tab":
|
||||
case "ArrowLeft":
|
||||
case "ArrowRight":
|
||||
// case "ArrowLeft":
|
||||
// case "ArrowRight":
|
||||
case "Space":
|
||||
case ",":
|
||||
case " ":
|
||||
confirmed(); break;
|
||||
confirmed("New"); break;
|
||||
case "Escape":
|
||||
canceled(); break;
|
||||
}
|
||||
|
@ -128,17 +134,18 @@ const canceled = () => {
|
|||
isEditing.value = false
|
||||
}
|
||||
|
||||
const confirmed = () => {
|
||||
const confirmed = (option:"BestMatch" | "New") => {
|
||||
if (!previousValue || !currentItem.value || !otherItems.value) return
|
||||
|
||||
// Sanitize label
|
||||
currentItem.value.label = currentItem.value.label.replace(',', '').replace(' ', '').trim()
|
||||
|
||||
// Apply the identical, otherwise the best match, if available
|
||||
// Save the choice
|
||||
currentItem.value
|
||||
= otherItems.value?.find(({ label })=>label === currentItem.value?.label.trim())
|
||||
|| match.value
|
||||
|| currentItem.value
|
||||
= option === "New"
|
||||
? match.value && other.value(match.value).item.isCurrent
|
||||
? match.value
|
||||
: { label: currentItem.value.label.replace(',', '').replace(' ', '').trim(), type: "custom" }
|
||||
: match.value && option === "BestMatch"
|
||||
? match.value
|
||||
: currentItem.value
|
||||
|
||||
// Close dropdown
|
||||
isEditing.value = false
|
||||
|
@ -148,20 +155,28 @@ const confirmed = () => {
|
|||
emit('confirmed')
|
||||
}
|
||||
|
||||
/* TODO (2.0.0+):
|
||||
- Add MusicBrainz Tags from the Funkwhale Database
|
||||
*/
|
||||
const sortedOthers = computed(()=>
|
||||
otherItems.value && currentItem.value
|
||||
? otherItems.value.map((item) =>
|
||||
item.label.toLowerCase().includes(currentItem.value?.label.toLowerCase() || '')
|
||||
? [item.label.length - (currentItem.value?.label.length || 0), item] as const /* TODO: Use a more sophisticated algorithm for suggestions */
|
||||
: [99, item] as const
|
||||
[ 1-stringSimilarity(item.label, currentItem.value?.label || ''), item] as const
|
||||
)
|
||||
.filter(([delta, item]) =>
|
||||
// The current label is empty
|
||||
currentItem.value!.label!.length < 2
|
||||
// OR The difference between the other label and the current one is less than 100%
|
||||
|| delta<1
|
||||
)
|
||||
.sort(([deltaA, a], [deltaB, b]) =>
|
||||
// Sort from the lowest to the highest delta
|
||||
deltaA - deltaB
|
||||
)
|
||||
.map(([delta, item], index) =>
|
||||
index===0 && delta < 99 && currentItem.value && currentItem.value.label.length>0 && currentItem.value.label !== previousValue?.label
|
||||
index===0 && delta < 0.99 && currentItem.value && currentItem.value.label.length>0 && currentItem.value.label !== previousValue?.label
|
||||
? [-1, item] as const /* It's a match */
|
||||
: [delta, item] as const /* It's not a match */
|
||||
: [delta, item] as const /* It's not a direct match */
|
||||
)
|
||||
: []
|
||||
)
|
||||
|
@ -172,6 +187,7 @@ const match = computed(()=>
|
|||
: undefined
|
||||
)
|
||||
|
||||
/* Properties of any non-current item */
|
||||
const other = computed(() => (option: Item) => ({
|
||||
item: {
|
||||
onClick: () => {
|
||||
|
@ -187,11 +203,11 @@ const other = computed(() => (option: Item) => ({
|
|||
isEditing.value = false
|
||||
},
|
||||
isMatch: match.value?.label === option.label,
|
||||
isSame: option.label === currentItem.value?.label
|
||||
isCurrent: option.label === currentItem.value?.label
|
||||
},
|
||||
action: option.type === 'custom'
|
||||
? {
|
||||
title: 'Delete custom',
|
||||
title: t('vui.delete'),
|
||||
icon: 'bi-trash',
|
||||
onClick: () => {
|
||||
if (!currentItem.value || !otherItems.value) return;
|
||||
|
@ -207,7 +223,7 @@ const current = computed(() => (
|
|||
: currentItem.value.label === '' && previousValue?.label !== ''
|
||||
? {
|
||||
attributes: {
|
||||
title: `Reset to ${previousValue?.label || currentItem.value}`,
|
||||
title: t('vui.resetTo', { previousValue: (previousValue || currentItem.value)?.label }),
|
||||
icon: 'bi-arrow-counterclockwise'
|
||||
},
|
||||
onClick: () => {
|
||||
|
@ -218,7 +234,7 @@ const current = computed(() => (
|
|||
: currentItem.value.label === previousValue?.label && currentItem.value.type==='custom' && !otherItems.value?.find(({ label })=>label === currentItem.value?.label.trim()) && currentItem.value.label !== ''
|
||||
? {
|
||||
attributes: {
|
||||
title: `Delete ${currentItem.value.label}`,
|
||||
title: t('vui.deletItem', { item: currentItem.value.label }),
|
||||
icon: 'bi-trash',
|
||||
destructive: true
|
||||
},
|
||||
|
@ -228,23 +244,26 @@ const current = computed(() => (
|
|||
isEditing.value = false
|
||||
}
|
||||
} as const
|
||||
: currentItem.value.label !== match.value?.label && currentItem.value.type === 'custom' && currentItem.value.label.trim() !== '' && !otherItems.value?.find(({ label })=>label === currentItem.value?.label.trim())
|
||||
: currentItem.value.label !== match.value?.label
|
||||
&& currentItem.value.type === 'custom'
|
||||
&& currentItem.value.label.trim() !== ''
|
||||
&& !otherItems.value?.find(({ label }) => label === currentItem.value?.label.trim())
|
||||
? {
|
||||
attributes: {
|
||||
title: `Add ${currentItem.value.label}`,
|
||||
title: t('vui.addItem', { item: currentItem.value.label }),
|
||||
icon: 'bi-plus',
|
||||
'aria-pressed': !match.value,
|
||||
'solid': !match.value,
|
||||
primary: true
|
||||
},
|
||||
onClick: () => {
|
||||
if (!otherItems.value || !currentItem.value || otherItems.value.find(({ label })=>label === currentItem.value?.label.trim())) return
|
||||
if (!otherItems.value || !currentItem.value || otherItems.value.find(({ label }) => label === currentItem.value?.label.trim()))
|
||||
return
|
||||
otherItems.value.push({...currentItem.value})
|
||||
otherItems.value = unique(otherItems.value)
|
||||
}
|
||||
} as const
|
||||
: undefined
|
||||
))
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -296,20 +315,29 @@ const current = computed(() => (
|
|||
ghost
|
||||
v-bind="current?.attributes"
|
||||
square-small
|
||||
style="border-radius: 4px;"
|
||||
style="border-radius: 0 4px 4px 0;"
|
||||
:class="$style['input-delete-button']"
|
||||
@click.stop.prevent="current?.onClick"
|
||||
/>
|
||||
</template>
|
||||
</PopoverItem>
|
||||
<hr>
|
||||
<hr v-if="sortedOthers.length > 0">
|
||||
|
||||
<!-- Other items, Sorted by matchingness -->
|
||||
|
||||
<PopoverItem
|
||||
v-for="[, option] in sortedOthers"
|
||||
:key="option.label"
|
||||
:aria-pressed="other(option).item.isMatch || other(option).item.isSame || undefined"
|
||||
v-bind="(other(option).item.isMatch || other(option).item.isCurrent
|
||||
? ['aria-selected', 'solid', 'primary'] as const
|
||||
: [] as const
|
||||
).reduce((acc, key) => ({ ...acc, [key]: true }), {})"
|
||||
:icon-after="other(option).item.isMatch || other(option).item.isCurrent
|
||||
? 'bi-arrow-return-left'
|
||||
: undefined"
|
||||
:title="other(option).item.isMatch || other(option).item.isCurrent
|
||||
? t('vui.pressKeyToAction', { key: 'ENTER', action: 'accept' })
|
||||
: undefined"
|
||||
@click.stop.prevent="other(option).item.onClick"
|
||||
>
|
||||
<span :class="other(option).item.isMatch && $style.match">
|
||||
|
@ -329,7 +357,7 @@ const current = computed(() => (
|
|||
</template>
|
||||
</PopoverItem>
|
||||
|
||||
<hr>
|
||||
<hr v-if="cancel && sortedOthers.length > 0">
|
||||
|
||||
<PopoverItem
|
||||
v-if="cancel"
|
||||
|
@ -353,121 +381,124 @@ const current = computed(() => (
|
|||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.pill {
|
||||
position: relative;
|
||||
display: block;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
outline: 0px transparent;
|
||||
border: 0px;
|
||||
.pill {
|
||||
position: relative;
|
||||
display: block;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
outline: 0px transparent;
|
||||
border: 0px;
|
||||
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
|
||||
border-radius: 100vh;
|
||||
border-radius: 100vh;
|
||||
|
||||
// Negative margins for increased interactive area; visual correction for rounded shape
|
||||
margin: -4px -4px;
|
||||
padding: 0px;
|
||||
// Negative margins for increased interactive area; visual correction for rounded shape
|
||||
margin: -4 -8px;
|
||||
padding: 4px 4px;
|
||||
|
||||
border-radius: 100vh;
|
||||
border-radius: 100vh;
|
||||
|
||||
width: fit-content;
|
||||
width: fit-content;
|
||||
|
||||
> .container {
|
||||
cursor:var(--cursor, pointer);
|
||||
|
||||
> .container {
|
||||
|
||||
border-radius: inherit;
|
||||
|
||||
> .pill-content {
|
||||
// 1px border
|
||||
padding: 4px 9px;
|
||||
white-space: nowrap;
|
||||
min-width: 56px;
|
||||
border-radius: inherit;
|
||||
|
||||
> .pill-content {
|
||||
// 1px border
|
||||
padding: 4px 9px;
|
||||
white-space: nowrap;
|
||||
min-width: 56px;
|
||||
border-radius: inherit;
|
||||
//Works as anchor point for popup
|
||||
position: relative;
|
||||
|
||||
//Works as anchor point for popup
|
||||
position: relative;
|
||||
|
||||
&input {
|
||||
min-width: 44px; flex-basis: 44px;
|
||||
}
|
||||
|
||||
&:focus-visible, &:focus {
|
||||
outline: 1px solid var(--focus-ring-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:has(+.pill-action) {
|
||||
margin-right: -26px;
|
||||
padding-right: 26px;
|
||||
}
|
||||
&input {
|
||||
min-width: 44px; flex-basis: 44px;
|
||||
}
|
||||
|
||||
> .pill-image {
|
||||
position: relative;
|
||||
border-radius: inherit;
|
||||
overflow: hidden;
|
||||
height: 26px;
|
||||
aspect-ratio: 1;
|
||||
align-content: center;
|
||||
|
||||
> * {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
> i.bi {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
> img {
|
||||
object-fit: cover;
|
||||
}
|
||||
&:focus-visible, &:focus {
|
||||
outline: 1px solid var(--focus-ring-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
> .pill-action {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 9px;
|
||||
margin: -9px;
|
||||
aspect-ratio: 1;
|
||||
border-radius: inherit;
|
||||
overflow: hidden;
|
||||
align-content: center;
|
||||
flex-shrink:0;
|
||||
&:has(+.pill-action) {
|
||||
margin-right: -26px;
|
||||
padding-right: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
> * {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
> .pill-image {
|
||||
position: relative;
|
||||
border-radius: inherit;
|
||||
overflow: hidden;
|
||||
height: 26px;
|
||||
aspect-ratio: 1;
|
||||
align-content: center;
|
||||
|
||||
> * {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
> i.bi {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
> img {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.no-underline) {
|
||||
text-decoration: underline;
|
||||
> .pill-action {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
cursor: var(--cursor, pointer);
|
||||
height: 44px;
|
||||
padding: 9px;
|
||||
margin: -9px;
|
||||
aspect-ratio: 1;
|
||||
border-radius: inherit;
|
||||
overflow: hidden;
|
||||
align-content: center;
|
||||
flex-shrink:0;
|
||||
|
||||
> * {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
font-weight: normal;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&.is-focused,
|
||||
&:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
.input {
|
||||
// Position the input label within a 40px high popover item
|
||||
margin: -4px -16px;
|
||||
position: relative;
|
||||
top: -4px;
|
||||
&:has(+* .input-delete-button:hover) input{
|
||||
background: var(--background-color);
|
||||
color: var(--disabled-color) !important;
|
||||
}
|
||||
|
||||
&:hover:not(.no-underline) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
font-weight: normal;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&.is-focused,
|
||||
&:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
.input {
|
||||
// Position the input label within a 40px high popover item
|
||||
margin: -4px -16px;
|
||||
position: relative;
|
||||
top: -4px;
|
||||
&:has(+* .input-delete-button:hover) input{
|
||||
background: var(--background-color);
|
||||
color: var(--disabled-color) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { color } from '~/composables/color'
|
||||
|
||||
import { uniqBy } from 'lodash-es'
|
||||
|
||||
import Pill from './Pill.vue'
|
||||
import Layout from './Layout.vue'
|
||||
import Button from './Button.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/**
|
||||
* Use `get` to read the pills into your app.
|
||||
* Use `set` to write your app's state back into the pills.
|
||||
|
@ -33,11 +38,18 @@ const emptyItem = {
|
|||
label: '', type: 'custom'
|
||||
} as const
|
||||
|
||||
const nextIndex = ref<number | undefined>(undefined)
|
||||
const nextIndex = ref<number | undefined>()
|
||||
|
||||
const unique = (value: Item[]) => uniqBy(value, item => item.label)
|
||||
|
||||
const sanitize = () => {
|
||||
if (model.value.others) {
|
||||
model.value.currents = [...model.value.currents.filter(({ label }) => label !== ''), { ...emptyItem }]
|
||||
// Filter out empty items and add an empty item at the endfor the user to add pills
|
||||
model.value.currents = unique([...model.value.currents.filter(({ label }) => label !== ''), { ...emptyItem }])
|
||||
// Filter out others that are already in the current list
|
||||
model.value.others = model.value.others.filter(({ label }) => model.value.currents.every(item => item.label !== label))
|
||||
// Store the result, excluding the added empty item
|
||||
// TODO: Check if this needs to run on every sanitization
|
||||
props.get({ ...model.value, currents: [...model.value.currents.filter(({ label }) => label !== '')] });
|
||||
}
|
||||
}
|
||||
|
@ -48,10 +60,9 @@ watch(model, () => {
|
|||
sanitize()
|
||||
})
|
||||
|
||||
sanitize();
|
||||
|
||||
onMounted(() => {
|
||||
model.value = props.set(model.value)
|
||||
sanitize();
|
||||
})
|
||||
</script>
|
||||
|
||||
|
@ -83,7 +94,10 @@ onMounted(() => {
|
|||
<Layout
|
||||
flex
|
||||
gap-4
|
||||
v-bind="color({}, ['solid', 'default', 'secondary'])()"
|
||||
v-bind="{
|
||||
...$attrs,
|
||||
...color({}, ['solid', 'default', 'secondary'])()
|
||||
}"
|
||||
:class="$style.list"
|
||||
>
|
||||
<Pill
|
||||
|
@ -93,15 +107,15 @@ onMounted(() => {
|
|||
v-model:others="model.others"
|
||||
:cancel="cancel"
|
||||
:autofocus="index === nextIndex && nextIndex < model.currents.length"
|
||||
outline
|
||||
no-underline
|
||||
:class="[$style.pill, $style[
|
||||
isStatic
|
||||
? 'static'
|
||||
: model.currents[index].label === ''
|
||||
? 'empty'
|
||||
: model.currents[index].type
|
||||
]]"
|
||||
v-bind="model.currents[index].label === '' ? {'solid': true, 'default': true} : {'secondary':true}"
|
||||
:class="[
|
||||
$style.pill,
|
||||
isStatic ? $style.static
|
||||
: model.currents[index].label === '' ? $style.empty
|
||||
: model.currents[index].type === 'custom' ? $style.custom
|
||||
: $style.preset
|
||||
]"
|
||||
@opened="() => { model = props.set(model); }"
|
||||
@closed="() => { sanitize(); }"
|
||||
@confirmed="() => { next(index) }"
|
||||
|
@ -119,11 +133,11 @@ onMounted(() => {
|
|||
primary
|
||||
round
|
||||
icon="bi-x"
|
||||
title="Deselect"
|
||||
:title="t('vui.deselect')"
|
||||
@click.stop.prevent="() => {
|
||||
if (!model.others) return
|
||||
model.others.push({...model.currents[index]});
|
||||
model.currents[index] = {label: '', type: 'custom'}
|
||||
model.others.push({ ...model.currents[index] });
|
||||
model.currents[index] = { label: '', type: 'custom' }
|
||||
sanitize()
|
||||
}"
|
||||
/>
|
||||
|
@ -134,36 +148,42 @@ onMounted(() => {
|
|||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.pills {
|
||||
>.label {
|
||||
margin-top: -18px;
|
||||
padding-bottom: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
>.list {
|
||||
position: relative;
|
||||
|
||||
// Compensation for round shapes -> https://en.wikipedia.org/wiki/Overshoot_(typography)
|
||||
margin: 0 -4px;
|
||||
|
||||
// padding: 4px;
|
||||
border-radius: 22px;
|
||||
|
||||
gap: 8px;
|
||||
padding: 2px;
|
||||
|
||||
min-height: 36px;
|
||||
|
||||
.empty {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
&:hover:has(select)>.list {
|
||||
box-shadow: inset 0 0 0 4px var(--border-color)
|
||||
}
|
||||
:has(>select:focus) {
|
||||
box-shadow: inset 0 0 0 4px var(--focus-ring-color)
|
||||
}
|
||||
.pills {
|
||||
>.label {
|
||||
margin-top: -18px;
|
||||
padding-bottom: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
>.list {
|
||||
position: relative;
|
||||
|
||||
// Compensation for round shapes -> https://en.wikipedia.org/wiki/Overshoot_(typography)
|
||||
margin: 0 -4px;
|
||||
|
||||
border-radius: 22px;
|
||||
|
||||
gap: 0px;
|
||||
padding: 4.5px 6px;
|
||||
|
||||
min-height: 36px;
|
||||
|
||||
// Different kinds of pills
|
||||
> .pill {
|
||||
background: transparent !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
.empty {
|
||||
flex-grow: 1;
|
||||
--cursor: text;
|
||||
}
|
||||
|
||||
}
|
||||
&:hover:has(select)>.list {
|
||||
box-shadow: inset 0 0 0 4px var(--border-color)
|
||||
}
|
||||
:has(>select:focus) {
|
||||
box-shadow: inset 0 0 0 4px var(--focus-ring-color)
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, inject, provide, shallowReactive, watch, onScopeDispose } from 'vue'
|
||||
import { whenever, useElementBounding, onClickOutside } from '@vueuse/core'
|
||||
import { whenever, useElementBounding, onClickOutside, refDebounced } from '@vueuse/core'
|
||||
|
||||
import { isMobileView, useScreenSize } from '~/composables/screen'
|
||||
import { POPOVER_INJECTION_KEY, POPOVER_CONTEXT_INJECTION_KEY } from '~/injection-keys'
|
||||
|
@ -14,6 +14,10 @@ import { type ColorProps, type DefaultProps, type RaisedProps, color } from '~/c
|
|||
|
||||
const isOpen = defineModel<boolean>({ default: false })
|
||||
|
||||
// Delay closing by 300ms, but allow immediate closing
|
||||
const shouldDelayClose = ref(true)
|
||||
const isOpenDelayed = refDebounced(isOpen, () => isOpen.value ? 0 : (shouldDelayClose.value ? 300 : 0))
|
||||
|
||||
const { positioning = 'vertical', ...colorProps } = defineProps<{
|
||||
positioning?:'horizontal' | 'vertical'
|
||||
} &(ColorProps | DefaultProps) & RaisedProps>()
|
||||
|
@ -27,12 +31,14 @@ const inSlot = ref()
|
|||
const mobileClickOutside = (event: MouseEvent) => {
|
||||
const inPopover = !!(event.target as HTMLElement).closest('.funkwhale.popover')
|
||||
if (isMobile.value && !inPopover) {
|
||||
shouldDelayClose.value = false
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
onClickOutside(popover, async (event) => {
|
||||
const inPopover = !!(event.target as HTMLElement).closest('.funkwhale.popover')
|
||||
if (!isMobile.value && !inPopover) {
|
||||
shouldDelayClose.value = false
|
||||
isOpen.value = false
|
||||
}
|
||||
}, { ignore: [slot] })
|
||||
|
@ -48,7 +54,7 @@ whenever(isOpen, update, { immediate: true })
|
|||
|
||||
const { width: screenWidth, height: screenHeight } = useScreenSize()
|
||||
|
||||
// TODO (basic functionality):
|
||||
// TODO (2.0.0+) ~Type::A11y #2487:
|
||||
// - I can't operate the popup with a keyboard. Remove barrier for people not using a mouse (A11y)
|
||||
// - Switching to submenus is error-prone. When moving cursor into freshly opened submenu, it should not close if the cursor crosses another menu item
|
||||
// - Large menus disappear. When menus get big, they need to scroll.
|
||||
|
@ -98,11 +104,15 @@ onScopeDispose(() => {
|
|||
stack?.splice(stack.indexOf(isOpen), 1)
|
||||
})
|
||||
|
||||
// Check if there's an ancestral context to inherit close function from
|
||||
const ancestralContext = inject(POPOVER_CONTEXT_INJECTION_KEY, null)
|
||||
|
||||
// Provide context for child items
|
||||
const hoveredItem = ref(-2)
|
||||
provide(POPOVER_CONTEXT_INJECTION_KEY, {
|
||||
items: ref(0),
|
||||
hoveredItem
|
||||
hoveredItem,
|
||||
close: ancestralContext?.close ?? (() => { isOpen.value = false })
|
||||
})
|
||||
|
||||
// Closing
|
||||
|
@ -115,7 +125,10 @@ const closeChild = () => {
|
|||
|
||||
// Recursively close popover tree
|
||||
watch(isOpen, (isOpen) => {
|
||||
if (isOpen) return
|
||||
if (isOpen) {
|
||||
shouldDelayClose.value = true
|
||||
return
|
||||
}
|
||||
closeChild()
|
||||
})
|
||||
</script>
|
||||
|
@ -136,7 +149,7 @@ watch(isOpen, (isOpen) => {
|
|||
</div>
|
||||
|
||||
<teleport
|
||||
v-if="isOpen"
|
||||
v-if="isOpenDelayed"
|
||||
to="body"
|
||||
>
|
||||
<div
|
||||
|
|
|
@ -7,9 +7,6 @@ import Button from '~/components/ui/Button.vue'
|
|||
import Link from '~/components/ui/Link.vue'
|
||||
import Heading from '~/components/ui/Heading.vue'
|
||||
|
||||
const actionComponents
|
||||
= { Button, Link }
|
||||
|
||||
const props = defineProps<{
|
||||
columnsPerItem?: 1 | 2 | 3 | 4
|
||||
alignLeft?: boolean
|
||||
|
@ -106,7 +103,7 @@ const props = defineProps<{
|
|||
</template>
|
||||
<!-- Action! You can either specify `to` or `onClick`. -->
|
||||
<component
|
||||
:is="'onClick' in action ? actionComponents.Button : actionComponents.Link"
|
||||
:is="'to' in action ? Link : Button"
|
||||
v-if="action"
|
||||
thin-font
|
||||
min-content
|
||||
|
@ -141,7 +138,7 @@ const props = defineProps<{
|
|||
transition: max-height .5s, grid-template-rows .3s, padding .2s;
|
||||
`"
|
||||
v-bind="columnsPerItem
|
||||
? { grid: `auto / repeat(auto-fit, 46px)` }
|
||||
? { grid: 'auto / repeat(auto-fit, 46px)' }
|
||||
: { flex: true }
|
||||
"
|
||||
>
|
||||
|
|
|
@ -0,0 +1,256 @@
|
|||
<script setup lang="ts" generic="TOption extends string|number">
|
||||
import { nextTick, onMounted, onUnmounted, ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { type ColorProps, type VariantProps, type DefaultProps, type RaisedProps, type PastelProps, color } from '~/composables/color.ts'
|
||||
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
|
||||
/** Select a value from a list of labeled options. */
|
||||
|
||||
const props = defineProps<{
|
||||
icon?: string;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
autofocus?: boolean;
|
||||
} & (ColorProps | DefaultProps | PastelProps)
|
||||
& VariantProps
|
||||
& RaisedProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const select = ref()
|
||||
|
||||
const previouslyFocusedElement = ref()
|
||||
|
||||
onMounted(() => props.autofocus && nextTick(() => {
|
||||
previouslyFocusedElement.value = document.activeElement
|
||||
previouslyFocusedElement.value?.blur()
|
||||
select.value.focus()
|
||||
// Use initial value if current is undefined
|
||||
current.value = current.value ?? initial.value
|
||||
}))
|
||||
|
||||
onUnmounted(() =>
|
||||
previouslyFocusedElement.value?.focus()
|
||||
)
|
||||
|
||||
const current = defineModel<TOption | string>('current')
|
||||
const options = defineModel<Record<TOption, string>>('options', { required: true })
|
||||
const initial = defineModel<TOption>('initial')
|
||||
|
||||
// const selected =computed(()=> current.value ?? props.placeholder ?? initial.value ?? '')
|
||||
const selected = computed<TOption | string>({
|
||||
get():TOption | string{
|
||||
return current.value ?? initial.value ?? props.placeholder ?? ''
|
||||
},
|
||||
set(newValue: TOption | string) {
|
||||
current.value = newValue
|
||||
}
|
||||
})
|
||||
|
||||
const reset = () => { current.value = initial.value }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout
|
||||
stack
|
||||
no-gap
|
||||
label
|
||||
class="funkwhale select"
|
||||
>
|
||||
<span
|
||||
v-if="$slots['label']"
|
||||
:class="$style.label"
|
||||
>
|
||||
<slot name="label" />
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="label"
|
||||
:class="$style.label"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
|
||||
<select
|
||||
v-bind="{...$attrs, ...color(props, ['solid', 'secondary'])()}"
|
||||
ref="select"
|
||||
v-model="selected"
|
||||
:autofocus="autofocus || undefined"
|
||||
>
|
||||
<option
|
||||
v-for="([option, label_], index) in placeholder ? [[placeholder, placeholder], ...Object.entries(options)] : Object.entries(options)"
|
||||
:key="index"
|
||||
:value="option"
|
||||
:disabled="label_ === placeholder"
|
||||
:selected="label_ === placeholder"
|
||||
>
|
||||
{{ label_ }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Left side icon -->
|
||||
|
||||
<div
|
||||
v-if="icon"
|
||||
:class="$style.prefix"
|
||||
>
|
||||
<i :class="['bi', icon]" />
|
||||
</div>
|
||||
|
||||
<!-- Right side -->
|
||||
|
||||
<div
|
||||
v-if="$slots['input-right']"
|
||||
:class="$style['input-right']"
|
||||
>
|
||||
<span :class="$style['span-right']">
|
||||
<slot name="input-right" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Reset -->
|
||||
|
||||
<Button
|
||||
v-if="initial && initial !== current"
|
||||
ghost
|
||||
primary
|
||||
square-small
|
||||
icon="bi-arrow-counterclockwise"
|
||||
:class="[$style['input-right'], $style.reset]"
|
||||
:on-click="reset"
|
||||
:title="t('components.library.EditForm.button.reset')"
|
||||
/>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
:global(.funkwhale.select) {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
--gap: 8px;
|
||||
--height: 48px;
|
||||
--v: 8px;
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
> select {
|
||||
width: 100%;
|
||||
min-height:var(--height);
|
||||
padding: calc(var(--v) - 0.5px) 16px calc(var(--v) + 0.5px) 16px;
|
||||
font-size: 14px;
|
||||
line-height: var(--height);
|
||||
border-radius: var(--fw-border-radius);
|
||||
color:var(--color);
|
||||
|
||||
&:hover {
|
||||
box-shadow: inset 0 0 0 3px var(--border-color);
|
||||
border-color: var(--border-color);
|
||||
&:global(.primary) {
|
||||
outline: 3px solid var(--focus-ring-color);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
&:focus {
|
||||
box-shadow: inset 0 0 0 3px var(--focus-ring-color);
|
||||
border-color: var(--focus-ring-color);
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
select:required:invalid {
|
||||
color: var(--fw-placeholder-color);
|
||||
}
|
||||
option[value=""][disabled] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:has(.prefix>i) > select {
|
||||
padding-left: 36px;
|
||||
}
|
||||
|
||||
> .label {
|
||||
margin-top: -18px;
|
||||
padding-bottom: var(--gap);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&:has(>[required])>.label:after {
|
||||
content: ' *';
|
||||
}
|
||||
|
||||
> .prefix,
|
||||
> .input-right {
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: var(--fw-placeholder-color);
|
||||
}
|
||||
|
||||
> .prefix {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
|
||||
height: calc(100% - 13px);
|
||||
min-width: 40px;
|
||||
display: flex;
|
||||
|
||||
> i {
|
||||
font-size:18px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
&:has(>i) {
|
||||
/* Icon prefixes allow click-through; i.e. the user can open the dropdown by clicking the icon */
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:has(>.prefix)>input {
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
> .input-right {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
height: 100%;
|
||||
min-width: 48px;
|
||||
display: flex;
|
||||
|
||||
> i {
|
||||
font-size:18px;
|
||||
}
|
||||
|
||||
> .span-right {
|
||||
padding: calc(var(--padding-v) - 1px) var(--padding) calc(var(--padding-v) + 1px) var(--padding);
|
||||
|
||||
> .button {
|
||||
margin-right: -16px;
|
||||
margin-top: 2px;
|
||||
border-bottom-left-radius: 0px;
|
||||
border-top-left-radius: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
>.reset {
|
||||
min-width: auto;
|
||||
margin: 4px;
|
||||
border-radius: 4px;
|
||||
max-height: 36px;
|
||||
max-width: 36px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -254,6 +254,7 @@ onMounted(() => {
|
|||
secondary
|
||||
square-small
|
||||
icon="bi-paragraph"
|
||||
:aria-label="t('vui.aria.text.paragraph')"
|
||||
:aria-pressed="isParagraph || undefined"
|
||||
:disabled="preview"
|
||||
@click="paragraph"
|
||||
|
@ -262,6 +263,7 @@ onMounted(() => {
|
|||
secondary
|
||||
square-small
|
||||
icon="bi-type-h1"
|
||||
:aria-label="t('vui.aria.text.heading1')"
|
||||
:aria-pressed="isHeading1 || undefined"
|
||||
:disabled="preview"
|
||||
@click="heading1"
|
||||
|
@ -270,6 +272,7 @@ onMounted(() => {
|
|||
secondary
|
||||
square-small
|
||||
icon="bi-type-h2"
|
||||
:aria-label="t('vui.aria.text.heading2')"
|
||||
:aria-pressed="isHeading2 || undefined"
|
||||
:disabled="preview"
|
||||
@click="heading2"
|
||||
|
@ -278,6 +281,7 @@ onMounted(() => {
|
|||
secondary
|
||||
square-small
|
||||
icon="bi-quote"
|
||||
:aria-label="t('vui.aria.text.quote')"
|
||||
:aria-pressed="isQuote || undefined"
|
||||
:disabled="preview"
|
||||
@click="quote"
|
||||
|
@ -286,6 +290,7 @@ onMounted(() => {
|
|||
secondary
|
||||
square-small
|
||||
icon="bi-list-ol"
|
||||
:aria-label="t('vui.aria.text.orderedList')"
|
||||
:aria-pressed="isOrderedList || undefined"
|
||||
:disabled="preview"
|
||||
@click="orderedList"
|
||||
|
@ -294,6 +299,7 @@ onMounted(() => {
|
|||
secondary
|
||||
square-small
|
||||
icon="bi-list-ul"
|
||||
:aria-label="t('vui.aria.text.unorderedList')"
|
||||
:aria-pressed="isUnorderedList || undefined"
|
||||
:disabled="preview"
|
||||
@click="unorderedList"
|
||||
|
@ -305,6 +311,7 @@ onMounted(() => {
|
|||
secondary
|
||||
square-small
|
||||
icon="bi-type-bold"
|
||||
:aria-label="t('vui.aria.text.bold')"
|
||||
:disabled="preview"
|
||||
@click="bold"
|
||||
/>
|
||||
|
@ -312,6 +319,7 @@ onMounted(() => {
|
|||
secondary
|
||||
square-small
|
||||
icon="bi-type-italic"
|
||||
:aria-label="t('vui.aria.text.italic')"
|
||||
:disabled="preview"
|
||||
@click="italics"
|
||||
/>
|
||||
|
@ -319,6 +327,7 @@ onMounted(() => {
|
|||
secondary
|
||||
square-small
|
||||
icon="bi-type-strikethrough"
|
||||
:aria-label="t('vui.aria.text.strikethrough')"
|
||||
:disabled="preview"
|
||||
@click="strikethrough"
|
||||
/>
|
||||
|
@ -326,6 +335,7 @@ onMounted(() => {
|
|||
secondary
|
||||
square-small
|
||||
icon="bi-link-45deg"
|
||||
:aria-label="t('vui.aria.text.link')"
|
||||
:disabled="preview"
|
||||
@click="link"
|
||||
/>
|
||||
|
@ -354,7 +364,7 @@ onMounted(() => {
|
|||
:aria-pressed="preview || undefined"
|
||||
@click="preview = !preview"
|
||||
>
|
||||
{{ t('components.common.ContentForm.button.preview') }}
|
||||
{{ t('vui.preview') }}
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
|
|
|
@ -50,6 +50,10 @@
|
|||
font-weight:600;
|
||||
}
|
||||
|
||||
&:has(>[required])>.label:after {
|
||||
content: ' *';
|
||||
}
|
||||
|
||||
> .prefix,
|
||||
> .input-right {
|
||||
align-items: center;
|
||||
|
|
|
@ -7,6 +7,7 @@ const value = defineModel<boolean>()
|
|||
<template>
|
||||
<PopoverItem
|
||||
class="checkbox"
|
||||
:keep-open="true"
|
||||
@click="value = !value"
|
||||
>
|
||||
<i :class="['bi', value ? 'bi-check-square' : 'bi-square']" />
|
||||
|
|
|
@ -2,19 +2,29 @@
|
|||
import { inject, ref } from 'vue'
|
||||
import { type RouterLinkProps, RouterLink } from 'vue-router'
|
||||
import { POPOVER_CONTEXT_INJECTION_KEY, type PopoverContext } from '~/injection-keys'
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
|
||||
const animation = ref<'none' | 'flash'>('none')
|
||||
|
||||
const emit = defineEmits<{ setId: [value: number] }>()
|
||||
|
||||
const isOpen = ref(true)
|
||||
// Delay closing by 300ms
|
||||
const isOpenDelayed = refDebounced(isOpen, () => isOpen.value ? 0 : 300)
|
||||
|
||||
const { parentPopoverContext, to } = defineProps<{
|
||||
parentPopoverContext?: PopoverContext;
|
||||
to?:RouterLinkProps['to'];
|
||||
icon?: string;
|
||||
iconAfter?: string;
|
||||
keepOpen?: boolean;
|
||||
}>()
|
||||
const { items, hoveredItem } = parentPopoverContext ?? inject(POPOVER_CONTEXT_INJECTION_KEY, {
|
||||
const { items, hoveredItem, close } = parentPopoverContext ?? inject(POPOVER_CONTEXT_INJECTION_KEY, {
|
||||
items: ref(0),
|
||||
hoveredItem: ref(-2)
|
||||
hoveredItem: ref(-2),
|
||||
close: () => { isOpen.value = false }
|
||||
})
|
||||
|
||||
const id = items.value++
|
||||
|
@ -22,62 +32,83 @@ emit('setId', id)
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
v-if="to && typeof to === 'string' && to.startsWith('http')"
|
||||
:href="to.toString()"
|
||||
class="popover-item"
|
||||
target="_blank"
|
||||
>
|
||||
<i
|
||||
v-if="icon"
|
||||
:class="['bi', icon]"
|
||||
/>
|
||||
<slot />
|
||||
<template v-if="isOpenDelayed">
|
||||
<a
|
||||
v-if="to && typeof to === 'string' && to.startsWith('http')"
|
||||
:href="to.toString()"
|
||||
class="popover-item"
|
||||
target="_blank"
|
||||
@mouseover="hoveredItem = id"
|
||||
@click="() => { if (!keepOpen) { animation = 'flash'; close(); } }"
|
||||
>
|
||||
<i
|
||||
v-if="icon"
|
||||
:class="['bi', icon]"
|
||||
/>
|
||||
<slot />
|
||||
|
||||
<div class="after">
|
||||
<slot name="after" />
|
||||
</div>
|
||||
</a>
|
||||
<RouterLink
|
||||
v-else-if="to"
|
||||
:to="to"
|
||||
class="popover-item"
|
||||
@mouseover="hoveredItem = id"
|
||||
>
|
||||
<i
|
||||
v-if="icon"
|
||||
:class="['bi', icon]"
|
||||
/>
|
||||
<slot />
|
||||
<div class="after">
|
||||
<i
|
||||
v-if="iconAfter"
|
||||
:class="['bi', iconAfter]"
|
||||
/>
|
||||
<slot name="after" />
|
||||
</div>
|
||||
</a>
|
||||
<RouterLink
|
||||
v-else-if="to"
|
||||
:to="to"
|
||||
class="popover-item"
|
||||
@mouseover="hoveredItem = id"
|
||||
@click="() => { if (!keepOpen) { animation = 'flash'; close(); } }"
|
||||
>
|
||||
<i
|
||||
v-if="icon"
|
||||
:class="['bi', icon]"
|
||||
/>
|
||||
<slot />
|
||||
|
||||
<div class="after">
|
||||
<slot name="after" />
|
||||
</div>
|
||||
</RouterLink>
|
||||
<Button
|
||||
v-else
|
||||
ghost
|
||||
thin-font
|
||||
v-bind="$attrs"
|
||||
style="
|
||||
<div class="after">
|
||||
<i
|
||||
v-if="iconAfter"
|
||||
:class="['bi', iconAfter]"
|
||||
/>
|
||||
<slot name="after" />
|
||||
</div>
|
||||
</RouterLink>
|
||||
<Button
|
||||
v-else
|
||||
ghost
|
||||
thin-font
|
||||
v-bind="{ ...$attrs, onClick: undefined }"
|
||||
style="
|
||||
width: 100%;
|
||||
textAlign: left;
|
||||
text-align: left;
|
||||
gap: 8px;
|
||||
"
|
||||
:icon="icon"
|
||||
class="popover-item"
|
||||
@mouseover="hoveredItem = id"
|
||||
>
|
||||
<slot />
|
||||
:icon="icon"
|
||||
class="popover-item"
|
||||
:on-click="(event:unknown) => {
|
||||
($attrs.onClick as Function | undefined)?.(event);
|
||||
if (!keepOpen) { animation = 'flash'; close(); }
|
||||
}"
|
||||
@mouseover="hoveredItem = id"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<div class="after">
|
||||
<slot name="after" />
|
||||
</div>
|
||||
</Button>
|
||||
<div class="after">
|
||||
<i
|
||||
v-if="iconAfter"
|
||||
:class="['bi', iconAfter]"
|
||||
/>
|
||||
<slot name="after" />
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
div { color:var(--fw-text-color); }
|
||||
div { color:var(--fw-text-color); }
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
|
@ -91,6 +122,8 @@ emit('setId', id)
|
|||
border-radius: var(--fw-border-radius);
|
||||
white-space: nowrap;
|
||||
|
||||
animation: v-bind('animation') 0.15s steps(1, end) 1 reverse;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--hover-background-color);
|
||||
}
|
||||
|
@ -133,7 +166,15 @@ emit('setId', id)
|
|||
display: flex;
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
> i:last-child {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@keyframes flash {
|
||||
50% {
|
||||
filter: invert(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -3,11 +3,12 @@ import PopoverRadioItem from './PopoverRadioItem.vue'
|
|||
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { choices } = defineProps<{ choices:string[] }>()
|
||||
const { choices, keepOpen } = defineProps<{ choices:string[], keepOpen?: false }>()
|
||||
|
||||
const filteredChoices = computed(() => new Set(choices))
|
||||
|
||||
const value = defineModel<string>('modelValue', { required: true })
|
||||
const isOpen = defineModel<boolean>('isOpen', { default: true })
|
||||
|
||||
// NOTE: Due to the usage of a ref inside a Proxy, this is reactive.
|
||||
const choiceValues = new Proxy<Record<string, boolean>>(Object.create(null), {
|
||||
|
@ -19,6 +20,10 @@ const choiceValues = new Proxy<Record<string, boolean>>(Object.create(null), {
|
|||
if (!val || typeof key === 'symbol') return false
|
||||
|
||||
value.value = key
|
||||
|
||||
if (!keepOpen) {
|
||||
isOpen.value = false
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
@ -29,6 +34,7 @@ const choiceValues = new Proxy<Record<string, boolean>>(Object.create(null), {
|
|||
v-for="choice of filteredChoices"
|
||||
:key="choice"
|
||||
v-model="choiceValues[choice]"
|
||||
:keep-open="keepOpen"
|
||||
>
|
||||
{{ choice }}
|
||||
</PopoverRadioItem>
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import PopoverItem from './PopoverItem.vue'
|
||||
|
||||
const { keepOpen } = defineProps<{ keepOpen?: boolean }>()
|
||||
|
||||
const value = defineModel<boolean>('modelValue', { required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverItem
|
||||
class="checkbox"
|
||||
:keep-open="keepOpen"
|
||||
@click="value = !value"
|
||||
>
|
||||
<i :class="['bi', value ? 'bi-record-circle' : 'bi-circle']" />
|
||||
|
|
|
@ -7,7 +7,8 @@ import PopoverItem from './PopoverItem.vue'
|
|||
|
||||
const context = inject(POPOVER_CONTEXT_INJECTION_KEY, {
|
||||
items: ref(0),
|
||||
hoveredItem: ref(-2)
|
||||
hoveredItem: ref(-2),
|
||||
close: () => { isOpen.value = false }
|
||||
})
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
@ -25,6 +26,7 @@ watchEffect(() => {
|
|||
<PopoverItem
|
||||
:parent-popover-context="context"
|
||||
class="submenu"
|
||||
:keep-open="true"
|
||||
@click="isOpen = !isOpen"
|
||||
@internal:id="id = $event"
|
||||
>
|
||||
|
|
|
@ -62,26 +62,22 @@ const setPage = (page: number) => {
|
|||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const labels = computed(() => ({
|
||||
pagination: t('components.vui.Pagination.label'),
|
||||
previousPage: t('components.vui.Pagination.previous'),
|
||||
nextPage: t('components.vui.Pagination.next')
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- TODO (2.0.0+): Use semantic tags and <Link> component -->
|
||||
<div
|
||||
v-if="maxPage > 1"
|
||||
class="ui pagination menu component-pagination"
|
||||
role="navigation"
|
||||
:aria-label="labels.pagination"
|
||||
:aria-label="t('vui.aria.pagination')"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
:disabled="current - 1 < 1 || null"
|
||||
:disabled="current < 2 || null"
|
||||
role="button"
|
||||
:aria-label="labels.previousPage"
|
||||
:class="[{ 'disabled': current - 1 < 1 }, 'item']"
|
||||
:aria-label="t('vui.aria.pagination.gotoPrevious')"
|
||||
:class="[{ 'disabled': current < 2 }, 'item']"
|
||||
@click.prevent.stop="setPage(current - 1)"
|
||||
>
|
||||
<i class="angle left icon" />
|
||||
|
@ -114,7 +110,7 @@ const labels = computed(() => ({
|
|||
href="#"
|
||||
:disabled="current + 1 > maxPage || null"
|
||||
role="button"
|
||||
:aria-label="labels.nextPage"
|
||||
:aria-label="t('vui.aria.pagination.gotoNext')"
|
||||
:class="[{ disabled: current + 1 > maxPage }, 'item']"
|
||||
@click.prevent.stop="setPage(current + 1)"
|
||||
>
|
||||
|
|
|
@ -6,6 +6,8 @@ import { i18n } from '~/init/locale'
|
|||
|
||||
const { t } = i18n.global
|
||||
|
||||
// TODO: Remove this unnecessary abstraction (unless it has a purpose)
|
||||
|
||||
export default () => ({
|
||||
fields: {
|
||||
privacy_level: {
|
||||
|
|
|
@ -6,9 +6,17 @@ import { useRoute } from 'vue-router'
|
|||
import { watch, readonly } from 'vue'
|
||||
|
||||
export interface OrderingProps {
|
||||
/** Custom name for storing ordering preferences */
|
||||
orderingConfigName?: RouteRecordName
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes ordering state between URL query parameters and localStorage,
|
||||
* with automatic persistence across page reloads.
|
||||
*
|
||||
* @param props - Configuration options
|
||||
* @returns Ordering state and update handlers
|
||||
*/
|
||||
export default <T extends string = string>(props: OrderingProps) => {
|
||||
const route = useRoute()
|
||||
|
||||
|
@ -24,6 +32,8 @@ export default <T extends string = string>(props: OrderingProps) => {
|
|||
paginateBy: route.meta.paginateBy ?? 50
|
||||
}))
|
||||
|
||||
// TODO: I would like to replace the prefix `pref` with something that feels more solid.
|
||||
// The implicit renaming here somewhat obscures the author's intention...
|
||||
const {
|
||||
orderingDirection: prefOrderingDirection,
|
||||
paginateBy: prefPaginateBy,
|
||||
|
@ -32,7 +42,15 @@ export default <T extends string = string>(props: OrderingProps) => {
|
|||
replaceRef: false
|
||||
})
|
||||
|
||||
const normalizeDirection = (direction: string) => direction === '+' ? '' : '-'
|
||||
/**
|
||||
* Normalizes ordering direction for URL query parameters.
|
||||
* @param direction - '+' or some other string
|
||||
* @returns Empty string for ascending, '-' for descending
|
||||
*/
|
||||
const normalizeDirection = (direction: string) =>
|
||||
direction === '+'
|
||||
? ''
|
||||
: '-'
|
||||
|
||||
const queryOrdering = useRouteQuery(
|
||||
'ordering',
|
||||
|
@ -60,7 +78,12 @@ export default <T extends string = string>(props: OrderingProps) => {
|
|||
prefOrdering.value = ordering.replace(/^[+-]/, '')
|
||||
}, { immediate: true })
|
||||
|
||||
// NOTE: We're using `flush: 'post'` to make sure that the `onOrderingUpdate` callback is called after all updates are done
|
||||
/**
|
||||
* Registers a callback to execute when ordering preferences change.
|
||||
* @param fn - Callback function to execute on updates
|
||||
* @returns Watcher cleanup function
|
||||
* NOTE: We're using `flush: 'post'` to make sure that the `onOrderingUpdate` callback is called after all updates are done
|
||||
*/
|
||||
const onOrderingUpdate = (fn: () => void) => watch(preferences, fn, {
|
||||
flush: 'post'
|
||||
})
|
||||
|
|
|
@ -2,6 +2,10 @@ import { useRouteQuery } from '@vueuse/router'
|
|||
import { syncRef } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Syncs ref `page` with the homonymous route query parameter
|
||||
* @returns {Ref<number>} The page number
|
||||
*/
|
||||
export default () => {
|
||||
const pageQuery = useRouteQuery<string>('page', '1')
|
||||
const page = ref<number>()
|
||||
|
|
|
@ -5,11 +5,32 @@ import { refWithControl } from '@vueuse/core'
|
|||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
/**
|
||||
* Configuration options for the smart search composable.
|
||||
*/
|
||||
export interface SmartSearchProps {
|
||||
/** Initial query string to populate the search */
|
||||
defaultQuery?: string
|
||||
/** Whether to sync search state with the browser URL */
|
||||
updateUrl?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables structured search queries like "status:pending category:music" while maintaining
|
||||
* bidirectional sync between raw query strings and parsed tokens. Supports URL synchronization
|
||||
* and programmatic search manipulation.
|
||||
*
|
||||
* @param props - Configuration options for the search behavior
|
||||
* @returns Object containing search methods and reactive query state
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const search = useSmartSearch({ updateUrl: true })
|
||||
* search.addSearchToken('status', 'pending')
|
||||
* search.addSearchToken('category', 'music')
|
||||
* // Generates query: "status:pending category:music"
|
||||
* ```
|
||||
*/
|
||||
export default (props: SmartSearchProps) => {
|
||||
const query = refWithControl(props.defaultQuery ?? '')
|
||||
const tokens = ref([] as Token[])
|
||||
|
@ -19,6 +40,13 @@ export default (props: SmartSearchProps) => {
|
|||
}, { immediate: true })
|
||||
|
||||
const updateHandlers = new Set<() => void>()
|
||||
|
||||
/**
|
||||
* Calls a function whenever search tokens have changed.
|
||||
*
|
||||
* @param fn - Callback function
|
||||
* @returns Cleanup function to unregister the callback
|
||||
*/
|
||||
const onSearch = (fn: () => void) => {
|
||||
updateHandlers.add(fn)
|
||||
return () => updateHandlers.delete(fn)
|
||||
|
@ -40,6 +68,13 @@ export default (props: SmartSearchProps) => {
|
|||
// this.fetchData()
|
||||
}, { deep: true })
|
||||
|
||||
/**
|
||||
* Retrieves the value of a specific search token by its field key.
|
||||
*
|
||||
* @param key - The field name to search for (e.g., 'status', 'category')
|
||||
* @param fallback - Default value to return if the token is not found
|
||||
* @returns The token's value if found, otherwise the fallback value
|
||||
*/
|
||||
const getTokenValue = (key: string, fallback: string) => {
|
||||
const matching = tokens.value.find(token => {
|
||||
return token.field === key
|
||||
|
@ -48,6 +83,16 @@ export default (props: SmartSearchProps) => {
|
|||
return matching?.value ?? fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or updates a search token with the specified field and value.
|
||||
*
|
||||
* If the value is empty, removes all tokens with the given field.
|
||||
* If tokens with the field already exist, updates their values.
|
||||
* If no tokens with the field exist, creates a new token.
|
||||
*
|
||||
* @param key - The field name for the search token
|
||||
* @param value - The value for the search token (empty string removes the token)
|
||||
*/
|
||||
const addSearchToken = (key: string, value: string) => {
|
||||
if (value === '') {
|
||||
tokens.value = tokens.value.filter(token => {
|
||||
|
|
|
@ -8,6 +8,7 @@ export type WidthProps =
|
|||
| { buttonWidth?: true }
|
||||
| { small?: true }
|
||||
| { medium?: true }
|
||||
| { larger?: true }
|
||||
| { auto?: true }
|
||||
| { full?: true }
|
||||
| { grow?: true }
|
||||
|
@ -25,6 +26,7 @@ const widths = {
|
|||
buttonWidth: 'width: 136px; --grid-column: span 2; flex-grow: 0; min-width: min-content;',
|
||||
small: 'width: 202px; --grid-column: span 3;',
|
||||
medium: 'width: 280px; --grid-column: span 4;',
|
||||
larger: 'width: 358px; --grid-column: span 5;',
|
||||
auto: 'width: auto;',
|
||||
full: 'width: auto; --grid-column: 1 / -1; place-self: stretch;',
|
||||
grow: 'flex-grow: 1;',
|
||||
|
|
|
@ -15,6 +15,7 @@ export const TABS_INJECTION_KEY = Symbol('tabs') as InjectionKey<{
|
|||
export interface PopoverContext {
|
||||
items: Ref<number>
|
||||
hoveredItem: Ref<number>
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export const POPOVER_INJECTION_KEY = Symbol('popover') as InjectionKey<Ref<boolean>[]>
|
||||
|
|
|
@ -3,6 +3,15 @@
|
|||
"loading": "Loading…"
|
||||
},
|
||||
"vui": {
|
||||
"addItem": "Add {item}",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"deselect": "Deselect",
|
||||
"deletItem": "Delete {item}",
|
||||
"pressKey": "Press {key}",
|
||||
"pressKeyToAction": "Press {key} to {action}",
|
||||
"preview": "Preview",
|
||||
"resetTo": "Reset to {previousValue}",
|
||||
"radio": "Radio",
|
||||
"albums": "{n} album | {n} albums",
|
||||
"tracks": "{n} track | {n} tracks",
|
||||
|
@ -14,6 +23,27 @@
|
|||
"next": "Next"
|
||||
},
|
||||
"aria": {
|
||||
"close": "Close",
|
||||
"text": {
|
||||
"paragraph": "paragraph",
|
||||
"heading1": "heading1",
|
||||
"heading2": "heading2",
|
||||
"heading3": "heading3",
|
||||
"heading4": "heading4",
|
||||
"heading5": "heading5",
|
||||
"heading6": "heading6",
|
||||
"quote": "quote",
|
||||
"orderedList": "ordered list",
|
||||
"unorderedList": "unordered list",
|
||||
"bold": "bold",
|
||||
"italic": "italic",
|
||||
"strikethrough": "strikethrough",
|
||||
"link": "link"
|
||||
},
|
||||
"password": {
|
||||
"show": "Show password",
|
||||
"hide": "Hide password"
|
||||
},
|
||||
"pagination": {
|
||||
"nav": "Pagination Navigation",
|
||||
"gotoPage": "Go to Page {n}",
|
||||
|
@ -2956,13 +2986,6 @@
|
|||
"more": "Show 1 more tag | Show {n} more tags"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vui": {
|
||||
"Pagination": {
|
||||
"label": "Pagination",
|
||||
"next": "Next Page",
|
||||
"previous": "Previous Page"
|
||||
}
|
||||
}
|
||||
},
|
||||
"composables": {
|
||||
|
|
|
@ -15,8 +15,6 @@ import '~/style/_main.scss'
|
|||
|
||||
import '~/api'
|
||||
|
||||
import 'virtual:uno.css'
|
||||
|
||||
// NOTE: Set the theme as fast as possible
|
||||
useTheme()
|
||||
|
||||
|
|
|
@ -88,7 +88,7 @@
|
|||
:is(body.theme-light, html:not(.dark)>body:not(.theme-dark)), .force-light-theme.force-light-theme.force-light-theme {
|
||||
--focus-ring-color: var(--fw-primary);
|
||||
|
||||
.default, .funkwhale, .VPDoc {
|
||||
.default, .funkwhale.default, .funkwhale:not(.primary *):not(.secondary *):not(.primary):not(.secondary), .VPDoc {
|
||||
--link-color:var(--fw-blue-400);
|
||||
--link-hover-color:var(--fw-blue-500);
|
||||
|
||||
|
@ -170,6 +170,13 @@
|
|||
--color-over-transparent: var(--background-color);
|
||||
--background-color:var(--fw-blue-400);
|
||||
--border-color:var(--fw-blue-010);
|
||||
|
||||
--pressed-color:var(--fw-blue-010);
|
||||
--pressed-background-color:var(--fw-blue-900);
|
||||
|
||||
// Since the focus outline color is primary, it would be invisible if overlaid with the element itself, so we shift it outward:
|
||||
outline-offset: 2px;
|
||||
|
||||
&> .primary {
|
||||
--border-color:var(--fw-blue-010);
|
||||
}
|
||||
|
@ -188,9 +195,9 @@
|
|||
--disabled-border-color:transparent;
|
||||
|
||||
&.raised {
|
||||
--background-color:var(--fw-blue-500);
|
||||
--hover-background-color:var(--fw-blue-600);
|
||||
--active-background-color:var(--fw-blue-700);
|
||||
--background-color:var(--fw-blue-600);
|
||||
--hover-background-color:var(--fw-blue-700);
|
||||
--active-background-color:var(--fw-blue-800);
|
||||
}
|
||||
|
||||
&:not(:is(.ghost, .outline)) {
|
||||
|
@ -317,7 +324,7 @@
|
|||
:is(body.theme-dark, html.dark>body:not(.theme-light)), .force-dark-theme.force-dark-theme.force-dark-theme {
|
||||
--focus-ring-color: var(--fw-gray-600);
|
||||
|
||||
.default, .funkwhale, .VPDoc {
|
||||
.default, .funkwhale.default, .funkwhale:not(.primary *):not(.secondary *):not(.primary):not(.secondary), .VPDoc {
|
||||
--link-color:var(--fw-gray-300);
|
||||
--link-hover-color:var(--fw-gray-200);
|
||||
|
||||
|
@ -327,7 +334,7 @@
|
|||
|
||||
--hover-color:var(--fw-beige-400);
|
||||
--hover-background-color:var(--fw-gray-850);
|
||||
--hover-border-color:var(--fw-beige-400);
|
||||
--hover-border-color:var(--fw-gray-850);
|
||||
|
||||
--active-color:var(--fw-gray-200);
|
||||
--active-background-color:var(--fw-gray-700);
|
||||
|
@ -373,7 +380,7 @@
|
|||
|
||||
--hover-color:var(--fw-gray-200);
|
||||
--hover-background-color:var(--fw-gray-800);
|
||||
--hover-border-color:var(--fw-gray-300);
|
||||
--hover-border-color:var(--fw-gray-800);
|
||||
|
||||
--active-color:color-mix(in oklab, var(--fw-gray-200) 50%, var(--fw-gray-300));
|
||||
--active-background-color:color-mix(in oklab, var(--fw-gray-950), var(--fw-gray-970));
|
||||
|
@ -413,18 +420,24 @@
|
|||
--active-background-color:var(--fw-blue-800);
|
||||
|
||||
--pressed-color:var(--fw-blue-010);
|
||||
--pressed-background-color:var(--fw-blue-700);
|
||||
--pressed-background-color:var(--fw-blue-900);
|
||||
|
||||
--disabled-color:color-mix(in oklab, var(--fw-blue-010) 10%, var(--fw-blue-100));
|
||||
--disabled-background-color:color-mix(in oklab, var(--fw-blue-700) 50%, var(--fw-blue-600));
|
||||
--disabled-border-color:transparent;
|
||||
--disabled-border-color: var(--hover-background-color);
|
||||
|
||||
--focus-ring-color: var(--background-color);
|
||||
|
||||
// Since the focus outline color is the primary background, it would be invisible if overlaid with the element itself, so we shift it outward:
|
||||
// A11y: indicate primary focus though shape rather than color
|
||||
outline-offset: 2px;
|
||||
|
||||
&.raised {
|
||||
--background-color:var(--fw-blue-500);
|
||||
--hover-background-color:var(--fw-blue-400);
|
||||
--active-background-color:var(--fw-blue-900);
|
||||
--disabled-background-color:color-mix(in oklab, var(--fw-blue-500) 20%, var(--fw-blue-600));
|
||||
--background-color: var(--fw-blue-400);
|
||||
--hover-background-color: var(--fw-blue-100);
|
||||
--hover-border-color: var(--fw-blue-100);
|
||||
--active-background-color: var(--fw-blue-900);
|
||||
--disabled-background-color: color-mix(in oklab, var(--fw-blue-500) 20%, var(--fw-blue-600));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -522,7 +535,7 @@
|
|||
|
||||
:is(.VPDoc .vp-doc, .funkwhale){
|
||||
|
||||
// (3a) Applying colors to everything that has no explicit color props
|
||||
// (3a) Applying colors to everything that has no explicit color props (for A11y)
|
||||
|
||||
color: var(--color);
|
||||
|
||||
|
@ -546,10 +559,12 @@
|
|||
}
|
||||
|
||||
// Indicate autofocus when using a mouse
|
||||
// The [fake-focus] attribute enables the same focus decoration for
|
||||
// inputs that currently can be confirmed by pressing ENTER
|
||||
|
||||
[autofocus]:focus:not(:focus-visible) {
|
||||
outline: 4px solid var(--autofocus-outline-color);
|
||||
outline-offset: 2px;
|
||||
[autofocus]:focus:not(:focus-visible), [fake-focus=true] {
|
||||
outline: 4px solid var(--autofocus-outline-color) !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
|
||||
// Fallback for decorative elements without explicit color props
|
||||
|
@ -576,7 +591,7 @@
|
|||
&:hover:not(:has(.interactive:hover)):not([disabled]) {
|
||||
color: var(--hover-color, var(--color));
|
||||
background-color: var(--hover-background-color);
|
||||
border-color: var(--hover-background-color);
|
||||
border-color: var(--hover-border-color, var(--hover-background-color));
|
||||
}
|
||||
&[aria-pressed=true] {
|
||||
color: var(--pressed-color, var(--active-color));
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
:not(.active) button.title {
|
||||
outline-color: white;
|
||||
}
|
||||
button, *[role="button"] {
|
||||
/* button, *[role="button"] {
|
||||
* {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
.reset.button {
|
||||
margin-top: 0.5em;
|
||||
|
@ -61,9 +61,9 @@ button.reset {
|
|||
}
|
||||
}
|
||||
|
||||
[role="button"] {
|
||||
/* [role="button"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
} */
|
||||
|
||||
|
||||
.button-group {
|
||||
|
|
|
@ -66,7 +66,7 @@ const continueInBackground = () => {
|
|||
router.push('/upload/running')
|
||||
}
|
||||
|
||||
// TODO (whole file): Delete this file, please.
|
||||
// TODO (2.0.0+): Delete this file, please.
|
||||
|
||||
// Sorting
|
||||
const sortItems = reactive([
|
||||
|
|
|
@ -5,10 +5,3 @@
|
|||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
main {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -29,7 +29,7 @@ const isOpen = useModal('language').isOpen
|
|||
ghost
|
||||
thin-font
|
||||
small
|
||||
align-text="left"
|
||||
align-text="start"
|
||||
:aria-pressed="key===locale || undefined"
|
||||
@click="setI18nLanguage(key)"
|
||||
>
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
import type { Album, Artist, Tag, Track } from '~/types'
|
||||
import type { components } from '~/generated/types'
|
||||
|
||||
/**
|
||||
* Fetch an item from the API.
|
||||
* - Rate limiting: Caches the result for 1 second to prevent over-fetching and request duplication (override with { immediate: true})
|
||||
* - Sharing reactive objects across components: Avoid data duplication, and auto-update the Ui whenever an updated version of the data is re-fetched
|
||||
* - Strongly typed results
|
||||
* - TODO: Errors and Loading states (`undefined` can mean an error occurred, or the request is still pending)
|
||||
*
|
||||
* **Example**
|
||||
* ```ts
|
||||
* import { useDataStore } from '~/ui/stores/data'
|
||||
*
|
||||
* const data = useDataStore()
|
||||
*
|
||||
* artist15 = data.get("artist", "15") // Ref<Artist | undefined>
|
||||
* const album23 = data.get("album", "23") // Ref<Album | undefined>
|
||||
* ```
|
||||
* As soon as you re-fetch data, all references to the same object in all components using this store will be updated.
|
||||
*
|
||||
* Note: Pinia does not support destructuring.
|
||||
* Do not write `{ get } = useDataStore()`
|
||||
*/
|
||||
export const useDataStore
|
||||
= defineStore('data', () => {
|
||||
|
||||
// Type map that associates cache keys with their corresponding types
|
||||
type ItemType = {
|
||||
artist: Artist
|
||||
album: Album
|
||||
track: Track
|
||||
// Add new types here (channel: Channel...)
|
||||
}
|
||||
|
||||
type Items<I extends keyof ItemType> = Record<number | string, {
|
||||
result: Ref<ItemType[I] | undefined>;
|
||||
timestamp: number;
|
||||
}>;
|
||||
|
||||
type Cache = {
|
||||
[I in keyof ItemType]: Items<I>
|
||||
}
|
||||
|
||||
const cache: Cache = {
|
||||
artist: {},
|
||||
album: {},
|
||||
track: {}
|
||||
}
|
||||
|
||||
const tagsCache = ref<Tag[]>([])
|
||||
const tagsTimestamp = ref(0)
|
||||
|
||||
/**
|
||||
* @returns an auto-updating reference to all tags or `[]` if either none are loaded yet, or there was an error
|
||||
*/
|
||||
const tags = () => {
|
||||
// Re-fetch if immediate is true or the item is not cached or older than 1 second
|
||||
if (tagsTimestamp.value < Date.now() - 1000) {
|
||||
axios.get<components['schemas']['PaginatedTagList']>('tags/', { params: { page_size: 10000 } }).then(({ data }) => {
|
||||
tagsTimestamp.value = Date.now();
|
||||
tagsCache.value = data.results;
|
||||
});
|
||||
}
|
||||
return tagsCache;
|
||||
}
|
||||
|
||||
/** Inspect the cache with the Vue Devtools (Pinia tab); read-only */
|
||||
const data = computed(() => cache)
|
||||
|
||||
/**
|
||||
* @param type - either 'artist' or 'album' etc.
|
||||
* @param id - The ID of the item to fetch.
|
||||
* @param immediate - Whether to re-fetch immediately (default: only re-fetch data older than 1 second)
|
||||
* @returns an auto-updating reference to `undefined` if there is an error or the item is not yet loaded, or the actual item once it is loaded.
|
||||
*
|
||||
* Tip: Re-run after 1 second to refresh the data.
|
||||
*/
|
||||
const get = <I extends keyof ItemType>(type: I, id: number | string, options?: { immediate?: boolean }) => {
|
||||
// Override limited typescript inference (Remove assertion once typescript can infer correctly)
|
||||
const items = cache[type] as Items<I>
|
||||
|
||||
// Initialize the object if it doesn't exist
|
||||
if (!items[id])
|
||||
items[id] = { result: ref(undefined) as Ref<ItemType[I] | undefined>, timestamp: 0 }
|
||||
|
||||
// Re-fetch if immediate is true or the item is not cached or older than 1 second
|
||||
if (options?.immediate || items[id].timestamp < Date.now() - 1000)
|
||||
axios.get<ItemType[I]>(`${type}s/${id}/`, { params: { refresh: 'true' } }).then(({ data }) => {
|
||||
items[id].result.value = data;
|
||||
items[id].timestamp = Date.now();
|
||||
})
|
||||
return items[id].result
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
get,
|
||||
tagsCache,
|
||||
tags
|
||||
}
|
||||
})
|
|
@ -10,12 +10,14 @@ import type { paths, components } from '~/generated/types'
|
|||
import { humanSize, truncate } from '~/utils/filters'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouteQuery } from '@vueuse/router'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
import ImportStatusModal from '~/components/library/ImportStatusModal.vue'
|
||||
|
||||
import useSmartSearch from '~/composables/navigation/useSmartSearch'
|
||||
// TODO (2.0.0+): Consolidate token logic from useSmartSearch and search.ts
|
||||
// import useSmartSearch from '~/composables/navigation/useSmartSearch'
|
||||
import useSharedLabels from '~/composables/locale/useSharedLabels'
|
||||
import useOrdering from '~/composables/navigation/useOrdering'
|
||||
import useErrorHandler from '~/composables/useErrorHandler'
|
||||
|
@ -31,12 +33,16 @@ import Loader from '~/components/ui/Loader.vue'
|
|||
import Pagination from '~/components/ui/Pagination.vue'
|
||||
import Table from '~/components/ui/Table.vue'
|
||||
import Slider from '~/components/ui/Slider.vue'
|
||||
import Select from '~/components/ui/Select.vue'
|
||||
|
||||
interface Props extends SmartSearchProps, OrderingProps {
|
||||
object: Actor
|
||||
filters?: object
|
||||
|
||||
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
|
||||
// // Was merged on 2022-10-26
|
||||
// // https://github.com/vuejs/core/issues/4498
|
||||
// TODO (2.0.0+): Simplify this code if possible. It seems that it supports unnecessary cases (?), and Vue might support a more declarative way to express its intention by now... (?)
|
||||
orderingConfigName?: RouteRecordName
|
||||
defaultQuery?: string
|
||||
updateUrl?: boolean
|
||||
|
@ -44,13 +50,10 @@ interface Props extends SmartSearchProps, OrderingProps {
|
|||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
defaultQuery: '',
|
||||
updateUrl: false,
|
||||
updateUrl: true,
|
||||
filters: () => ({}),
|
||||
orderingConfigName: undefined
|
||||
})
|
||||
|
||||
const search = ref()
|
||||
|
||||
const page = usePage()
|
||||
const result = ref<paths['/api/v2/uploads/']['get']['responses']['200']['content']['application/json']>()
|
||||
|
||||
|
@ -93,13 +96,7 @@ const isAllSelected = computed<boolean | 'mixed'>({
|
|||
}
|
||||
})
|
||||
|
||||
// For privacy slider
|
||||
const options = {
|
||||
me: sharedLabels.fields.privacy_level.choices.me,
|
||||
instance: sharedLabels.fields.privacy_level.choices.instance,
|
||||
everyone: sharedLabels.fields.privacy_level.choices.everyone
|
||||
} as const satisfies Record<PrivacyLevel, string>
|
||||
|
||||
// For privacy slider and <select>
|
||||
|
||||
// Model for use in global slider `privacy_level`
|
||||
const globalPrivacyLevel = computed<PrivacyLevel | undefined>({
|
||||
|
@ -139,17 +136,10 @@ const globalPrivacyLevel = computed<PrivacyLevel | undefined>({
|
|||
}
|
||||
})
|
||||
|
||||
const { onSearch, query, addSearchToken, getTokenValue } = useSmartSearch(props)
|
||||
const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props)
|
||||
/// SEARCH
|
||||
|
||||
const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [
|
||||
['creation_date', 'creation_date'],
|
||||
['modification_date', 'modification_date'],
|
||||
['accessed_date', 'accessed_date'],
|
||||
['size', 'size'],
|
||||
['bitrate', 'bitrate'],
|
||||
['duration', 'duration']
|
||||
]
|
||||
// const { onSearch, query, addSearchToken, getTokenValue, token } = useSmartSearch(props)
|
||||
const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
@ -161,8 +151,8 @@ const fetchData = async () => {
|
|||
scope: 'me',
|
||||
page: page.value,
|
||||
page_size: paginateBy.value,
|
||||
q: query.value,
|
||||
ordering: orderingString.value,
|
||||
...Object.fromEntries(tokens.get()),
|
||||
...props.filters
|
||||
}})
|
||||
|
||||
|
@ -175,11 +165,6 @@ const fetchData = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
onSearch(() => (page.value = 1))
|
||||
watch(page, fetchData)
|
||||
onOrderingUpdate(fetchData)
|
||||
fetchData()
|
||||
|
||||
const labels = computed(() => ({
|
||||
searchPlaceholder: t('components.manage.library.UploadsTable.placeholder.search')
|
||||
}))
|
||||
|
@ -190,134 +175,243 @@ const displayName = (item: Item): string => {
|
|||
|
||||
const detailedUpload = ref<Item>()
|
||||
const showUploadDetailModal = ref(false)
|
||||
|
||||
const privacyOptions = {
|
||||
me: sharedLabels.fields.privacy_level.choices.me,
|
||||
instance: sharedLabels.fields.privacy_level.choices.instance,
|
||||
everyone: sharedLabels.fields.privacy_level.choices.everyone
|
||||
} as const satisfies Record<PrivacyLevel, string>
|
||||
|
||||
|
||||
// Current logic:
|
||||
// - [x] `ordering` and `orderingDirection` are 2-way synced with the
|
||||
// `ordering` parameter of the URL and parametrize `fetchData`
|
||||
// - [x] `query` is 2-way synced with the `query` parameter of the URL
|
||||
// - [x] `query.q` field is 2-way synced with the `search` input
|
||||
// - [x] `status` and `privacy_level` are linked to tokens/fields inside `query`
|
||||
// - [x] Inside `q`, quoted ranges are interpreted as atomic tokens;
|
||||
// otherwise words are tokens (Is this backend logic?).
|
||||
// - [x] `query` provides parameters for search (`fetchData`)
|
||||
//
|
||||
// TODO (2.0.0+): Consolidate token logic from useSmartSearch and search.ts
|
||||
// - [ ] Document usage sites for tokenized search logic
|
||||
// - [ ] Document logic and check in with backend to confirm completeness
|
||||
// - [ ] Consolidate search.ts with useSmartSearch composable if possible
|
||||
// - [ ] Once ES2025 is activated, refactor logic to use iterator method chain
|
||||
// instead of imperative procedures
|
||||
// - [ ] Strongly type parameters according to endpoint (check if schema is complete)
|
||||
// - [ ] Integrate into `useDataStore` (data.ts) to activate search result caching and
|
||||
// eager fetching
|
||||
//
|
||||
// Test cases (draft):
|
||||
// - q = `"Hello you"` searches a single full-text token
|
||||
// - q = `"Hello you" privacy_level:me` filters it by privacy_level==='me'
|
||||
// - q = `a b` gives the same result as `b a` because the tokens form a set (?)
|
||||
//
|
||||
// Ux considerations:
|
||||
// - The `token` behavior is not communicated through the Ui.
|
||||
// A potential way to communicate it would be to
|
||||
// - Draw pill-shaped pastel backgrounds behind each token
|
||||
// - Integrate the (duplicate) `Select` controls into the `Input`
|
||||
// - Replace `Search` with `Filter` to make it clear that we are already
|
||||
// operating on a smaller pool: the user's Uploads, not global/federation.
|
||||
// - The ordering is cumbersome and at the wrong place (it's not a filter).
|
||||
// - Use conventional arrow controls on table headers for switching
|
||||
// between all the ordering configurations
|
||||
// TODO (2.0.0+): User-test, re-design and consolidate search/filter interface
|
||||
// **A11y (UX):**
|
||||
// The search/filter interface is perceivable
|
||||
// - [ ] All users gather the purpose and semantics of all operations available
|
||||
// The search/filter interface is operable
|
||||
// - [ ] Users can access all controls by using only their keyboard
|
||||
// - [ ] Users have enough time
|
||||
// - [ ] No seizures or physical reactions
|
||||
// - [ ] Users can always tell where they are (navigation, focus, cursor...) and how to find what they are looking for
|
||||
// - [ ] Users can operate the interface with pointers (including aborting functions)
|
||||
// The search/filter interface is understandable
|
||||
// - [ ] User can easily read and understand content
|
||||
// - [ ] Users can successfully predict next steps
|
||||
// - [ ] Context changes are only initiated on user request
|
||||
// - [ ] If errors are possible, correcting them is easy
|
||||
// The search/filter interface is robust
|
||||
// - [ ] It can be interpreted by 95% of browsers, and by standard assistive tech
|
||||
// The search/filter interface conforms to WCAG 2.1
|
||||
// **Consolidation (DX):**
|
||||
// - [ ] Where possible, search/filter interfaces are implemented with the same patterns,
|
||||
// or abstracted into components if that reduces cognitive load on developers
|
||||
// - [ ] Data handling is consolidated around the generated schema and the Url as the
|
||||
// source of truth, and abstracted into composables/stores where possible
|
||||
|
||||
/* Sync up `query` with tokens stored in the `q` parameter of the Url */
|
||||
const query = useRouteQuery<string>('query', '')
|
||||
// const query = ref(query.value ?? '')
|
||||
// syncRef(q, query, { direction: 'ltr' })
|
||||
|
||||
/* Go to first page whenever the query parameters change */
|
||||
watch([query, ordering], () =>
|
||||
{ page.value = 1 }
|
||||
)
|
||||
|
||||
/* Represent the `q` parameter of the Url as a Map of tokens.
|
||||
- Key-Value pairs `key:value` are stored as [key, value] in the Map
|
||||
- Deduplication: The last key in the Url overrides all previous ones
|
||||
- Words `w` and phrases `"x y` are concatenated in order of insertion under the `''` key
|
||||
and moved to the start of `q` resp. `tokens`.
|
||||
|
||||
TODO (2.0.0+): The quote/unquote feature from search.ts is still missing in this file!
|
||||
*/
|
||||
const tokens = {
|
||||
get: () => query.value
|
||||
.split(' ')
|
||||
.filter(token => token.trim())
|
||||
.map(token => token.split(':'))
|
||||
.filter(([head, ...tail]) => tail.length !== 1 || tail[0].trim())
|
||||
.reduce((dict, [head, ...tail]) =>
|
||||
// TODO: Once we activate ES2025, we can use pattern matching here:
|
||||
tail.length === 1
|
||||
? dict.set(head, tail[0])
|
||||
: dict.set('q', `${dict.get('q') ?? ''} ${head} ${tail.join(':')}`.trim())
|
||||
, new Map<string, string>()),
|
||||
set: (dict: Map<string, string>) => {
|
||||
const fullText = dict.get('q') ?? ''
|
||||
const keyValuePairs = Array.from(dict)
|
||||
.filter(([key, value]) => key !== 'q' )
|
||||
.map(([key, value]) => `${key}:${value}`)
|
||||
|
||||
query.value = [fullText, ...keyValuePairs].filter(part => part.trim()).join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
/* Ref with a token of form `key:value` within the Url parameter `q`.
|
||||
- Updating the `q` parameter updates the token, and vice versa
|
||||
- Deduplication: The last key in the Url overrides all previous ones
|
||||
- Words and phrases are accessible under the `'q'` key */
|
||||
const token = (key: string) =>
|
||||
computed({
|
||||
get: () => tokens.get().get(key) ?? '',
|
||||
set: (value: string) => tokens.set(tokens.get().set(key, value))
|
||||
})
|
||||
|
||||
const search = token('q')
|
||||
|
||||
/* Options and `refs` for each filter `<Select>` control */
|
||||
const searchFilters = ref({
|
||||
'privacy_level': {
|
||||
label: t('components.manage.library.UploadsTable.label.visibility'),
|
||||
current: token('privacy_level'),
|
||||
options: {
|
||||
'': t('components.manage.library.UploadsTable.option.all'),
|
||||
...privacyOptions
|
||||
}
|
||||
},
|
||||
'status': {
|
||||
label: t('components.manage.library.UploadsTable.label.status'),
|
||||
current: token('import_status'),
|
||||
options: {
|
||||
'': t('components.manage.library.UploadsTable.option.all'),
|
||||
'pending': t('components.manage.library.UploadsTable.option.pending'),
|
||||
'skipped': t('components.manage.library.UploadsTable.option.skipped'),
|
||||
'errored': t('components.manage.library.UploadsTable.option.failed'),
|
||||
'finished': t('components.manage.library.UploadsTable.option.finished')
|
||||
}
|
||||
},
|
||||
'ordering': {
|
||||
label: t('components.manage.library.UploadsTable.ordering.label'),
|
||||
current: ordering,
|
||||
options: {
|
||||
'creation_date': sharedLabels.filters.creation_date,
|
||||
'modification_date': sharedLabels.filters.modification_date,
|
||||
'accessed_date': sharedLabels.filters.accessed_date,
|
||||
'size': sharedLabels.filters.size,
|
||||
'bitrate': sharedLabels.filters.bitrate,
|
||||
'duration': sharedLabels.filters.duration
|
||||
} satisfies Partial<Record<OrderingField, string>>
|
||||
},
|
||||
'orderingDirection': {
|
||||
label: t('components.manage.library.UploadsTable.ordering.direction.label'),
|
||||
current: orderingDirection,
|
||||
options: {
|
||||
'+': t('components.manage.library.UploadsTable.ordering.direction.ascending'),
|
||||
'-': t('components.manage.library.UploadsTable.ordering.direction.descending')
|
||||
}
|
||||
}
|
||||
} as const)
|
||||
|
||||
// Reload data when changing page
|
||||
watch(page, fetchData)
|
||||
|
||||
// Reset page and reload data when privacy level or import status changes
|
||||
watch([token('privacy_level'), token('import_status')], () => {
|
||||
page.value !== 1
|
||||
? page.value = 1
|
||||
: fetchData();
|
||||
});
|
||||
|
||||
onOrderingUpdate(fetchData)
|
||||
fetchData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ui inline form">
|
||||
<div class="fields">
|
||||
<div class="ui six wide field">
|
||||
<form @submit.prevent="query = search.value">
|
||||
<Input
|
||||
id="uploads-search"
|
||||
ref="search"
|
||||
v-model="query"
|
||||
name="search"
|
||||
search
|
||||
:label="t('components.manage.library.UploadsTable.label.search')"
|
||||
:placeholder="labels.searchPlaceholder"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<Spacer :size="16" />
|
||||
<Layout flex>
|
||||
<Spacer grow />
|
||||
<div class="field">
|
||||
<label for="uploads-visibility">{{ t('components.manage.library.UploadsTable.label.visibility') }}</label>
|
||||
<select
|
||||
id="uploads-visibility"
|
||||
class="ui dropdown"
|
||||
:value="getTokenValue('privacy_level', '')"
|
||||
@change="addSearchToken('privacy_level', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">
|
||||
{{ t('components.manage.library.UploadsTable.option.all') }}
|
||||
</option>
|
||||
<option value="me">
|
||||
{{ sharedLabels.fields.privacy_level.shortChoices.me }}
|
||||
</option>
|
||||
<option value="instance">
|
||||
{{ sharedLabels.fields.privacy_level.shortChoices.instance }}
|
||||
</option>
|
||||
<option value="everyone">
|
||||
{{ sharedLabels.fields.privacy_level.shortChoices.everyone }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="uploads-status">{{ t('components.manage.library.UploadsTable.label.status') }}</label>
|
||||
<select
|
||||
id="uploads-status"
|
||||
class="ui dropdown"
|
||||
:value="getTokenValue('status', '')"
|
||||
@change="addSearchToken('status', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">
|
||||
{{ t('components.manage.library.UploadsTable.option.all') }}
|
||||
</option>
|
||||
<option value="pending">
|
||||
{{ t('components.manage.library.UploadsTable.option.pending') }}
|
||||
</option>
|
||||
<option value="skipped">
|
||||
{{ t('components.manage.library.UploadsTable.option.skipped') }}
|
||||
</option>
|
||||
<option value="errored">
|
||||
{{ t('components.manage.library.UploadsTable.option.failed') }}
|
||||
</option>
|
||||
<option value="finished">
|
||||
{{ t('components.manage.library.UploadsTable.option.finished') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="uploads-ordering">{{ t('components.manage.library.UploadsTable.ordering.label') }}</label>
|
||||
<select
|
||||
id="uploads-ordering"
|
||||
v-model="ordering"
|
||||
class="ui dropdown"
|
||||
>
|
||||
<option
|
||||
v-for="(option, key) in orderingOptions"
|
||||
:key="key"
|
||||
:value="option[0]"
|
||||
>
|
||||
{{ sharedLabels.filters[option[1]] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="uploads-ordering-direction">{{ t('components.manage.library.UploadsTable.ordering.direction.label') }}</label>
|
||||
<select
|
||||
id="uploads-ordering-direction"
|
||||
v-model="orderingDirection"
|
||||
class="ui dropdown"
|
||||
>
|
||||
<option value="+">
|
||||
{{ t('components.manage.library.UploadsTable.ordering.direction.ascending') }}
|
||||
</option>
|
||||
<option value="-">
|
||||
{{ t('components.manage.library.UploadsTable.ordering.direction.descending') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</Layout>
|
||||
<Spacer />
|
||||
<Layout
|
||||
form
|
||||
flex
|
||||
@submit.prevent="fetchData"
|
||||
>
|
||||
<!-- Enter a search string and start the search -->
|
||||
|
||||
<!-- Edit the currently selected items -->
|
||||
<!-- TODO (2.0.0+): Replace with `Pills` component and allow editing all tokens (filters) -->
|
||||
|
||||
<Spacer />
|
||||
<Spacer />
|
||||
<div
|
||||
class="default solid raised"
|
||||
style="margin: -32px; padding: 32px;"
|
||||
>
|
||||
<Slider
|
||||
v-model="globalPrivacyLevel"
|
||||
:disabled="selectedItems.length === 0 ? true : undefined"
|
||||
:options="options"
|
||||
:label="`Privacy level (${ selectedItems.length } items)`"
|
||||
/>
|
||||
</div>
|
||||
<Spacer />
|
||||
</div>
|
||||
<Input
|
||||
v-model="search"
|
||||
:label="t('components.manage.library.UploadsTable.label.search')"
|
||||
:placeholder="labels.searchPlaceholder"
|
||||
search
|
||||
style="min-width: min(100%, 520px)"
|
||||
/>
|
||||
|
||||
<!-- Filter the search results -->
|
||||
|
||||
<!-- TODO (2.0.0+): Integrate these filters as `Pills` into above control -->
|
||||
|
||||
<Select
|
||||
v-for="[id, filter] in Object.entries(searchFilters)"
|
||||
:id="`uploads-${id}`"
|
||||
:key="id"
|
||||
v-model:current="filter.current"
|
||||
v-model:options="filter.options"
|
||||
:label="filter.label"
|
||||
/>
|
||||
</Layout>
|
||||
|
||||
<Spacer />
|
||||
|
||||
<!-- Edit the currently selected items -->
|
||||
|
||||
<div :class="['default solid raised', $style.toolbox]">
|
||||
<Spacer />
|
||||
<Slider
|
||||
v-model="globalPrivacyLevel"
|
||||
:disabled="selectedItems.length === 0 ? true : undefined"
|
||||
:options="privacyOptions"
|
||||
:label="`Privacy level (${ selectedItems.length } items)`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Select my items -->
|
||||
|
||||
<!-- TODO (wvffle): Check if :upload shouldn't be v-model:upload -->
|
||||
<!-- Alternative design: v-model of type components['schemas']['UploadForOwner'] | null (:show would be non-null) -->
|
||||
<!-- TODO (flupsi): Check if we can safely upgrade from type Upload to type components['schemas']['UploadForOwner'] -->
|
||||
<!-- TODO (2.0.0+): Check if we can safely upgrade from type Upload to type components['schemas']['UploadForOwner'] -->
|
||||
<import-status-modal
|
||||
v-if="detailedUpload"
|
||||
v-model:show="showUploadDetailModal"
|
||||
:upload="detailedUpload as unknown as Upload"
|
||||
/>
|
||||
<Loader v-if="isLoading" />
|
||||
<Loader
|
||||
v-if="isLoading"
|
||||
style="height: 0;"
|
||||
/>
|
||||
|
||||
<Table
|
||||
v-if="result"
|
||||
|
@ -383,7 +477,7 @@ const showUploadDetailModal = ref(false)
|
|||
<Pill
|
||||
:title="t('components.manage.library.UploadsTable.table.upload.header.visibility')"
|
||||
v-bind="item.privacy_level
|
||||
? { onClick: () => addSearchToken('privacy_level', item.privacy_level as PrivacyLevel) }
|
||||
? { onClick: () => { token('privacy_level').value = item.privacy_level as PrivacyLevel } }
|
||||
: { disabled: true }
|
||||
"
|
||||
>
|
||||
|
@ -399,9 +493,9 @@ const showUploadDetailModal = ref(false)
|
|||
pending: 'blue',
|
||||
finished: 'green',
|
||||
errored: 'red',
|
||||
skipped: 'purple'
|
||||
skipped: 'purple',
|
||||
}[item.import_status]]: true }"
|
||||
@click="addSearchToken('import_status', item.import_status)"
|
||||
@click="() => { token('import_status').value = item.import_status }"
|
||||
>
|
||||
{{ item.import_status }}
|
||||
<template #action>
|
||||
|
@ -447,3 +541,17 @@ const showUploadDetailModal = ref(false)
|
|||
{{ t('components.manage.library.UploadsTable.pagination.results', { start: ((page-1) * paginateBy) + 1, end: ((page-1) * paginateBy) + result.results.length, total: result.count }) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.toolbox {
|
||||
margin: 0 -32px;
|
||||
padding: 32px;
|
||||
max-height: 20em;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
|
||||
&:has([disabled]) {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -25,16 +25,11 @@ import Nav from '~/components/ui/Nav.vue'
|
|||
import Alert from '~/components/ui/Alert.vue'
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
|
||||
interface Events {
|
||||
(e: 'updated', value: components['schemas']['FullActor']): void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
username: string
|
||||
domain?: string | null
|
||||
}
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
domain: null
|
||||
})
|
||||
|
|
|
@ -13,10 +13,6 @@ import Modal from '~/components/ui/Modal.vue'
|
|||
import Button from '~/components/ui/Button.vue'
|
||||
import Link from '~/components/ui/Link.vue'
|
||||
|
||||
interface Events {
|
||||
(e: 'updated', value: Actor): void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
object: Actor | null
|
||||
}
|
||||
|
@ -25,7 +21,6 @@ const store = useStore()
|
|||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
defineProps<Props>()
|
||||
|
||||
const step = ref(1)
|
||||
|
|
|
@ -13,7 +13,7 @@ defineProps<Props>()
|
|||
<template>
|
||||
<section>
|
||||
<channel-entries
|
||||
:default-cover="object.artist?.cover"
|
||||
:default-cover="object.artist?.cover || null"
|
||||
:is-podcast="object.artist?.content_category === 'podcast'"
|
||||
:limit="25"
|
||||
:filters="{channel: object.uuid, ordering: 'creation_date'}"
|
||||
|
|
|
@ -234,13 +234,15 @@ const showCreateModal = ref(false)
|
|||
:placeholder="labels.searchPlaceholder"
|
||||
/>
|
||||
<Pills
|
||||
v-if="typeof tags === 'object'"
|
||||
:get="model => { tags = model.currents.map(({ label }) => label) }"
|
||||
:set="model => ({
|
||||
...model,
|
||||
others: [],
|
||||
currents: tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
||||
})"
|
||||
:label="t('components.library.Podcasts.label.tags')"
|
||||
style="max-width: 150px;"
|
||||
style="max-width: 350px;"
|
||||
/>
|
||||
<Layout
|
||||
stack
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
"noUnusedLocals": true,
|
||||
"noImplicitAny": true,
|
||||
"experimentalDecorators": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"typeRoots": ["node_modules", "node_modules/@types"],
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
|
@ -29,17 +30,13 @@
|
|||
"src/docs/**/*.ts",
|
||||
"ui-docs/**/*.md"
|
||||
],
|
||||
"exclude": [
|
||||
"ui-docs/components/ui/link.md",
|
||||
"ui-docs/components/ui/popover.md",
|
||||
"ui-docs/using-color.md"
|
||||
],
|
||||
"vueCompilerOptions": {
|
||||
"vitePressExtensions": [".md"],
|
||||
"plugins": [
|
||||
"@vue-macros/volar/define-options",
|
||||
"@vue-macros/volar/define-models",
|
||||
"@vue-macros/volar/define-props",
|
||||
"@vue-macros/volar/define-props-refs",
|
||||
"@vue-macros/volar/short-vmodel",
|
||||
"@vue-macros/volar/define-slots",
|
||||
"@vue-macros/volar/export-props",
|
||||
"@vue-macros/volar/jsx-directive"
|
||||
]
|
||||
"plugins": []
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,6 +43,7 @@ export default defineConfig({
|
|||
],
|
||||
},
|
||||
{ text: 'Input', link: '/components/ui/input' },
|
||||
{ text: 'Select', link: '/components/ui/select' },
|
||||
{ text: 'Slider', link: '/components/ui/slider' },
|
||||
{ text: 'Popover (Dropdown Menu)', link: '/components/ui/popover' },
|
||||
{ text: 'Textarea', link: '/components/ui/textarea' },
|
||||
|
|
|
@ -477,9 +477,9 @@ See [Using width](/using-width) and [Using alignment](/using-alignment)
|
|||
<Button alignSelf="center">🐌</Button>
|
||||
<Button alignSelf="end">🐌</Button>
|
||||
<hr />
|
||||
<Button alignText="left">🐌</Button>
|
||||
<Button alignText="start">🐌</Button>
|
||||
<Button alignText="center">🐌</Button>
|
||||
<Button alignText="right">🐌</Button>
|
||||
<Button alignText="end">🐌</Button>
|
||||
```
|
||||
|
||||
<Layout class="preview solid primary" stack no-gap>
|
||||
|
@ -493,8 +493,28 @@ See [Using width](/using-width) and [Using alignment](/using-alignment)
|
|||
<Button alignSelf="center">🐌</Button>
|
||||
<Button alignSelf="end">🐌</Button>
|
||||
<hr />
|
||||
<Button alignText="left">🐌</Button>
|
||||
<Button alignText="start">🐌</Button>
|
||||
<Button alignText="center">🐌</Button>
|
||||
<Button alignText="right">🐌</Button>
|
||||
<Button alignText="end">🐌</Button>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
### Override the cursor
|
||||
|
||||
With the css variable `--cursor`, you can override the default (pointer).
|
||||
|
||||
<Button style="--cursor: text;">Text cursor here</Button>
|
||||
|
||||
### Emphasize a button that responds to SPACE with `autofocus` and ENTER with `fake-focus`
|
||||
|
||||
If the user is likely to choose a certain button in a given situation, give it the `autofocus` attribute.
|
||||
This will put the focus on it so that the user can confirm it by pressing `SPACE` and doesn't need to navigate to it.
|
||||
The button will be emphasized with a contrasting outline. Primary buttons will draw the outline outside their shape
|
||||
for increased emphasis and visibility (A11y).
|
||||
|
||||
Make sure to not steal focus from a more important control, and to to visibly return focus to whare it was before
|
||||
after the button is gone. See the `Modal` component ([docs](./modal)) for a complete example.
|
||||
|
||||
**If pressing ENTER implicitly activates the button** (and only then), give the button a `fake-focus` attribute.
|
||||
It indicates that "pressing the enter key has the same effect as pressing this button". An example is a "Best match"
|
||||
button under an input where the user can search for matching items. This is implemented [in the `Pill` component](./pill)
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
import Link from '~/components/ui/Link.vue'
|
||||
import Card from '~/components/ui/Card.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import OptionsButton from '~/components/ui/button/Options.vue'
|
||||
|
@ -278,7 +277,7 @@ Large Buttons or links at the bottom edge of the card serve as Call-to-Actions (
|
|||
>
|
||||
The easiest way to get started with Funkwhale is to register an account on a public pod.
|
||||
<template #action>
|
||||
<Button secondary full @click="alert('Open the pod picker')">Action!
|
||||
<Button secondary grow @click="alert('Open the pod picker')">Action!
|
||||
</Button>
|
||||
</template>
|
||||
</Card>
|
||||
|
@ -403,7 +402,7 @@ Instead, use the [action area](#add-an-action) to offer the primary link:
|
|||
<Button secondary low-height :onClick="()=>alert('Button clicked')">Click me!</Button>
|
||||
<Link secondary to="./card.md" align-text="end">Open this file in a new window</Link>
|
||||
<template #action>
|
||||
<Link solid full primary to="./card.md" align-text="center">Details</Link>
|
||||
<Link solid grow primary to="./card.md" align-text="center">Details</Link>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import Button from '~/components/ui/Button.vue'
|
|||
import Input from '~/components/ui/Input.vue'
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
|
||||
const isOpen = ref(false)
|
||||
const isOpen2 = ref(false)
|
||||
|
@ -19,11 +20,9 @@ watchEffect(() => {
|
|||
})
|
||||
|
||||
const isOpen4 = ref(false)
|
||||
const isOpen5 = ref(false)
|
||||
const isOpen6 = ref(false)
|
||||
const isOpen7 = ref(false)
|
||||
const isOpen8 = ref(false)
|
||||
const isOpen9 = ref(false)
|
||||
const isOpen10 = ref(false)
|
||||
|
||||
const input = ref('Episcosaurus')
|
||||
|
@ -82,6 +81,7 @@ Make sure to add `autofocus` to the preferred button.
|
|||
|
||||
<template #actions>
|
||||
<Button secondary @click="isOpen2 = false" icon="bi-arrow-left"/>
|
||||
<Button icon="bi-arrow-right"/>
|
||||
<Button autofocus @click="isOpen = false">
|
||||
Ok
|
||||
</Button>
|
||||
|
@ -97,6 +97,8 @@ Make sure to add `autofocus` to the preferred button.
|
|||
Modal content
|
||||
<template #actions>
|
||||
<Button secondary @click="isOpen2 = false" icon="bi-arrow-left"/>
|
||||
<Button disabled icon="bi-arrow-right"/>
|
||||
<Spacer grow />
|
||||
<Button primary autofocus @click="isOpen2 = false">
|
||||
Ok
|
||||
</Button>
|
||||
|
@ -341,6 +343,13 @@ If there are no action slots and no cancel button, the close button is auto-focu
|
|||
|
||||
The `autofocus` prop, when set to `off`, overrides this behavior.
|
||||
|
||||
## Handle focus programmatically
|
||||
|
||||
**If pressing ENTER implicitly activates a button, link, or `Card`** (and only then), give that button, link or `Card` a `fake-focus` attribute.
|
||||
It indicates that "pressing the enter key has the same effect as pressing this button". An example would be a "Search" modal where the best match among the search results.
|
||||
|
||||
_TODO: Implement `autofocus` and `fake-focus` props for `Card`, and implement said behavior_
|
||||
|
||||
## Responsivity
|
||||
|
||||
### Designing for small screens
|
||||
|
|
|
@ -255,3 +255,11 @@ const others = ref([
|
|||
{{ current }} (+ {{ others.length }} other options)
|
||||
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
### Override the cursor
|
||||
|
||||
With the css variable `--cursor`, you can override the default (pointer):
|
||||
|
||||
```css
|
||||
--cursor: text;
|
||||
```
|
||||
|
|
|
@ -10,6 +10,7 @@ import PopoverCheckbox from "~/components/ui/popover/PopoverCheckbox.vue"
|
|||
import PopoverItem from "~/components/ui/popover/PopoverItem.vue"
|
||||
import PopoverRadio from "~/components/ui/popover/PopoverRadio.vue"
|
||||
import PopoverSubmenu from "~/components/ui/popover/PopoverSubmenu.vue"
|
||||
import Toggle from "~/components/ui/Toggle.vue"
|
||||
|
||||
// String values
|
||||
|
||||
|
@ -40,6 +41,7 @@ const extraItemsMenu = ref(false)
|
|||
const linksMenu = ref(false)
|
||||
const fullMenu= ref(false)
|
||||
const isOpen = ref(false)
|
||||
const keepOpen = ref(false)
|
||||
</script>
|
||||
|
||||
```ts
|
||||
|
@ -203,7 +205,32 @@ const bcPrivacy = ref("pod");
|
|||
</Pill>
|
||||
</template>
|
||||
<template #items>
|
||||
<PopoverRadio v-model="bcPrivacy" :choices="privacyChoices"/>
|
||||
<PopoverRadio v-model="bcPrivacy" :choices="privacyChoices" />
|
||||
</template>
|
||||
</Popover>
|
||||
|
||||
## Keep the popover open
|
||||
|
||||
By default, the popover closes when a radiobutton, link, or other item is chosen. The exception is with checkboxes because the user can select multiple options. Override the default behavior by setting the `keep-open` prop on any of the following components:
|
||||
|
||||
- `<PopoverRadio>` - keep the popover open when any radio item is selected
|
||||
- `<PopoverRadioItem>` - keep the popover open when a specific radio item is selected
|
||||
- `<PopoverItem>` - keep the popover open when the link or button is activated
|
||||
- `<Popover>` - keep the popover open when any item is selected (clicking outside the popover still closes it)
|
||||
|
||||
```vue
|
||||
<Popover v-model="keepOpen">
|
||||
<Toggle v-model="keepOpen" label="Show privacy controls" />
|
||||
<template #items>
|
||||
<PopoverRadio v-model="bcPrivacy" :choices="privacyChoices" keep-open />
|
||||
</template>
|
||||
</Popover>
|
||||
```
|
||||
|
||||
<Popover v-model="keepOpen">
|
||||
<Toggle v-model="keepOpen" label="Show privacy controls" />
|
||||
<template #items>
|
||||
<PopoverRadio v-model="bcPrivacy" :choices="privacyChoices" keep-open />
|
||||
</template>
|
||||
</Popover>
|
||||
|
||||
|
@ -411,6 +438,10 @@ const isOpen = ref(false)
|
|||
<PopoverCheckbox v-model="bc">
|
||||
Bandcamp
|
||||
</PopoverCheckbox>
|
||||
<PopoverItem :to="{ name: 'learn-more' }">
|
||||
Learn more...
|
||||
</PopoverItem>
|
||||
<PopoverItem>Cancel</PopoverItem>
|
||||
</template>
|
||||
</PopoverSubmenu>
|
||||
</template>
|
||||
|
@ -428,6 +459,10 @@ const isOpen = ref(false)
|
|||
<PopoverCheckbox v-model="bc">
|
||||
Bandcamp
|
||||
</PopoverCheckbox>
|
||||
<PopoverItem to="https://docs.funkwhale.audio">
|
||||
Learn more...
|
||||
</PopoverItem>
|
||||
<PopoverItem>Cancel</PopoverItem>
|
||||
</template>
|
||||
</PopoverSubmenu>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,254 @@
|
|||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
import Select from "~/components/ui/Select.vue";
|
||||
import Button from "~/components/ui/Button.vue";
|
||||
import Layout from "~/components/ui/Layout.vue";
|
||||
import Modal from "~/components/ui/Modal.vue";
|
||||
import Heading from "~/components/ui/Heading.vue";
|
||||
|
||||
const current = ref(2);
|
||||
const options = ref({1: 'One', 2: 'Two', 3: 'Three'});
|
||||
|
||||
const nullable = ref(undefined);
|
||||
|
||||
const initial = ref(1);
|
||||
|
||||
const isOpen = ref(false);
|
||||
</script>
|
||||
|
||||
```ts
|
||||
import Select from "~/components/ui/Select.vue"
|
||||
```
|
||||
|
||||
# Select
|
||||
|
||||
Select a value from a list of labeled options.
|
||||
|
||||
Uses two v-model bindings: `v-model:current` for the selected value and `v-model:options` for the available options. Each option is a `value: label` field where value is `string | number`.
|
||||
|
||||
_Note that this component currently extends the native HTML `<select>` element and inherits its frustrating shortcomings, especially when it comes to styleing. For future paths, see [this excellent article on css-tricks](https://css-tricks.com/striking-a-balance-between-native-and-custom-select-elements/)_.
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
<Layout flex>
|
||||
|
||||
<Layout stack no-gap>
|
||||
|
||||
```ts
|
||||
const current = ref(2);
|
||||
const options = ref({
|
||||
1: 'One',
|
||||
2: 'Two',
|
||||
3: 'Three'
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
```vue-html
|
||||
<Select
|
||||
label="Select an option"
|
||||
v-model:current="current"
|
||||
v-model:options="options"
|
||||
/>
|
||||
```
|
||||
|
||||
</Layout>
|
||||
|
||||
<div class="preview">
|
||||
<Select
|
||||
label="Select an option"
|
||||
v-model:current="current"
|
||||
v-model:options="options"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</Layout>
|
||||
|
||||
## Add a custom label
|
||||
|
||||
The select supports a `label` slot for custom label content instead of using the `label` prop.
|
||||
|
||||
<Layout flex>
|
||||
|
||||
```vue-html
|
||||
<Select
|
||||
v-model:current="current"
|
||||
v-model:options="options"
|
||||
>
|
||||
<template #label>
|
||||
<strong>Custom Label</strong>
|
||||
</template>
|
||||
</Select>
|
||||
```
|
||||
|
||||
<div class="preview">
|
||||
|
||||
<Select
|
||||
v-model:current="current"
|
||||
v-model:options="options"
|
||||
>
|
||||
<template #label>
|
||||
<i class="bi bi-star"/> Custom Label
|
||||
</template>
|
||||
</Select>
|
||||
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
## Add a prefix icon
|
||||
|
||||
Use the `icon` prop to add a Bootstrap icon before the select input.
|
||||
|
||||
<Layout flex>
|
||||
|
||||
```vue-html
|
||||
<Select
|
||||
label="Navigation"
|
||||
icon="bi-house"
|
||||
v-model:current="current"
|
||||
v-model:options="options"
|
||||
/>
|
||||
```
|
||||
|
||||
<div class="preview">
|
||||
<Select
|
||||
label="Navigation"
|
||||
icon="bi-house"
|
||||
v-model:current="current"
|
||||
v-model:options="options"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</Layout>
|
||||
|
||||
## Add placeholder text
|
||||
|
||||
Use an informative `placeholder` when no valid option is initially selected.
|
||||
The placeholder cannot be re-selected but stays visible.
|
||||
|
||||
<Layout flex>
|
||||
|
||||
<Layout stack no-gap>
|
||||
|
||||
```ts
|
||||
const nullable = ref(undefined)
|
||||
```
|
||||
|
||||
```vue-html
|
||||
<Select
|
||||
label="Choose an option"
|
||||
placeholder="Select one..."
|
||||
v-model:current="nullable"
|
||||
v-model:options="options"
|
||||
/>
|
||||
```
|
||||
</Layout>
|
||||
|
||||
<div class="preview">
|
||||
<Select
|
||||
label="Choose an option"
|
||||
placeholder="Select one..."
|
||||
v-model:current="nullable"
|
||||
v-model:options="options"
|
||||
/>
|
||||
{{ nullable ?? "No option selected" }}
|
||||
</div>
|
||||
|
||||
</Layout>
|
||||
|
||||
## Let the user reset to an initial value
|
||||
|
||||
Set the `initial` prop. A `reset` button appears when the current value is different from the initial value.
|
||||
|
||||
<Layout flex>
|
||||
<Layout stack no-gap>
|
||||
|
||||
```ts
|
||||
const initial = ref(1)
|
||||
```
|
||||
|
||||
```vue-html
|
||||
<Select
|
||||
label="Resettable selection"
|
||||
v-model:current="current"
|
||||
v-model:options="options"
|
||||
v-model:initial="initial"
|
||||
/>
|
||||
```
|
||||
|
||||
</Layout>
|
||||
<div class="preview">
|
||||
<Select
|
||||
label="Resettable selection"
|
||||
v-model:current="current"
|
||||
v-model:options="options"
|
||||
v-model:initial="initial"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</Layout>
|
||||
|
||||
## Auto-focus the component
|
||||
|
||||
Use the `autofocus` prop to focus the select input immediately when the component mounts.
|
||||
Try it out: Click the `Open modal` button, then use the arrow keys to select an option. Close the modal with `ESC` and re-open it with `SPACE`.
|
||||
|
||||
<Layout flex>
|
||||
|
||||
```vue-html
|
||||
<Button primary @click="isOpen = true">
|
||||
Open modal
|
||||
</Button>
|
||||
<Modal v-model="isOpen" title="My modal">
|
||||
Modal content
|
||||
</Modal>
|
||||
```
|
||||
|
||||
<div class="preview">
|
||||
<Button primary @click="isOpen = true">
|
||||
Open modal
|
||||
</Button>
|
||||
<Modal v-model="isOpen">
|
||||
<Select
|
||||
autofocus
|
||||
v-model:current="current"
|
||||
v-model:options="options"
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
</Layout>
|
||||
|
||||
## Colors and variants
|
||||
|
||||
The Select component supports standard color and variant props from [the color composable](../../using-color) including `primary`, `secondary`, `solid`, `ghost`, `outline`, and other styling props.
|
||||
|
||||
<Layout flex>
|
||||
|
||||
```vue-html
|
||||
<Select v-for = "color in [
|
||||
'primary',
|
||||
'ghost',
|
||||
'outline',
|
||||
'green',
|
||||
'destructive',
|
||||
'raised'
|
||||
]"
|
||||
v-bind="{[color]: true}"
|
||||
v-model:current="current"
|
||||
v-model:options="options"
|
||||
/>
|
||||
```
|
||||
|
||||
<Layout stack gap-8 class="preview">
|
||||
<Select v-for = "color in ['primary', 'ghost', 'outline', 'green', 'destructive', 'raised']"
|
||||
v-bind="{[color]: true}"
|
||||
v-model:current="current"
|
||||
v-model:options="options"
|
||||
/>
|
||||
</Layout>
|
||||
|
||||
</Layout>
|
||||
|
||||
<!-- prettier-ignore-end -->
|
|
@ -68,3 +68,5 @@ Pass a `big` prop to create a larger toggle.
|
|||
/>
|
||||
|
||||
</Layout>
|
||||
|
||||
<Button />
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
## Setting up your IDE
|
||||
|
||||
If you are using vscode, [enable `Vue` code hints in the `.md`
|
||||
If you are using _vscode_, [enable `Vue` code hints in the `.md`
|
||||
docs](https://vitepress.dev/guide/using-vue#vs-code-intellisense-support):
|
||||
|
||||
```json
|
||||
|
@ -12,6 +12,20 @@ docs](https://vitepress.dev/guide/using-vue#vs-code-intellisense-support):
|
|||
"vue.server.includeLanguages": ["vue", "markdown"]
|
||||
```
|
||||
|
||||
I (flupsi) recommend [the quick, free editor _zed_](https://zed.dev) with the default `Vue` extension.
|
||||
|
||||
## Setting up a reproducible environment
|
||||
|
||||
To set up a reproducible environment independent from your system's installed software packages, use `docker` or `mise`.
|
||||
|
||||
Read the [funkwhale docs about docker here](https://docs.funkwhale.audio/developer/setup/docker.html).
|
||||
|
||||
To quickly run tests, linters, etc. locally, you don't need to install `node` or `yarn` globally.
|
||||
|
||||
1. [Install mise](https://mise.jdx.dev/)
|
||||
2. Run `mise use yarn@1` to enable the `yarn` command in your current shell
|
||||
3. Now you can run all commands such as `yarn dev:docs` or `yarn lint` without installing funkwhale-specific tools on your system globally.
|
||||
|
||||
## Adding new UI components
|
||||
|
||||
::: tip Prerequisites
|
||||
|
|
|
@ -1,14 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { type Track, type User } from '~/types'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Card from '~/components/ui/Card.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import Toggle from '~/components/ui/Toggle.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
import Activity from '~/components/ui/Activity.vue'
|
||||
import Section from '~/components/ui/Section.vue'
|
||||
|
||||
const alignLeft = ref(false)
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import Layout from '../src/components/ui/Layout.vue'
|
||||
import Card from '../src/components/ui/Card.vue'
|
||||
import Spacer from '../src/components/ui/Spacer.vue'
|
||||
</script>
|
||||
|
||||
# Funkwhale design component library
|
||||
|
@ -9,43 +8,35 @@ import Spacer from '../src/components/ui/Spacer.vue'
|
|||
## Plan
|
||||
|
||||
<Layout flex>
|
||||
|
||||
<Card default raised to="/designing-pages"
|
||||
title="Designing pages"
|
||||
min-content
|
||||
/>
|
||||
|
||||
<Card default raised to="https://design.funkwhale.audio"
|
||||
title="UI designs" >
|
||||
Check out the design system on our Penpot.
|
||||
</Card>
|
||||
|
||||
<Card default raised to="/designing-pages"
|
||||
title="Designing pages"
|
||||
min-content
|
||||
/>
|
||||
<Card default raised to="https://design.funkwhale.audio"
|
||||
title="UI designs" >
|
||||
Check out the design system on our Penpot.
|
||||
</Card>
|
||||
</Layout>
|
||||
|
||||
## Use
|
||||
|
||||
<Layout flex>
|
||||
|
||||
<Card default raised to='/using-components'
|
||||
title="Using components"
|
||||
min-content
|
||||
/>
|
||||
|
||||
<Card default raised to="/using-color"
|
||||
title="Adding Color"
|
||||
min-content
|
||||
/>
|
||||
|
||||
<Card default raised to="/using-width"
|
||||
title="Setting width and height"
|
||||
min-content
|
||||
/>
|
||||
|
||||
<Card default raised to="/using-alignment"
|
||||
title="Aligning elements"
|
||||
min-content
|
||||
/>
|
||||
|
||||
<Card default raised to='/using-components'
|
||||
title="Using components"
|
||||
min-content
|
||||
/>
|
||||
<Card default raised to="/using-color"
|
||||
title="Adding Color"
|
||||
min-content
|
||||
/>
|
||||
<Card default raised to="/using-width"
|
||||
title="Setting width and height"
|
||||
min-content
|
||||
/>
|
||||
<Card default raised to="/using-alignment"
|
||||
title="Aligning elements"
|
||||
min-content
|
||||
/>
|
||||
</Layout>
|
||||
|
||||
## Contribute
|
||||
|
|
|
@ -1,21 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { type Track, type User } from '~/types'
|
||||
|
||||
import Card from '~/components/ui/Card.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import Toggle from '~/components/ui/Toggle.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
import Activity from '~/components/ui/Activity.vue'
|
||||
import Section from '~/components/ui/Section.vue'
|
||||
|
||||
const alignLeft = ref(false)
|
||||
|
||||
const attributes = computed(() => ({
|
||||
style: alignLeft.value ? 'justify-content: start' : ''
|
||||
}))
|
||||
// TODO: flesh out
|
||||
</script>
|
||||
|
||||
# Navigation
|
||||
|
|
|
@ -13,17 +13,6 @@ const route = useRoute();
|
|||
const here = route.path
|
||||
</script>
|
||||
|
||||
<Alert blue style="margin: 0 -48px">
|
||||
Want to fix colors?
|
||||
<Spacer h />
|
||||
<Layout flex no-gap>
|
||||
<Link solid primary to="#change-a-color-value">Change a color value</Link>
|
||||
<Link solid primary to="#alter-the-shade-of-a-color">Alter the shade of a color</Link>
|
||||
<Link solid primary to="#choose-a-different-style-for-a-specific-variant">Modify a specific variant</Link>
|
||||
</Layout>
|
||||
</Alert>
|
||||
<Spacer />
|
||||
|
||||
# Using Color
|
||||
|
||||
## Add color via props
|
||||
|
|
|
@ -2,13 +2,13 @@ import { visualizer } from 'rollup-plugin-visualizer'
|
|||
import { defineConfig, type PluginOption } from 'vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import UnoCSS from 'unocss/vite'
|
||||
|
||||
|
||||
|
||||
import manifest from './pwa-manifest.json'
|
||||
|
||||
import VueI18n from '@intlify/unplugin-vue-i18n/vite'
|
||||
import Vue from '@vitejs/plugin-vue'
|
||||
import VueMacros from 'unplugin-vue-macros/vite'
|
||||
import { nodePolyfills } from 'vite-plugin-node-polyfills'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
|
@ -23,13 +23,8 @@ export const exPort = port
|
|||
export default defineConfig(({ mode }) => ({
|
||||
envPrefix: ['VUE_', 'TAURI_', 'FUNKWHALE_SENTRY_'],
|
||||
plugins: [
|
||||
// https://vue-macros.sxzz.moe/
|
||||
VueMacros({
|
||||
plugins: {
|
||||
// https://github.com/vitejs/vite/tree/main/packages/plugin-vue
|
||||
vue: Vue()
|
||||
}
|
||||
}),
|
||||
// https://github.com/vitejs/vite/tree/main/packages/plugin-vue
|
||||
Vue(),
|
||||
|
||||
// https://github.com/intlify/bundle-tools/tree/main/packages/vite-plugin-vue-i18n
|
||||
VueI18n({
|
||||
|
@ -57,16 +52,23 @@ export default defineConfig(({ mode }) => ({
|
|||
// see: https://github.com/Borewit/music-metadata-browser/issues/836
|
||||
nodePolyfills(),
|
||||
|
||||
// https://unocss.dev/
|
||||
UnoCSS(),
|
||||
|
||||
vueDevTools()
|
||||
],
|
||||
server: {
|
||||
port: +(process.env.VUE_PORT ?? 8080),
|
||||
watch: {
|
||||
usePolling: true
|
||||
},
|
||||
allowedHosts: [".funkwhale.test"],
|
||||
hmr: {
|
||||
overlay: false
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['vue', 'vue-router', 'pinia', 'axios', 'lodash-es'],
|
||||
exclude: ['@sentry/vue', '@sentry/tracing']
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: '#', replacement: fileURLToPath(new URL('./src/ui/workers', import.meta.url)) },
|
||||
|
@ -86,22 +88,31 @@ export default defineConfig(({ mode }) => ({
|
|||
}
|
||||
}
|
||||
},
|
||||
esbuild: {
|
||||
target: mode === 'development' ? 'esnext' : 'es2020',
|
||||
supported: {
|
||||
'top-level-await': true
|
||||
}
|
||||
},
|
||||
build: {
|
||||
target: mode === 'development'
|
||||
? 'esnext'
|
||||
: ['es2020', 'chrome87', 'firefox78', 'safari14', 'edge88'],
|
||||
sourcemap: true,
|
||||
minify: mode === 'development' ? false : 'esbuild',
|
||||
// https://rollupjs.org/configuration-options/
|
||||
rollupOptions: {
|
||||
output: {
|
||||
output: mode === 'production' ? {
|
||||
manualChunks: {
|
||||
axios: ['axios', 'axios-auth-refresh'],
|
||||
dompurify: ['dompurify'],
|
||||
jquery: ['jquery'],
|
||||
lodash: ['lodash-es'],
|
||||
moment: ['moment'],
|
||||
sentry: ['@sentry/vue', '@sentry/tracing'],
|
||||
'standardized-audio-context': ['standardized-audio-context'],
|
||||
'vue-router': ['vue-router']
|
||||
}
|
||||
}
|
||||
} : {}
|
||||
}
|
||||
},
|
||||
test: {
|
||||
|
|
8157
front/yarn.lock
8157
front/yarn.lock
Plik diff jest za duży
Load Diff
Ładowanie…
Reference in New Issue