feat (front): new ui on start page #2477 NOCHANGELOG

merge-requests/2950/head
Flupsi 2025-08-04 21:32:51 +00:00 zatwierdzone przez petitminion
rodzic d33490c219
commit 1db86d404f
81 zmienionych plików z 5628 dodań i 5548 usunięć

3
.gitignore vendored
Wyświetl plik

@ -135,8 +135,9 @@ flake.lock
# Zed
.zed/
# Node version (asdf)
# Node version (asdf, mise)
.tool-versions
mise.toml
# Lychee link checker
.lycheecache

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -0,0 +1 @@
nodeLinker: node-modules

Wyświetl plik

@ -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

Wyświetl plik

@ -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",

Wyświetl plik

@ -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'
]
}
}
}

Wyświetl plik

@ -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>

Wyświetl plik

@ -67,7 +67,7 @@ const performSearch = () => {
}
watch(
[() => store.state.moderation.lastUpdate, page],
() => [store.state.moderation.lastUpdate, page.value],
() => fetchData(),
{ immediate: true }
)

Wyświetl plik

@ -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']"

Wyświetl plik

@ -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')"
/>

Wyświetl plik

@ -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,

Wyświetl plik

@ -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>

Wyświetl plik

@ -32,7 +32,7 @@ withDefaults(defineProps<Props>(), {
total: 0
})
const { page } = defineModels<{ page: number, }>()
const page = defineModel<number>('page', { required: true })
</script>
<template>

Wyświetl plik

@ -83,7 +83,7 @@ onMounted(() => {
})
watch(
[() => store.state.moderation.lastUpdate, page],
() => [store.state.moderation.lastUpdate, page.value],
() => fetchData(),
{ immediate: true }
)

Wyświetl plik

@ -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>

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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"

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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>

Wyświetl plik

@ -1,12 +1,3 @@
<template>
<div class="main page-library">
<RouterView />
</div>
<RouterView />
</template>
<style scoped lang="scss">
main {
padding: 32px;
}
</style>

Wyświetl plik

@ -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

Wyświetl plik

@ -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 }
)

Wyświetl plik

@ -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"
/>

Wyświetl plik

@ -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] }>()

Wyświetl plik

@ -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

Wyświetl plik

@ -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;

Wyświetl plik

@ -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

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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;

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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

Wyświetl plik

@ -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 }
"
>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -50,6 +50,10 @@
font-weight:600;
}
&:has(>[required])>.label:after {
content: ' *';
}
> .prefix,
> .input-right {
align-items: center;

Wyświetl plik

@ -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']" />

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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']" />

Wyświetl plik

@ -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"
>

Wyświetl plik

@ -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)"
>

Wyświetl plik

@ -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: {

Wyświetl plik

@ -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'
})

Wyświetl plik

@ -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>()

Wyświetl plik

@ -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 => {

Wyświetl plik

@ -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;',

Wyświetl plik

@ -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>[]>

Wyświetl plik

@ -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": {

Wyświetl plik

@ -15,8 +15,6 @@ import '~/style/_main.scss'
import '~/api'
import 'virtual:uno.css'
// NOTE: Set the theme as fast as possible
useTheme()

Wyświetl plik

@ -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));

Wyświetl plik

@ -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 {

Wyświetl plik

@ -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([

Wyświetl plik

@ -5,10 +5,3 @@
<template>
<RouterView />
</template>
<style scoped lang="scss">
main {
padding: 32px;
}
</style>

Wyświetl plik

@ -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)"
>

Wyświetl plik

@ -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
}
})

Wyświetl plik

@ -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>

Wyświetl plik

@ -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
})

Wyświetl plik

@ -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)

Wyświetl plik

@ -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'}"

Wyświetl plik

@ -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

Wyświetl plik

@ -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": []
}
}

Wyświetl plik

@ -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' },

Wyświetl plik

@ -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)

Wyświetl plik

@ -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>

Wyświetl plik

@ -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

Wyświetl plik

@ -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;
```

Wyświetl plik

@ -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>

Wyświetl plik

@ -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 -->

Wyświetl plik

@ -68,3 +68,5 @@ Pass a `big` prop to create a larger toggle.
/>
</Layout>
<Button />

Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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: {

Plik diff jest za duży Load Diff