feat(ui): [WIP] Layout section component

This commit is contained in:
upsiflu 2025-01-03 13:42:39 +01:00
parent 4a62f05d1e
commit 860f12512c
13 changed files with 1007 additions and 27 deletions

View File

@ -158,6 +158,16 @@ const sortedFiles = computed(() => {
const hasActiveUploads = computed(() => files.value.some(file => file.active)) const hasActiveUploads = computed(() => files.value.some(file => file.active))
// const isOpen = computed({
// get() {
// return store.state.ui.modalsOpen.has(modalName);
// },
// set(value) {
// store.commit('ui/setModal', [modalName, value]);
// }
// })
// //
// Quota status // Quota status
// //

View File

@ -1,16 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import type { RouterLinkProps } from 'vue-router';
import { type ColorProps, type DefaultProps, type PastelProps, type RaisedProps, type VariantProps, color } from '~/composables/color' import { type ColorProps, type DefaultProps, type PastelProps, type RaisedProps, type VariantProps, color } from '~/composables/color'
import { type WidthProps, width } from '~/composables/width' import { type WidthProps, width } from '~/composables/width'
const props = defineProps<{ const props = defineProps<{
columnWidth?: string, columnWidth?: string,
noGap?: true,
noRule?: true, noRule?: true,
noWrap?: true noWrap?: true,
} } & { [P in "stack" | "grid" | "flex" | "columns" | "row" | "page"]?: true | string }
& { [P in "stack" | "grid" | "flex" | "columns" | "row" | "page"]?: true | string }
& { [C in "nav" | "aside" | "header" | "footer" | "main" | "label" | "form" | "h1" | "h2" | "h3" | "h4" | "h5"]?: true } & { [C in "nav" | "aside" | "header" | "footer" | "main" | "label" | "form" | "h1" | "h2" | "h3" | "h4" | "h5"]?: true }
& { [G in 'no-gap' | 'gap-16' | 'gap-12' | 'gap-8' | 'gap-auto' ]?: true }
& (PastelProps | ColorProps | DefaultProps) & (PastelProps | ColorProps | DefaultProps)
& RaisedProps & RaisedProps
& VariantProps & VariantProps
@ -18,6 +18,10 @@ const props = defineProps<{
const columnWidth = props.columnWidth ?? '46px' const columnWidth = props.columnWidth ?? '46px'
const maybeGap = Object.entries(props).find(
([key, value]) => value===true && key.startsWith('gap'))
const gapWidth = maybeGap ? `${maybeGap[0].replace('gap', '')}px` : '32px'
const attributes = computed(() => ({ const attributes = computed(() => ({
...color(props)(width(props)()), ...color(props)(width(props)()),
layout: layout:
@ -27,6 +31,8 @@ const attributes = computed(() => ({
props.columns ? 'columns' : props.columns ? 'columns' :
'stack' 'stack'
})) }))
console.log("GRID", props.grid, props.grid ? 'grid-custom' : 'none')
</script> </script>
<template> <template>
@ -34,7 +40,7 @@ const attributes = computed(() => ({
:is="props.nav ? 'nav' : props.aside ? 'aside' : props.header ? 'header' : props.footer ? 'footer' : props.main ? 'main' : props.label ? 'label' : props.form ? 'form' : props.h1 ? 'h1' : props.h2 ? 'h2' : props.h3 ? 'h3' : props.h4 ? 'h4' : props.h5 ? 'h5' : 'div'" :is="props.nav ? 'nav' : props.aside ? 'aside' : props.header ? 'header' : props.footer ? 'footer' : props.main ? 'main' : props.label ? 'label' : props.form ? 'form' : props.h1 ? 'h1' : props.h2 ? 'h2' : props.h3 ? 'h3' : props.h4 ? 'h4' : props.h5 ? 'h5' : 'div'"
:class="[ :class="[
$style.layout, $style.layout,
noGap || $style.gap, ('noGap' in props && props.noGap === true) || $style.gap,
noWrap || $style.wrap, noWrap || $style.wrap,
]" v-bind="attributes"> ]" v-bind="attributes">
<slot /> <slot />
@ -47,7 +53,7 @@ const attributes = computed(() => ({
/* Override --gap with your preferred value */ /* Override --gap with your preferred value */
gap: var(--gap, 32px); gap: var(--gap, v-bind(gapWidth));
&:not(.gap) { &:not(.gap) {
gap: 0; gap: 0;
} }
@ -80,7 +86,7 @@ const attributes = computed(() => ({
&[layout=grid-custom] { &[layout=grid-custom] {
display: grid; display: grid;
grid: v-bind(grid); grid: v-bind("props.grid");
grid-auto-flow: row dense; grid-auto-flow: row dense;
place-content: center; place-content: center;
/* If the grid has a fixed size smaller than its container, center it */ /* If the grid has a fixed size smaller than its container, center it */

View File

@ -0,0 +1,130 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { RouterLinkProps } from 'vue-router';
import Card from '~/components/ui/Card.vue'
import Layout from '~/components/ui/Layout.vue'
import Toggle from '~/components/ui/Toggle.vue'
import Spacer from '~/components/ui/layout/Spacer.vue'
import Button from '~/components/ui/Button.vue'
import Link from '~/components/ui/Link.vue'
import Activity from '~/components/ui/Activity.vue'
const props = defineProps<{
[M in 'tiny-items' | 'small-items' | 'medium-items']?: true }
& { [H in 'h1' | 'h2' | 'h3']?: string }
& { action?: { text: string } & (RouterLinkProps | { onClick: (...args: any[]) => void | Promise<void> }) }>()
const [headingLevel, title] =
props.h1 ? ['h1', props.h1]
: props.h2 ? ['h2', props.h2]
: ['h3', props.h3]
const headerGrid =
`auto / repeat(auto-fit, calc(46px * ${props['tiny-items'] ? 2 : props['small-items'] ? 3 : 4} + 32px * 2))`
console.log(headerGrid);
</script>
<template>
<section>
<Layout header :grid="headerGrid">
<!-- The title row's width is a multiple of the expected items' column span -->
<Layout flex no-gap
style="grid-column: 1 / -1; align-self: baseline;"
>
<!-- Set distance between baseline and previous row -->
<Spacer v
:size="64"
style="outline:1px solid red; align-self: baseline;"
/>
<!-- Flexible row content -->
<!-- Note that the `h3` uses its padding to create the 24px bottom gap -->
<component :is="headingLevel" style="align-self: baseline; padding:0 0 24px 10px; margin:0;">
{{ title }}
</component>
<Spacer grow />
<!-- Action! You can either specify `to` or `onClick`. -->
<Button v-if="props.action && 'onClick' in props.action"
ghost thin auto align-self="baseline"
style="grid-column:-1;"
:onClick="props.action.onClick"
>
{{ props.action.text }}
</Button>
<Link v-if="props.action && 'to' in props.action"
ghost thin auto align-self="baseline"
:to="props.action.to"
>
{{ props.action.text }}
</Link>
</Layout>
</Layout>
<Layout main
grid="auto / repeat(auto-fit, 46px)"
>
<slot />
</Layout>
</section>
</template>
<style module>
.layout {
transition: all .15s;
/* Override --gap with your preferred value */
gap: var(--gap, v-bind(gapWidth));
&:not(.gap) {
gap: 0;
}
/* Growth */
&:has(:global(>.grow)) {
>:not(:global(.grow)) {
flex-grow: 0;
}
}
/* Layout strategy */
&[layout=columns] {
column-count: auto;
column-width: v-bind(columnWidth);
display: block;
column-rule: 1px solid v-bind("noRule ? 'transparent' : 'var(--border-color)'");
}
&[layout=grid] {
display: grid;
grid-template-columns:
repeat(auto-fit, v-bind(columnWidth));
grid-auto-flow: row dense;
place-content: center;
/* If the grid has a fixed size smaller than its container, center it */
}
&[layout=grid-custom] {
display: grid;
grid: v-bind(grid);
grid-auto-flow: row dense;
place-content: center;
/* If the grid has a fixed size smaller than its container, center it */
}
&[layout=stack] {
display: flex;
flex-direction: column;
}
&[layout=flex] {
display: flex;
flex-direction: row;
flex-wrap: v-bind('props.noWrap ? "nowrap" : "wrap"');
}
}
</style>

View File

@ -26,11 +26,13 @@ const styles = {
const getStyle = (props: Partial<WidthProps>) => (key: Key): string => const getStyle = (props: Partial<WidthProps>) => (key: Key): string =>
// @ts-ignore // @ts-ignore
typeof styles[key] === 'function' && key in props ? typeof styles[key] === 'function' && key in props ?
// @ts-ignore
styles[key]( styles[key](
// TODO: Make the typescript compiler understand `key in props` // TODO: Make the typescript compiler understand `key in props`
// @ts-ignore // @ts-ignore
props[key] props[key]
) )
: styles[key] : styles[key]
// All keys are exclusive // All keys are exclusive

View File

@ -5,6 +5,7 @@ import { color } from '~/composables/color';
import Sidebar from '~/ui/components/Sidebar.vue' import Sidebar from '~/ui/components/Sidebar.vue'
import ShortcutsModal from './modals/Shortcuts.vue' import ShortcutsModal from './modals/Shortcuts.vue'
import LanguagesModal from './modals/Languages.vue' import LanguagesModal from './modals/Languages.vue'
import UploadModal from './modals/Upload.vue';
// Fake content // Fake content
onMounted(async () => { onMounted(async () => {
@ -17,9 +18,10 @@ onMounted(async () => {
<template> <template>
<div class="funkwhale grid"> <div class="funkwhale grid">
<Sidebar/> <Sidebar/>
<RouterView v-bind="color({}, ['default', 'solid'])()" /> <RouterView v-bind="color({}, ['default', 'solid'])" />
<LanguagesModal /> <LanguagesModal />
<ShortcutsModal /> <ShortcutsModal />
<UploadModal />
</div> </div>
</template> </template>

View File

@ -35,8 +35,8 @@ const logoUrl = computed(() => store.state.auth.authenticated ? 'library.index'
</script> </script>
<template> <template>
<Layout aside :class="[$style.sidebar, $style['sticky-content']]" default solid raised> <Layout aside default solid raised gap-12 :class="[$style.sidebar, $style['sticky-content']]">
<Layout flex no-gap header style="justify-content:space-between; padding-right:8px;"> <Layout header flex no-gap style="justify-content:space-between; padding-right:8px;">
<Link <Link
:to="{name: logoUrl}" :to="{name: logoUrl}"
:class="$style['logo']" :class="$style['logo']"
@ -55,10 +55,13 @@ const logoUrl = computed(() => store.state.auth.authenticated ? 'library.index'
> >
</Link> </Link>
<Link to="/upload" <Button
align-self="center"
round icon="bi-upload" round icon="bi-upload"
class="is-icon-only" class="is-icon-only"
ghost ghost
@click="store.commit('ui/toggleModal', 'upload')"
:aria-pressed="store.state.ui.modalsOpen.has('languages') || undefined"
> >
<Transition> <Transition>
<div <div
@ -72,7 +75,7 @@ const logoUrl = computed(() => store.state.auth.authenticated ? 'library.index'
/> />
</div> </div>
</Transition> </Transition>
</Link> </Button>
<UserMenu/> <UserMenu/>

View File

@ -3,10 +3,13 @@ import { computed, ref, reactive } from 'vue'
import { useUploadsStore } from '~/ui/stores/upload' import { useUploadsStore } from '~/ui/stores/upload'
import { bytesToHumanSize } from '~/ui/composables/bytes' import { bytesToHumanSize } from '~/ui/composables/bytes'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useStore } from '~/store'
import UploadList from '~/ui/components/UploadList.vue' import UploadList from '~/ui/components/UploadList.vue'
import Alert from '~/components/ui/Alert.vue' import Alert from '~/components/ui/Alert.vue'
import Button from '~/components/ui/Button.vue' import Button from '~/components/ui/Button.vue'
import Modal from '~/components/ui/Modal.vue' import Modal from '~/components/ui/Modal.vue'
import Input from '~/components/ui/Input.vue'
import FileUploadWidget from '~/components/library/FileUploadWidget.vue'
const uploads = useUploadsStore() const uploads = useUploadsStore()
@ -67,23 +70,35 @@ const sortItems = reactive([
]) ])
const currentSort = ref(sortItems[0]) const currentSort = ref(sortItems[0])
const store = useStore()
// Filtering // Filtering
const filterItems = reactive([ const filterItems = reactive([
{ label: 'All', value: 'all' } { label: 'All', value: 'all' }
]) ])
const currentFilter = ref(filterItems[0]) const currentFilter = ref(filterItems[0])
const modalName = 'upload'
const isOpen = computed({
get() {
return store.state.ui.modalsOpen.has(modalName);
},
set(value) {
store.commit('ui/setModal', [modalName, value]);
}
})
</script> </script>
<template> <template>
<Modal <Modal
v-model="libraryOpen" v-model="isOpen"
title="Upload music to library" title="Upload..."
> >
<template #alert="closeAlert"> <template #alert="closeAlert">
<Alert yellow> <Alert yellow>
Before uploading, please ensure your files are tagged properly. Before uploading, please ensure your files are tagged properly.
We recommend using Picard for that purpose. We recommend using Picard for that purpose.
<template #actions> <template #actions>
<Button @click="closeAlert"> <Button @click="closeAlert">
Got it Got it
@ -92,8 +107,9 @@ const currentFilter = ref(filterItems[0])
</Alert> </Alert>
</template> </template>
<FwFileInput <Input
:accept="['.flac', '.ogg', '.opus', '.mp3', '.aac', '.aif', '.aiff', '.m4a']" type="file"
:accept="['.flac', '.ogg', '.opus', '.mp3', '.aac', '.aif', '.aiff', '.m4a'].join(', ')"
multiple multiple
auto-reset auto-reset
@files="processFiles" @files="processFiles"

View File

@ -0,0 +1,176 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useStore } from '~/store'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut';
import Modal from '~/components/ui/Modal.vue'
import Button from '~/components/ui/Button.vue'
import Input from '~/components/ui/Input.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/layout/Spacer.vue'
import Alert from '~/components/ui/Alert.vue';
import Card from '~/components/ui/Card.vue';
import Pagination from '~/components/ui/Pagination.vue';
import type { Actor, Channel } from '~/types';
import FileUploadWidget from '~/components/library/FileUploadWidget.vue';
import type { VueUploadItem } from 'vue-upload-component';
import UploadForm from '~/components/channels/UploadForm.vue';
import FileUpload from '~/components/library/FileUpload.vue';
const { t } = useI18n()
const store = useStore()
const modalName = 'upload'
const isOpen = computed({
get() {
return store.state.ui.modalsOpen.has(modalName);
},
set(value) {
store.commit('ui/setModal', [modalName, value]);
}
})
onKeyboardShortcut('u', () => store.commit('ui/toggleModal', modalName))
const dummyActor = (number:number) : Actor => ({
id: number,
summary: "actor summary",
preferred_username: `actor ${number}`,
full_username: "actor full username",
is_local: false,
domain: "domain"
})
const dummyChannel=(number:number) : Channel=>
({
id : number,
uuid : 'uuid',
actor: dummyActor(number),
attributed_to: dummyActor(number),
rss_url: "rss url string",
subscriptions_count: 10,
downloads_count: 10,
content_category: 'music'
})
type UploadDestination = 'channel' | 'library' | 'podcast'
type State =
{ uploadDestination? : UploadDestination, page: typeof pages[number], files?: string[] }
// initial state
const state = ref<State>({page : 'selectDestination'});
const pages = ['selectDestination', 'uploadFiles', 'uploadsInProgress'] as const
// Step 1
const destinationSelected = (destination: UploadDestination) =>
state.value = {...state.value, uploadDestination:destination, page:'uploadFiles' }
// Step 2
const filesSelected = (e: InputEvent)=>{
state.value = {...state.value, files: [] }
}
const modalTitle = computed(()=>
({ 'selectDestination' : 'Upload', 'uploadFiles' : 'Select files for upload', 'uploadsInProgress': 'Uploading...'}
[state.value.page])
)
// Upload input metadata
const values = reactive({
channel: null,
license: null,
album: null
})
const channels = [0,1,2,3,4].map(dummyChannel)
</script>
<template>
<Modal overPopover
:title="modalTitle"
v-model="isOpen"
>
<template #alert v-if="state.page === 'selectDestination'">
<Alert yellow>
Before uploading, please ensure your files are tagged properly.
We recommend using Picard for that purpose.
</Alert>
</template>
<Layout flex style="place-content:center" v-if="state.page === 'selectDestination'">
<Card small solid secondary title="Music"
@click="destinationSelected('channel')"
icon="bi-upload"
>
<template #image>
<i class="bi bi-music-note-beamed solid primary" :class="$style.icon"></i>
</template>
Publish music you make
</Card>
<Card small solid secondary title="Podcast"
@click="destinationSelected('podcast')"
icon="bi-upload"
>
<template #image>
<i class="bi bi-mic-fill solid primary" :class="$style.icon"></i>
</template>
Publish podcasts you make
</Card>
<Card small solid secondary title="Mix & Share"
@click="destinationSelected('library')"
icon="bi-upload"
>
<template #image>
<i class="bi bi-headphones solid secondary raised" :class="$style.icon"></i>
</template>
Host music you listen to
</Card>
</Layout>
<Layout stack v-if="state.page === 'uploadFiles'">
UPLOAD FILES
<!-- <Input
type="file"
:accept="['.flac', '.ogg', '.opus', '.mp3', '.aac', '.aif', '.aiff', '.m4a'].join(', ')"
multiple
auto-reset
@input="filesSelected"
/> -->
<!-- <UploadForm></UploadForm> -->
<FileUpload :library="{ uuid: 'string'}"></FileUpload>
{{ state.files }}
</Layout>
<template #actions>
<Button secondary
>
Cancel
</Button>
<Button disabled v-if="state.page === 'selectDestination'">
Continue in background' : 'Save and close'
</Button>
<Button primary v-if="state.page === 'uploadFiles'">
Continue in background' : 'Save and close'
</Button>
</template>
</Modal>
</template>
<style module>
.icon {
font-size:100px;
padding:28px;
inset:0;
display:block;
}
</style>

View File

@ -49,6 +49,7 @@ export default defineConfig({
text: 'Layout', link: '/components/ui/layout/', text: 'Layout', link: '/components/ui/layout/',
items: [ items: [
{ text: "Spacer", link: "/components/ui/layout/spacer" }, { text: "Spacer", link: "/components/ui/layout/spacer" },
{ text: "Section", link: "/components/ui/layout/section" },
{ text: "Using `flex`", link: "/components/ui/layout/flex" }, { text: "Using `flex`", link: "/components/ui/layout/flex" },
{ text: "Using `stack`", link: "/components/ui/layout/stack" }, { text: "Using `stack`", link: "/components/ui/layout/stack" },
{ text: "Using `grid`", link: "/components/ui/layout/grid" }, { text: "Using `grid`", link: "/components/ui/layout/grid" },

View File

@ -0,0 +1,227 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { type Track, type User } from '~/types'
import Card from '~/components/ui/Card.vue'
import Layout from '~/components/ui/Layout.vue'
import Toggle from '~/components/ui/Toggle.vue'
import Spacer from '~/components/ui/layout/Spacer.vue'
import Button from '~/components/ui/Button.vue'
import Activity from '~/components/ui/Activity.vue'
import Section from '~/components/ui/layout/Section.vue'
const alignLeft = ref(false)
const attributes = computed(() => ({
style: alignLeft.value ? 'justify-content: start' : ''
}))
const track: Track = {
id: 0,
fid: "",
title: 'Some lovely track',
description: {
content_type: 'text/markdown',
text: `**New:** Music for the eyes!`
},
cover: {
uuid: "",
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',
medium_square_crop: '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',
large_square_crop: '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'
}
},
tags: ["example"],
uploads: [],
downloads_count: 1927549377,
artist_credit: [{
artist: {
id: 0,
fid: "",
name: "The Artist",
description: {
content_type: 'text/markdown',
text: `I'm a musician based on the internet.
Find all my music on [Funkwhale](https://funkwhale.audio)!`},
tags: [],
content_category: 'music',
albums: [],
tracks_count: 1,
attributed_to: {
id: 0,
summary: "",
preferred_username: "User12345",
full_username: "User12345",
is_local: false,
domain: "myDomain.io"
},
is_local: false,
is_playable: true
},
credit: "",
joinphrase: " and ",
index: 22
}],
disc_number: 7,
listen_url: "https://funkwhale.audio",
creation_date: "12345",
attributed_to: {
id: 0,
summary: "",
preferred_username: "User12345",
full_username: "User12345",
is_local: false,
domain: "myDomain.io"
},
is_playable: true,
is_local: false
}
const user: User = {
id: 12,
avatar: {
uuid: "",
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',
medium_square_crop: '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',
large_square_crop: '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'
}
},
email: "user12345@example.org",
summary: { text: "Hi! I'm Example from The Internet.", content_type: "text" },
username: "user12345",
full_username: "user12345",
instance_support_message_display_date: "?",
funkwhale_support_message_display_date: "?",
is_superuser: true,
privacy_level: "everyone"
}
</script>
# Layout section
<Layout flex>
<Toggle v-model="alignLeft" label="Left-align the layout"/>
</Layout>
---
<div class="preview" style="margin: 0 -40px; padding: 0 25px;">
<Section small-items h1="Hello" :action="{ text:'more...', to:'/' }">
<Card small title="Relatively Long Album Name">
Artist Name
13 tracks
</Card>
<Card small title="Relatively Long Album Name">
Artist Name
13 tracks
</Card>
<Card small title="Relatively Long Album Name">
Artist Name
13 tracks
</Card>
</Section>
<Layout
grid="auto / repeat(auto-fit, calc(46px _ 3 + 32px _ 2))"
v-bind="attributes"
>
<!-- The title row's width is a multiple of 3 rows -->
<Layout flex no-gap
style="grid-column: 1 / -1; align-self: baseline;"
>
<!-- Set distance between baseline and previous row -->
<Spacer v
:size="64"
style="outline:1px solid red; align-self: baseline;"
/>
<!-- Flexible row content -->
<!-- Note that the `h3` uses its padding to create the 24px bottom gap -->
<h3 style="align-self: baseline; padding:0 0 24px 10px; margin:0;">
Albums
</h3>
<Spacer grow />
<Button ghost thin auto align-self="baseline"
style="grid-column:-1;"
>
Show all
</Button>
</Layout>
</Layout>
<Layout solid default
style="position:relative;"
grid="auto / repeat(auto-fit, 46px)"
v-bind="attributes"
>
<Card small title="Relatively Long Album Name">
Artist Name
13 tracks
</Card>
<Card small title="Relatively Long Album Name">
Artist Name
13 tracks
</Card>
<Card small title="Relatively Long Album Name">
Artist Name
13 tracks
</Card>
<Card small title="Relatively Long Album Name">
Artist Name
13 tracks
</Card>
<Card small title="Relatively Long Album Name">
Artist Name
13 tracks
</Card>
</Layout>
<Layout
grid="auto / repeat(auto-fit, calc(46px _ 4 + 32px _ 3))"
v-bind="attributes"
>
<Layout flex no-gap
style="grid-column: 1 / -1; align-self: baseline;"
>
<!-- Set distance between baseline and previous row -->
<Spacer v
:size="64"
style="outline:1px solid red; align-self: baseline;"
/>
<!-- Flexible row content -->
<!-- Note that the `h3` uses its padding to create the 24px bottom gap -->
<h3 style="align-self: baseline; padding:0 0 24px 10px; margin:0;">
Tracks
</h3>
<Spacer grow />
<Button ghost thin auto align-self="baseline"
style="grid-column:-1;"
>
Show all
</Button>
</Layout>
</Layout>
<Layout solid default
style="position:relative;"
grid="auto / repeat(auto-fit, 46px)"
v-bind="attributes"
>
<Activity :track="track" :user="user" />
<Activity :track="track" :user="user" />
<Activity :track="track" :user="user" />
</Layout>
</div>

View File

@ -14,23 +14,21 @@ You can align items inside `flex` and `grid` layouts.
<template <template
v-for="alignment in ['start', 'center', 'end', 'auto', 'baseline', 'stretch']" v-for="alignment in ['start', 'center', 'end', 'auto', 'baseline', 'stretch']"
:key="alignment" :key="alignment">
> <div style="position:relative;place-self:stretch; grid-area: span 2 / span 2; min-height: 72px; border:.5px solid red; display:grid; grid: 1fr / 1fr; grid-auto-flow: column;"
>
<div style="position:relative;place-self:stretch; grid-area: span 2 / span 2; min-height: 72px; border:.5px solid red; display:grid; grid: 1fr / 1fr; grid-auto-flow: column;" <Button auto primary :align-self="alignment">🐌</Button>
>
<Button auto primary :align-self="alignment">🐌</Button>
_ _
<span style="position:absolute; right: 0; margin-left:-20px; bottom:0;">align-self={{ alignment }}</span> <span style="position:absolute; right: 0; margin-left:-20px; bottom:0;">align-self={{ alignment }}</span>
</div>
</div>
</template> </template>
<template <template
v-for="alignment in ['center', 'stretch']" v-for="alignment in ['center', 'stretch']"
:key="alignment" :key="alignment">
>
<div style="position:relative;place-self:stretch; grid-area: span 2 / span 2; min-height: 72px; border:.5px solid red; display:grid; grid: 1fr / 1fr; grid-auto-flow: column;" <div style="position:relative;place-self:stretch; grid-area: span 2 / span 2; min-height: 72px; border:.5px solid red; display:grid; grid: 1fr / 1fr; grid-auto-flow: column;"
> >

6
package.json Normal file
View File

@ -0,0 +1,6 @@
{
"devDependencies": {
"openapi-typescript": "^7.4.4",
"typescript": "^5.7.2"
}
}

403
pnpm-lock.yaml Normal file
View File

@ -0,0 +1,403 @@
lockfileVersion: "9.0"
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
openapi-typescript:
specifier: ^7.4.4
version: 7.4.4(typescript@5.7.2)
typescript:
specifier: ^5.7.2
version: 5.7.2
packages:
"@babel/code-frame@7.26.2":
resolution:
{
integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==,
}
engines: { node: ">=6.9.0" }
"@babel/helper-validator-identifier@7.25.9":
resolution:
{
integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==,
}
engines: { node: ">=6.9.0" }
"@redocly/ajv@8.11.2":
resolution:
{
integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==,
}
"@redocly/config@0.17.1":
resolution:
{
integrity: sha512-CEmvaJuG7pm2ylQg53emPmtgm4nW2nxBgwXzbVEHpGas/lGnMyN8Zlkgiz6rPw0unASg6VW3wlz27SOL5XFHYQ==,
}
"@redocly/openapi-core@1.26.1":
resolution:
{
integrity: sha512-xRuVZqMVRFzqjbUCpOTra4tbnmQMWsya996omZMV3WgD084Z6OWB3FXflhAp93E/yAmbWlWZpddw758AyoaLSw==,
}
engines: { node: ">=14.19.0", npm: ">=7.0.0" }
agent-base@7.1.3:
resolution:
{
integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==,
}
engines: { node: ">= 14" }
ansi-colors@4.1.3:
resolution:
{
integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==,
}
engines: { node: ">=6" }
argparse@2.0.1:
resolution:
{
integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==,
}
balanced-match@1.0.2:
resolution:
{
integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==,
}
brace-expansion@2.0.1:
resolution:
{
integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==,
}
change-case@5.4.4:
resolution:
{
integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==,
}
colorette@1.4.0:
resolution:
{
integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==,
}
debug@4.4.0:
resolution:
{
integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==,
}
engines: { node: ">=6.0" }
peerDependencies:
supports-color: "*"
peerDependenciesMeta:
supports-color:
optional: true
fast-deep-equal@3.1.3:
resolution:
{
integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==,
}
https-proxy-agent@7.0.6:
resolution:
{
integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==,
}
engines: { node: ">= 14" }
index-to-position@0.1.2:
resolution:
{
integrity: sha512-MWDKS3AS1bGCHLBA2VLImJz42f7bJh8wQsTGCzI3j519/CASStoDONUBVz2I/VID0MpiX3SGSnbOD2xUalbE5g==,
}
engines: { node: ">=18" }
js-levenshtein@1.1.6:
resolution:
{
integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==,
}
engines: { node: ">=0.10.0" }
js-tokens@4.0.0:
resolution:
{
integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==,
}
js-yaml@4.1.0:
resolution:
{
integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==,
}
hasBin: true
json-schema-traverse@1.0.0:
resolution:
{
integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==,
}
minimatch@5.1.6:
resolution:
{
integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==,
}
engines: { node: ">=10" }
ms@2.1.3:
resolution:
{
integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==,
}
node-fetch@2.7.0:
resolution:
{
integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==,
}
engines: { node: 4.x || >=6.0.0 }
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
openapi-typescript@7.4.4:
resolution:
{
integrity: sha512-7j3nktnRzlQdlHnHsrcr6Gqz8f80/RhfA2I8s1clPI+jkY0hLNmnYVKBfuUEli5EEgK1B6M+ibdS5REasPlsUw==,
}
hasBin: true
peerDependencies:
typescript: ^5.x
parse-json@8.1.0:
resolution:
{
integrity: sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA==,
}
engines: { node: ">=18" }
picocolors@1.1.1:
resolution:
{
integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==,
}
pluralize@8.0.0:
resolution:
{
integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==,
}
engines: { node: ">=4" }
require-from-string@2.0.2:
resolution:
{
integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==,
}
engines: { node: ">=0.10.0" }
supports-color@9.4.0:
resolution:
{
integrity: sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==,
}
engines: { node: ">=12" }
tr46@0.0.3:
resolution:
{
integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==,
}
type-fest@4.31.0:
resolution:
{
integrity: sha512-yCxltHW07Nkhv/1F6wWBr8kz+5BGMfP+RbRSYFnegVb0qV/UMT0G0ElBloPVerqn4M2ZV80Ir1FtCcYv1cT6vQ==,
}
engines: { node: ">=16" }
typescript@5.7.2:
resolution:
{
integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==,
}
engines: { node: ">=14.17" }
hasBin: true
uri-js-replace@1.0.1:
resolution:
{
integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==,
}
webidl-conversions@3.0.1:
resolution:
{
integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==,
}
whatwg-url@5.0.0:
resolution:
{
integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==,
}
yaml-ast-parser@0.0.43:
resolution:
{
integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==,
}
yargs-parser@21.1.1:
resolution:
{
integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==,
}
engines: { node: ">=12" }
snapshots:
"@babel/code-frame@7.26.2":
dependencies:
"@babel/helper-validator-identifier": 7.25.9
js-tokens: 4.0.0
picocolors: 1.1.1
"@babel/helper-validator-identifier@7.25.9": {}
"@redocly/ajv@8.11.2":
dependencies:
fast-deep-equal: 3.1.3
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
uri-js-replace: 1.0.1
"@redocly/config@0.17.1": {}
"@redocly/openapi-core@1.26.1(supports-color@9.4.0)":
dependencies:
"@redocly/ajv": 8.11.2
"@redocly/config": 0.17.1
colorette: 1.4.0
https-proxy-agent: 7.0.6(supports-color@9.4.0)
js-levenshtein: 1.1.6
js-yaml: 4.1.0
minimatch: 5.1.6
node-fetch: 2.7.0
pluralize: 8.0.0
yaml-ast-parser: 0.0.43
transitivePeerDependencies:
- encoding
- supports-color
agent-base@7.1.3: {}
ansi-colors@4.1.3: {}
argparse@2.0.1: {}
balanced-match@1.0.2: {}
brace-expansion@2.0.1:
dependencies:
balanced-match: 1.0.2
change-case@5.4.4: {}
colorette@1.4.0: {}
debug@4.4.0(supports-color@9.4.0):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 9.4.0
fast-deep-equal@3.1.3: {}
https-proxy-agent@7.0.6(supports-color@9.4.0):
dependencies:
agent-base: 7.1.3
debug: 4.4.0(supports-color@9.4.0)
transitivePeerDependencies:
- supports-color
index-to-position@0.1.2: {}
js-levenshtein@1.1.6: {}
js-tokens@4.0.0: {}
js-yaml@4.1.0:
dependencies:
argparse: 2.0.1
json-schema-traverse@1.0.0: {}
minimatch@5.1.6:
dependencies:
brace-expansion: 2.0.1
ms@2.1.3: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
openapi-typescript@7.4.4(typescript@5.7.2):
dependencies:
"@redocly/openapi-core": 1.26.1(supports-color@9.4.0)
ansi-colors: 4.1.3
change-case: 5.4.4
parse-json: 8.1.0
supports-color: 9.4.0
typescript: 5.7.2
yargs-parser: 21.1.1
transitivePeerDependencies:
- encoding
parse-json@8.1.0:
dependencies:
"@babel/code-frame": 7.26.2
index-to-position: 0.1.2
type-fest: 4.31.0
picocolors@1.1.1: {}
pluralize@8.0.0: {}
require-from-string@2.0.2: {}
supports-color@9.4.0: {}
tr46@0.0.3: {}
type-fest@4.31.0: {}
typescript@5.7.2: {}
uri-js-replace@1.0.1: {}
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
yaml-ast-parser@0.0.43: {}
yargs-parser@21.1.1: {}