mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
ENH - adopt Music dropdown and playlist artwork
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
@@ -3,43 +3,106 @@ import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(new URL('./MusicApp.vue', import.meta.url), 'utf8')
|
||||
const menuSource = source.slice(
|
||||
source.indexOf('<SkyPopover'),
|
||||
source.indexOf('</SkyPopover>') + '</SkyPopover>'.length,
|
||||
const playlistPlaceholder = readFileSync(
|
||||
new URL('../../assets/img/music/playlist-placeholder.jpg', import.meta.url),
|
||||
)
|
||||
const menuSource = source.match(/<SkyDropdown[\s\S]*?\/>/)?.[0] ?? ''
|
||||
const menuItemsSource = source.slice(
|
||||
source.indexOf('const menuItems = computed'),
|
||||
source.indexOf('function eventValue'),
|
||||
)
|
||||
const menuDispatcherSource = source.slice(
|
||||
source.indexOf('function selectMenuItem'),
|
||||
source.indexOf('function shareTrack'),
|
||||
)
|
||||
|
||||
describe('MusicApp action menu contract', () => {
|
||||
it('uses the shared anchored Sky popover and its close paths', () => {
|
||||
expect(source).toContain('SkyPopover,')
|
||||
it('uses the shared anchored Sky dropdown and its close paths', () => {
|
||||
expect(source).toContain('SkyDropdown,')
|
||||
expect(source).toContain('const menuTarget = ref<HTMLElement | null>(null)')
|
||||
expect(source.split('menuTarget.value = event.currentTarget')).toHaveLength(
|
||||
3,
|
||||
)
|
||||
expect(menuSource).toContain(
|
||||
':opened="addMenuOpened || actionMenuOpened"',
|
||||
)
|
||||
expect(menuSource).toContain(':items="menuItems"')
|
||||
expect(menuSource).toContain(':label="menuLabel"')
|
||||
expect(menuSource).toContain(':opened="addMenuOpened || actionMenuOpened"')
|
||||
expect(menuSource).toContain(':target="menuTarget"')
|
||||
expect(menuSource).toContain('@backdropclick="dismissMenus"')
|
||||
expect(menuSource).toContain('@escape="dismissMenus"')
|
||||
expect(menuSource).toContain('@positionerror="dismissMenus"')
|
||||
expect(menuSource).toContain('@select="selectMenuItem"')
|
||||
})
|
||||
|
||||
it('keeps every add, playlist, and track action in the central menu', () => {
|
||||
expect(menuSource).toContain('@click="shareActivePlaylist"')
|
||||
expect(menuSource).toContain('@click="openActivePlaylistTrackPicker"')
|
||||
expect(menuSource).toContain('openSheet')
|
||||
expect(menuSource).toContain('rename')
|
||||
expect(menuSource).toContain('@click="requestDeletePlaylist"')
|
||||
expect(menuSource).toContain('youtube')
|
||||
expect(menuSource).toContain('@click="openNewPlaylist()"')
|
||||
expect(menuSource).toContain('@click="shareTrack()"')
|
||||
expect(menuSource).toContain('@click="openPlaylistPicker(actionTrack)"')
|
||||
expect(menuSource).toContain('@click="removeFromActivePlaylist"')
|
||||
expect(menuSource).toContain('@click="requestRemoveTrack"')
|
||||
expect(menuSource.split('variant="danger"')).toHaveLength(4)
|
||||
it('exposes accessible expanded state on every plus and more trigger', () => {
|
||||
expect(source.match(/aria-haspopup="menu"/g)).toHaveLength(5)
|
||||
expect(source.split(':aria-expanded="addMenuOpened"')).toHaveLength(3)
|
||||
expect(source.split('actionTrack?.id === track.id')).toHaveLength(4)
|
||||
expect(source.split('actionTrack?.source === track.source')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('does not retain the manual popover geometry or dismiss overlay', () => {
|
||||
it('keeps every add, playlist, and track action in the central dropdown', () => {
|
||||
for (const id of [
|
||||
'share-playlist',
|
||||
'add-songs',
|
||||
'rename-playlist',
|
||||
'delete-playlist',
|
||||
'add-youtube',
|
||||
'new-playlist',
|
||||
'share-track',
|
||||
'add-to-playlist',
|
||||
'remove-from-playlist',
|
||||
'remove-from-library',
|
||||
]) {
|
||||
expect(menuItemsSource).toContain(`id: '${id}'`)
|
||||
expect(menuDispatcherSource).toContain(`case '${id}'`)
|
||||
}
|
||||
|
||||
for (const action of [
|
||||
'shareActivePlaylist()',
|
||||
'openActivePlaylistTrackPicker()',
|
||||
"openSheet('rename')",
|
||||
'requestDeletePlaylist()',
|
||||
"openSheet('youtube')",
|
||||
'openNewPlaylist()',
|
||||
'shareTrack()',
|
||||
'openPlaylistPicker(actionTrack.value)',
|
||||
'void removeFromActivePlaylist()',
|
||||
'requestRemoveTrack()',
|
||||
]) {
|
||||
expect(menuDispatcherSource).toContain(action)
|
||||
}
|
||||
|
||||
expect(menuItemsSource.match(/destructive: true/g)).toHaveLength(3)
|
||||
expect(menuItemsSource.match(/separatorBefore:/g)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('uses the generated playlist artwork when no usable preview exists', () => {
|
||||
expect(source).toContain(
|
||||
"import playlistPlaceholder from '@/assets/img/music/playlist-placeholder.jpg'",
|
||||
)
|
||||
expect(source).toContain("phone.t('Apps.music.playlistActions')")
|
||||
expect(source).not.toContain('playlist-placeholder.png')
|
||||
expect([...playlistPlaceholder.subarray(0, 3)]).toEqual([0xff, 0xd8, 0xff])
|
||||
expect(playlistPlaceholder.byteLength).toBeGreaterThan(80_000)
|
||||
expect(playlistPlaceholder.byteLength).toBeLessThan(150_000)
|
||||
expect(source).toContain('track.artwork.trim().length > 0')
|
||||
expect(source).toContain('.filter(hasPlaylistArtwork)')
|
||||
expect(source.match(/class="music-playlist-placeholder"/g)).toHaveLength(2)
|
||||
expect(source.match(/@error="usePlaylistPlaceholder"/g)).toHaveLength(2)
|
||||
expect(source).toContain('image.src = playlistPlaceholder')
|
||||
expect(source).toContain(
|
||||
'imageUrl: playlistArtwork(playlist)[0]?.artwork || playlistPlaceholder',
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.music-playlist-placeholder\s*\{[^}]*grid-column:\s*1 \/ -1;[^}]*grid-row:\s*1 \/ -1;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('does not retain the generic or manually positioned popover menu', () => {
|
||||
expect(source).not.toContain('SkyPopover,')
|
||||
expect(source).not.toContain('<SkyPopover')
|
||||
expect(source).not.toContain('SkyListButton,')
|
||||
expect(source).not.toContain('<sky-list-button')
|
||||
expect(source).not.toContain('positionPopover')
|
||||
expect(source).not.toContain('popoverStyle')
|
||||
expect(source).not.toContain('MUSIC_POPOVER_')
|
||||
|
||||
@@ -5,17 +5,16 @@ import {
|
||||
SkyButton,
|
||||
SkyDialog,
|
||||
SkyDialogButton,
|
||||
SkyDropdown,
|
||||
SkyGlass,
|
||||
SkyLink,
|
||||
SkyList,
|
||||
SkyListButton,
|
||||
SkyField,
|
||||
SkyListItem,
|
||||
SkyNavbar,
|
||||
SkyNavbarBackLink,
|
||||
SkyAppPage,
|
||||
SkyPillNavigation,
|
||||
SkyPopover,
|
||||
SkySegmented,
|
||||
SkySegmentedButton,
|
||||
SkySpinner,
|
||||
@@ -39,7 +38,6 @@ import {
|
||||
Share2,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Trash2,
|
||||
Volume1,
|
||||
Volume2,
|
||||
X,
|
||||
@@ -47,6 +45,7 @@ import {
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import playlistPlaceholder from '@/assets/img/music/playlist-placeholder.jpg'
|
||||
import { useMusicStore } from '@/stores/music'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
@@ -62,6 +61,24 @@ type MusicSheet =
|
||||
| 'rename'
|
||||
| 'track-picker'
|
||||
| 'youtube'
|
||||
type MusicMenuAction =
|
||||
| 'add-songs'
|
||||
| 'add-to-playlist'
|
||||
| 'add-youtube'
|
||||
| 'delete-playlist'
|
||||
| 'new-playlist'
|
||||
| 'remove-from-library'
|
||||
| 'remove-from-playlist'
|
||||
| 'rename-playlist'
|
||||
| 'share-playlist'
|
||||
| 'share-track'
|
||||
type MusicMenuItem = {
|
||||
destructive?: boolean
|
||||
id: MusicMenuAction
|
||||
label: string
|
||||
separatorBefore?: boolean
|
||||
}
|
||||
type PlaylistArtworkTrack = MusicTrack & { artwork: string }
|
||||
|
||||
const MUSIC_SHEET_TRANSITION_MS = 420
|
||||
|
||||
@@ -131,6 +148,69 @@ const pageTitle = computed(() => {
|
||||
if (activePlaylist.value) return activePlaylist.value.name
|
||||
return phone.t(`Apps.music.tabs.${activeTab.value}`)
|
||||
})
|
||||
const menuLabel = computed(() => {
|
||||
if (addMenuOpened.value && activePlaylist.value) {
|
||||
return phone.t('Apps.music.playlistActions')
|
||||
}
|
||||
return phone.t(
|
||||
addMenuOpened.value ? 'Apps.music.addMusic' : 'Apps.music.songActions',
|
||||
)
|
||||
})
|
||||
const menuItems = computed<MusicMenuItem[]>(() => {
|
||||
if (addMenuOpened.value) {
|
||||
if (activePlaylist.value) {
|
||||
return [
|
||||
{
|
||||
id: 'share-playlist',
|
||||
label: phone.t('Apps.easyShare.name'),
|
||||
},
|
||||
{ id: 'add-songs', label: phone.t('Apps.music.addSongs') },
|
||||
{
|
||||
id: 'rename-playlist',
|
||||
label: phone.t('Apps.music.renamePlaylist'),
|
||||
},
|
||||
{
|
||||
destructive: true,
|
||||
id: 'delete-playlist',
|
||||
label: phone.t('Apps.music.deletePlaylist'),
|
||||
separatorBefore: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{ id: 'add-youtube', label: phone.t('Apps.music.addYouTube') },
|
||||
{ id: 'new-playlist', label: phone.t('Apps.music.newPlaylist') },
|
||||
]
|
||||
}
|
||||
|
||||
if (!actionMenuOpened.value) return []
|
||||
|
||||
const items: MusicMenuItem[] = [
|
||||
{ id: 'share-track', label: phone.t('Apps.easyShare.name') },
|
||||
{
|
||||
id: 'add-to-playlist',
|
||||
label: phone.t('Apps.music.addToPlaylist'),
|
||||
},
|
||||
]
|
||||
if (activePlaylist.value) {
|
||||
items.push({
|
||||
destructive: true,
|
||||
id: 'remove-from-playlist',
|
||||
label: phone.t('Apps.music.removeFromPlaylist'),
|
||||
separatorBefore: true,
|
||||
})
|
||||
}
|
||||
if (actionTrack.value?.source === 'youtube') {
|
||||
items.push({
|
||||
destructive: true,
|
||||
id: 'remove-from-library',
|
||||
label: phone.t('Apps.music.removeFromLibrary'),
|
||||
separatorBefore: !activePlaylist.value,
|
||||
})
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
function eventValue(event: Event): string {
|
||||
return (event.target as HTMLInputElement | null)?.value ?? ''
|
||||
@@ -237,6 +317,41 @@ function openTrackMenu(event: MouseEvent, track: MusicTrack): void {
|
||||
actionMenuOpened.value = true
|
||||
}
|
||||
|
||||
function selectMenuItem(id: string): void {
|
||||
switch (id as MusicMenuAction) {
|
||||
case 'share-playlist':
|
||||
shareActivePlaylist()
|
||||
break
|
||||
case 'add-songs':
|
||||
openActivePlaylistTrackPicker()
|
||||
break
|
||||
case 'rename-playlist':
|
||||
openSheet('rename')
|
||||
break
|
||||
case 'delete-playlist':
|
||||
requestDeletePlaylist()
|
||||
break
|
||||
case 'add-youtube':
|
||||
openSheet('youtube')
|
||||
break
|
||||
case 'new-playlist':
|
||||
openNewPlaylist()
|
||||
break
|
||||
case 'share-track':
|
||||
shareTrack()
|
||||
break
|
||||
case 'add-to-playlist':
|
||||
openPlaylistPicker(actionTrack.value)
|
||||
break
|
||||
case 'remove-from-playlist':
|
||||
void removeFromActivePlaylist()
|
||||
break
|
||||
case 'remove-from-library':
|
||||
requestRemoveTrack()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function shareTrack(
|
||||
selectedTrack: MusicTrack | null = actionTrack.value,
|
||||
): void {
|
||||
@@ -265,6 +380,7 @@ function shareActivePlaylist(): void {
|
||||
appId: 'music',
|
||||
copyText: `${playlist.name} · ${playlist.entries.length}`,
|
||||
id: playlist.id,
|
||||
imageUrl: playlistArtwork(playlist)[0]?.artwork || playlistPlaceholder,
|
||||
kind: 'playlist',
|
||||
link: `skyphone://music/playlist/${playlist.id}`,
|
||||
subtitle: phone.t('Apps.music.songCount', {
|
||||
@@ -416,8 +532,15 @@ async function deleteActivePlaylist(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function playlistArtwork(playlist: MusicPlaylist): MusicTrack[] {
|
||||
return music.tracksForPlaylist(playlist).slice(0, 4)
|
||||
function hasPlaylistArtwork(track: MusicTrack): track is PlaylistArtworkTrack {
|
||||
return typeof track.artwork === 'string' && track.artwork.trim().length > 0
|
||||
}
|
||||
|
||||
function playlistArtwork(playlist: MusicPlaylist): PlaylistArtworkTrack[] {
|
||||
return music
|
||||
.tracksForPlaylist(playlist)
|
||||
.filter(hasPlaylistArtwork)
|
||||
.slice(0, 4)
|
||||
}
|
||||
|
||||
function playlistHasTrack(playlist: MusicPlaylist, track: MusicTrack): boolean {
|
||||
@@ -449,6 +572,16 @@ function hideBrokenArtwork(event: Event): void {
|
||||
image.style.display = 'none'
|
||||
}
|
||||
|
||||
function usePlaylistPlaceholder(event: Event): void {
|
||||
const image = event.currentTarget as HTMLImageElement
|
||||
if (image.dataset.playlistFallback === 'true') {
|
||||
image.style.display = 'none'
|
||||
return
|
||||
}
|
||||
image.dataset.playlistFallback = 'true'
|
||||
image.src = playlistPlaceholder
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return '0:00'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
@@ -554,6 +687,8 @@ onBeforeUnmount(() => {
|
||||
component="button"
|
||||
icon-only
|
||||
:aria-label="phone.t('Apps.music.playlistActions')"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="addMenuOpened"
|
||||
@click="openAddMenu"
|
||||
>
|
||||
<Ellipsis :size="22" />
|
||||
@@ -563,6 +698,8 @@ onBeforeUnmount(() => {
|
||||
component="button"
|
||||
icon-only
|
||||
:aria-label="phone.t('Apps.music.addMusic')"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="addMenuOpened"
|
||||
@click="openAddMenu"
|
||||
>
|
||||
<Plus :size="24" />
|
||||
@@ -589,14 +726,15 @@ onBeforeUnmount(() => {
|
||||
:style="fallbackArtwork(track)"
|
||||
>
|
||||
<img
|
||||
v-if="track.artwork"
|
||||
:src="track.artwork"
|
||||
alt=""
|
||||
@error="hideBrokenArtwork"
|
||||
@error="usePlaylistPlaceholder"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<Music2 v-else :size="68" />
|
||||
<span v-else class="music-playlist-placeholder">
|
||||
<img :src="playlistPlaceholder" alt="" />
|
||||
</span>
|
||||
</div>
|
||||
<h1>{{ activePlaylist.name }}</h1>
|
||||
<p>
|
||||
@@ -657,6 +795,12 @@ onBeforeUnmount(() => {
|
||||
component="button"
|
||||
icon-only
|
||||
:aria-label="phone.t('Apps.music.songActions')"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="
|
||||
actionMenuOpened &&
|
||||
actionTrack?.id === track.id &&
|
||||
actionTrack?.source === track.source
|
||||
"
|
||||
@click="openTrackMenu($event, track)"
|
||||
>
|
||||
<Ellipsis :size="20" />
|
||||
@@ -761,6 +905,12 @@ onBeforeUnmount(() => {
|
||||
component="button"
|
||||
icon-only
|
||||
:aria-label="phone.t('Apps.music.songActions')"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="
|
||||
actionMenuOpened &&
|
||||
actionTrack?.id === track.id &&
|
||||
actionTrack?.source === track.source
|
||||
"
|
||||
@click="openTrackMenu($event, track)"
|
||||
>
|
||||
<Ellipsis :size="20" />
|
||||
@@ -800,14 +950,15 @@ onBeforeUnmount(() => {
|
||||
:style="fallbackArtwork(track)"
|
||||
>
|
||||
<img
|
||||
v-if="track.artwork"
|
||||
:src="track.artwork"
|
||||
alt=""
|
||||
@error="hideBrokenArtwork"
|
||||
@error="usePlaylistPlaceholder"
|
||||
/>
|
||||
</i>
|
||||
</template>
|
||||
<ListMusic v-else :size="44" />
|
||||
<span v-else class="music-playlist-placeholder">
|
||||
<img :src="playlistPlaceholder" alt="" />
|
||||
</span>
|
||||
</span>
|
||||
<strong>{{ playlist.name }}</strong>
|
||||
<small>
|
||||
@@ -867,6 +1018,12 @@ onBeforeUnmount(() => {
|
||||
component="button"
|
||||
icon-only
|
||||
:aria-label="phone.t('Apps.music.songActions')"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="
|
||||
actionMenuOpened &&
|
||||
actionTrack?.id === track.id &&
|
||||
actionTrack?.source === track.source
|
||||
"
|
||||
@click="openTrackMenu($event, track)"
|
||||
>
|
||||
<Ellipsis :size="20" />
|
||||
@@ -956,82 +1113,16 @@ onBeforeUnmount(() => {
|
||||
</SkySegmented>
|
||||
</SkyPillNavigation>
|
||||
|
||||
<SkyPopover
|
||||
:aria-label="
|
||||
phone.t(
|
||||
addMenuOpened ? 'Apps.music.addMusic' : 'Apps.music.songActions',
|
||||
)
|
||||
"
|
||||
<SkyDropdown
|
||||
:items="menuItems"
|
||||
:label="menuLabel"
|
||||
:opened="addMenuOpened || actionMenuOpened"
|
||||
role="dialog"
|
||||
:target="menuTarget"
|
||||
@backdropclick="dismissMenus"
|
||||
@escape="dismissMenus"
|
||||
@positionerror="dismissMenus"
|
||||
>
|
||||
<sky-list component="div" nested>
|
||||
<template v-if="addMenuOpened && activePlaylist">
|
||||
<sky-list-button link-component="button" @click="shareActivePlaylist">
|
||||
<Share2 :size="18" /> {{ phone.t('Apps.easyShare.name') }}
|
||||
</sky-list-button>
|
||||
<sky-list-button
|
||||
link-component="button"
|
||||
@click="openActivePlaylistTrackPicker"
|
||||
>
|
||||
<CirclePlus :size="18" /> {{ phone.t('Apps.music.addSongs') }}
|
||||
</sky-list-button>
|
||||
<sky-list-button link-component="button" @click="openSheet('rename')">
|
||||
<ListMusic :size="18" />
|
||||
{{ phone.t('Apps.music.renamePlaylist') }}
|
||||
</sky-list-button>
|
||||
<sky-list-button
|
||||
link-component="button"
|
||||
variant="danger"
|
||||
@click="requestDeletePlaylist"
|
||||
>
|
||||
<Trash2 :size="18" /> {{ phone.t('Apps.music.deletePlaylist') }}
|
||||
</sky-list-button>
|
||||
</template>
|
||||
<template v-else-if="addMenuOpened">
|
||||
<sky-list-button
|
||||
link-component="button"
|
||||
@click="openSheet('youtube')"
|
||||
>
|
||||
<ExternalLink :size="18" /> {{ phone.t('Apps.music.addYouTube') }}
|
||||
</sky-list-button>
|
||||
<sky-list-button link-component="button" @click="openNewPlaylist()">
|
||||
<ListMusic :size="18" /> {{ phone.t('Apps.music.newPlaylist') }}
|
||||
</sky-list-button>
|
||||
</template>
|
||||
<template v-else-if="actionMenuOpened">
|
||||
<sky-list-button link-component="button" @click="shareTrack()">
|
||||
<Share2 :size="18" /> {{ phone.t('Apps.easyShare.name') }}
|
||||
</sky-list-button>
|
||||
<sky-list-button
|
||||
link-component="button"
|
||||
@click="openPlaylistPicker(actionTrack)"
|
||||
>
|
||||
<CirclePlus :size="18" /> {{ phone.t('Apps.music.addToPlaylist') }}
|
||||
</sky-list-button>
|
||||
<sky-list-button
|
||||
v-if="activePlaylist"
|
||||
link-component="button"
|
||||
variant="danger"
|
||||
@click="removeFromActivePlaylist"
|
||||
>
|
||||
<X :size="18" /> {{ phone.t('Apps.music.removeFromPlaylist') }}
|
||||
</sky-list-button>
|
||||
<sky-list-button
|
||||
v-if="actionTrack?.source === 'youtube'"
|
||||
link-component="button"
|
||||
variant="danger"
|
||||
@click="requestRemoveTrack"
|
||||
>
|
||||
<Trash2 :size="18" /> {{ phone.t('Apps.music.removeFromLibrary') }}
|
||||
</sky-list-button>
|
||||
</template>
|
||||
</sky-list>
|
||||
</SkyPopover>
|
||||
@select="selectMenuItem"
|
||||
/>
|
||||
|
||||
<div class="music-form-sheet">
|
||||
<sky-sheet :opened="Boolean(activeSheet)" @backdropclick="closeSheet">
|
||||
@@ -1729,6 +1820,17 @@ onBeforeUnmount(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.music-playlist-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1 / -1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.music-playlist-tile > svg,
|
||||
.music-playlist-art > svg {
|
||||
grid-column: 1 / -1;
|
||||
|
||||
Reference in New Issue
Block a user