diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 84d7a7a10d..67c006f83c 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -79,7 +79,8 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div v-if="appearNote.files && appearNote.files.length > 0"> - <MkMediaList ref="galleryEl" :mediaList="appearNote.files" :originalEntityUrl="`${url}/notes/${appearNote.id}`"/> + <EmMediaList v-if="inEmbedPage" ref="galleryEl" :mediaList="appearNote.files" :originalEntityUrl="`${url}/notes/${appearNote.id}`"/> + <MkMediaList v-else ref="galleryEl" :mediaList="appearNote.files"/> </div> <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :readOnly="inEmbedPage" :class="$style.poll"/> <div v-if="isEnabledUrlPreview"> @@ -187,6 +188,7 @@ import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue'; import MkMediaList from '@/components/MkMediaList.vue'; +import EmMediaList from '@/embed/components/EmMediaList.vue'; import MkCwButton from '@/components/MkCwButton.vue'; import MkPoll from '@/components/MkPoll.vue'; import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; diff --git a/packages/frontend/src/embed/components/EmMediaAudio.vue b/packages/frontend/src/embed/components/EmMediaAudio.vue deleted file mode 100644 index 4e25554aac..0000000000 --- a/packages/frontend/src/embed/components/EmMediaAudio.vue +++ /dev/null @@ -1,531 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div - ref="playerEl" - v-hotkey="keymap" - tabindex="0" - :class="[ - $style.audioContainer, - (audio.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive, - ]" - @contextmenu.stop - @keydown.stop -> - <button v-if="hide" :class="$style.hidden" @click="show"> - <div :class="$style.hiddenTextWrapper"> - <b v-if="audio.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.audio}${audio.size ? ' ' + bytes(audio.size) : ''})` : '' }}</b> - <b v-else style="display: block;"><i class="ti ti-music"></i> {{ defaultStore.state.dataSaver.media && audio.size ? bytes(audio.size) : i18n.ts.audio }}</b> - <span style="display: block;">{{ i18n.ts.clickToShow }}</span> - </div> - </button> - - <div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.nativeAudioContainer"> - <audio - ref="audioEl" - preload="metadata" - controls - :class="$style.nativeAudio" - @keydown.prevent - > - <source :src="audio.url" :type="audio.type"> - </audio> - </div> - - <div v-else :class="$style.audioControls"> - <audio - ref="audioEl" - preload="metadata" - tabindex="-1" - @keydown.prevent - > - <source :src="audio.url" :type="audio.type"> - </audio> - <div :class="[$style.controlsChild, $style.controlsLeft]"> - <button - :class="['_button', $style.controlButton]" - tabindex="-1" - @click.stop="togglePlayPause" - > - <i v-if="isPlaying" class="ti ti-player-pause-filled"></i> - <i v-else class="ti ti-player-play-filled"></i> - </button> - </div> - <div :class="[$style.controlsChild, $style.controlsRight]"> - <button - :class="['_button', $style.controlButton]" - tabindex="-1" - @click.stop="() => {}" - @mousedown.prevent.stop="showMenu" - > - <i class="ti ti-settings"></i> - </button> - </div> - <div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div> - <div :class="[$style.controlsChild, $style.controlsVolume]"> - <button - :class="['_button', $style.controlButton]" - tabindex="-1" - @click.stop="toggleMute" - > - <i v-if="volume === 0" class="ti ti-volume-3"></i> - <i v-else class="ti ti-volume"></i> - </button> - <MkMediaRange - v-model="volume" - :class="$style.volumeSeekbar" - /> - </div> - <MkMediaRange - v-model="rangePercent" - :class="$style.seekbarRoot" - :buffer="bufferedDataRatio" - /> - </div> -</div> -</template> - -<script lang="ts" setup> -import { shallowRef, watch, computed, ref, inject, onDeactivated, onActivated, onMounted } from 'vue'; -import * as Misskey from 'misskey-js'; -import type { MenuItem } from '@/types/menu.js'; -import { defaultStore } from '@/store.js'; -import { i18n } from '@/i18n.js'; -import * as os from '@/os.js'; -import { type Keymap } from '@/scripts/hotkey.js'; -import bytes from '@/filters/bytes.js'; -import { hms } from '@/filters/hms.js'; -import MkMediaRange from '@/components/MkMediaRange.vue'; -import { $i, iAmModerator } from '@/account.js'; - -const props = defineProps<{ - audio: Misskey.entities.DriveFile; -}>(); - -const inEmbedPage = inject<boolean>('EMBED_PAGE', false); - -const keymap = { - 'up': { - allowRepeat: true, - callback: () => { - if (inEmbedPage) return; - if (hasFocus() && audioEl.value) { - volume.value = Math.min(volume.value + 0.1, 1); - } - }, - }, - 'down': { - allowRepeat: true, - callback: () => { - if (inEmbedPage) return; - if (hasFocus() && audioEl.value) { - volume.value = Math.max(volume.value - 0.1, 0); - } - }, - }, - 'left': { - allowRepeat: true, - callback: () => { - if (inEmbedPage) return; - if (hasFocus() && audioEl.value) { - audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0); - } - }, - }, - 'right': { - allowRepeat: true, - callback: () => { - if (inEmbedPage) return; - if (hasFocus() && audioEl.value) { - audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration); - } - }, - }, - 'space': () => { - if (inEmbedPage) return; - if (hasFocus()) { - togglePlayPause(); - } - }, -} as const satisfies Keymap; - -// PlayerElもしくはその子要素にフォーカスがあるかどうか -function hasFocus() { - if (!playerEl.value) return false; - return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement); -} - -const playerEl = shallowRef<HTMLDivElement>(); -const audioEl = shallowRef<HTMLAudioElement>(); - -// eslint-disable-next-line vue/no-setup-props-reactivity-loss -const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore')); - -async function show() { - if (props.audio.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) { - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.ts.sensitiveMediaRevealConfirm, - }); - if (canceled) return; - } - - hide.value = false; -} - -// Menu -const menuShowing = ref(false); - -function showMenu(ev: MouseEvent) { - let menu: MenuItem[] = []; - - menu = [ - // TODO: 再生キューに追加 - { - type: 'switch', - text: i18n.ts._mediaControls.loop, - icon: 'ti ti-repeat', - ref: loop, - }, - { - type: 'radio', - text: i18n.ts._mediaControls.playbackRate, - icon: 'ti ti-clock-play', - ref: speed, - options: { - '0.25x': 0.25, - '0.5x': 0.5, - '0.75x': 0.75, - '1.0x': 1, - '1.25x': 1.25, - '1.5x': 1.5, - '2.0x': 2, - }, - }, - { - type: 'divider', - }, - { - text: i18n.ts.hide, - icon: 'ti ti-eye-off', - action: () => { - hide.value = true; - }, - }, - ]; - - if (iAmModerator) { - menu.push({ - text: props.audio.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, - icon: props.audio.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation', - danger: true, - action: () => toggleSensitive(props.audio), - }); - } - - if ($i?.id === props.audio.userId) { - menu.push({ - type: 'divider', - }, { - type: 'link' as const, - text: i18n.ts._fileViewer.title, - icon: 'ti ti-info-circle', - to: `/my/drive/file/${props.audio.id}`, - }); - } - - menuShowing.value = true; - os.popupMenu(menu, ev.currentTarget ?? ev.target, { - align: 'right', - onClosing: () => { - menuShowing.value = false; - }, - }); -} - -function toggleSensitive(file: Misskey.entities.DriveFile) { - os.apiWithDialog('drive/files/update', { - fileId: file.id, - isSensitive: !file.isSensitive, - }); -} - -// MediaControl: Common State -const oncePlayed = ref(false); -const isReady = ref(false); -const isPlaying = ref(false); -const isActuallyPlaying = ref(false); -const elapsedTimeMs = ref(0); -const durationMs = ref(0); -const rangePercent = computed({ - get: () => { - return (elapsedTimeMs.value / durationMs.value) || 0; - }, - set: (to) => { - if (!audioEl.value) return; - audioEl.value.currentTime = to * durationMs.value / 1000; - }, -}); -const volume = ref(.25); -const speed = ref(1); -const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える -const bufferedEnd = ref(0); -const bufferedDataRatio = computed(() => { - if (!audioEl.value) return 0; - return bufferedEnd.value / audioEl.value.duration; -}); - -// MediaControl Events -function togglePlayPause() { - if (!isReady.value || !audioEl.value) return; - - if (isPlaying.value) { - audioEl.value.pause(); - isPlaying.value = false; - } else { - audioEl.value.play(); - isPlaying.value = true; - oncePlayed.value = true; - } -} - -function toggleMute() { - if (volume.value === 0) { - volume.value = .25; - } else { - volume.value = 0; - } -} - -let onceInit = false; -let mediaTickFrameId: number | null = null; -let stopAudioElWatch: () => void; - -function init() { - if (onceInit) return; - onceInit = true; - - stopAudioElWatch = watch(audioEl, () => { - if (audioEl.value) { - isReady.value = true; - - function updateMediaTick() { - if (audioEl.value) { - try { - bufferedEnd.value = audioEl.value.buffered.end(0); - } catch (err) { - bufferedEnd.value = 0; - } - - elapsedTimeMs.value = audioEl.value.currentTime * 1000; - - if (audioEl.value.loop !== loop.value) { - loop.value = audioEl.value.loop; - } - } - mediaTickFrameId = window.requestAnimationFrame(updateMediaTick); - } - - updateMediaTick(); - - audioEl.value.addEventListener('play', () => { - isActuallyPlaying.value = true; - }); - - audioEl.value.addEventListener('pause', () => { - isActuallyPlaying.value = false; - isPlaying.value = false; - }); - - audioEl.value.addEventListener('ended', () => { - oncePlayed.value = false; - isActuallyPlaying.value = false; - isPlaying.value = false; - }); - - durationMs.value = audioEl.value.duration * 1000; - audioEl.value.addEventListener('durationchange', () => { - if (audioEl.value) { - durationMs.value = audioEl.value.duration * 1000; - } - }); - - audioEl.value.volume = volume.value; - } - }, { - immediate: true, - }); -} - -watch(volume, (to) => { - if (audioEl.value) audioEl.value.volume = to; -}); - -watch(speed, (to) => { - if (audioEl.value) audioEl.value.playbackRate = to; -}); - -watch(loop, (to) => { - if (audioEl.value) audioEl.value.loop = to; -}); - -onMounted(() => { - init(); -}); - -onActivated(() => { - init(); -}); - -onDeactivated(() => { - isReady.value = false; - isPlaying.value = false; - isActuallyPlaying.value = false; - elapsedTimeMs.value = 0; - durationMs.value = 0; - bufferedEnd.value = 0; - hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore'); - stopAudioElWatch(); - onceInit = false; - if (mediaTickFrameId) { - window.cancelAnimationFrame(mediaTickFrameId); - mediaTickFrameId = null; - } -}); -</script> - -<style lang="scss" module> -.audioContainer { - container-type: inline-size; - position: relative; - border: .5px solid var(--divider); - border-radius: var(--radius); - overflow: clip; - - &:focus-visible { - outline: none; - } -} - -.sensitive { - position: relative; - - &::after { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - pointer-events: none; - border-radius: inherit; - box-shadow: inset 0 0 0 4px var(--warn); - } -} - -.hidden { - width: 100%; - background: #000; - border: none; - outline: none; - font: inherit; - color: inherit; - cursor: pointer; - padding: 12px 0; - display: flex; - align-items: center; - justify-content: center; -} - -.hiddenTextWrapper { - text-align: center; - font-size: 0.8em; - color: #fff; -} - -.audioControls { - display: grid; - grid-template-areas: - "left time . volume right" - "seekbar seekbar seekbar seekbar seekbar"; - grid-template-columns: auto auto 1fr auto auto; - align-items: center; - gap: 4px 8px; - padding: 10px; -} - -.controlsChild { - display: flex; - align-items: center; - gap: 4px; - - .controlButton { - padding: 6px; - border-radius: calc(var(--radius) / 2); - font-size: 1.05rem; - - &:hover { - color: var(--accent); - background-color: var(--accentedBg); - } - - &:focus-visible { - outline: none; - } - } -} - -.controlsLeft { - grid-area: left; -} - -.controlsRight { - grid-area: right; -} - -.controlsTime { - grid-area: time; - font-size: .9rem; -} - -.controlsVolume { - grid-area: volume; - - .volumeSeekbar { - display: none; - } -} - -.seekbarRoot { - grid-area: seekbar; -} - -@container (min-width: 500px) { - .audioControls { - grid-template-areas: "left seekbar time volume right"; - grid-template-columns: auto 1fr auto auto auto; - } - - .controlsVolume { - .volumeSeekbar { - max-width: 90px; - display: block; - flex-grow: 1; - } - } -} - -.nativeAudioContainer { - display: flex; - align-items: center; - padding: 6px; -} - -.nativeAudio { - display: block; - width: 100%; -} -</style> diff --git a/packages/frontend/src/embed/components/EmMediaBanner.vue b/packages/frontend/src/embed/components/EmMediaBanner.vue index 0b87ffdcff..435da238a4 100644 --- a/packages/frontend/src/embed/components/EmMediaBanner.vue +++ b/packages/frontend/src/embed/components/EmMediaBanner.vue @@ -4,70 +4,52 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root"> - <EmMediaAudio v-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :audio="media"/> - <div v-else-if="media.isSensitive && hide" :class="$style.sensitive" @click="show"> - <span style="font-size: 1.6em;"><i class="ti ti-alert-triangle"></i></span> - <b>{{ i18n.ts.sensitive }}</b> - <span>{{ i18n.ts.clickToShow }}</span> +<a :href="href" target="_blank" :class="$style.root"> + <div :class="$style.label"> + <template v-if="media.type.startsWith('audio')"><i class="ti ti-music"></i> {{ i18n.ts.audio }}</template> + <template v-else><i class="ti ti-file"></i> {{ i18n.ts.file }}</template> </div> - <a - v-else :class="$style.download" - :href="media.url" - :title="media.name" - :download="media.name" - > - <span style="font-size: 1.6em;"><i class="ti ti-download"></i></span> - <b>{{ media.name }}</b> - </a> -</div> + <div :class="$style.go"> + <i class="ti ti-chevron-right"></i> + </div> +</a> </template> -<script lang="ts" setup> -import { ref } from 'vue'; +<script setup lang="ts"> import * as Misskey from 'misskey-js'; -import EmMediaAudio from './EmMediaAudio.vue'; import { i18n } from '@/i18n.js'; -const props = defineProps<{ +defineProps<{ media: Misskey.entities.DriveFile; + href: string; }>(); - -const hide = ref(true); - -async function show() { - hide.value = false; -} </script> <style lang="scss" module> .root { - width: 100%; - border-radius: 4px; - margin-top: 4px; - overflow: clip; -} - -.download, -.sensitive { + box-sizing: border-box; display: flex; align-items: center; - font-size: 12px; - padding: 8px 12px; - white-space: nowrap; + width: 100%; + padding: var(--margin); + margin-top: 4px; + border: 1px solid var(--inputBorder); + border-radius: var(--radius); + background-color: var(--panel); + transition: background-color .1s, border-color .1s; + + &:hover { + text-decoration: none; + border-color: var(--inputBorderHover); + background-color: var(--buttonHoverBg); + } } -.download { - background: var(--noteAttachedFile); +.label { + font-size: .9em; } -.sensitive { - background: #111; - color: #fff; -} - -.audio { - border-radius: 8px; - overflow: clip; +.go { + margin-left: auto; } </style> diff --git a/packages/frontend/src/embed/components/EmMediaImage.vue b/packages/frontend/src/embed/components/EmMediaImage.vue index 9e88513998..5d79d4484a 100644 --- a/packages/frontend/src/embed/components/EmMediaImage.vue +++ b/packages/frontend/src/embed/components/EmMediaImage.vue @@ -8,14 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only <a :title="image.name" :class="$style.imageContainer" - :href="image.url" + :href="href ?? image.url" target="_blank" rel="noopener" - style="cursor: zoom-in;" > <ImgWithBlurhash :hash="image.blurhash" - :src="(defaultStore.state.dataSaver.media && hide) ? null : url" + :src="hide ? null : url" :forceBlurhash="hide" :cover="hide || cover" :alt="image.comment || image.name" @@ -26,10 +25,10 @@ SPDX-License-Identifier: AGPL-3.0-only /> </a> <template v-if="hide"> - <div :class="$style.hiddenText"> + <div :class="$style.hiddenText" @click="hide = !hide"> <div :class="$style.hiddenTextWrapper"> - <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b> - <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.dataSaver.media && image.size ? bytes(image.size) : i18n.ts.image }}</b> + <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}</b> + <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ i18n.ts.image }}</b> <span v-if="controls" style="display: block;">{{ i18n.ts.clickToShow }}</span> </div> </div> @@ -39,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="image.comment" :class="$style.indicator">ALT</div> <div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div> </div> - <i class="ti ti-eye-off" :class="$style.hide" @click.stop="hide = true"></i> + <i v-if="!hide" class="ti ti-eye-off" :class="$style.hide" @click.stop="hide = true"></i> </div> </template> @@ -52,6 +51,7 @@ import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ image: Misskey.entities.DriveFile; + href?: string; raw?: boolean; cover?: boolean; }>(), { diff --git a/packages/frontend/src/embed/components/EmMediaList.vue b/packages/frontend/src/embed/components/EmMediaList.vue index 9ea55aee8d..ef20ecd23f 100644 --- a/packages/frontend/src/embed/components/EmMediaList.vue +++ b/packages/frontend/src/embed/components/EmMediaList.vue @@ -6,8 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> <div v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :class="$style.banner"> - <XBanner :media="media" :inert="true"/> - <a v-if="originalEntityUrl" :href="originalEntityUrl" target="_blank" rel="noopener" :class="$style.mediaLinkForEmbed"></a> + <XBanner :media="media" :href="originalEntityUrl"/> </div> <div v-if="mediaList.filter(media => previewable(media)).length > 0" :class="$style.container"> <div @@ -17,9 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-only ]" > <div v-for="media in mediaList.filter(media => previewable(media))" :class="$style.media"> - <XVideo v-if="media.type.startsWith('video')" :key="`video:${media.id}`" :video="media" :class="$style.mediaInner" :inert="true"/> - <XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :class="$style.mediaInner" class="image" :inert="true" :data-id="media.id" :image="media" :raw="raw"/> - <a v-if="originalEntityUrl" :href="originalEntityUrl" target="_blank" rel="noopener" :class="$style.mediaLinkForEmbed"></a> + <XVideo v-if="media.type.startsWith('video')" :key="`video:${media.id}`" :class="$style.mediaInner" :video="media" :href="originalEntityUrl"/> + <XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :class="$style.mediaInner" class="image" :image="media" :raw="raw" :href="originalEntityUrl"/> </div> </div> </div> @@ -43,8 +41,6 @@ const props = defineProps<{ originalEntityUrl?: string; }>(); -const pswpZIndex = os.claimZIndex('middle'); -document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString()); const count = computed(() => props.mediaList.filter(media => previewable(media)).length); let activeEl: HTMLElement | null = null; @@ -150,14 +146,4 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => { .banner { position: relative; } - -.mediaLinkForEmbed::after { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 1; - content: ''; -} </style> diff --git a/packages/frontend/src/embed/components/EmMediaVideo.vue b/packages/frontend/src/embed/components/EmMediaVideo.vue new file mode 100644 index 0000000000..ad0cd08391 --- /dev/null +++ b/packages/frontend/src/embed/components/EmMediaVideo.vue @@ -0,0 +1,65 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<a :href="href" target="_blank" :class="$style.root"> + <img v-if="!video.isSensitive && video.thumbnailUrl" :class="$style.thumbnail" :src="video.thumbnailUrl"> + <div :class="$style.videoOverlayPlayButton"><i class="ti ti-player-play-filled"></i></div> +</a> +</template> + +<script setup lang="ts"> +import * as Misskey from 'misskey-js'; +import { i18n } from '@/i18n.js'; + +defineProps<{ + video: Misskey.entities.DriveFile; + href: string; +}>(); +</script> + +<style lang="scss" module> +.root { + position: relative; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: auto; + aspect-ratio: 16 / 9; + padding: var(--margin); + border: 1px solid var(--divider); + border-radius: var(--radius); + background-color: #000; + + &:hover { + text-decoration: none; + } +} + +.thumbnail { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.videoOverlayPlayButton { + background: var(--accent); + color: #fff; + padding: 1rem; + border-radius: 99rem; + + font-size: 1rem; + line-height: 1rem; + + &:focus-visible { + outline: none; + } +} +</style>