kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
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 buildsmerge-requests/2805/head
rodzic
fef8a8b4fd
commit
6990e80bdc
|
@ -75,6 +75,7 @@ api/staticfiles
|
|||
api/static
|
||||
api/.pytest_cache
|
||||
api/celerybeat-*
|
||||
|
||||
# Front
|
||||
oldfront/node_modules/
|
||||
front/static/translations
|
||||
|
@ -88,7 +89,15 @@ front/tests/e2e/reports
|
|||
front/test_results.xml
|
||||
front/coverage/
|
||||
front/selenium-debug.log
|
||||
|
||||
# Vitepress
|
||||
front/ui-docs/.vitepress/cache
|
||||
front/ui-docs/.vitepress/dist
|
||||
front/ui-docs/public
|
||||
|
||||
# Docs
|
||||
docs/_build
|
||||
|
||||
#Tauri
|
||||
front/tauri/gen
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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" />
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 ` ` 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"> </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"> </fw-button>
|
||||
<fw-button color="destructive" icon="bi-trash">
|
||||
Delete
|
||||
</fw-button>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,9 @@
|
|||
# Options Button
|
||||
|
||||
-> For use cases, see [components/popover](../popover)
|
||||
|
||||
```vue-html
|
||||
<fw-options-button />
|
||||
```
|
||||
|
||||
<fw-options-button />
|
|
@ -0,0 +1,8 @@
|
|||
.funkwhale {
|
||||
&.options-button {
|
||||
will-change: transform;
|
||||
transition: all .2s ease;
|
||||
font-size: 0.6rem !important;
|
||||
padding: 0.6em !important;
|
||||
}
|
||||
}
|
|
@ -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 />
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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" />
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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" />
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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}`)
|
||||
}
|
|
@ -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)
|
|
@ -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>
|
|
@ -16,7 +16,7 @@ export const locales: Record<SupportedLanguages, Locale> = {
|
|||
ca: {
|
||||
label: 'Català'
|
||||
},
|
||||
"ca@valencia": {
|
||||
'ca@valencia': {
|
||||
label: 'Català (Valencia)'
|
||||
},
|
||||
cs: {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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: '../../src/components/ui/activity.md' },
|
||||
{ text: 'Alert', link: '../../src/components/ui/alert' },
|
||||
{
|
||||
text: 'Card', link: '../../src/components/ui/card',
|
||||
items: [
|
||||
{ text: 'Album Card', link: '../../src/components/ui/card/album' },
|
||||
{ text: 'Artist Card', link: '../../src/components/ui/card/artist' },
|
||||
{ text: 'Playlist Card', link: '../../src/components/ui/card/playlist' },
|
||||
{ text: 'Podcast Card', link: '../../src/components/ui/card/podcast' },
|
||||
{ text: 'Radio Card', link: '../../src/components/ui/card/radio' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Content Navigation',
|
||||
items: [
|
||||
{ text: 'Pagination', link: '../../src/components/ui/pagination' },
|
||||
{ text: 'Table of Contents', link: '../../src/components/ui/toc' },
|
||||
{ text: 'Tabs', link: '../../src/components/ui/tabs' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Form',
|
||||
items: [
|
||||
{
|
||||
text: 'Button', link: '../../src/components/ui/button',
|
||||
items: [
|
||||
{ text: 'Options Button', link: '../../src/components/ui/button/options' },
|
||||
{ text: 'Play Button', link: '../../src/components/ui/button/play' },
|
||||
],
|
||||
},
|
||||
{ text: 'Input', link: '../../src/components/ui/input' },
|
||||
{ text: 'Popover', link: '../../src/components/ui/popover' },
|
||||
{ text: 'Textarea', link: '../../src/components/ui/textarea' },
|
||||
{ text: 'Toggle', link: '../../src/components/ui/toggle' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Layout', link: '../../src/components/ui/layout/',
|
||||
items: [{ text: "Spacer", link: "../../src/components/ui/layout/spacer" }]
|
||||
},
|
||||
{ text: 'Loader', link: '../../src/components/ui/loader' },
|
||||
{ text: 'Modal', link: '../../src/components/ui/modal' },
|
||||
{ text: 'Pill', link: '../../src/components/ui/pill' },
|
||||
],
|
||||
},
|
||||
],
|
||||
search: {
|
||||
provider: 'local',
|
||||
},
|
||||
},
|
||||
})
|
|
@ -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>
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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]]
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
1020
front/yarn.lock
1020
front/yarn.lock
Plik diff jest za duży
Load Diff
Ładowanie…
Reference in New Issue