Porównaj commity

...

3 Commity

Autor SHA1 Wiadomość Data
upsiflu 3b72aca7af chore(ui): move docs markdown files into ui-docs because vitepress could not access parent directory 2024-11-26 15:19:19 +01:00
upsiflu 6990e80bdc feat(ui-docs): implement dev:docs, build:docs and serve:docs (vitepress)
NOTE: type:module added to front/package.json. TODO: Check if app still builds
2024-11-26 12:59:11 +01:00
upsiflu fef8a8b4fd chore(i18n): Integrate Ui translations (vui namespace) #2355 2024-11-25 10:55:05 +01:00
84 zmienionych plików z 11129 dodań i 11 usunięć

11
.gitignore vendored
Wyświetl plik

@ -75,11 +75,13 @@ api/staticfiles
api/static
api/.pytest_cache
api/celerybeat-*
# Front
oldfront/node_modules/
front/static/translations
front/node_modules/
front/dist/
front/dev-dist/
front/npm-debug.log*
front/yarn-debug.log*
front/yarn-error.log*
@ -88,7 +90,16 @@ front/tests/e2e/reports
front/test_results.xml
front/coverage/
front/selenium-debug.log
# Vitepress
front/ui-docs/.vitepress/.vite
front/ui-docs/.vitepress/cache
front/ui-docs/.vitepress/dist
front/ui-docs/public
# Docs
docs/_build
#Tauri
front/tauri/gen

Wyświetl plik

@ -1,13 +1,17 @@
{
"name": "front",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "Funkwhale front-end",
"author": "Funkwhale Collective <contact@funkwhale.audio>",
"scripts": {
"dev": "vite",
"dev:docs": "VP_DOCS=true vitepress dev ui-docs",
"build": "vite build --mode development",
"build:deployment": "vite build",
"build:docs": "VP_DOCS=true vitepress build ui-docs",
"serve:docs": "VP_DOCS=true vitepress serve ui-docs",
"serve": "vite preview",
"test": "vitest run",
"test:unit": "vitest run --coverage",
@ -88,7 +92,7 @@
"@vue/eslint-config-standard": "8.0.1",
"@vue/eslint-config-typescript": "12.0.0",
"@vue/test-utils": "2.2.7",
"@vue/tsconfig": "0.5.1",
"@vue/tsconfig": "0.6.0",
"cypress": "13.6.4",
"eslint": "8.57.0",
"eslint-config-standard": "17.1.0",
@ -115,6 +119,7 @@
"vite-plugin-node-polyfills": "0.17.0",
"vite-plugin-pwa": "0.14.4",
"vite-plugin-vue-devtools": "^7.5.2",
"vitepress": "1.5.0",
"vitest": "1.3.1",
"vue-tsc": "1.8.27",
"workbox-core": "6.5.4",

Wyświetl plik

@ -0,0 +1,69 @@
<script setup lang="ts">
import { FwOptionsButton, FwPlayButton } from '~/components'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { computed } from 'vue'
import type { Track, User } from '~/types/models'
const { t } = useI18n()
const play = defineEmit<[track: Track]>()
const track = defineProp<Track>('track', { required: true })
const user = defineProp<User>('user', { required: true })
const profileParams = computed(() => {
const [username, domain] = user.value.full_username.split('@')
return { username, domain }
})
let navigate = (to: 'track' | 'artist' | 'user') => {}
if (import.meta.env.PROD) {
const router = useRouter()
navigate = (to: 'track' | 'artist' | 'user') => to === 'track'
? router.push({ name: 'library.tracks.detail', params: { id: track.value.id } })
: to === 'artist'
? router.push({ name: 'library.artists.detail', params: { id: track.value.artist.id } })
: router.push({ name: 'profile.full', params: profileParams.value })
}
</script>
<template>
<div
class="funkwhale activity"
@click="navigate('track')"
>
<div class="activity-image">
<img :src="track.cover.urls.original" />
<fw-play-button
@play="play(track)"
:round="false"
:shadow="false"
/>
</div>
<div class="activity-content">
<div class="track-title">{{ track.name }}</div>
<a
@click.stop="navigate('artist')"
class="funkwhale link artist"
>
{{ track.artist.name }}
</a>
<a
@click.stop="navigate('user')"
class="funkwhale link user"
>
{{ t('vui.by-user', { username: user.username}) }}
</a>
</div>
<div>
<fw-options-button />
</div>
</div>
</template>
<style lang="scss">
@import './style.scss'
</style>

Wyświetl plik

@ -0,0 +1,24 @@
<script setup lang="ts">
import { useColorOrPastel } from '~/composables/colors'
import type { PastelProps } from '~/types/common-props'
const props = defineProps<PastelProps>()
const color = useColorOrPastel(() => props.color, 'blue')
</script>
<template>
<div
class="funkwhale is-colored alert"
:class="[color]"
>
<slot />
<div v-if="$slots.actions" class="actions">
<slot name="actions" />
</div>
</div>
</template>
<style lang="scss">
@import './style.scss'
</style>

Wyświetl plik

@ -0,0 +1,80 @@
<script setup lang="ts">
import { useColor } from '~/composables/colors'
import { FwLoader } from '~/components'
import { ref, computed, useSlots } from 'vue'
import type { ColorProps } from '~/types/common-props'
interface Props {
variant?: 'solid' | 'outline' | 'ghost'
width?: 'standard' | 'auto' | 'full'
alignText?: 'left' | 'center' | 'right'
isActive?: boolean
isLoading?: boolean
shadow?: boolean
round?: boolean
icon?: string
onClick?: (...args: any[]) => void | Promise<void>
}
const props = defineProps<Props & ColorProps>()
const color = useColor(() => props.color)
const slots = useSlots()
const iconOnly = computed(() => !!props.icon && !slots.default)
const internalLoader = ref(false)
const isLoading = computed(() => props.isLoading || internalLoader.value)
const click = async (...args: any[]) => {
internalLoader.value = true
try {
await props.onClick?.(...args)
} finally {
internalLoader.value = false
}
}
</script>
<template>
<button
class="funkwhale is-colored button"
:class="[
color,
'is-' + (variant ?? 'solid'),
'is-' + (width ?? 'standard'),
'is-aligned-' + (alignText ?? 'center'),
{
'is-active': isActive,
'is-loading': isLoading,
'icon-only': iconOnly,
'has-icon': !!icon,
'is-round': round,
'is-shadow': shadow
}
]"
@click="click"
>
<i
v-if="icon"
:class="['bi', icon]"
/>
<span>
<slot />
</span>
<fw-loader
v-if="isLoading"
:container="false"
/>
</button>
</template>
<style lang="scss">
@import './style.scss'
</style>

Wyświetl plik

@ -0,0 +1,189 @@
<script setup lang="ts">
import { useCssModule } from 'vue'
import { FwPill } from '~/components'
import { computed } from 'vue'
import { type RouterLinkProps, RouterLink } from 'vue-router';
import type { Pastel } from '~/types/common-props';
import Layout from '../layout/Layout.vue';
import Spacer from '../layout/Spacer.vue';
interface Props extends Partial<RouterLinkProps> {
title: string
category?: true | "h1" | "h2" | "h3" | "h4" | "h5"
color?: Pastel
image?: string | { src: string, style?: "withPadding" }
tags?: string[]
}
const props = defineProps<Props>()
const image = typeof props.image === 'string' ? { src: props.image } : props.image
const isExternalLink = computed(() => {
return typeof props.to === 'string' && props.to.startsWith('http')
})
const c = useCssModule()
</script>
<style module>
.card {
/* Override --width with your preferred value */
--fw-card-width: var(--width, 320px);
--fw-card-padding: 24px;
position: relative;
color: var(--fw-text-color);
background-color: var(--fw-bg-color);
box-shadow: 0 3px 12px 2px rgb(0 0 0 / 20%);
border-radius: var(--fw-border-radius);
font-size: 1rem;
width: var(--fw-card-width);
>.covering {
position: absolute;
inset: 0;
}
&:has(>.image) {
text-align: center;
}
>.image {
overflow: hidden;
border-radius: var(--fw-border-radius) var(--fw-border-radius) 0 0;
object-fit: cover;
width: 100%;
aspect-ratio: 1;
&.with-padding {
margin: var(--fw-card-padding) var(--fw-card-padding) calc(var(--fw-card-padding) / 2) var(--fw-card-padding);
width: calc(100% - 2 * var(--fw-card-padding));
border-radius: var(--fw-border-radius);
}
}
>.title {
padding: 0 var(--fw-card-padding);
line-height: 1.3em;
font-size: 1.125em;
font-weight: bold;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
&.is-category>.title {
font-size: 1.75em;
}
>.alert {
padding-left: var(--fw-card-padding);
padding-right: var(--fw-card-padding);
}
>.tags {
/* Tags have an inherent padding which we offset here: */
padding: 0 calc(var(--fw-card-padding) - 12px);
}
>.content {
padding: 0 var(--fw-card-padding);
/* Consider making all line height, vertical paddings, margins and borders,
a multiple of a global vertical rhythm so that side-by-side lines coincide */
line-height: 24px;
}
>.footer {
padding: calc(var(--fw-card-padding) - 4px);
display: flex;
align-items: center;
font-size: 0.8125rem;
>.options-button {
margin-left: auto;
}
}
>.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);
>*:not(.with-padding) {
margin: 0;
flex-grow: 1;
border-top-left-radius: 0;
border-top-right-radius: 0;
border: 8px solid transparent;
&:not(:first-child) {
border-bottom-left-radius: 0;
}
&:not(:last-child) {
border-bottom-right-radius: 0;
}
}
}
}
</style>
<template>
<Layout stack :class="{ [c.card]: true, [c['is-category']]: category }" style="--gap:16px">
<a v-if="props.to && isExternalLink" :class="c.covering" :href="to?.toString()" target="_blank">
Link to `to`
</a>
<RouterLink v-if="props.to && !isExternalLink" :to="props.to" v-slot="{ isActive, href, navigate }">
<a :href="href" @click="navigate" :class="{ [c.covering]: true, activeClass: isActive }">
LINK
</a>
</RouterLink>
<!-- Image -->
<div v-if="$slots.image" :class="c.image">
<slot name="image" :src="image" />
</div>
<img v-else-if="image" :src="image?.src"
:class="{ [c.image]: true, [c['with-padding']]: image?.style === 'withPadding' }" />
<Spacer v-else style="--size:12px" />
<!-- Content -->
<component :class="c.title" :is="typeof category === 'string' ? category : 'h6'">{{ title }}</component>
<fw-alert v-if="$slots.alert" :class="c.alert">
<slot name="alert" />
</fw-alert>
<div v-if="tags" :class="c.tags">
<fw-pill v-for="tag in tags" :key="tag">
#{{ tag }}
</fw-pill>
</div>
<div v-if="$slots.default" :class="c.content">
<slot />
</div>
<!-- Footer and Action -->
<div v-if="$slots.footer" :class="c.footer">
<slot name="footer" />
</div>
<div v-if="$slots.action" :class="c.action">
<slot name="action" />
</div>
<Spacer v-else-if="!$slots.footer" style="--size:16px" />
</Layout>
</template>

Wyświetl plik

@ -0,0 +1,38 @@
<script setup lang="ts">
import { ref } from 'vue'
const icon = defineProp<string>()
const placeholder = defineProp<string>()
const modelValue = defineModel<string>({
required: true
})
const input = ref()
</script>
<template>
<div
:class="{ 'has-icon': !!icon }"
class="funkwhale input"
@click="input.focus()"
>
<div v-if="icon" class="prefix">
<i :class="['bi', icon]" />
</div>
<input
v-bind="$attrs"
v-model="modelValue"
ref="input"
:placeholder="placeholder"
@click.stop
/>
<div v-if="$slots['input-right']" class="input-right">
<slot name="input-right" />
</div>
</div>
</template>
<style lang="scss">
@import './style.scss'
</style>

Wyświetl plik

@ -0,0 +1,80 @@
<script setup lang="ts">
import { useCssModule } from 'vue'
const props = defineProps<{ [P in "stack" | "grid" | "flex"]?: true } & { columnWidth?: number }>()
const classes = useCssModule()
const columnWidth = props.columnWidth ?? 320
</script>
<template>
<div :class="[
classes.gap,
props.grid ? classes.grid
: props.flex ? classes.flex
: classes.stack
]">
<slot />
</div>
</template>
<style module>
/* Override --gap with your preferred value */
.gap {
gap: var(--gap, 32px);
}
.grid {
display: grid;
grid-template-columns:
repeat(auto-fit, minmax(calc(v-bind(columnWidth) * 1px), min-content));
grid-auto-flow: row dense;
:global(>.span-2-rows) {
grid-row: span 2;
height: auto;
--height: auto;
}
:global(>.span-3-rows) {
grid-row: span 3;
height: auto;
--height: auto;
}
:global(>.span-4-rows) {
grid-row: span 4;
height: auto;
--height: auto;
}
:global(>.span-2-columns) {
grid-column: span 2;
width: auto;
--width: auto;
}
:global(>.span-3-columns) {
grid-column: span 3;
width: auto;
--width: auto;
}
:global(>.span-4-columns) {
grid-column: span 4;
width: auto;
--width: auto;
}
}
.stack {
display: flex;
flex-direction: column;
}
.flex {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
</style>

Wyświetl plik

@ -0,0 +1,19 @@
<script setup lang="ts">
const container = defineProp('container', { default: true })
</script>
<template>
<div v-if="container" class="funkwhale loader-container">
<div class="funkwhale loader">
<div class="loader" />
</div>
</div>
<div v-else class="funkwhale loader">
<div class="loader" />
</div>
</template>
<style lang="scss">
@import './style.scss'
</style>

Wyświetl plik

@ -0,0 +1,93 @@
<script setup lang="ts">
import { computed } from 'vue'
import { FwSanitizedHtml } from '~/components'
import { char, createRegExp, exactly, global, oneOrMore, word } from 'magic-regexp/further-magic'
import showdown from 'showdown'
interface Props {
md: string
}
const props = defineProps<Props>()
showdown.extension('openExternalInNewTab', {
type: 'output',
regex: createRegExp(
exactly('<a'),
char.times.any(),
exactly(' href="'),
oneOrMore(
// TODO: Use negative set when implemented: https://github.com/danielroe/magic-regexp/issues/237#issuecomment-1606056174
char
),
exactly('">'),
[global]
),
replace (text: string) {
const href = createRegExp(
exactly('href="'),
oneOrMore(
// TODO: Use negative set when implemented: https://github.com/danielroe/magic-regexp/issues/237#issuecomment-1606056174
char
).as('url'),
exactly('">')
)
const matches = text.match(href)
const url = matches?.groups?.url ?? './'
if ((!url.startsWith('http://') && !url.startsWith('https://')) || url.startsWith('mailto:')) {
return text
}
try {
const { hostname } = new URL(url)
return hostname !== location.hostname
? text.replace(href, `href="${url}" target="_blank" rel="noopener noreferrer">`)
: text
} catch {
return text.replace(href, `href="${url}" target="_blank" rel="noopener noreferrer">`)
}
}
})
showdown.extension('linkifyTags', {
type: 'language',
regex: createRegExp(
exactly('#'),
oneOrMore(word),
[global]
),
// regex: /#[^\W]+/g,
replace (text: string) {
return `<a href="/library/tags/${text.slice(1)}">${text}</a>`
}
})
const markdown = new showdown.Converter({
extensions: ['openExternalInNewTab', 'linkifyTags'],
ghMentions: true,
ghMentionsLink: '/@{u}',
simplifiedAutoLink: true,
openLinksInNewWindow: false,
simpleLineBreaks: true,
strikethrough: true,
tables: true,
tasklists: true,
underline: true,
noHeaderId: true,
headerLevelStart: 3,
literalMidWordUnderscores: true,
excludeTrailingPunctuationFromURLs: true,
encodeEmails: true,
emoji: true
})
const html = computed(() => markdown.makeHtml(props.md))
</script>
<template>
<fw-sanitized-html :html="html" />
</template>

Wyświetl plik

@ -0,0 +1,38 @@
<script setup lang="ts">
const title = defineProp<string>('title', { required: true })
const open = defineModel<boolean>({ required: true })
</script>
<template>
<Teleport to="body">
<Transition mode="out-in">
<div v-if="open" @click.exact.stop="open = false" class="funkwhale overlay">
<div @click.stop class="funkwhale modal" :class="$slots.alert && 'has-alert'" >
<h2>
{{ title }}
<FwButton icon="bi-x-lg" color="secondary" variant="ghost" @click="open = false" />
</h2>
<div class="modal-content">
<Transition>
<div v-if="$slots.alert" class="alert-container">
<div>
<slot name="alert" />
</div>
</div>
</Transition>
<slot />
</div>
<div v-if="$slots.actions" class="modal-actions">
<slot name="actions" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style lang="scss">
@import './style.scss'
</style>

Wyświetl plik

@ -0,0 +1,118 @@
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { ref, computed } from 'vue'
import { isMobileView } from '~/composables/screen'
import { preventNonNumeric } from '~/utils/event-validators'
const { t } = useI18n()
const pages = defineProp<number>('pages', {
required: true,
validator: (value: number) => value > 0
})
const page = defineModel<number>('page', {
required: true,
validator: (value: number) => value > 0
})
const goTo = ref<number>()
const range = (start: number, end: number) => Array.from({ length: end - start + 1 }, (_, i) => i + start)
const renderPages = computed(() => {
const start = range(2, 5)
const end = range(pages.value - 4, pages.value - 1)
const pagesArray = [1]
if (page.value < 5) pagesArray.push(...start)
if (page.value >= 5 && page.value <= pages.value - 4) {
pagesArray.push(page.value - 1)
pagesArray.push(page.value)
pagesArray.push(page.value + 1)
}
if (page.value > pages.value - 4) pagesArray.push(...end)
pagesArray.push(pages.value)
return pagesArray.filter((page, index, pages) => pages.indexOf(page) === index)
})
const pagination = ref()
const { width } = useElementSize(pagination)
const isSmall = isMobileView(width)
const setPage = () => {
if (goTo.value == null) return
page.value = Math.min(pages.value, Math.max(1, goTo.value))
}
</script>
<template>
<nav ref="pagination" :aria-label="t('vui.aria.pagination.nav')" :class="{ 'is-small': isSmall }"
class="funkwhale pagination" role="navigation">
<ul class="pages">
<li>
<fw-button @click="page -= 1" :disabled="page <= 1" :aria-label="t('vui.aria.pagination.gotoPrevious')"
color="secondary" outline>
<i class="bi bi-chevron-left" />
<span v-if="!isSmall">{{ t('vui.pagination.previous') }}</span>
</fw-button>
</li>
<template v-if="!isSmall">
<template v-for="(i, index) in renderPages" :key="i">
<li>
<fw-button v-if="i <= pages && i > 0 && pages > 3" @click="page = i" color="secondary"
:aria-label="page !== i ? t('vui.aria.pagination.gotoPage', i) : t('vui.aria.pagination.currentPage', page)"
:outline="page !== i">
{{ i }}
</fw-button>
</li>
<li v-if="i + 1 < renderPages[index + 1]"></li>
</template>
</template>
<template v-else>
<li>
<fw-button @click="page = 1" color="secondary"
:aria-label="page !== 1 ? t('vui.aria.pagination.gotoPage', page) : t('vui.aria.pagination.currentPage', page)"
:outline="page !== 1">
1
</fw-button>
</li>
<li v-if="page === 1 || page === pages"></li>
<li v-else>
<fw-button color="secondary" :aria-label="t('vui.aria.pagination.currentPage', page)" aria-current="true">
{{ page }}
</fw-button>
</li>
<li>
<fw-button @click="page = pages" color="secondary"
:aria-label="page !== pages ? t('vui.aria.pagination.gotoPage', page) : t('vui.aria.pagination.currentPage', page)"
:outline="page !== pages">
{{ pages }}
</fw-button>
</li>
</template>
<li>
<fw-button @click="page += 1" :disabled="page >= pages" :aria-label="t('vui.aria.pagination.gotoNext')"
color="secondary" outline>
<span v-if="!isSmall">{{ t('vui.pagination.next') }}</span>
<i class="bi bi-chevron-right" />
</fw-button>
</li>
</ul>
<div class="goto">
{{ t('vui.go-to') }}
<fw-input :placeholder="page.toString()" @keyup.enter="setPage" @keydown="preventNonNumeric"
v-model.number="goTo" />
</div>
</nav>
</template>
<style lang="scss">
@import './style.scss'
</style>

Wyświetl plik

@ -0,0 +1,22 @@
<script setup lang="ts">
import { useColorOrPastel } from '~/composables/colors'
import type { ColorProps, PastelProps } from '~/types/common-props'
const props = defineProps<ColorProps | PastelProps>()
const color = useColorOrPastel(() => props.color, 'secondary')
</script>
<template>
<button class="funkwhale is-colored pill" :class="[color]">
<div v-if="!!$slots.image" class="pill-image">
<slot name="image" />
</div>
<div class="pill-content">
<slot />
</div>
</button>
</template>
<style lang="scss">
@import './style.scss';
</style>

Wyświetl plik

@ -0,0 +1,135 @@
<script setup lang="ts">
import { computed, ref, inject, provide, shallowReactive, watch, onScopeDispose } from 'vue'
import { whenever, useElementBounding, onClickOutside } from '@vueuse/core'
import { POPOVER_INJECTION_KEY, POPOVER_CONTEXT_INJECTION_KEY } from '~/injection-keys'
import { isMobileView, useScreenSize } from '~/composables/screen'
const open = defineModel('open', { default: false })
const positioning = defineProp<'horizontal' | 'vertical'>('positioning', { default: 'vertical' })
// Template refs
const popover = ref()
const slot = ref()
// Click outside
const mobileClickOutside = (event: MouseEvent) => {
const inPopover = !!(event.target as HTMLElement).closest('.funkwhale.popover')
if (isMobile.value && !inPopover) {
open.value = false
}
}
onClickOutside(popover, async (event) => {
const inPopover = !!(event.target as HTMLElement).closest('.funkwhale.popover')
if (!isMobile.value && !inPopover) {
open.value = false
}
}, { ignore: [slot] })
// Auto positioning
const isMobile = isMobileView()
const { width, height, left, top, update } = useElementBounding(() => slot.value?.children[0])
const { width: popoverWidth, height: popoverHeight } = useElementBounding(popover, {
windowScroll: false
})
whenever(open, update, { immediate: true })
const { width: screenWidth, height: screenHeight } = useScreenSize()
const position = computed(() => {
if (positioning.value === 'vertical' || isMobile.value) {
let offsetTop = top.value + height.value
if (offsetTop + popoverHeight.value > screenHeight.value) {
offsetTop -= popoverHeight.value + height.value
}
let offsetLeft = left.value
if (offsetLeft + popoverWidth.value > screenWidth.value) {
offsetLeft -= popoverWidth.value - width.value
}
return {
left: offsetLeft + 'px',
top: offsetTop + 'px'
}
}
let offsetTop = top.value
if (offsetTop + popoverHeight.value > screenHeight.value) {
offsetTop -= popoverHeight.value - height.value
}
let offsetLeft = left.value + width.value
if (offsetLeft + popoverWidth.value > screenWidth.value) {
offsetLeft -= popoverWidth.value + width.value
}
return {
left: offsetLeft + 'px',
top: offsetTop + 'px'
}
})
// Popover close stack
let stack = inject(POPOVER_INJECTION_KEY)
if (!stack) {
provide(POPOVER_INJECTION_KEY, stack = shallowReactive([]))
}
stack.push(open)
onScopeDispose(() => {
stack?.splice(stack.indexOf(open), 1)
})
// Provide context for child items
const hoveredItem = ref(-2)
provide(POPOVER_CONTEXT_INJECTION_KEY, {
items: ref(0),
hoveredItem
})
// Closing
const closeChild = () => {
const ref = stack?.[stack.indexOf(open) + 1]
if (!ref) return
ref.value = false
}
// Recursively close popover tree
watch(open, (isOpen) => {
if (isOpen) return
closeChild()
})
</script>
<template>
<div ref="slot" class="funkwhale popover-container">
<slot
:isOpen="open"
:toggleOpen="() => open = !open"
:open="() => open = true"
:close="() => open = false"
/>
</div>
<teleport v-if="open" to="body">
<div
:class="{ 'is-mobile': isMobile }"
class="funkwhale popover-outer"
@click.stop="mobileClickOutside"
>
<div
ref="popover"
:style="position"
:class="{ 'is-mobile': isMobile }"
class="funkwhale popover"
>
<slot name="items" />
</div>
</div>
</teleport>
</template>
<style lang="scss">
@import './style.scss'
</style>

Wyświetl plik

@ -0,0 +1,17 @@
<script setup lang="ts">
import DOMPurify from 'dompurify'
import { computed, h } from 'vue'
const as = defineProp<string>('as', { default: 'div' })
const rawHtml = defineProp<string>('html', { required: true })
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
// set all elements owning target to target=_blank
if ('target' in node) {
node.setAttribute('target', '_blank')
}
})
const html = computed(() => DOMPurify.sanitize(rawHtml.value))
defineRender(() => h(as.value, { innerHTML: html.value }))
</script>

Wyświetl plik

@ -0,0 +1,25 @@
<script setup lang="ts">
import { TABS_INJECTION_KEY } from '~/injection-keys'
import { whenever } from '@vueuse/core'
import { inject, ref } from 'vue'
const title = defineProp<string>('title', { required: true })
const icon = defineProp<string|undefined>('icon')
const { currentTab, tabs, icons } = inject(TABS_INJECTION_KEY, {
currentTab: ref(title.value),
tabs: [],
icons: [],
})
whenever(() => !tabs.includes(title.value), () => {
tabs.push(title.value)
icons.push(icon.value)
}, { immediate: true })
</script>
<template>
<div v-if="currentTab === title" class="tab-content">
<slot />
</div>
</template>

Wyświetl plik

@ -0,0 +1,45 @@
<script setup lang="ts">
import { TABS_INJECTION_KEY } from '~/injection-keys'
import { provide, reactive, ref, watch } from 'vue'
const currentTab = ref()
const tabs = reactive([] as string[])
const icons = reactive([] as string[])
provide(TABS_INJECTION_KEY, {
currentTab,
tabs,
icons
})
watch(() => tabs.length, (to, from) => {
if (from === 0) {
currentTab.value = tabs[0]
}
})
</script>
<template>
<div class="funkwhale tabs">
<div class="tabs-header">
<button v-for="(tab, index) in tabs" :key="tab" :class="{ 'is-active': currentTab === tab }"
@click="currentTab = tab"
@keydown.left="currentTab = tabs[(tabs.findIndex(t => t === currentTab) - 1 + tabs.length) % tabs.length]"
@keydown.right="currentTab = tabs[(tabs.findIndex(t => t === currentTab) + 1) % tabs.length]" class="tabs-item">
<div class="is-spacing">{{ tab }}</div>
<label v-if="icons[index]" class="is-icon">{{ icons[index] }}</label>
<label>{{ tab }}</label>
</button>
<div class="tabs-right">
<slot name="tabs-right" />
</div>
</div>
<slot />
</div>
</template>
<style lang="scss">
@import './style.scss'
</style>

Wyświetl plik

@ -0,0 +1,222 @@
<script setup lang="ts">
import { useTextareaAutosize, computedWithControl, useManualRefHistory, watchDebounced } from '@vueuse/core'
import { FwButton } from '~/components'
import { nextTick, computed, ref, type ComputedRef } from 'vue'
const max = defineProp<number>('max', {
default: Infinity
})
const placeholder = defineProp<string>('placeholder', {
default: ''
})
const { modelValue: value } = defineModels<{
modelValue: string
}>()
const { undo, redo, commit: commitHistory, last } = useManualRefHistory(value)
const { textarea, triggerResize } = useTextareaAutosize({ input: value })
const commit = () => {
triggerResize()
commitHistory()
}
const preview = ref(false)
watchDebounced(value, (value) => {
if (value !== last.value.snapshot) {
commit()
}
}, { debounce: 300 })
const lineNumber = computedWithControl(
() => [textarea.value, value.value],
() => {
const { selectionStart } = textarea.value ?? {}
return value.value.slice(0, selectionStart).split('\n').length - 1
}
)
const updateLineNumber = () => setTimeout(lineNumber.trigger, 0)
const currentLine = computed({
get: () => value.value.split('\n')[lineNumber.value],
set: (line) => {
const content = value.value.split('\n')
content[lineNumber.value] = line
value.value = content.join('\n')
}
})
// Textarea manipulation
const splice = async (start: number, deleteCount: number, items?: string) => {
let { selectionStart, selectionEnd } = textarea.value
const lineBeginning = value.value.slice(0, selectionStart).lastIndexOf('\n') + 1
let lineStart = selectionStart - lineBeginning
let lineEnd = selectionEnd - lineBeginning
const text = currentLine.value.split('')
text.splice(start, deleteCount, items ?? '')
currentLine.value = text.join('')
if (start <= lineStart) {
lineStart += items?.length ?? 0
lineStart -= deleteCount
}
if (start <= lineEnd) {
lineEnd += items?.length ?? 0
lineEnd -= deleteCount
}
selectionStart = lineBeginning + Math.max(0, lineStart)
selectionEnd = lineBeginning + Math.max(0, lineEnd)
textarea.value.focus()
await nextTick()
textarea.value.setSelectionRange(selectionStart, selectionEnd)
}
const newLineOperations = new Map<RegExp, (event: KeyboardEvent, line: string, groups: string[]) => void>()
const newline = async (event: KeyboardEvent) => {
const line = currentLine.value
for (const regexp of newLineOperations.keys()) {
const matches = line.match(regexp) ?? []
if (matches.length > 0) {
newLineOperations.get(regexp)?.(event, line, matches.slice(1))
}
}
}
// Conditions
const isHeading1 = computed(() => currentLine.value.startsWith('# '))
const isHeading2 = computed(() => currentLine.value.startsWith('## '))
const isQuote = computed(() => currentLine.value.startsWith('> '))
const isUnorderedList = computed(() => currentLine.value.startsWith('- ') || currentLine.value.startsWith('* '))
const isOrderedList = computed(() => /^\d+\. /.test(currentLine.value))
const isParagraph = computed(() => !isHeading1.value && !isHeading2.value && !isQuote.value && !isUnorderedList.value && !isOrderedList.value)
// Prefix operations
const paragraph = async (shouldCommit = true) => {
if (isHeading1.value || isQuote.value || isUnorderedList.value) {
await splice(0, 2)
if (shouldCommit) commit()
return
}
if (isHeading2.value || isOrderedList.value) {
await splice(0, 3)
if (shouldCommit) commit()
return
}
}
const prefixOperation = (prefix: string, condition?: ComputedRef<boolean>) => async () => {
if (condition?.value) {
return paragraph()
}
await paragraph(false)
await splice(0, 0, prefix)
return commit()
}
const heading1 = prefixOperation('# ', isHeading1)
const heading2 = prefixOperation('## ', isHeading2)
const quote = prefixOperation('> ', isQuote)
const orderedList = prefixOperation('1. ', isOrderedList)
const unorderedList = prefixOperation('- ', isUnorderedList)
// Newline operations
const newlineOperation = (regexp: RegExp, newLineHandler: (line: string, groups: string[]) => Promise<void> | void) => {
newLineOperations.set(regexp, async (event, line, groups) => {
event.preventDefault()
if (new RegExp(regexp.toString().slice(1, -1) + '$').test(line)) {
return paragraph()
}
await newLineHandler(line, groups)
lineNumber.trigger()
return commit()
})
}
newlineOperation(/^(\d+)\. /, (line, [lastNumber]) => splice(line.length, 0, `\n${+lastNumber + 1}. `))
newlineOperation(/^- /, (line) => splice(line.length, 0, `\n- `))
newlineOperation(/^> /, (line) => splice(line.length, 0, `\n> `))
newlineOperation(/^\* /, (line) => splice(line.length, 0, `\n* `))
// Inline operations
const inlineOperation = (chars: string) => async () => {
const { selectionStart, selectionEnd } = textarea.value
const lineBeginning = value.value.slice(0, selectionStart).lastIndexOf('\n') + 1
await splice(selectionStart - lineBeginning, 0, chars)
await splice(selectionEnd - lineBeginning + chars.length, 0, chars)
const start = selectionStart === selectionEnd
? selectionStart + chars.length
: selectionEnd + chars.length * 2
textarea.value.setSelectionRange(start, start)
return commit()
}
const bold = inlineOperation('**')
const italics = inlineOperation('_')
const strikethrough = inlineOperation('~~')
const link = async () => {
const { selectionStart, selectionEnd } = textarea.value
const lineBeginning = value.value.slice(0, selectionStart).lastIndexOf('\n') + 1
await splice(selectionStart - lineBeginning, 0, '[')
await splice(selectionEnd - lineBeginning + 1, 0, '](url)')
textarea.value.setSelectionRange(selectionEnd + 3, selectionEnd + 6)
return commit()
}
// Fix focus
const focus = () => textarea.value.focus()
</script>
<template>
<div :class="{ 'has-preview': preview }" class="funkwhale textarea" @mousedown.prevent="focus" @mouseup.prevent="focus">
<fw-markdown :md="value" class="preview" />
<textarea ref="textarea" @click="updateLineNumber" @mousedown.stop @mouseup.stop @keydown.left="updateLineNumber"
@keydown.right="updateLineNumber" @keydown.up="updateLineNumber" @keydown.down="updateLineNumber"
@keydown.enter="newline" @keydown.ctrl.shift.z.exact.prevent="redo" @keydown.ctrl.z.exact.prevent="undo"
@keydown.ctrl.b.exact.prevent="bold" @keydown.ctrl.i.exact.prevent="italics"
@keydown.ctrl.shift.x.exact.prevent="strikethrough" @keydown.ctrl.k.exact.prevent="link" :maxlength="max"
:placeholder="placeholder" v-model="value" id="textarea_id" />
<div class="textarea-buttons">
<fw-button @click="preview = !preview" icon="bi-eye" color="secondary" :is-active="preview" />
<div class="separator" />
<fw-button @click="heading1" icon="bi-type-h1" color="secondary" :is-active="isHeading1" :disabled="preview" />
<fw-button @click="heading2" icon="bi-type-h2" color="secondary" :is-active="isHeading2" :disabled="preview" />
<fw-button @click="paragraph" icon="bi-paragraph" color="secondary" :is-active="isParagraph" :disabled="preview" />
<fw-button @click="quote" icon="bi-quote" color="secondary" :is-active="isQuote" :disabled="preview" />
<fw-button @click="orderedList" icon="bi-list-ol" color="secondary" :is-active="isOrderedList"
:disabled="preview" />
<fw-button @click="unorderedList" icon="bi-list-ul" color="secondary" :is-active="isUnorderedList"
:disabled="preview" />
<div class="separator" />
<fw-button @click="bold" icon="bi-type-bold" color="secondary" :disabled="preview" />
<fw-button @click="italics" icon="bi-type-italic" color="secondary" :disabled="preview" />
<fw-button @click="strikethrough" icon="bi-type-strikethrough" color="secondary" :disabled="preview" />
<fw-button @click="link" icon="bi-link-45deg" color="secondary" :disabled="preview" />
<span v-if="max !== Infinity && typeof max === 'number'" class="letter-count">{{ max - value.length }}</span>
</div>
</div>
</template>
<style lang="scss">
@import './style.scss'
</style>

Wyświetl plik

@ -0,0 +1,52 @@
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import { slugify } from 'transliteration'
import { useScroll } from '@vueuse/core';
const heading = defineProp<'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'>('heading', {
default: 'h1'
})
const toc = ref()
const headings = computed(() => toc.value?.querySelectorAll(heading.value) ?? [])
watchEffect(() => {
for (const heading of headings.value) {
heading.id = slugify(heading.textContent)
}
})
const activeLink = ref()
const { y } = useScroll(window)
watchEffect(() => {
let lastActive = headings.value[0]
for (const heading of headings.value) {
if (y.value > heading.offsetTop) {
lastActive = heading
}
}
activeLink.value = lastActive?.id
})
</script>
<template>
<div ref="toc" class="funkwhale toc">
<div class="toc-content">
<slot />
</div>
<div class="toc-toc">
<div class="toc-links">
<button v-for="h of headings" :key="h.id" :class="{ 'is-active': activeLink === h.id }"
@click.prevent="h.scrollIntoView({ behavior: 'smooth' })">
{{ h.textContent }}
</button>
</div>
</div>
</div>
</template>
<style lang="scss">
@import './style.scss'
</style>

Wyświetl plik

@ -0,0 +1,24 @@
<script setup lang="ts">
interface Props {
big?: boolean
}
defineProps<Props>()
const { modelValue: enabled } = defineModels<{
modelValue: boolean
}>()
</script>
<template>
<div
class="funkwhale toggle"
:class="{ 'is-active': enabled, 'is-big': big }"
>
<input type="checkbox" v-model="enabled" />
</div>
</template>
<style lang="scss">
@import './style.scss'
</style>

Wyświetl plik

@ -0,0 +1,123 @@
.funkwhale {
&.activity {
padding: 12px 6px;
@include light-theme {
color: var(--fw-gray-500);
+ .funkwhale.activity {
border-top: 1px solid var(--fw-gray-300);
}
> .activity-content {
> .track-title {
color: var(--fw-gray-900);
}
> .artist {
--fw-link-color: var(--fw-gray-900);
}
> .user {
--fw-link-color: var(--fw-gray-500);
}
}
.play-button {
background: rgba(255, 255, 255, .5);
&:hover {
--fw-text-color: var(--fw-gray-800) !important;
}
}
}
@include dark-theme {
+ .funkwhale.activity {
border-top: 1px solid var(--fw-gray-800);
}
> .activity-content {
> .track-title {
color: var(--fw-gray-300);
}
> .artist {
--fw-link-color: var(--fw-gray-300);
}
> .user {
--fw-link-color: var(--fw-gray-500);
}
}
.play-button {
background: rgba(0, 0, 0, .2);
&:hover {
background: rgba(0, 0, 0, .8);
--fw-text-color: var(--fw-gray-200) !important;
}
}
}
cursor: pointer;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 12px;
> .activity-image {
position: relative;
width: 40px;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--fw-border-radius);
> img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
}
> .play-button {
position: absolute;
top: 0;
left: 0;
padding: 0 !important;
width: 100%;
aspect-ratio: 1;
margin: 0;
border: 0 !important;
opacity: 0;
}
}
&:hover {
.play-button {
opacity: 1;
}
}
> .activity-content {
display: flex;
flex-direction: column;
align-items: flex-start;
> .track-title {
font-weight: 700;
line-height: 1.5em;
}
> .artist {
line-height: 1.5em;
font-size: 0.857rem;
}
> .user {
line-height: 1.5em;
font-size: 0.8125rem;
}
}
}
}

Wyświetl plik

@ -0,0 +1,44 @@
.funkwhale.alert {
color: var(--fw-gray-900);
@include light-theme {
background-color: var(--fw-pastel-1, var(--fw-bg-color));
> .actions .funkwhale.button {
--fw-bg-color: var(--fw-pastel-2);
&:hover, &.is-hovered {
--fw-bg-color: var(--fw-pastel-3);
}
&:active, &.is-active {
--fw-bg-color: var(--fw-pastel-4);
}
}
}
@include dark-theme {
background-color: var(--fw-pastel-3, var(--fw-bg-color));
> .actions .funkwhale.button {
--fw-bg-color: var(--fw-pastel-4);
&:hover, &.is-hovered {
--fw-bg-color: var(--fw-pastel-2);
}
&:active, &.is-active {
--fw-bg-color: var(--fw-pastel-1);
}
}
}
padding: 0.625rem 2rem;
line-height: 1.2;
display: flex;
align-items: center;
> .actions {
margin-left: auto;
}
}

Wyświetl plik

@ -0,0 +1,157 @@
.funkwhale {
&.button {
background-color: var(--fw-bg-color);
color: var(--fw-text-color);
border: 1px solid var(--fw-bg-color);
@include light-theme {
&.is-secondary.is-outline {
--fw-bg-color: var(--fw-gray-600);
--fw-text-color: var(--fw-gray-700);
&[disabled] {
--fw-bg-color: var(--fw-gray-600) !important;
--fw-text-color: var(--fw-gray-600) !important;
}
&.is-hovered,
&:hover {
--fw-bg-color: var(--fw-gray-700) !important;
--fw-text-color: var(--fw-gray-800) !important;
}
&.is-active,
&:active {
--fw-text-color: var(--fw-red-010) !important;
border: 1px solid var(--fw-gray-600) !important;
}
}
&.is-outline {
&:not(:active):not(.is-active) {
background-color: transparent !important;
--fw-text-color:--fw-gray-400;
}
}
&.is-ghost {
&:not(:active):not(.is-active):not(:hover):not(.is-hovered) {
background-color: transparent !important;
border-color: transparent !important;
--fw-text-color:--fw-gray-400;
}
}
}
@include dark-theme {
&.is-secondary.is-outline {
--fw-bg-color: var(--fw-gray-500);
--fw-text-color: var(--fw-gray-400);
&[disabled] {
--fw-bg-color: var(--fw-gray-600) !important;
--fw-text-color: var(--fw-gray-700) !important;
}
&.is-hovered,
&:hover {
--fw-bg-color: var(--fw-gray-600) !important;
--fw-text-color: var(--fw-gray-500) !important;
}
&.is-active,
&:active {
--fw-text-color: var(--fw-red-010) !important;
--fw-bg-color: var(--fw-gray-700) !important;
border: 1px solid var(--fw-gray-600) !important;
}
}
&.is-outline {
&:not(:active):not(.is-active) {
background-color: transparent !important;
}
}
&.is-ghost {
&:not(:active):not(.is-active):not(:hover):not(.is-hovered) {
background-color: transparent !important;
border-color: transparent !important;
}
}
}
@include docs {
color: var(--fw-text-color) !important;
}
position: relative;
display: inline-flex;
align-items: center;
white-space: nowrap;
font-family: $font-main;
font-weight: 900;
font-size: 0.875em;
line-height: 1em;
padding: 0.642857142857em;
border-radius: var(--fw-border-radius);
margin: 0 0.5ch;
transform: translateX(var(--fw-translate-x)) translateY(var(--fw-translate-y)) scale(var(--fw-scale));
transition: all .2s ease;
&.is-aligned-center {
justify-content: center;
}
&.is-aligned-left {
justify-content: flex-start;
}
&.is-aligned-right {
justify-content: flex-end;
}
&.is-shadow {
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.2);
}
&:not(.icon-only):not(.is-auto) {
min-width: 8.5rem;
}
&.is-full {
display: block;
}
&.is-round {
border-radius: 100vh;
}
&[disabled] {
font-weight: normal;
pointer-events: none;
}
&.is-loading {
@extend .is-active;
> span {
opacity: 0;
}
}
i.bi {
font-size: 1.2rem;
}
i.bi + span:not(:empty) {
margin-left: 1ch;
}
}
}

Wyświetl plik

@ -0,0 +1,11 @@
<script setup lang="ts">
import { FwButton } from '~/components'
</script>
<template>
<fw-button icon="bi-three-dots-vertical" class="options-button" color="secondary" variant="ghost" />
</template>
<style lang="scss">
@import './style.scss'
</style>

Wyświetl plik

@ -0,0 +1,13 @@
<script setup lang="ts">
import { FwButton } from '~/components'
const play = defineEmit()
</script>
<template>
<fw-button @click="play()" icon="bi-play-fill" class="play-button" shadow round />
</template>
<style lang="scss">
@import './style.scss';
</style>

Wyświetl plik

@ -0,0 +1,8 @@
.funkwhale {
&.options-button {
will-change: transform;
transition: all .2s ease;
font-size: 0.6rem !important;
padding: 0.6em !important;
}
}

Wyświetl plik

@ -0,0 +1,40 @@
.funkwhale {
&.play-button {
@include light-theme {
--fw-bg-color: var(--fw-red-010) !important;
--fw-text-color: var(--fw-gray-600) !important;
&:hover {
--fw-text-color: var(--fw-pastel-4, var(--fw-primary)) !important;
}
}
@include dark-theme {
--fw-bg-color: var(--fw-gray-800) !important;
--fw-text-color: var(--fw-gray-300) !important;
&:hover {
--fw-text-color: var(--fw-pastel-2, var(--fw-blue-400)) !important;
}
}
will-change: transform;
font-size: 0.6rem !important;
padding: 0.625em !important;
border: 0px !important;
i {
font-size: 2rem;
&::before {
transform: translateX(1px);
backface-visibility: hidden;
}
}
&:hover {
--fw-scale: 1.091;
}
}
}

Wyświetl plik

@ -0,0 +1,92 @@
.funkwhale {
&.input {
background-color: var(--fw-bg-color);
box-shadow: inset 0 0 0 4px var(--fw-border-color);
input {
&::placeholder {
color: var(--fw-placeholder-color);
}
}
.prefix,
.input-right {
color: var(--fw-placeholder-color);
}
@include light-theme {
--fw-bg-color: var(--fw-gray-100);
--fw-border-color: var(--fw-bg-color);
--fw-placeholder-color: var(--fw-gray-600);
&:hover {
--fw-border-color: var(--fw-gray-300);
}
&:focus-within {
--fw-border-color: var(--fw-primary);
--fw-bg-color: var(--fw-blue-010);
}
}
@include dark-theme {
--fw-bg-color: var(--fw-gray-850);
--fw-border-color: var(--fw-bg-color);
--fw-placeholder-color: var(--fw-gray-300);
&:hover {
--fw-border-color: var(--fw-gray-700);
}
&:focus-within {
--fw-border-color: var(--fw-primary);
--fw-bg-color: var(--fw-gray-800);
}
}
position: relative;
display: flex;
align-items: center;
border-radius: var(--fw-border-radius);
overflow: hidden;
cursor: text;
&.has-icon {
input {
padding-left: 36px;
}
}
input {
display: block;
width: 100%;
padding: 8px 12px;
font-size: 14px;
font-family: $font-main;
background-color: transparent;
line-height: inherit;
border: none;
}
.prefix,
.input-right {
display: flex;
align-items: center;
font-size: 14px;
pointer-events: none;
}
.prefix {
position: absolute;
top: 0;
left: 4px;
bottom: 0;
width: 32px;
justify-content: center;
}
.input-right {
padding-right: 12px;
}
}
}

Wyświetl plik

@ -0,0 +1,18 @@
<script setup lang="ts">
import { useCssModule } from 'vue'
const classes = useCssModule()
</script>
<template>
<div :class="classes.spacer"> 
</div>
</template>
<style module>
.spacer {
--size: var(--size, 32px);
width: var(--size);
height: var(--size);
}
</style>

Wyświetl plik

@ -0,0 +1,60 @@
.funkwhale {
&.loader-container {
position: relative;
height: 100%;
}
&.loader {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
font-size: 1.2em;
// Modified version of https://github.com/lukehaas/css-loaders
> .loader,
> .loader:after {
border-radius: 50%;
width: 1em;
height: 1em;
}
> .loader:after {
content: '';
position: absolute;
display: block;
border: .11em solid currentColor;
opacity: 0.2;
margin-left: -0.11em;
margin-top: -0.11em;
}
> .loader {
position: relative;
text-indent: -9999em;
font-size: 1em;
border-top: .11em solid transparent;
border-right: .11em solid transparent;
border-bottom: .11em solid transparent;
border-left: .11em solid currentColor;
will-change: transform;
transform: rotate(0deg);
animation: rotate .5s infinite linear;
@keyframes rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
}
}
}

Wyświetl plik

@ -0,0 +1,109 @@
.funkwhale.modal {
background: var(--fw-bg-color);
box-shadow: 0 2px 4px 2px rgba(#000, 0.2);
border-radius: 1rem;
max-width: min(90vw, 40rem);
width: 100%;
display: grid;
max-height: 90vh;
grid-template-rows: auto 1fr auto;
> h2 {
font-size: 1.25em;
padding: 1.625rem 4.5rem;
line-height: 1.2;
text-align: center;
position: relative;
> .funkwhale.button {
font-size: 1rem;
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-49%);
}
}
.modal-content {
padding: 1rem 2rem;
overflow: auto;
position: relative;
> .alert-container {
position: sticky;
top: -1rem;
margin: -1rem -2rem 1rem;
display: grid;
grid-template-rows: 1fr;
&.v-enter-active,
&.v-leave-active {
transition: grid-template-rows 0.2s ease;
}
&.v-enter-from,
&.v-leave-to {
grid-template-rows: 0fr;
}
> div {
overflow: hidden;
}
}
}
.modal-actions {
padding: 0.75rem 2rem 1rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
> :first-child {
margin-left: 0px !important;
}
> :last-child {
margin-right: 0px !important;
}
}
}
.funkwhale.overlay {
background: rgba(#000, .2);
position: fixed;
inset: 0;
z-index: 9001;
display: flex;
align-items: center;
justify-content: center;
&.v-enter-active,
&.v-leave-active {
transition: opacity 0.2s ease;
.funkwhale.modal {
transition: transform 0.2s ease;
}
}
&.v-enter-from,
&.v-leave-to {
opacity: 0;
.funkwhale.modal {
transform: translateY(1rem);
}
}
&.v-leave-to {
.funkwhale.modal {
transform: translateY(-1rem);
}
}
}

Wyświetl plik

@ -0,0 +1,89 @@
.funkwhale {
&.pagination {
@include light-theme {
> .goto {
border-left: 1px solid var(--fw-gray-200);
}
}
@include dark-theme {
> .goto {
border-left: 1px solid var(--fw-gray-800);
}
}
height: 34px;
display: flex;
&.is-small {
> .pages {
width: auto;
}
> .goto {
margin-right: auto;
}
}
> ul.pages {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
list-style: none;
margin: 0;
padding: 0;
> li {
margin-top: 0;
margin: 0 0.5ch;
text-align: center;
&:not(:first-child):not(:last-child) {
width: 34px;
}
> .funkwhale.button {
min-width: 34px;
height: 34px;
padding-top: 0;
padding-bottom: 0;
margin: 0;
}
&:first-child > .funkwhale.button,
&:last-child > .funkwhale.button {
min-width: 94px;
text-align: center;
}
&:first-child > .funkwhale.button span > span {
padding-left: 0.5ch;
}
&:last-child > .funkwhale.button span > span {
padding-right: 0.5ch;
}
}
}
> .goto {
margin-left: 16px;
padding-left: 16px;
display: flex;
align-items: center;
white-space: nowrap;
> .funkwhale.input {
margin-left: 16px;
width: calc(3ch + 32px);
input {
text-align: center;
padding: 6px 8px;
}
}
}
}
}

Wyświetl plik

@ -0,0 +1,66 @@
.funkwhale {
&.pill {
color: var(--fw-text-color);
@include light-theme {
background-color: var(--fw-pastel-2, var(--fw-bg-color));
}
@include dark-theme {
--fw-darken-pastel: color-mix(in srgb, var(--fw-pastel-4) 90%, black);
background-color: var(--fw-darken-pastel, var(--fw-bg-color));
}
position: relative;
display: inline-flex;
font-family: $font-main;
line-height: 1em;
font-size: small;
border-radius: 100vh;
margin: 0 0.5ch;
> .pill-content {
padding: 0.5em 0.75em;
white-space: nowrap;
}
> .pill-image {
position: relative;
aspect-ratio: 1;
border-radius: 50%;
overflow: hidden;
height: calc(2em - 4px);
margin: 2px;
+ .pill-content {
padding-left: 0.25em;
}
> * {
height: 100%;
width: 100%;
}
> img {
object-fit: cover;
}
}
&:hover {
text-decoration: underline;
}
&[disabled] {
font-weight: normal;
cursor: default;
}
&.is-focused,
&:focus {
box-shadow: none !important;
}
}
}

Wyświetl plik

@ -0,0 +1,89 @@
.funkwhale.popover-container {
width: max-content;
}
.funkwhale.popover-outer {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 99999;
&:not(.is-mobile) {
pointer-events: none;
}
}
.funkwhale {
&.popover {
border: 1px solid var(--fw-border-color);
background: var(--fw-bg-color);
hr {
border-bottom: 1px solid var(--fw-border-color);
}
@include light-theme {
--fw-border-color: var(--fw-gray-500);
.popover-item:hover {
background-color: var(--fw-gray-100);
}
}
@include dark-theme {
--fw-border-color: var(--fw-gray-800);
.popover-item:hover {
background-color: var(--fw-gray-800);
}
}
pointer-events: auto;
&.is-mobile {
width: 90vw;
margin: 0 5vw;
left: 0 !important;
box-shadow: 0 0 0 1000vh rgba(0,0,0,0.2),
0 0 100vh rgba(0,0,0,0.6);
}
position: absolute;
padding: 16px;
border-radius: var(--fw-border-radius);
min-width: 246px;
z-index: 999;
font-size: 0.875rem;
hr {
padding-top: 12px;
margin-bottom: 12px;
}
.popover-item {
cursor: pointer;
padding-left: 8px;
height: 32px;
display: flex;
align-items: center;
border-radius: var(--fw-border-radius);
white-space: nowrap;
> .bi:first-child {
margin-right: 16px;
}
> .bi:last-child {
margin-right: 8px;
}
> .after {
margin-left: auto;
}
}
}
}

Wyświetl plik

@ -0,0 +1,17 @@
<script setup lang="ts">
const value = defineModel<boolean>('modelValue', { required: true })
</script>
<template>
<fw-popover-item
@click="value = !value"
class="checkbox"
>
<i :class="['bi', value ? 'bi-check-square' : 'bi-square']" />
<slot />
<template #after>
<slot name="after" />
</template>
</fw-popover-item>
</template>

Wyświetl plik

@ -0,0 +1,27 @@
<script setup lang="ts">
import { inject, ref } from 'vue'
import { POPOVER_CONTEXT_INJECTION_KEY, type PopoverContext } from '~/injection-keys'
const setId = defineEmit<[value: number]>('internal:id')
const parentPopoverContext = defineProp<PopoverContext>('vuiParentPopoverContext')
const { items, hoveredItem } = parentPopoverContext.value ?? inject(POPOVER_CONTEXT_INJECTION_KEY, {
items: ref(0),
hoveredItem: ref(-2)
})
const id = items.value++
setId(id)
</script>
<template>
<div
@mouseover="hoveredItem = id"
class="popover-item"
>
<slot />
<div class="after" />
<slot name="after" />
</div>
</template>

Wyświetl plik

@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue'
const choices = defineProp<string[]>('choices', {
required: true,
})
const filteredChoices = computed(() => new Set(choices.value))
const value = defineModel<string>('modelValue', { required: 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), {
get(_, key) {
return key === value.value
},
set(_, key, val) {
if (!val || typeof key === 'symbol') return false
value.value = key
return true
}
})
</script>
<template>
<fw-popover-radio-item v-for="choice of filteredChoices" :key="choice" v-model="choiceValues[choice]">
{{ choice }}
</fw-popover-radio-item>
</template>

Wyświetl plik

@ -0,0 +1,14 @@
<script setup lang="ts">
const value = defineModel<boolean>('modelValue', { required: true })
</script>
<template>
<fw-popover-item @click="value = !value" class="checkbox">
<i :class="['bi', value ? 'bi-record-circle' : 'bi-circle']" />
<slot />
<template #after>
<slot name="after" />
</template>
</fw-popover-item>
</template>

Wyświetl plik

@ -0,0 +1,39 @@
<script setup lang="ts">
import FwPopover from '../Popover.vue'
import { inject, ref, watchEffect } from 'vue'
import { POPOVER_CONTEXT_INJECTION_KEY } from '~/injection-keys';
const context = inject(POPOVER_CONTEXT_INJECTION_KEY, {
items: ref(0),
hoveredItem: ref(-2)
})
const open = ref(false)
const id = ref(-1)
watchEffect(() => {
open.value = context.hoveredItem.value === id.value
})
</script>
<template>
<fw-popover v-model:open="open" positioning="horizontal">
<fw-popover-item
@click="open = !open"
@internal:id="id = $event"
:vui-parent-popover-context="context"
class="submenu"
>
<slot />
<template #after>
<slot name="after">
<i class="bi bi-chevron-right" />
</slot>
</template>
</fw-popover-item>
<template #items>
<slot name="items" />
</template>
</fw-popover>
</template>

Wyświetl plik

@ -0,0 +1,67 @@
.funkwhale {
&.tabs {
color: var(--fw-text-color);
@include light-theme {
--fw-border-color: var(--fw-gray-300);
}
@include dark-theme {
--fw-text-color: var(--fw-gray-300);
--fw-border-color: var(--fw-gray-700);
}
> .tabs-header {
display: flex;
align-items: end;
padding-bottom: 0px;
margin-bottom: 23px;
border-bottom: 1px solid var(--fw-border-color);
&:has(:focus-visible) {
outline:1px dotted currentColor;
}
> .tabs-item {
font-size: 1rem;
padding: 8px;
min-width: 96px;
text-align: center;
cursor: pointer;
position: relative;
.is-spacing {
height: 0;
visibility: hidden;
}
.is-icon {
display: block;
position: relative;
transform: translateY(-.5rem);
font-size: 1.5em;
color: var(--fw-gray-500);
}
&.is-active, .is-spacing {
font-weight: 900;
&::after {
content: '';
display: block;
height: 4px;
background-color: var(--fw-secondary);
margin: 0 auto;
width: calc(10% + 2rem);
position: absolute;
inset: auto 0 -2.5px 0;
border-radius: 100vh;
}
}
}
> .tabs-right {
margin-left: auto;
}
}
}
}

Wyświetl plik

@ -0,0 +1,140 @@
.funkwhale {
&.textarea {
background-color: var(--fw-bg-color);
box-shadow: inset 0 0 0 4px var(--fw-border-color);
&.has-preview {
background-color: var(--fw-bg-color);
}
> .textarea-buttons {
border-top: 1px solid var(--fw-buttons-border-color);
> .funkwhale.button:not(:hover):not(:active):not(.is-active) {
--fw-bg-color: transparent;
}
> .separator {
background-color: var(--fw-buttons-border-color);
}
> .letter-count {
color: var(--fw-buttons-border-color);
}
}
@include light-theme {
--fw-border-color: var(--fw-bg-color);
--fw-buttons-border-color: var(--fw-gray-400);
--fw-bg-color: var(--fw-gray-100);
--fw-buttons-color: var(--fw-gray-100);
&:hover {
--fw-border-color: var(--fw-gray-300);
}
&.has-preview,
&:focus-within {
--fw-border-color: var(--fw-primary) !important;
--fw-bg-color: var(--fw-blue-010);
}
}
@include dark-theme {
--fw-bg-color: var(--fw-gray-850);
--fw-border-color: var(--fw-bg-color);
--fw-buttons-border-color: var(--fw-gray-950);
--fw-buttons-color: var(--fw-gray-700);
&:hover {
--fw-border-color: var(--fw-gray-700);
}
&.has-preview,
&:focus-within {
--fw-border-color: var(--fw-primary) !important;
--fw-bg-color: var(--fw-gray-800);
}
}
position: relative;
padding: 8px;
border-radius: var(--fw-border-radius);
&.has-preview,
&:focus-within {
> .textarea-buttons {
opacity: 1;
transform: translateY(0rem);
pointer-events: auto;
}
}
&.has-preview > .preview {
opacity: 1;
transform: translateY(0rem);
pointer-events: auto;
}
> .preview {
position: absolute;
top: 8px;
left: 8px;
right: 8px;
bottom: 42px;
overflow-y: auto;
background-color: inherit;
opacity: 0;
transform: translateY(.5rem);
pointer-events: none;
transition: all .2s ease;
}
> textarea {
display: block;
width: 100%;
min-height: 240px;
padding: 8px 12px 40px;
font-family: monospace;
background: transparent;
&:placeholder-shown {
font-family: $font-main;
}
}
> .textarea-buttons {
display: flex;
align-items: center;
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
opacity: 0;
transform: translateY(.5rem);
pointer-events: none;
transition: all .2s ease;
padding-top: 4px;
> .funkwhale.button {
font-size: 1.2rem;
padding: 0.2em;
}
> .separator {
width: 1px;
height: 28px;
margin: 0 8px;
}
> .letter-count {
margin-left: auto;
padding-right: 16px;
}
}
}
}

Wyświetl plik

@ -0,0 +1,65 @@
.funkwhale {
&.toc {
> .toc-toc > .toc-links > button {
--fw-link-color: var(--fw-text-color) !important;
&.is-active::before {
background-color: var(--fw-secondary);
}
}
@include light-theme {
--fw-border-color: var(--fw-gray-300);
}
@include dark-theme {
--fw-border-color: var(--fw-gray-700);
> .toc-toc > .toc-links > button {
--fw-text-color: var(--fw-gray-300);
}
}
display: grid;
grid-template-columns: 1fr 280px;
gap: 1rem;
> .toc-toc {
border-left: 1px solid var(--fw-border-color);
> .toc-links {
position: sticky;
top: 0;
padding-left: 8px;
@include docs {
top: 72px;
}
> button {
position: relative;
font-size: 1rem;
padding: 4px 8px 4px 11px;
display: block;
width: 100%;
text-align: left;
&.is-active {
font-weight: 900;
&::before {
content: '';
width: 4px;
height: 100%;
position: absolute;
right: 100%;
top: 0;
border-radius: 100vh;
}
}
}
}
}
}
}

Wyświetl plik

@ -0,0 +1,96 @@
.funkwhale {
&.toggle {
background-color: var(--fw-bg-color);
@include light-theme {
--fw-bg-color: var(--fw-gray-500);
&::before {
background: var(--fw-red-010);
}
&:hover {
--fw-bg-color: var(--fw-gray-300);
}
&.is-active {
--fw-bg-color: var(--fw-blue-100);
&:hover {
--fw-bg-color: var(--fw-blue-400);
}
&::before {
background: var(--fw-blue-010);
}
}
}
@include dark-theme {
--fw-bg-color: var(--fw-gray-600);
&::before {
background: var(--fw-red-010);
}
&:hover {
--fw-bg-color: var(--fw-gray-700);
}
&.is-active {
--fw-bg-color: var(--fw-bg-400);
&:hover {
--fw-bg-color: var(--fw-blue-500);
}
&::before {
background: var(--fw-blue-010);
}
}
}
position: relative;
border-radius: 100vw;
overflow: hidden;
height: 20px;
aspect-ratio: 2;
> input {
opacity: 0;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
&::before {
display: block;
content: '';
position: absolute;
top: 2px;
left: 2px;
height: calc(100% - 4px);
aspect-ratio: 1;
border-radius: 50%;
transition: transform .2s ease;
}
&.is-big {
height: 28px;
}
&.is-active {
&::before {
transform: translateX(calc(100% + 4px));
}
}
&[disabled] {
opacity: 0;
pointer-events: none;
}
}
}

Wyświetl plik

@ -0,0 +1,15 @@
import { toValue, type MaybeRefOrGetter } from "@vueuse/core"
import { computed } from 'vue'
import type { Color, Pastel } from '~/types/common-props'
export function useColor(color: MaybeRefOrGetter<Color | undefined>, defaultColor: Color = 'primary') {
return computed(() => `is-${toValue(color) ?? defaultColor}`)
}
export function usePastel(color: MaybeRefOrGetter<Pastel | undefined>, defaultColor: Pastel = 'blue') {
return computed(() => `is-${toValue(color) ?? defaultColor}`)
}
export function useColorOrPastel<T extends Color | Pastel>(color: MaybeRefOrGetter<T | undefined>, defaultColor: T) {
return computed(() => `is-${toValue(color) ?? defaultColor}`)
}

Wyświetl plik

@ -0,0 +1,17 @@
import {
type MaybeRefOrGetter,
createGlobalState,
toValue,
useWindowSize,
} from '@vueuse/core'
import { computed } from 'vue'
const MOBILE_WIDTH = 640
export const useScreenSize = createGlobalState(() =>
useWindowSize({ includeScrollbar: false })
)
export const isMobileView = (
width: MaybeRefOrGetter<number> = useScreenSize().width
) =>
computed(() => (toValue(width) ?? Number.POSITIVE_INFINITY) <= MOBILE_WIDTH)

Wyświetl plik

@ -0,0 +1,15 @@
import type { InjectionKey, Ref } from "vue"
export const TABS_INJECTION_KEY = Symbol('tabs') as InjectionKey<{
tabs: string[]
icons: (string|undefined)[]
currentTab: Ref<string>
}>
export interface PopoverContext {
items: Ref<number>
hoveredItem: Ref<number>
}
export const POPOVER_INJECTION_KEY = Symbol('popover') as InjectionKey<Ref<boolean>[]>
export const POPOVER_CONTEXT_INJECTION_KEY = Symbol('popover context') as InjectionKey<PopoverContext>

Wyświetl plik

@ -1,6 +1,6 @@
import type { VueI18nOptions } from 'vue-i18n'
export type SupportedLanguages = 'ar' | 'ca' | 'cs' | 'de' | 'en_GB' | 'en_US' | 'eo' | 'es' | 'eu' | 'fr_FR'
export type SupportedLanguages = 'ar' | 'ca' | 'ca@valencia' | 'cs' | 'de' | 'en_GB' | 'en_US' | 'eo' | 'es' | 'eu' | 'fr_FR'
| 'gl' | 'hu' | 'it' | 'ja_JP' | 'kab_DZ' | 'ko_KR' | 'nb_NO' | 'nl' | 'oc' | 'pl' | 'pt_BR' | 'pt_PT'
| 'ru' | 'sq' | 'zh_Hans' | 'zh_Hant' | 'fa_IR' | 'ml' | 'sv' | 'el' | 'nn_NO'
@ -16,6 +16,9 @@ export const locales: Record<SupportedLanguages, Locale> = {
ca: {
label: 'Català'
},
'ca@valencia': {
label: 'Català (Valencia)'
},
cs: {
label: 'Čeština'
},

Wyświetl plik

@ -2,6 +2,27 @@
"App": {
"loading": "প্রকরিয়ারত…"
},
"vui": {
"tracks": "{n}টি গান | {n}টি গান",
"by-user": "{'@'}{username} থেকে",
"aria": {
"pagination": {
"gotoPage": "{n} পৃষ্ঠায় যাও",
"gotoNext": "পরের পৃষ্ঠায় যাও",
"currentPage": "বর্তমান পৃষ্ঠা, পৃষ্ঠা {n}",
"gotoPrevious": "পূর্ববর্তী পৃষ্ঠায় যাও",
"nav": "পৃষ্ঠাকার দিকসংকেত"
}
},
"radio": "বেতার",
"go-to": "এখানে যাও",
"albums": "{n}টি অ্যালবাম | {n}টি অ্যালবাম",
"episodes": "{n}টি পর্ব | {n}টি পর্ব",
"pagination": {
"previous": "পূর্ববর্তী",
"next": "পরবর্তী"
}
},
"components": {
"About": {
"description": {

Wyświetl plik

@ -2,6 +2,27 @@
"App": {
"loading": "Carregant…"
},
"vui": {
"radio": "Ràdio",
"albums": "{n} àlbum | {n} àlbums",
"tracks": "{n} pista | {n} pistes",
"episodes": "{n} episodi | {n} episodis",
"by-user": "per {'@'}{username}",
"go-to": "Anar a",
"pagination": {
"previous": "Prèvia",
"next": "Següent"
},
"aria": {
"pagination": {
"nav": "Navegació per Paginació",
"gotoPage": "Anar a Pàgina {n}",
"gotoPrevious": "Anar a la Pàgina Prèvia",
"gotoNext": "Anar a la Pàgina Següent",
"currentPage": "Pàgina actual, Pàgina {n}"
}
}
},
"components": {
"About": {
"description": {

Plik diff jest za duży Load Diff

Wyświetl plik

@ -2,6 +2,27 @@
"App": {
"loading": "Wird geladen…"
},
"vui": {
"radio": "Radio",
"albums": "{n} Album | {n} Alben",
"tracks": "{n} Titel | {n} Titel",
"episodes": "{n} Episode | {n} Episoden",
"by-user": "von {'@'}{username}",
"go-to": "Gehe zu",
"pagination": {
"previous": "Zurück",
"next": "Weiter"
},
"aria": {
"pagination": {
"nav": "Nummerierte Navigation",
"gotoPage": "Gehe zu Seite {n}",
"gotoPrevious": "Gehe zur vorherigen Seite",
"gotoNext": "Gehe zur nächsten Seite",
"currentPage": "Aktuelle Seite, Seite {n}"
}
}
},
"components": {
"About": {
"description": {

Wyświetl plik

@ -2,6 +2,27 @@
"App": {
"loading": "Loading…"
},
"vui": {
"radio": "Radio",
"by-user": "by {'@'}{username}",
"go-to": "Go to",
"pagination": {
"previous": "Previous",
"next": "Next"
},
"albums": "{n} album | {n} albums",
"tracks": "{n} track | {n} tracks",
"episodes": "{n} episode | {n} episodes",
"aria": {
"pagination": {
"nav": "Pagination Navigation",
"gotoPage": "Goto Page {n}",
"gotoPrevious": "Goto Previous Page",
"gotoNext": "Goto Next Page",
"currentPage": "Current Page, Page {n}"
}
}
},
"components": {
"About": {
"description": {

Wyświetl plik

@ -2,6 +2,27 @@
"App": {
"loading": "Loading…"
},
"vui": {
"radio": "Radio",
"albums": "{n} album | {n} albums",
"tracks": "{n} track | {n} tracks",
"episodes": "{n} episode | {n} episodes",
"by-user": "by {'@'}{username}",
"go-to": "Go to",
"pagination": {
"previous": "Previous",
"next": "Next"
},
"aria": {
"pagination": {
"nav": "Pagination Navigation",
"gotoPage": "Go to Page {n}",
"gotoPrevious": "Go to Previous Page",
"gotoNext": "Go to Next Page",
"currentPage": "Current Page, Page {n}"
}
}
},
"components": {
"About": {
"description": {

Wyświetl plik

@ -2,6 +2,27 @@
"App": {
"loading": "Cargando…"
},
"vui": {
"radio": "Radio",
"albums": "{n} álbum | {n} álbumes",
"tracks": "{n} pista | {n} pistas",
"episodes": "{n} episodio | {n} episodios",
"by-user": "de {'@'}{username}",
"go-to": "Vaya a",
"pagination": {
"previous": "Anterior",
"next": "Siguiente"
},
"aria": {
"pagination": {
"nav": "Paginación Navegación",
"currentPage": "Página corriente, Página {n}",
"gotoPage": "Ir a la página {n}",
"gotoPrevious": "Ir a la página anterior",
"gotoNext": "Ir a la página siguiente"
}
}
},
"components": {
"About": {
"description": {

Wyświetl plik

@ -2,6 +2,27 @@
"App": {
"loading": "Kargatzen…"
},
"vui": {
"tracks": "Pista {n} | {n} pista",
"episodes": "Atal {n} | {n} atal",
"by-user": "honen eskutik: {'@'}{username}",
"go-to": "Joan hona:",
"pagination": {
"previous": "Aurrekoa",
"next": "Hurrengoa"
},
"aria": {
"pagination": {
"nav": "Orrikatzearen nabigazioa",
"gotoPage": "Joan orri honetara: {n}",
"gotoPrevious": "Joan aurreko orrira",
"gotoNext": "Joan hurrengo orrira",
"currentPage": "Uneko orria: {n}"
}
},
"albums": "Album {n} | {n} album",
"radio": "Irratia"
},
"components": {
"About": {
"description": {

Wyświetl plik

@ -2,6 +2,27 @@
"App": {
"loading": "Chargement…"
},
"vui": {
"tracks": "{n} piste | {n} pistes",
"go-to": "Aller à",
"pagination": {
"previous": "Précédent",
"next": "Suivant"
},
"aria": {
"pagination": {
"currentPage": "Page Actuelle, Page {n}",
"gotoPage": "Aller à la Page {n}",
"gotoPrevious": "Aller à la Page Précédente",
"gotoNext": "Aller à la Page Suivante",
"nav": "Navigation par Pagination"
}
},
"radio": "Radio",
"albums": "{n} album | {n} albums",
"episodes": "{n} épisode | {n} épisodes",
"by-user": "par {'@'}{username}"
},
"components": {
"About": {
"description": {

Wyświetl plik

@ -2,6 +2,32 @@
"App": {
"loading": "Laden…"
},
"vui": {
"radio": "Radio",
"albums": "{n} album | {n} albums",
"tracks": "{n} nummer | {n} nummers",
"episodes": "{n} aflevering | {n} afleveringen",
"by-user": "door {'@'}{username}",
"go-to": "Ga naar",
"pagination": {
"previous": "Vorige",
"next": "Volgende"
},
"privacy-level": {
"private": "Privé",
"public": "Openbaar",
"pod": "pod"
},
"aria": {
"pagination": {
"nav": "Pagina navigatie",
"gotoPage": "Ga naar pagina {n}",
"gotoPrevious": "Ga naar Vorige Pagina",
"gotoNext": "Ga naar Volgende Pagina",
"currentPage": "Huidige pagina, pagina {n}"
}
}
},
"components": {
"About": {
"description": {

Wyświetl plik

@ -2,6 +2,27 @@
"App": {
"loading": "Yükleniyor…"
},
"vui": {
"radio": "Radyo",
"albums": "{n} albüm | {n} albümler",
"tracks": "{n} parça | {n} parçalar",
"episodes": "{n} bölüm | {n} bölümler",
"by-user": "by {'@'}{username}",
"go-to": "Git",
"pagination": {
"previous": "Önceki",
"next": "Sonraki"
},
"aria": {
"pagination": {
"nav": "Sayfalandırma Navigasyonu",
"gotoPage": "Sayfaya Git {n}",
"gotoPrevious": "Önceki Sayfaya Git",
"gotoNext": "Sonraki Sayfaya Git",
"currentPage": "Geçerli Sayfa, Sayfa {n}"
}
}
},
"components": {
"About": {
"description": {

Wyświetl plik

@ -2,6 +2,27 @@
"App": {
"loading": "加载中…"
},
"vui": {
"tracks": "{n} 歌曲 | {n} 歌曲",
"episodes": "{n} 节目 | {n} 节目",
"by-user": "由 {'@'}{用户名}",
"go-to": "去",
"pagination": {
"previous": "以前的",
"next": "下一个"
},
"aria": {
"pagination": {
"nav": "分页导航",
"gotoPage": "转到页面 {n}",
"gotoPrevious": "转到上一页",
"gotoNext": "转到下一页",
"currentPage": "当前页面,第 {n} 页"
}
},
"radio": "电台",
"albums": "{n} 专辑 | {n} 专辑"
},
"components": {
"About": {
"description": {

Wyświetl plik

@ -19,7 +19,14 @@
"~/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "test/**/*.ts"],
"include": [
"src/**/*.ts",
"src/**/*.vue",
"vite.config.ts",
"test/**/*.ts",
"src/docs/vite.config.ts",
"src/docs/**/*.ts"
],
"vueCompilerOptions": {
"plugins": [
"@vue-macros/volar/define-options",

Wyświetl plik

@ -0,0 +1,66 @@
import { defineConfig } from 'vitepress'
export default defineConfig({
title: 'Funkwhale Vue Components',
cleanUrls: true,
cacheDir: './.vitepress/.vite',
themeConfig: {
nav: [
{ text: 'Home', link: 'https://funkwhale.audio' },
{ text: 'Gitlab', link: 'https://dev.funkwhale.audio/funkwhale/ui' },
],
sidebar: [
{
text: 'Components',
items: [
{ text: 'Activity', link: '/components/ui/activity' },
{ text: 'Alert', link: '/components/ui/alert' },
{
text: 'Card', link: '/components/ui/card',
items: [
{ text: 'Album Card', link: '/components/ui/card/album' },
{ text: 'Artist Card', link: '/components/ui/card/artist' },
{ text: 'Playlist Card', link: '/components/ui/card/playlist' },
{ text: 'Podcast Card', link: '/components/ui/card/podcast' },
{ text: 'Radio Card', link: '/components/ui/card/radio' },
],
},
{
text: 'Content Navigation',
items: [
{ text: 'Pagination', link: '/components/ui/pagination' },
{ text: 'Table of Contents', link: '/components/ui/toc' },
{ text: 'Tabs', link: '/components/ui/tabs' },
],
},
{
text: 'Form',
items: [
{
text: 'Button', link: '/components/ui/button',
items: [
{ text: 'Options Button', link: '/components/ui/button/options' },
{ text: 'Play Button', link: '/components/ui/button/play' },
],
},
{ text: 'Input', link: '/components/ui/input' },
{ text: 'Popover', link: '/components/ui/popover' },
{ text: 'Textarea', link: '/components/ui/textarea' },
{ text: 'Toggle', link: '/components/ui/toggle' },
],
},
{
text: 'Layout', link: '/components/ui/layout/',
items: [{ text: "Spacer", link: "../../src/components/ui/layout/spacer" }]
},
{ text: 'Loader', link: '/components/ui/loader' },
{ text: 'Modal', link: '/components/ui/modal' },
{ text: 'Pill', link: '/components/ui/pill' },
],
},
],
search: {
provider: 'local',
},
},
})

Wyświetl plik

@ -0,0 +1,33 @@
<script setup>
import DefaultTheme from 'vitepress/theme'
const { Layout } = DefaultTheme
</script>
<template>
<Layout>
</Layout>
</template>
<style>
:root {
--vp-sidebar-width: 250px;
}
.VPNavBarTitle .title {
font-size: 14px;
}
.preview,
.vp-doc .preview {
padding: 16px 0;
flex-grow: 1;
/* .preview overrides the cascade coming from .vp-docs and other containers
that may leak rules here */
p {
margin: 0;
line-height: normal
}
}
</style>

Wyświetl plik

@ -0,0 +1,28 @@
import { createI18n } from 'vue-i18n'
import DefaultTheme from 'vitepress/theme'
import en from '../../../src/locales/en_US.json'
import Layout from './Layout.vue'
// import '~/../dist/style.css'
export default {
...DefaultTheme,
Layout: Layout,
enhanceApp({ app }) {
const i18n = createI18n({
legacy: false,
locale: 'en',
fallbackLocale: 'en',
messages: { en }
})
// app.use(Funkwhale)
// Simsalabim: Incantation for a confused i18n... Thank you s-ol https://github.com/vikejs/vike/discussions/1778#discussioncomment-10192261
if (!('__VUE_PROD_DEVTOOLS__' in globalThis)) {
(globalThis as any).__VUE_PROD_DEVTOOLS__ = false;
}
app.use(i18n)
}
}

Wyświetl plik

@ -0,0 +1,61 @@
<script setup lang="ts">
const track = {
name: 'Some lovely track',
artist: {
name: 'Artist'
},
cover: {
urls: {
original: 'https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb'
}
}
}
const user = {
username: 'username'
}
</script>
# Activity
Activities display history entries for a Funkwhale pod. Each item contains the following information:
- An image
- A track title
- An artist name
- A username
- A [popover](./../popover.md)
| Prop | Data type | Required? | Description |
| ------- | ------------ | --------- | -------------------------------------------- |
| `track` | Track object | Yes | The track to render in the activity entry. |
| `user` | User object | Yes | The user associated with the activity entry. |
## Single items
You can render a single activity item by passing the track and user information to the `<fw-activity>` component.
```vue-html
<fw-activity :track="track" :user="user" />
```
<fw-activity :track="track" :user="user" />
## Activity lists
You can display a list of activity items by passing a `v-for` directive and adding a `key` to the item. The `key` must be unique to the list.
::: info
Items in a list are visually separated by a 1px border.
:::
```vue-html{4-5}
<fw-activity
:track="track"
:user="user"
v-for="i in 3"
:key="i"
/>
```
<fw-activity :track="track" :user="user" v-for="i in 3" :key="i" />

Wyświetl plik

@ -0,0 +1,98 @@
# Alert
| Prop | Data type | Required? | Default | Description |
| ------- | -------------------------------------------------- | --------- | ----------- | -------------------------------- |
| `color` | `blue` \| `red` \| `purple` \| `green` \| `yellow` | No | `secondary` | The color of the alert container |
## Alert colors
Funkwhale alerts support a range of pastel colors for visual appeal.
::: details Colors
- Red
- Blue
- Purple
- Green
- Yellow
:::
### Blue
```vue-html
<fw-alert color="blue">
Blue alert
</fw-alert>
```
<fw-alert color="blue">
Blue alert
</fw-alert>
### Red
```vue-html
<fw-alert color="red">
Red alert
</fw-alert>
```
<fw-alert color="red">
Red alert
</fw-alert>
### Purple
```vue-html
<fw-alert color="purple">
Purple alert
</fw-alert>
```
<fw-alert color="purple">
Purple alert
</fw-alert>
### Green
```vue-html
<fw-alert color="green">
Green alert
</fw-alert>
```
<fw-alert color="green">
Green alert
</fw-alert>
### Yellow
```vue-html
<fw-alert color="yellow">
Yellow alert
</fw-alert>
```
<fw-alert color="yellow">
Yellow alert
</fw-alert>
## Alert actions
```vue-html{2-4}
<fw-alert>
Awesome artist
<template #actions>
<fw-button>Got it</fw-button>
</template>
</fw-alert>
```
<fw-alert>
Awesome artist
<template #actions>
<fw-button>Got it</fw-button>
</template>
</fw-alert>

Wyświetl plik

@ -0,0 +1,276 @@
<script setup>
const click = () => new Promise(resolve => setTimeout(resolve, 1000))
</script>
# Button
Buttons are UI elements that users can interact with to perform actions. Funkwhale uses buttons in many contexts.
| Prop | Data type | Required? | Default | Description |
| ------------ | ----------------------------------------- | --------- | --------- | ------------------------------------------------------------------ |
| `variant` | `solid` \| `outline` \| `ghost` | No | `solid` | Whether to render the button as an solid, outline or ghost button. |
| `shadow` | Boolean | No | `false` | Whether to render the button with a shadow |
| `round` | Boolean | No | `false` | Whether to render the button as a round button |
| `icon` | String | No | | The icon attached to the button |
| `is-active` | Boolean | No | `false` | Whether the button is in an active state |
| `is-loading` | Boolean | No | `false` | Whether the button is in a loading state |
| `color` | `primary` \| `secondary` \| `destructive` | No | `primary` | Renders a colored button |
## Button colors
Buttons come in different types depending on the type of action they represent.
### Primary
Primary buttons represent **positive** actions such as uploading, confirming, and accepting changes.
::: info
This is the default type. If you don't specify a type, a primary button is rendered.
:::
```vue-html
<fw-button>
Primary button
</fw-button>
```
<fw-button>
Primary button
</fw-button>
### Secondary
Secondary buttons represent **neutral** actions such as cancelling a change or dismissing a notification.
```vue-html
<fw-button color="secondary">
Secondary button
</fw-button>
```
<fw-button color="secondary">
Secondary button
</fw-button>
### Destructive
Desctrutive buttons represent **dangerous** actions including deleting items or purging domain information.
```vue-html
<fw-button color="destructive">
Destructive button
</fw-button>
```
<fw-button color="destructive">
Destructive button
</fw-button>
## Button variants
Buttons come in different styles that you can use depending on the location of the button.
### Solid
Solid buttons have a filled background. Use these to emphasize the action the button performs.
::: info
This is the default style. If you don't specify a style, a solid button is rendered.
:::
```vue-html
<fw-button>
Filled button
</fw-button>
```
<fw-button>
Filled button
</fw-button>
### Outline
Outline buttons have a transparent background. Use these to deemphasize the action the button performs.
```vue-html
<fw-button variant="outline" color="secondary">
Outline button
</fw-button>
```
<fw-button variant="outline" color="secondary">
Outline button
</fw-button>
### Ghost
Ghost buttons have a transparent background and border. Use these to deemphasize the action the button performs.
```vue-html
<fw-button variant="ghost" color="secondary">
Ghost button
</fw-button>
```
<fw-button variant="ghost" color="secondary">
Ghost button
</fw-button>
## Button styles
### Shadow
You can give a button a shadow to add depth.
```vue-html
<fw-button shadow>
Shadow button
</fw-button>
```
<fw-button shadow>
Shadow button
</fw-button>
## Button shapes
You can choose different shapes for buttons depending on their location and use.
### Normal
Normal buttons are slightly rounded rectangles.
::: info
This is the default shape. If you don't specify a type, a normal button is rendered.
:::
```vue-html
<fw-button>
Normal button
</fw-button>
```
<fw-button>
Normal button
</fw-button>
### Round
Round buttons have fully rounded edges.
```vue-html
<fw-button round>
Round button
</fw-button>
```
<fw-button round>
Round button
</fw-button>
## Button states
You can pass a state to indicate whether a user can interact with a button.
### Active
A button is active when clicked by a user. You can force an active state by passing an `is-active` prop.
```vue-html
<fw-button is-active>
Active button
</fw-button>
```
<fw-button is-active>
Active button
</fw-button>
### Disabled
Disabled buttons are non-interactive and inherit a less bold color than the one provided. You can apply a disabled state by passing a `disabled` prop.
```vue-html
<fw-button disabled>
Disabled button
</fw-button>
```
<fw-button disabled>
Disabled button
</fw-button>
### Loading
If a user can't interact with a button until something has finished loading, you can add a spinner by passing the `is-loading` prop.
```vue-html
<fw-button is-loading>
Loading button
</fw-button>
```
<fw-button is-loading>
Loading button
</fw-button>
### Promise handling in `@click`
When a function passed to `@click` returns a promise, the button automatically toggles a loading state on click. When the promise resolves or is rejected, the loading state turns off.
::: danger
There is no promise rejection mechanism implemented in the `<fw-button>` component. Make sure the `@click` handler never rejects.
:::
```vue
<script setup lang="ts">
const click = () => new Promise((resolve) => setTimeout(resolve, 1000));
</script>
<template>
<fw-button @click="click"> Click me </fw-button>
</template>
```
<fw-button @click="click">
Click me
</fw-button>
You can override the promise state by passing a false `is-loading` prop.
```vue-html
<fw-button :is-loading="false">
Click me
</fw-button>
```
<fw-button :is-loading="false">
Click me
</fw-button>
## Icons
You can use [Bootstrap Icons](https://icons.getbootstrap.com/) in your button component
::: info
Icon buttons shrink down to the icon size if you don't pass any content. If you want to keep the button at full width with just an icon, add `&nbsp;` into the slot.
:::
```vue-html
<fw-button color="secondary" icon="bi-three-dots-vertical" />
<fw-button color="secondary" is-round icon="bi-x" />
<fw-button icon="bi-save">&nbsp;</fw-button>
<fw-button color="destructive" icon="bi-trash">
Delete
</fw-button>
```
<fw-button color="secondary" icon="bi-three-dots-vertical" />
<fw-button color="secondary" round icon="bi-x" />
<fw-button icon="bi-save">&nbsp;</fw-button>
<fw-button color="destructive" icon="bi-trash">
Delete
</fw-button>

Wyświetl plik

@ -0,0 +1,9 @@
# Options Button
-> For use cases, see [components/popover](../popover)
```vue-html
<fw-options-button />
```
<fw-options-button />

Wyświetl plik

@ -0,0 +1,9 @@
# Play Button
The play button is a specialized button used in many places across the Funkwhale app. Map a function to the `@play` event handler to toggle it on click.
```vue-html
<fw-play-button @play="play" />
```
<fw-play-button />

Wyświetl plik

@ -0,0 +1,242 @@
<script setup lang="ts">
import Card from '~/components/card/Card.vue'
import Spacer from '~/components/layout/Spacer.vue'
import { useRouter } from 'vue-router'
const alert = (message: string) => window.alert(message)
const router = useRouter()
</script>
# Card
Funkwhale cards are used to contain textual information, links, and interactive buttons. You can use these to create visually pleasing links to content or to present information.
::: details Parameters
#### Props
```ts
interface Props extends Partial<RouterLinkProps> {
title: string;
category?: true | "h1" | "h2" | "h3" | "h4" | "h5";
color?: Pastel;
image?: string | { src: string; style?: "withPadding" };
tags?: string[];
}
```
You have to set a title for the card by passing a `title` prop.
#### Style
- Set `--width` on the element to override the default Card width.
:::
```vue-html
<Card title="For music lovers">
Access your personal music collection from anywhere. Funkwhale gives you access to publication and sharing tools that you can use to promote your content across the web.
</Card>
```
<div class="preview">
<Card title="For music lovers">
Access your personal music collection from anywhere. Funkwhale gives you access to publication and sharing tools that you can use to promote your content across the web.
</Card></div>
## Card as a Link
::: warning
TODO: Test if it works. Set up a mock router in vitest.
:::
Add a `:to` prop, either containing an external link (`"https://..."`) or a Vue Router destination:
```ts
:to="{name: 'library.albums.detail', params: {id: album.id}}"
```
<div class="preview">
<Card
title="Frequently Asked Questions"
@click="alert('A quick answer!')"
>
Got a question about Funkwhale? Get a quick answer!
</Card></div>
## Card as a Category header
Category cards are basic cards that contain only a title. To create a category card, pass a `category` prop.
```vue-html{1}
<Card category
title="Example Translations"
/>
```
<div class="preview">
<Card category
title="Example Translations"
/>
</div>
## Add an Image
Pass an image source to the `image` prop or set `image.src` and `image.style`.
```vue-html{4,11-12}
<Card
style="--width:208px"
title="For music lovers"
image="https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb">
</Card>
<Card
style="--width:208px"
title="For music lovers"
:image="{ src:'https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb',
style:'withPadding' }">
</Card>
```
<fw-layout flex>
<div class="preview">
<Card
style="--width:208px"
title="For music lovers"
image="https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb" />
</div>
<div class="preview">
<Card
style="--width:208px"
title="For music lovers"
:image="{ src:'https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb',
style:'withPadding' }" />
</div>
</fw-layout>
## Add an Alert
```vue-html{2-4}
<Card title="Your Collection">
<template #alert>
Please annotate all items with the required metadata.
</template>
</Card>
```
<div class="preview">
<Card title="Your Collection">
<template #alert>Please annotate all items with the required metadata.</template>
</Card>
</div>
## Add a Footer
Items in this region are secondary and will be displayed smaller than the main content.
```vue-html{3-9}
<Card title="My items">
<template #alert> There are no items in this list </template>
<template #footer>
<fw-button variant="outline" icon="bi-upload" @click="alert('Uploaded. Press OK!')">
Upload
</fw-button>
<Spacer style="flex-grow: 1" />
<fw-options-button />
</template>
</Card>
```
<div class="preview">
<Card title="My items">
<template #alert>There are no items in this list
</template>
<template #footer>
<fw-button variant="outline" icon="bi-upload" @click="alert('Uploaded. Press OK!')">Upload</fw-button>
<Spacer style="flex-grow: 1" />
<fw-options-button />
</template>
</Card>
</div>
## Add an Action
Large Buttons or links at the bottom edge of the card serve as Call-to-Actions (CTA).
```vue-html{3-6}
<Card title="Join an existing pod">
The easiest way to get started with Funkwhale is to register an account on a public pod.
<template #action>
<fw-button @click="alert('Open the pod picker')">Action!
</fw-button>
</template>
</Card>
```
<div class="preview">
<Card title="Join an existing pod">
The easiest way to get started with Funkwhale is to register an account on a public pod.
<template #action>
<fw-button @click="alert('Open the pod picker')">Action!
</fw-button>
</template>
</Card>
</div>
If there are multiple actions, they will be presented in a row:
```vue-html{4,7}
<Card title="Delete this pod?">
You cannot undo this action.
<template #action>
<fw-button style="justify-content: flex-start;" icon="bi-chevron-left" color="secondary" variant="ghost">
Back
</fw-button>
<fw-button style="flex-grow:0;" color="destructive" @click="alert('Deleted')">
Delete
</fw-button>
</template>
</Card>
```
<div class="preview">
<Card title="Delete this pod?">
You cannot undo this action.
<template #action>
<fw-button style="width:50%; justify-content: flex-start;" icon="bi-chevron-left" color="secondary" variant="ghost">
Back
</fw-button>
<fw-button style="width:50%" color="destructive" @click="alert('Deleted')">
Delete
</fw-button>
</template>
</Card>
</div>
## Add Tags
You can include tags on a card by adding a list of `tags`. These are rendered as [pills](./pill.md).
```vue-html{3}
<Card
title="For music lovers"
:tags="['rock', 'folk', 'punk']"
>
Access your personal music collection from anywhere. Funkwhale gives you access to publication and sharing tools that you can use to promote your content across the web.
</Card>
```
<div class="preview">
<Card
title="For music lovers"
:tags="['rock', 'folk', 'punk']">
Access your personal music collection from anywhere. Funkwhale gives you access to publication and sharing tools that you can use to promote your content across the web.
</Card></div>

Wyświetl plik

@ -0,0 +1,54 @@
# Input
Inputs are areas in which users can enter information. In Funkwhale, these mostly take the form of search fields.
| Prop | Data type | Required? | Description |
| --------------- | --------- | --------- | --------------------------------------------------------------------------- |
| `placeholder` | String | No | The placeholder text that appears when the input is empty. |
| `icon` | String | No | The [Bootstrap icon](https://icons.getbootstrap.com/) to show on the input. |
| `v-model:value` | String | Yes | The text entered in the input. |
## Input model
You can link a user's input to form data by referencing the data in a `v-model` directive.
```vue-html{2}
<fw-input
v-model="value"
placeholder="Search"
/>
```
<fw-input placeholder="Search" />
## Input icons
Add a [Bootstrap icon](https://icons.getbootstrap.com/) to an input to make its purpose more visually clear.
```vue-html{3}
<fw-input
v-model="value"
icon="bi-search"
placeholder="Search"
/>
```
<fw-input icon="bi-search" placeholder="Search" />
## Input-right slot
You can add a template on the right-hand side of the input to guide the user's input.
```vue-html{2-4}
<fw-input v-model="value" placeholder="Search">
<template #input-right>
suffix
</template>
</fw-input>
```
<fw-input placeholder="Search">
<template #input-right>
suffix
</template>
</fw-input>

Wyświetl plik

@ -0,0 +1,147 @@
<script setup lang="ts">
import Layout from '~/components/layout/Layout.vue'
import Card from '~/components/card/Card.vue'
import Alert from '~/components/alert/Alert.vue'
</script>
# Layout
The following containers are responsive. Change your window's size or select a device preset from your browser's dev tools to see how layouts are affected by available space.
<fw-tabs>
<fw-tab title="Flex (default)" icon="⠖">
Items are laid out in a row and wrapped as they overflow the container.
By default, all items in a row assume the same (maximum) height.
```vue-html
<Layout flex>
<Card title="A" style="width:100px; min-width:100px"></Card>
<Card title="B" :tags="['funk', 'dunk', 'punk']"></Card>
<Card title="C" style="width:100px; min-width:100px"></Card>
<Card title="D"></Card>
</Layout>
```
<div class="preview">
<Layout flex>
<Card title="A" style="width:100px; min-width:100px"></Card>
<Card title="B" :tags="['funk', 'dunk', 'punk']"></Card>
<Card title="C" style="width:100px; min-width:100px"></Card>
<Card title="D"></Card>
</Layout>
</div>
::: info Use additional `flexbox` properties
Find a list of all styles here: [Flexbox guide on css-tricks.com](https://css-tricks.com/snippets/css/a-guide-to-flexbox/)
<Layout flex>
<div class="preview" style="font-size:11px; font-weight:bold; mix-blend-mode:luminosity;">
<Layout flex style="--gap: 4px;"> --gap: 4px
<Alert style="align-self: flex-end">align-self: flex-end</Alert>
<Alert style="flex-grow: 1">flex-grow: 1</Alert>
<Alert style="height: 5rem;">height: 5rem</Alert>
<Alert style="width: 100%">width: 100%</Alert>
</Layout>
</div>
</Layout>
:::
</fw-tab>
<fw-tab title="Grid" icon="ꖛ">
Align items both vertically and horizontally
##### Override the `:column-width` (in px):
<Layout flex>
```vue-html{1}
<Layout grid :column-width=90>
<Alert>A</Alert>
<Alert>B</Alert>
...
</Layout>
```
<div class="preview">
<Layout grid :column-width=90>
<Alert>A</Alert>
<Alert>B</Alert>
<Alert>C</Alert>
<Alert>D</Alert>
<Alert>E</Alert>
<Alert>F</Alert>
<Alert>G</Alert>
</Layout>
</div>
</Layout>
##### Let elements span multiple rows or columns:
```vue-html{2,4}
<Layout grid>
<Card title="A" class="span-2-columns"></Card>
<Card title="B"></Card>
<Card title="C" class="span-2-rows"></Card>
<Card title="D"></Card>
</Layout>
```
<Layout grid>
<Card title="A" class="span-2-columns">
```vue
<Card class="span-2-columns"></Card>
```
</Card>
<Card title="B"></Card>
<Card title="C" class="span-2-rows">
```vue
<Card class="span-2-rows"></Card>
```
</Card>
<Card title="D"></Card>
</Layout>
</fw-tab>
<fw-tab title="Stack" icon="𝌆">
Add space between vertically stacked items
<Layout flex>
```vue-html
<Layout stack>
<Card title="A"></Card>
<Card title="B"></Card>
<Card title="C"></Card>
<Card title="D"></Card>
<Card title="E"></Card>
</Layout>
```
<div class="preview">
<Layout stack>
<Card title="A"></Card>
<Card title="B"></Card>
<Card title="C"></Card>
<Card title="D"></Card>
<Card title="E"></Card>
</Layout>
</div>
</Layout>
</fw-tab>
</fw-tabs>

Wyświetl plik

@ -0,0 +1,75 @@
<script setup lang="ts">
import Layout from '~/components/layout/Layout.vue'
import Spacer from '~/components/layout/Spacer.vue'
import Alert from '~/components/alert/Alert.vue'
</script>
<style>
.preview {
padding:16px 0;
flex-grow:1;
}
</style>
# Spacer
Add a 16px gap between adjacent items.
##### Without spacer:
<Layout flex>
```vue-html
<Alert color="green">A</Alert>
<Alert color="red">B</Alert>
```
<div class="preview">
<Alert color="green">A</Alert>
<Alert color="red">B</Alert>
</div>
</Layout>
##### With spacer:
<Layout flex>
```vue-html{2}
<Alert color="green">A</Alert>
<Spacer/>
<Alert color="red">B</Alert>
```
<div class="preview">
<Alert color="green">A</Alert>
<Spacer/>
<Alert color="red">B</Alert>
</div>
</Layout>
##### Spacers can also be added for horizontal space:
<Layout flex>
```vue-html{4}
<Layout flex>
<Alert color="blue">A</Alert>
<Alert color="green">A</Alert>
<Spacer/>
<Alert color="red">B</Alert>
</Layout>
```
<div class="preview">
<Layout flex>
<Alert color="blue">A</Alert>
<Alert color="green">A</Alert>
<Spacer/>
<Alert color="red">B</Alert>
</Layout>
</div>
</Layout>

Wyświetl plik

@ -0,0 +1,48 @@
<style>
.docs-loader-container div[style^=width] {
border: 1px solid #666;
height: 2em;
}
</style>
# Loader
Loaders visually indicate when an operation is loading. This makes it visually clear that the user can't interact with the element until the loading process is complete.
| Prop | Data type | Required? | Description |
| ----------- | --------- | --------- | -------------------------------------------- |
| `container` | Boolean | No | Whether to create a container for the loader |
## Normal loader
```vue-html
<div style="width: 50%">
<fw-loader />
</div>
```
<div class="docs-loader-container">
<div style="width: 50%">
<fw-loader />
</div>
</div>
## No container
By default the `<fw-loader />` component creates a container that takes up 100% of its parent's height. You can disable this by passing a `:container="false"` property. The loader renders centered in the middle of the first parent that has `position: relative` set.
```vue-html
<div style="position: relative">
<div style="width: 50%">
<fw-loader :container="false" />
</div>
</div>
```
<div class="docs-loader-container">
<div style="position: relative">
<div style="width: 50%">
<fw-loader :container="false" />
</div>
</div>
</div>

Wyświetl plik

@ -0,0 +1,162 @@
<script setup lang="ts">
import { ref, watchEffect } from 'vue'
const open = ref(false)
const open2 = ref(false)
const open3 = ref(false)
const alertOpen = ref(true)
watchEffect(() => {
if (open3.value === false) {
alertOpen.value = true
}
})
const open4 = ref(false)
const open5 = ref(false)
</script>
# Modal
| Prop | Data type | Required? | Default | Description |
| --------- | ----------------- | --------- | ------- | -------------------------------- |
| `title` | `string` | Yes | | The modal title |
| `v-model` | `true` \| `false` | Yes | | Whether the modal is open or not |
## Modal open
```vue-html
<fw-modal v-model="open" title="My modal">
Modal content
</fw-modal>
<fw-button @click="open = true">
Open modal
</fw-button>
```
<fw-modal v-model="open" title="My modal">
Modal content
</fw-modal>
<fw-button @click="open = true">
Open modal
</fw-button>
## Modal actions
Use the `#actions` slot to add actions to a modal. Actions typically take the form of [buttons](/components/button).
```vue-html
<fw-modal v-model="open" title="My modal">
Modal content
<template #actions>
<fw-button @click="open = false" color="secondary">
Cancel
</fw-button>
<fw-button @click="open = false">
Ok
</fw-button>
</template>
</fw-modal>
<fw-button @click="open = true">
Open modal
</fw-button>
```
<fw-modal v-model="open2" title="My modal">
Modal content
<template #actions>
<fw-button @click="open2 = false" color="secondary">
Cancel
</fw-button>
<fw-button @click="open2 = false">
Ok
</fw-button>
</template>
</fw-modal>
<fw-button @click="open2 = true">
Open modal
</fw-button>
## Nested modals
You can nest modals to allow users to open a modal from inside another modal. This can be useful when creating a multi-step workflow.
```vue-html
<fw-modal v-model="open" title="My modal">
<fw-modal v-model="openNested" title="My modal">
Nested modal content
</fw-modal>
<fw-button @click="openNested = true">
Open nested modal
</fw-button>
</fw-modal>
<fw-button @click="open = true">
Open modal
</fw-button>
```
<fw-modal v-model="open4" title="My modal">
<fw-modal v-model="open5" title="My modal">
Nested modal content
</fw-modal>
<fw-button @click="open5 = true">
Open nested modal
</fw-button>
</fw-modal>
<fw-button @click="open4 = true">
Open modal
</fw-button>
## Alert inside modal
You can nest [Funkwhale alerts](/components/alert) to visually highlight content within the modal.
```vue-html
<fw-modal v-model="open" title="My modal">
Modal content
<template #alert v-if="alertOpen">
<fw-alert>
Alert content
<template #actions>
<fw-button @click="alertOpen = false">Close alert</fw-button>
</template>
</fw-alert>
</template>
</fw-modal>
<fw-button @click="open = true">
Open modal
</fw-button>
```
<fw-modal v-model="open3" title="My modal">
Modal content
<template #alert v-if="alertOpen">
<fw-alert>
Alert content
<template #actions>
<fw-button @click="alertOpen = false">Close alert</fw-button>
</template>
</fw-alert>
</template>
<template #actions>
<fw-button @click="open3 = false" color="secondary">
Cancel
</fw-button>
<fw-button @click="open3 = false">
Ok
</fw-button>
</template>
</fw-modal>
<fw-button @click="open3 = true">
Open modal
</fw-button>

Wyświetl plik

@ -0,0 +1,24 @@
<script setup lang="ts">
import { ref } from 'vue'
const page = ref(1)
</script>
# Pagination
The pagination component helps users navigate through large lists of results by splitting them up into pages.
| Prop | Data type | Required? | Description |
| -------------- | --------- | --------- | -------------------------------------- |
| `pages` | Number | Yes | The total number of pages to paginate. |
| `v-model:page` | Number | Yes | The page number of the current page. |
## Pagination model
Create a pagination bar by passing the number of pages to the `pages` prop. Use `v-model` to sync the selected page to your page data. Users can click on each button or input a specific page and hit `return`.
```vue-html
<fw-pagination :pages="8" v-model:page="page" />
```
<fw-pagination :pages="9" v-model:page="page" />

Wyświetl plik

@ -0,0 +1,161 @@
# Pill
Pills are decorative elements that display information about content they attach to. They can be links to other content or simple colored labels.
You can add text to pills by adding it between the `<fw-pill>` tags.
| Prop | Data type | Required? | Default | Description |
| ------- | ----------------------------------------------------------------------------------------------- | --------- | ----------- | ---------------------- |
| `color` | `primary` \| `secondary` \| `destructive` \| `blue` \| `red` \| `purple` \| `green` \| `yellow` | No | `secondary` | Renders a colored pill |
## Pill types
You can assign a type to your pill to indicate what kind of information it conveys.
::: details Types
- Primary
- Secondary
- Destructive
:::
### Primary
Primary pills convey **positive** information.
```vue-html
<fw-pill color="primary">
Primary pill
</fw-pill>
```
<fw-pill color="primary">
Primary pill
</fw-pill>
### Secondary
Secondary pills convey **neutral** or simple decorational information such as genre tags.
::: info
This is the default type for pills. If you don't specify a type, a **secondary** pill is rendered.
:::
```vue-html
<fw-pill>
Secondary pill
</fw-pill>
```
<fw-pill>
Secondary pill
</fw-pill>
### Destructive
Destructive pills convey **destructive** or **negative** information. Use these to indicate that information could cause issues such as data loss.
```vue-html
<fw-pill color="destructive">
Destructive pill
</fw-pill>
```
<fw-pill color="destructive">
Destructive pill
</fw-pill>
## Pill colors
Funkwhale pills support a range of pastel colors to create visually appealing interfaces.
::: details Colors
- Red
- Blue
- Purple
- Green
- Yellow
:::
### Blue
```vue-html
<fw-pill color="blue">
Blue pill
</fw-pill>
```
<fw-pill color="blue">
Blue pill
</fw-pill>
### Red
```vue-html
<fw-pill color="red">
Red pill
</fw-pill>
```
<fw-pill color="red">
Red pill
</fw-pill>
### Purple
```vue-html
<fw-pill color="purple">
Purple pill
</fw-pill>
```
<fw-pill color="purple">
Purple pill
</fw-pill>
### Green
```vue-html
<fw-pill color="green">
Green pill
</fw-pill>
```
<fw-pill color="green">
Green pill
</fw-pill>
### Yellow
```vue-html
<fw-pill color="yellow">
Yellow pill
</fw-pill>
```
<fw-pill color="yellow">
Yellow pill
</fw-pill>
## Image pills
Image pills contain a small circular image on their left. These can be used for decorative links such as artist links. To created an image pill, insert a link to the image between the pill tags as a `<template>`.
```vue-html{2-4}
<fw-pill>
<template #image>
<img src="/images/awesome-artist.png" />
</template>
Awesome artist
</fw-pill>
```
<fw-pill>
<template #image>
<div style="background-color: #0004" />
</template>
Awesome artist
</fw-pill>

Wyświetl plik

@ -0,0 +1,588 @@
<script setup lang="ts">
import { ref } from 'vue'
// String values
const privacyChoices = ['public', 'private', 'pod']
const bcPrivacy = ref('pod')
const ccPrivacy = ref('public')
// Boolean values
const bc = ref(false)
const cc = ref(false)
const share = ref(false)
// Alert control
const alert = (message: string) => window.alert(message)
// Menu controls
const emptyMenu = ref(false)
const separator = ref(false)
const singleItemMenu = ref(false)
const checkboxMenu = ref(false)
const radioMenu = ref(false)
const seperatorMenu = ref(false)
const subMenu = ref(false)
const extraItemsMenu = ref(false)
const fullMenu= ref(false)
</script>
# Popover
Popovers (`fw-popover`) are visually hidden menus of items activated by a simple button. Use popovers to create complex menus in a visually appealing way.
| Prop | Data type | Required? | Description |
| ------ | --------- | --------- | ---------------------------------------------------------- |
| `open` | Boolean | No | Controls whether the popover is open. Defaults to `false`. |
## Options button
The options button (`fw-options-button`) is a stylized button you can use to hide and show your popover. Use [Vue event handling](https://vuejs.org/guide/essentials/event-handling.html) to map the button to a boolean value.
```vue{7}
<script setup lang="ts">
const open = ref(false)
</script>
<template>
<fw-popover v-model:open="open">
<fw-options-button @click="open = !open" />
</fw-popover>
</template>
```
<fw-popover v-model:open="emptyMenu">
<fw-options-button @click="emptyMenu = !emptyMenu" />
</fw-popover>
You can also use the `toggleOpen` prop in the `<template #default`> tag if you prefer not to use refs to control the menu's visibility.
```vue{8-12}
<script setup lang="ts">
const privacyChoices = ['pod', 'public', 'private']
const bcPrivacy = ref('pod')
</script>
<template>
<fw-popover>
<template #default="{ toggleOpen }">
<fw-pill @click.stop="toggleOpen" :blue="bcPrivacy === 'pod'" :red="bcPrivacy === 'public'">
{{ bcPrivacy }}
</fw-pill>
</template>
<template #items>
<fw-popover-radio v-model="bcPrivacy" :choices="privacyChoices"/>
</template>
</fw-popover>
</template>
```
<fw-popover>
<template #default="{ toggleOpen }">
<fw-pill @click.stop="toggleOpen" :blue="bcPrivacy === 'pod'" :red="bcPrivacy === 'public'">
{{ bcPrivacy }}
</fw-pill>
</template>
<template #items>
<fw-popover-radio v-model="bcPrivacy" :choices="privacyChoices"/>
</template>
</fw-popover>
## Items
Popovers contain a list of menu items. Items can contain different information based on their type.
::: info
Lists of items must be nested inside a `<template #items>` tag directly under the `<fw-popover>` tag.
:::
### Popover item
The popover item (`fw-popover-item`) is a simple button that uses [Vue event handling](https://vuejs.org/guide/essentials/event-handling.html). Each item contains a [slot](https://vuejs.org/guide/components/slots.html) which you can use to add a menu label and icon.
```vue{10-13}
<script setup lang="ts">
const alert = (message: string) => window.alert(message)
const open = ref(false)
</script>
<template>
<fw-popover v-model:open="open">
<fw-options-button @click="open = !open" />
<template #items>
<fw-popover-item @click="alert('Report this object?')">
<i class="bi bi-exclamation" />
Report
</fw-popover-item>
</template>
</fw-popover>
</template>
```
<fw-popover v-model:open="singleItemMenu">
<fw-options-button @click="singleItemMenu = !singleItemMenu" />
<template #items>
<fw-popover-item @click="alert('Report this object?')">
<i class="bi bi-exclamation" />
Report
</fw-popover-item>
</template>
</fw-popover>
### Checkbox
The checkbox (`fw-popover-checkbox`) is an item that acts as a selectable box. Use [`v-model`](https://vuejs.org/api/built-in-directives.html#v-model) to bind the checkbox to a boolean value. Each checkbox contains a [slot](https://vuejs.org/guide/components/slots.html) which you can use to add a menu label.
```vue{11-16}
<script setup lang="ts">
const bc = ref(false)
const cc = ref(false)
const open = ref(false)
</script>
<template>
<fw-popover v-model:open="open">
<fw-options-button @click="open = !open" />
<template #items>
<fw-popover-checkbox v-model="bc">
Bandcamp
</fw-popover-checkbox>
<fw-popover-checkbox v-model="cc">
Creative commons
</fw-popover-checkbox>
</template>
</fw-popover>
</template>
```
<fw-popover v-model:open="checkboxMenu">
<fw-options-button @click="checkboxMenu = !checkboxMenu" />
<template #items>
<fw-popover-checkbox v-model="bc">
Bandcamp
</fw-popover-checkbox>
<fw-popover-checkbox v-model="cc">
Creative commons
</fw-popover-checkbox>
</template>
</fw-popover>
### Radio
The radio (`fw-popover-radio`) is an item that acts as a radio selector.
| Prop | Data type | Required? | Description |
| ------------ | --------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `modelValue` | String | Yes | The current value of the radio. Use [`v-model`](https://vuejs.org/api/built-in-directives.html#v-model) to bind this to a value. |
| `choices` | Array\<String\> | Yes | A list of choices. |
```vue
<script setup lang="ts">
const open = ref(false);
const currentChoice = ref("pod");
const privacy = ["public", "private", "pod"];
</script>
<template>
<fw-popover v-model:open="open">
<fw-options-button @click="open = !open" />
<template #items>
<fw-popover-radio v-model="currentChoice" :choices="choices" />
</template>
</fw-popover>
</template>
```
<fw-popover v-model:open="radioMenu">
<fw-options-button @click="radioMenu = !radioMenu" />
<template #items>
<fw-popover-radio v-model="bcPrivacy" :choices="privacyChoices"/>
</template>
</fw-popover>
### Separator
Use a standard horizontal rule (`<hr>`) to add visual separators to popover lists.
```vue{14}
<script setup lang="ts">
const bc = ref(false)
const cc = ref(false)
const open = ref(false)
</script>
<template>
<fw-popover v-model:open="open">
<fw-options-button @click="open = !open" />
<template #items>
<fw-popover-checkbox v-model="bc">
Bandcamp
</fw-popover-checkbox>
<hr>
<fw-popover-checkbox v-model="cc">
Creative commons
</fw-popover-checkbox>
</template>
</fw-popover>
</template>
```
<fw-popover v-model:open="seperatorMenu">
<fw-options-button @click="seperatorMenu = !seperatorMenu" />
<template #items>
<fw-popover-checkbox v-model="bc">
Bandcamp
</fw-popover-checkbox>
<hr>
<fw-popover-checkbox v-model="cc">
Creative commons
</fw-popover-checkbox>
</template>
</fw-popover>
## Submenus
To create more complex menus, you can use submenus (`fw-popover-submenu`). Submenus are menu items which contain other menu items.
```vue{10-18}
<script setup lang="ts">
const bc = ref(false)
const open = ref(false)
</script>
<template>
<fw-popover v-model:open="open">
<fw-options-button @click="open = !open" />
<template #items>
<fw-popover-submenu>
<i class="bi bi-collection" />
Organize and share
<template #items>
<fw-popover-checkbox v-model="bc">
Bandcamp
</fw-popover-checkbox>
</template>
</fw-popover-submenu>
</template>
</fw-popover>
</template>
```
<fw-popover v-model:open="subMenu">
<fw-options-button @click="subMenu = !subMenu" />
<template #items>
<fw-popover-submenu>
<i class="bi bi-collection" />
Organize and share
<template #items>
<fw-popover-checkbox v-model="bc">
Bandcamp
</fw-popover-checkbox>
</template>
</fw-popover-submenu>
</template>
</fw-popover>
## Extra items
You can add extra items to the right hand side of a popover item by nesting them in a `<template #after>` tag. Use this to add additional menus or buttons to menu items.
```vue{18-29,34-37}
<script setup lang="ts">
const bc = ref(false)
const privacyChoices = ['public', 'private', 'pod']
const bcPrivacy = ref('pod')
const open = ref(false)
</script>
<template>
<fw-popover v-model:open="open">
<fw-options-button @click="open = !open" />
<template #items>
<fw-popover-submenu>
<i class="bi bi-collection" />
Organize and share
<template #items>
<fw-popover-checkbox v-model="bc">
Bandcamp
<template #after>
<fw-popover>
<template #default="{ toggleOpen }">
<fw-pill @click.stop="toggleOpen" :blue="bcPrivacy === 'pod'" :red="bcPrivacy === 'public'">
{{ bcPrivacy }}
</fw-pill>
</template>
<template #items>
<fw-popover-radio v-model="bcPrivacy" :choices="privacyChoices"/>
</template>
</fw-popover>
</template>
</fw-popover-checkbox>
<hr>
<fw-popover-checkbox v-model="share">
Share by link
<template #after>
<fw-button @click.stop="alert('Link copied to clipboard')" color="secondary" round icon="bi-link" />
<fw-button @click.stop="alert('Here is your code')" color="secondary" round icon="bi-code" />
</template>
</fw-popover-checkbox>
</template>
</fw-popover-submenu>
</template>
</fw-popover>
</template>
```
<fw-popover v-model:open="extraItemsMenu">
<fw-options-button @click="extraItemsMenu = !extraItemsMenu" />
<template #items>
<fw-popover-submenu>
<i class="bi bi-collection" />
Organize and share
<template #items>
<fw-popover-checkbox v-model="bc">
Bandcamp
<template #after>
<fw-popover>
<template #default="{ toggleOpen }">
<fw-pill @click.stop="toggleOpen" :blue="bcPrivacy === 'pod'" :red="bcPrivacy === 'public'">
{{ bcPrivacy }}
</fw-pill>
</template>
<template #items>
<fw-popover-radio v-model="bcPrivacy" :choices="privacyChoices"/>
</template>
</fw-popover>
</template>
</fw-popover-checkbox>
<hr>
<fw-popover-checkbox v-model="share">
Share by link
<template #after>
<fw-button @click.stop="alert('Link copied to clipboard')" secondary round icon="bi-link" />
<fw-button @click.stop="alert('Here is your code')" secondary round icon="bi-code" />
</template>
</fw-popover-checkbox>
</template>
</fw-popover-submenu>
</template>
</fw-popover>
## Menu
Here is an example of a completed menu containing all supported features.
```vue
<script setup lang="ts">
const open = ref(false);
const bc = ref(false);
const cc = ref(false);
const share = ref(false);
const bcPrivacy = ref("pod");
const ccPrivacy = ref("public");
const privacyChoices = ["private", "pod", "public"];
</script>
<template>
<fw-popover v-model:open="open">
<fw-options-button @click="open = !open" />
<template #items>
<fw-popover-item>
<i class="bi bi-arrow-up-right" />
Play next
</fw-popover-item>
<fw-popover-item>
<i class="bi bi-arrow-down-right" />
Append to queue
</fw-popover-item>
<fw-popover-submenu>
<i class="bi bi-music-note-list" />
Add to playlist
<template #items>
<fw-popover-item>
<i class="bi bi-music-note-list" />
Sample playlist
</fw-popover-item>
<hr />
<fw-popover-item>
<i class="bi bi-plus-lg" />
New playlist
</fw-popover-item>
</template>
</fw-popover-submenu>
<hr />
<fw-popover-item>
<i class="bi bi-heart" />
Add to favorites
</fw-popover-item>
<fw-popover-submenu>
<i class="bi bi-collection" />
Organize and share
<template #items>
<fw-popover-checkbox v-model="bc">
Bandcamp
<template #after>
<fw-popover>
<template #default="{ toggleOpen }">
<fw-pill
@click.stop="toggleOpen"
:blue="bcPrivacy === 'pod'"
:red="bcPrivacy === 'public'"
>
{{ bcPrivacy }}
</fw-pill>
</template>
<template #items>
<fw-popover-radio
v-model="bcPrivacy"
:choices="privacyChoices"
/>
</template>
</fw-popover>
</template>
</fw-popover-checkbox>
<fw-popover-checkbox v-model="cc">
Creative Commons
<template #after>
<fw-popover>
<template #default="{ toggleOpen }">
<fw-pill
@click.stop="toggleOpen"
:blue="ccPrivacy === 'pod'"
:red="ccPrivacy === 'public'"
>
{{ ccPrivacy }}
</fw-pill>
</template>
<template #items>
<fw-popover-radio
v-model="ccPrivacy"
:choices="privacyChoices"
/>
</template>
</fw-popover>
</template>
</fw-popover-checkbox>
<hr />
<fw-popover-item>
<i class="bi bi-plus-lg" />
New library
</fw-popover-item>
<hr />
<fw-popover-checkbox v-model="share">
Share by link
<template #after>
<fw-button @click.stop color="secondary" round icon="bi-link" />
<fw-button @click.stop color="secondary" round icon="bi-code" />
</template>
</fw-popover-checkbox>
</template>
</fw-popover-submenu>
<fw-popover-item>
<i class="bi bi-cloud-download" />
Download
</fw-popover-item>
<hr />
<fw-popover-item>
<i class="bi bi-exclamation" />
Report
</fw-popover-item>
</template>
</fw-popover>
</template>
```
<fw-popover v-model:open="fullMenu">
<fw-options-button @click="fullMenu = !fullMenu" />
<template #items>
<fw-popover-item>
<i class="bi bi-arrow-up-right" />
Play next
</fw-popover-item>
<fw-popover-item>
<i class="bi bi-arrow-down-right" />
Append to queue
</fw-popover-item>
<fw-popover-submenu>
<i class="bi bi-music-note-list" />
Add to playlist
<template #items>
<fw-popover-item>
<i class="bi bi-music-note-list" />
Sample playlist
</fw-popover-item>
<hr>
<fw-popover-item>
<i class="bi bi-plus-lg" />
New playlist
</fw-popover-item>
</template>
</fw-popover-submenu>
<hr>
<fw-popover-item>
<i class="bi bi-heart" />
Add to favorites
</fw-popover-item>
<fw-popover-submenu>
<i class="bi bi-collection" />
Organize and share
<template #items>
<fw-popover-checkbox v-model="bc">
Bandcamp
<template #after>
<fw-popover>
<template #default="{ toggleOpen }">
<fw-pill @click.stop="toggleOpen" :blue="bcPrivacy === 'pod'" :red="bcPrivacy === 'public'">
{{ bcPrivacy }}
</fw-pill>
</template>
<template #items>
<fw-popover-radio v-model="bcPrivacy" :choices="privacyChoices"/>
</template>
</fw-popover>
</template>
</fw-popover-checkbox>
<fw-popover-checkbox v-model="cc">
Creative Commons
<template #after>
<fw-popover>
<template #default="{ toggleOpen }">
<fw-pill @click.stop="toggleOpen" :blue="ccPrivacy === 'pod'" :red="ccPrivacy === 'public'">
{{ ccPrivacy }}
</fw-pill>
</template>
<template #items>
<fw-popover-radio v-model="ccPrivacy" :choices="privacyChoices"/>
</template>
</fw-popover>
</template>
</fw-popover-checkbox>
<hr>
<fw-popover-item>
<i class="bi bi-plus-lg" />
New library
</fw-popover-item>
<hr>
<fw-popover-checkbox v-model="share">
Share by link
<template #after>
<fw-button @click.stop color="secondary" round icon="bi-link" />
<fw-button @click.stop color="secondary" round icon="bi-code" />
</template>
</fw-popover-checkbox>
</template>
</fw-popover-submenu>
<fw-popover-item>
<i class="bi bi-cloud-download" />
Download
</fw-popover-item>
<hr>
<fw-popover-item>
<i class="bi bi-exclamation" />
Report
</fw-popover-item>
</template>
</fw-popover>

Wyświetl plik

@ -0,0 +1,67 @@
# Tabs
Tabs are used to hide information until a user chooses to see it. You can use tabs to show two sets of information on the same page without the user needing to navigate away.
| Prop | Data type | Required? | Description |
| ------- | --------- | --------- | -------------------- |
| `title` | String | Yes | The title of the tab |
## Tabbed elements
::: warning
The `<fw-tab>` component must be nested inside a `<fw-tabs>` component.
:::
```vue-html
<fw-tabs>
<fw-tab title="Overview">Overview content</fw-tab>
<fw-tab title="Activity">Activity content</fw-tab>
</fw-tabs>
```
<fw-tabs>
<fw-tab title="Overview">Overview content</fw-tab>
<fw-tab title="Activity">Activity content</fw-tab>
</fw-tabs>
::: info
If you add the same tab multiple times, the tab is rendered once with the combined content from the duplicates.
:::
```vue-html{2,4}
<fw-tabs>
<fw-tab title="Overview">Overview content</fw-tab>
<fw-tab title="Activity">Activity content</fw-tab>
<fw-tab title="Overview">More overview content</fw-tab>
</fw-tabs>
```
<fw-tabs>
<fw-tab title="Overview">Overview content</fw-tab>
<fw-tab title="Activity">Activity content</fw-tab>
<fw-tab title="Overview">More overview content</fw-tab>
</fw-tabs>
## Tabs-right slot
You can add a template to the right side of the tabs using the `#tabs-right` directive.
```vue-html{5-7}
<fw-tabs>
<fw-tab title="Overview">Overview content</fw-tab>
<fw-tab title="Activity">Activity content</fw-tab>
<template #tabs-right>
<fw-input icon="bi-search" placeholder="Search" />
</template>
</fw-tabs>
```
<fw-tabs>
<fw-tab title="Overview">Overview content</fw-tab>
<fw-tab title="Activity">Activity content</fw-tab>
<template #tabs-right>
<fw-input icon="bi-search" placeholder="Search" />
</template>
</fw-tabs>

Wyświetl plik

@ -0,0 +1,65 @@
<script setup lang="ts">
import { ref } from 'vue'
const text1 = ref('# Funk\nwhale')
const text2 = ref('# Funk\nwhale')
const text3 = ref('')
</script>
# Textarea
Textareas are input blocks that enable users to write in textual information. These blocks are used throughout the Funkwhale interface for entering item descriptions, moderation notes, and custom notifications.
::: tip
Funkwhale supports Markdown syntax in textarea blocks.
:::
| Prop | Data type | Required? | Description |
| --------------- | --------- | --------- | ------------------------------------------------------------------ |
| `max` | Number | No | The maximum number of characters a user can enter in the textarea. |
| `placeholder` | String | No | The placeholder text shown on an empty textarea. |
| `v-model:value` | String | Yes | The text entered into the textarea. |
## Textarea model
Create a textarea and attach its input to a value using a `v-model` directive.
```vue-html{2}
<fw-textarea
v-model="text"
/>
```
<ClientOnly>
<fw-textarea v-model="text1" />
</ClientOnly>
## Textarea max length
You can set the maximum length (in characters) that a user can enter in a textarea by passing a `max` prop.
```vue-html{3}
<fw-textarea
v-model="text"
:max="20"
/>
```
<ClientOnly>
<fw-textarea v-model="text2" :max="20" />
</ClientOnly>
## Textarea placeholder
Add a placeholder to a textarea to guide users on what they should enter by passing a `placeholder` prop.
```vue-html{3}
<fw-textarea
v-model="text"
placeholder="Describe this track here…"
/>
```
<ClientOnly>
<fw-textarea v-model="text3" placeholder="Describe this track here…" />
</ClientOnly>

Wyświetl plik

@ -0,0 +1,98 @@
# Table of Contents
The table of contents component renders a navigation bar on the right of the screen. Users can click on the items in the contents bar to skip to specific headers.
| Prop | Data type | Required? | Description |
| --------- | -------------- | --------- | --------------------------------------------------- |
| `heading` | Enum\<String\> | No | The heading level rendered in the table of contents |
::: details Supported headings
- `h1`
- `h2`
- `h3`
- `h4`
- `h5`
- `h6`
:::
## Default
By default table of contents only renders `<h1>` tags
```vue-html
<fw-toc>
<h1>This is a Table of Contents</h1>
Content...
<h1>It automatically generates from headings</h1>
More content...
</fw-toc>
```
<ClientOnly>
<fw-toc>
<h1>This is a Table of Contents</h1>
<p>
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
</p>
<p>
Unde praesentium voluptates esse in placeat. Quis qui sint illo tempore omnis sed. Ab dicta omnis aut dolor voluptate maxime repudiandae ea. Aspernatur alias et architecto asperiores in. A sunt necessitatibus voluptatem veniam at dolore. Dolorum saepe est eveniet dignissimos laborum.
</p>
<p>
Qui impedit dicta earum. Qui repudiandae est magnam. Illum sit ratione exercitationem fugiat aut tempore. Ut sit deserunt ratione ut architecto deleniti ea magnam. Voluptatibus dignissimos voluptatem rem fugiat.
</p>
<h1>It automatically generates from headings</h1>
<p>
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
</p>
<p>
Unde praesentium voluptates esse in placeat. Quis qui sint illo tempore omnis sed. Ab dicta omnis aut dolor voluptate maxime repudiandae ea. Aspernatur alias et architecto asperiores in. A sunt necessitatibus voluptatem veniam at dolore. Dolorum saepe est eveniet dignissimos laborum.
</p>
<p>
Qui impedit dicta earum. Qui repudiandae est magnam. Illum sit ratione exercitationem fugiat aut tempore. Ut sit deserunt ratione ut architecto deleniti ea magnam. Voluptatibus dignissimos voluptatem rem fugiat.
</p>
</fw-toc>
</ClientOnly>
## Custom headings
You can specify the heading level you want to render in the table of contents by passing it to the `heading` prop.
```vue-html{2}
<fw-toc
heading="h2"
>
<h1>This is a Table of Contents</h1>
Content...
<h2>It automatically generates from headings</h2>
More content...
</fw-toc>
```
<ClientOnly>
<fw-toc heading="h2">
<h1>This is a Table of Contents</h1>
<p>
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
</p>
<p>
Unde praesentium voluptates esse in placeat. Quis qui sint illo tempore omnis sed. Ab dicta omnis aut dolor voluptate maxime repudiandae ea. Aspernatur alias et architecto asperiores in. A sunt necessitatibus voluptatem veniam at dolore. Dolorum saepe est eveniet dignissimos laborum.
</p>
<p>
Qui impedit dicta earum. Qui repudiandae est magnam. Illum sit ratione exercitationem fugiat aut tempore. Ut sit deserunt ratione ut architecto deleniti ea magnam. Voluptatibus dignissimos voluptatem rem fugiat.
</p>
<h2>It automatically generates from headings</h2>
<p>
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
</p>
<p>
Unde praesentium voluptates esse in placeat. Quis qui sint illo tempore omnis sed. Ab dicta omnis aut dolor voluptate maxime repudiandae ea. Aspernatur alias et architecto asperiores in. A sunt necessitatibus voluptatem veniam at dolore. Dolorum saepe est eveniet dignissimos laborum.
</p>
<p>
Qui impedit dicta earum. Qui repudiandae est magnam. Illum sit ratione exercitationem fugiat aut tempore. Ut sit deserunt ratione ut architecto deleniti ea magnam. Voluptatibus dignissimos voluptatem rem fugiat.
</p>
</fw-toc>
</ClientOnly>

Wyświetl plik

@ -0,0 +1,38 @@
<script setup lang="ts">
import { ref } from 'vue'
const toggle = ref(false)
const toggle2 = ref(false)
</script>
# Toggle
Toggles are basic form inputs that visually represent a boolean value. Toggles can be **on** (`true`) or **off** (`false`).
| Prop | Data type | Required? | Description |
| --------------- | --------- | --------- | ---------------------------------------- |
| `big` | Boolean | No | Controls whether a toggle is big or not. |
| `v-model:value` | Boolean | Yes | The value controlled by the toggle. |
## Normal toggle
Link your toggle to an input using the `v-model` directive.
```vue-html
<fw-toggle v-model="toggle" />
```
<fw-toggle v-model="toggle" />
## Big toggle
Pass a `big` prop to create a larger toggle.
```vue-html{2}
<fw-toggle
big
v-model="toggle"
/>
```
<fw-toggle big v-model="toggle2" />

Wyświetl plik

@ -0,0 +1,70 @@
<script setup lang="ts">
import Layout from '../src/components/ui/Layout.vue'
// import { RouterLink, RouterView } from "vue-router";
// import { createWebHistory, createRouter } from 'vue-router'
// import HomeView from './components/layout.md'
// import AboutView from './components/card.md'
// const routes = [
// { path: '/', component: HomeView },
// { path: '/about', component: AboutView },
// ]
// const router = createRouter({
// history: createWebHistory(),
// routes,
// })
// enhanceApp({app}) {
// app.use(router);
// }
</script>
# Funkwhale design component library
Welcome to the Funkwhale design component library. This repository contains a collection of reusable components written
in [Vue.js](https://vuejs.org) and [Sass](https://sass-lang.com).
<p>
<strong>Current route path:</strong>
<!-- {{ $route.fullPath }} -->
</p>
<!-- <nav>
<RouterLink to="/">Go to Home</RouterLink>
<RouterLink to="/about">Go to About</RouterLink>
</nav> -->
<main>
<!-- <RouterView /> -->
</main>
<Layout stack>
<a href="https://design.funkwhale.audio" style="text-decoration: none">
<fw-card title="Looking for the designs?">
Check out the design system on our Penpot.
</fw-card>
</a>
::: warning Deprecation of Activity, AlbumCard
We are moving some components into the main funkwhale repository. These components will not receive any updates
because they are coupled
with the API:
- Activity
- Album Card
- Artist Card
- Playlist Card
- Podcast Card
- Radio Card
Do not use these components in new projects!
:::
</Layout>
[[toc]]

Wyświetl plik

@ -0,0 +1,36 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vueDevTools from 'vite-plugin-vue-devtools'
import path from 'node:path'
export default defineConfig({
plugins: [vueDevTools()],
publicDir: false,
resolve: {
alias: {
'~': fileURLToPath(new URL('../src', import.meta.url)),
'/node_modules': fileURLToPath(new URL('../node_modules', import.meta.url))
}
},
css: {
preprocessorOptions: {
scss: {
// additionalData: `
// @import "~/styles/inc/theme";
// @import "~/styles/inc/docs";
// $docs: ${!!process.env.VP_DOCS};
// `,
},
},
},
build: {
rollupOptions: {
external: ["vue", 'vue-i18n', '@vueuse/core', 'vue-router', 'vue-devtools'],
output: {
globals: {
Vue: "vue"
}
}
},
},
})

Plik diff jest za duży Load Diff