feat: refactor asset display components and improve styling

This commit is contained in:
zurdi
2025-12-19 12:28:21 +00:00
parent be3615b51a
commit 68d0974009
5 changed files with 237 additions and 356 deletions

View File

@@ -2,15 +2,13 @@
import type { Emitter } from "mitt";
import { storeToRefs } from "pinia";
import { inject, ref } from "vue";
import { useI18n } from "vue-i18n";
import type { SaveSchema } from "@/__generated__";
import EmptySaves from "@/components/common/EmptyStates/EmptySaves.vue";
import AssetCard from "@/components/common/Game/AssetCard.vue";
import storeAuth from "@/stores/auth";
import { type DetailedRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
import { formatBytes, formatTimestamp, formatRelativeDate } from "@/utils";
const { t, locale } = useI18n();
const auth = storeAuth();
const { scopes } = storeToRefs(auth);
const props = defineProps<{ rom: DetailedRom }>();
@@ -112,70 +110,16 @@ function onCardClick(save: SaveSchema, event: MouseEvent) {
:key="save.id"
cols="6"
sm="4"
class="pa-1"
class="pa-1 align-self-end"
>
<v-hover v-slot="{ isHovering, props: saveProps }">
<v-card
v-bind="saveProps"
class="bg-toplayer transform-scale"
:class="{
'border-selected': selectedSaves.some((s) => s.id === save.id),
}"
@click="(e: MouseEvent) => onCardClick(save, e)"
>
<v-card-text class="pa-2">
<v-slide-x-transition>
<v-btn-group
v-if="isHovering"
class="position-absolute"
density="compact"
style="bottom: 4px; right: 4px"
>
<v-btn drawer :href="save.download_path" download size="small">
<v-icon>mdi-download</v-icon>
</v-btn>
<v-btn
v-if="scopes.includes('assets.write')"
drawer
size="small"
@click="
emitter?.emit('showDeleteSavesDialog', {
rom: props.rom,
saves: [save],
})
"
>
<v-icon class="text-romm-red"> mdi-delete </v-icon>
</v-btn>
</v-btn-group>
</v-slide-x-transition>
<v-row class="pa-1 text-caption" no-gutters>
{{ save.file_name }}
</v-row>
<v-row class="ga-1 pa-1" no-gutters>
<v-col v-if="save.emulator" cols="12">
<v-chip size="x-small" color="orange" label>
{{ save.emulator }}
</v-chip>
</v-col>
<v-col cols="12">
<v-chip size="x-small" label>
{{ formatBytes(save.file_size_bytes) }}
</v-chip>
</v-col>
<v-col cols="12">
<div class="mt-1">
{{ t("rom.updated") }}:
{{ formatTimestamp(save.updated_at, locale) }}
<span class="ml-1 text-grey text-caption"
>({{ formatRelativeDate(save.updated_at) }})</span
>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-hover>
<AssetCard
:asset="save"
type="save"
:selected="selectedSaves.some((s) => s.id === save.id)"
:rom="props.rom"
:scopes="scopes"
@click="(e: MouseEvent) => onCardClick(save, e)"
/>
</v-col>
</v-row>
<EmptySaves v-else />

View File

@@ -2,16 +2,13 @@
import type { Emitter } from "mitt";
import { storeToRefs } from "pinia";
import { inject, ref } from "vue";
import { useI18n } from "vue-i18n";
import type { StateSchema } from "@/__generated__";
import EmptySates from "@/components/common/EmptyStates/EmptyStates.vue";
import AssetCard from "@/components/common/Game/AssetCard.vue";
import storeAuth from "@/stores/auth";
import { type DetailedRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
import { formatBytes, formatTimestamp, formatRelativeDate } from "@/utils";
import { getEmptyCoverImage } from "@/utils/covers";
const { t, locale } = useI18n();
const auth = storeAuth();
const { scopes } = storeToRefs(auth);
const props = defineProps<{ rom: DetailedRom }>();
@@ -115,88 +112,16 @@ function onCardClick(state: StateSchema, event: MouseEvent) {
:key="state.id"
cols="6"
sm="4"
class="pa-1"
class="pa-1 align-self-end"
>
<v-hover v-slot="{ isHovering, props: stateProps }">
<v-card
v-bind="stateProps"
class="bg-toplayer transform-scale"
:class="{
'border-selected': selectedStates.some((s) => s.id === state.id),
}"
@click="(e: MouseEvent) => onCardClick(state, e)"
>
<v-card-text class="pa-2">
<v-row no-gutters>
<v-col cols="12">
<v-img
rounded
:src="
state.screenshot?.download_path ??
getEmptyCoverImage(state.file_name, 16 / 9)
"
:aspect-ratio="16 / 9"
>
<v-slide-x-transition>
<v-btn-group
v-if="isHovering"
class="position-absolute"
density="compact"
style="bottom: 4px; right: 4px"
>
<v-btn
drawer
:href="state.download_path"
download
size="small"
>
<v-icon>mdi-download</v-icon>
</v-btn>
<v-btn
v-if="scopes.includes('assets.write')"
drawer
size="small"
@click="
emitter?.emit('showDeleteStatesDialog', {
rom: props.rom,
states: [state],
})
"
>
<v-icon class="text-romm-red"> mdi-delete </v-icon>
</v-btn>
</v-btn-group>
</v-slide-x-transition>
</v-img>
</v-col>
</v-row>
<v-row class="py-2 text-caption" no-gutters>
{{ state.file_name }}
</v-row>
<v-row class="ga-1" no-gutters>
<v-col v-if="state.emulator" cols="12">
<v-chip size="x-small" color="orange" label>
{{ state.emulator }}
</v-chip>
</v-col>
<v-col cols="12">
<v-chip size="x-small" label>
{{ formatBytes(state.file_size_bytes) }}
</v-chip>
</v-col>
<v-col cols="12">
<div class="mt-1">
{{ t("rom.updated") }}:
{{ formatTimestamp(state.updated_at, locale) }}
<span class="ml-1 text-grey text-caption"
>({{ formatRelativeDate(state.updated_at) }})</span
>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-hover>
<AssetCard
:asset="state"
type="state"
:selected="selectedStates.some((s) => s.id === state.id)"
:rom="props.rom"
:scopes="scopes"
@click="(e: MouseEvent) => onCardClick(state, e)"
/>
</v-col>
</v-row>
<EmptySates v-else />

View File

@@ -0,0 +1,175 @@
<script setup lang="ts">
import type { Emitter } from "mitt";
import { inject } from "vue";
import { useI18n } from "vue-i18n";
import type { SaveSchema, StateSchema } from "@/__generated__";
import type { DetailedRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
import { formatBytes, formatTimestamp, formatRelativeDate } from "@/utils";
import { getEmptyCoverImage } from "@/utils/covers";
const { t, locale } = useI18n();
const emitter = inject<Emitter<Events>>("emitter");
export type AssetType = "save" | "state";
const props = withDefaults(
defineProps<{
asset: SaveSchema | StateSchema;
type: AssetType;
selected: boolean;
rom?: DetailedRom;
showHoverActions?: boolean;
showCloseButton?: boolean;
scopes?: string[];
cardStyle?: Record<string, any>;
transformScale?: boolean;
}>(),
{
showHoverActions: true,
showCloseButton: false,
scopes: () => [],
cardStyle: () => ({}),
transformScale: true,
},
);
const emit = defineEmits<{
(e: "click", event: MouseEvent): void;
(e: "close"): void;
}>();
function isState(asset: SaveSchema | StateSchema): asset is StateSchema {
return "screenshot" in asset;
}
function handleClick(event: MouseEvent) {
emit("click", event);
}
function handleClose() {
emit("close");
}
function handleDelete(event: Event) {
event.stopPropagation();
if (props.type === "save") {
emitter?.emit("showDeleteSavesDialog", {
rom: props.rom!,
saves: [props.asset as SaveSchema],
});
} else {
emitter?.emit("showDeleteStatesDialog", {
rom: props.rom!,
states: [props.asset as StateSchema],
});
}
}
</script>
<template>
<v-hover v-slot="{ isHovering, props: hoverProps }">
<v-card
v-bind="hoverProps"
:style="cardStyle"
class="bg-toplayer"
:class="{
'border-selected': selected,
'transform-scale': transformScale,
}"
@click="handleClick"
>
<v-card-text class="pa-2">
<v-slide-x-transition>
<v-btn-group
v-if="isHovering && showHoverActions"
class="position-absolute"
density="compact"
style="bottom: 4px; right: 4px"
>
<v-btn drawer :href="asset.download_path" download size="small">
<v-icon>mdi-download</v-icon>
</v-btn>
<v-btn
v-if="scopes.includes('assets.write')"
drawer
size="small"
@click="handleDelete"
>
<v-icon class="text-romm-red">mdi-delete</v-icon>
</v-btn>
</v-btn-group>
</v-slide-x-transition>
<!-- Screenshot for states -->
<v-row
v-if="type === 'state' && isState(asset)"
no-gutters
class="bg-surface"
>
<v-col cols="12">
<v-img
rounded
:src="
asset.screenshot?.download_path ??
getEmptyCoverImage(asset.file_name, 16 / 9)
"
:aspect-ratio="16 / 9"
/>
</v-col>
</v-row>
<!-- File name -->
<v-row class="text-caption py-2 px-1 text-primary" no-gutters>
{{ asset.file_name }}
</v-row>
<!-- Metadata -->
<v-row class="ga-1 pa-1" no-gutters>
<v-col cols="12">
<v-chip
v-if="asset.emulator"
size="x-small"
color="orange"
class="mr-2"
label
>
{{ asset.emulator }}
</v-chip>
<v-chip size="x-small" label>
{{ formatBytes(asset.file_size_bytes) }}
</v-chip>
</v-col>
<v-col cols="12">
<div class="mt-2">
{{ t("rom.updated") }}:
{{ formatTimestamp(asset.updated_at, locale) }}
</div>
</v-col>
<v-col cols="12">
<div class="mt-1">
<span class="text-grey text-caption"
>({{ formatRelativeDate(asset.updated_at) }})</span
>
</div>
</v-col>
</v-row>
<!-- Close button -->
<v-row v-if="showCloseButton" no-gutters>
<v-col class="text-right mt-auto pt-1">
<v-btn
variant="flat"
color="toplayer"
size="small"
icon
@click.stop="handleClose"
>
<v-icon>mdi-close-circle-outline</v-icon>
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-hover>
</template>

View File

@@ -47,14 +47,14 @@ body {
}
.transform-scale:hover,
.transform-scale:focus {
transform: scale(1.05) !important;
transform: scale(1.07) !important;
}
.pointer {
cursor: pointer !important;
}
.border-selected {
border: 1px solid rgba(var(--v-theme-primary)) !important;
transform: scale(1.1);
transform: scale(1.04);
}
.greyscale {
filter: grayscale(100%);

View File

@@ -8,6 +8,7 @@ import { useDisplay } from "vuetify";
import type { FirmwareSchema, SaveSchema, StateSchema } from "@/__generated__";
import EmptySaves from "@/components/common/EmptyStates/EmptySaves.vue";
import EmptyStates from "@/components/common/EmptyStates/EmptyStates.vue";
import AssetCard from "@/components/common/Game/AssetCard.vue";
import RomListItem from "@/components/common/Game/ListItem.vue";
import { ROUTES } from "@/plugins/router";
import firmwareApi from "@/services/api/firmware";
@@ -287,7 +288,7 @@ onBeforeUnmount(async () => {
'mt-2': smAndDown,
'pr-1': !smAndDown,
}"
:cols="smAndDown ? 12 : 6"
:cols="smAndDown ? 6 : 4"
>
<v-btn
block
@@ -309,75 +310,19 @@ onBeforeUnmount(async () => {
}}
</v-btn>
<v-expand-transition>
<v-card
<AssetCard
v-if="selectedState"
class="bg-toplayer transform-scale selected-card mx-1"
:asset="selectedState"
type="state"
:selected="false"
:show-hover-actions="false"
:show-close-button="true"
:card-style="{}"
class="selected-card mx-1"
:transform-scale="false"
:class="{ 'disabled-card': openSaveSelector }"
>
<v-card-text class="px-2 pb-2 pt-4">
<v-row no-gutters>
<v-col cols="6">
<v-img
rounded
:src="
selectedState.screenshot?.download_path ??
getEmptyCoverImage(
selectedState.file_name,
16 / 9,
)
"
:aspect-ratio="16 / 9"
/>
</v-col>
<v-col class="pl-2 d-flex flex-column" cols="6">
<v-row
class="px-1 text-caption text-primary"
no-gutters
>
{{ selectedState.file_name }}
</v-row>
<v-row no-gutters>
<v-col cols="12">
<v-list-item rounded class="px-1 text-caption">
{{ t("rom.updated") }}:
{{
formatTimestamp(
selectedState.updated_at,
locale,
)
}}
<span class="text-grey text-caption"
>({{
formatRelativeDate(
selectedState.updated_at,
)
}})</span
>
</v-list-item>
</v-col>
<v-col v-if="selectedState.emulator" cols="12">
<v-chip size="x-small" color="orange" label>
{{ selectedState.emulator }}
</v-chip>
</v-col>
</v-row>
<v-row no-gutters>
<v-col class="text-right mt-auto pt-1">
<v-btn
variant="flat"
color="toplayer"
size="small"
icon
@click="unselectState"
>
<v-icon>mdi-close-circle-outline</v-icon>
</v-btn>
</v-col>
</v-row>
</v-col>
</v-row>
</v-card-text>
</v-card>
@close="unselectState"
/>
</v-expand-transition>
</v-col>
</v-expand-transition>
@@ -390,7 +335,7 @@ onBeforeUnmount(async () => {
'mt-2': smAndDown,
'pl-1': !smAndDown,
}"
:cols="smAndDown ? 12 : 6"
:cols="smAndDown ? 6 : 4"
>
<v-btn
block
@@ -405,60 +350,19 @@ onBeforeUnmount(async () => {
}}
</v-btn>
<v-expand-transition>
<v-card
<AssetCard
v-if="selectedSave"
class="bg-toplayer transform-scale selected-card mx-1"
:asset="selectedSave"
type="save"
:selected="false"
:show-hover-actions="false"
:show-close-button="true"
:card-style="{}"
class="selected-card mx-1"
:transform-scale="false"
:class="{ 'disabled-card': openStateSelector }"
>
<v-card-text class="px-2 pb-2 pt-4">
<v-row no-gutters>
<v-col class="pl-2 d-flex flex-column" cols="6">
<v-row
class="px-1 text-caption text-primary"
no-gutters
>
{{ selectedSave.file_name }}
</v-row>
<v-row no-gutters>
<v-col cols="12">
<v-list-item rounded class="px-1 text-caption">
{{ t("rom.updated") }}:
{{
formatTimestamp(
selectedSave.updated_at,
locale,
)
}}
<span class="text-grey text-caption"
>({{
formatRelativeDate(selectedSave.updated_at)
}})</span
>
</v-list-item>
</v-col>
<v-col v-if="selectedSave.emulator" cols="12">
<v-chip size="x-small" color="orange" label>
{{ selectedSave.emulator }}
</v-chip>
</v-col>
</v-row>
<v-row no-gutters>
<v-col class="text-right mt-auto pt-2">
<v-btn
variant="flat"
color="toplayer"
size="small"
icon
@click="unselectSave"
>
<v-icon>mdi-close-circle-outline</v-icon>
</v-btn>
</v-col>
</v-row>
</v-col>
</v-row>
</v-card-text>
</v-card>
@close="unselectSave"
/>
</v-expand-transition>
</v-col>
</v-expand-transition>
@@ -480,57 +384,18 @@ onBeforeUnmount(async () => {
:key="state.id"
cols="6"
sm="4"
class="pa-1"
class="pa-1 align-self-end"
>
<v-card
:style="{
<AssetCard
:asset="state"
type="state"
:selected="selectedState?.id === state.id"
:show-hover-actions="false"
:card-style="{
zIndex: selectedState?.id === state.id ? 11 : undefined,
}"
class="bg-toplayer transform-scale"
:class="{
'border-selected': selectedState?.id === state.id,
}"
@click="selectState(state)"
>
<v-card-text class="pa-2">
<v-row no-gutters>
<v-col cols="12">
<v-img
rounded
:src="
state.screenshot?.download_path ??
getEmptyCoverImage(state.file_name, 16 / 9)
"
:aspect-ratio="16 / 9"
/>
</v-col>
</v-row>
<v-row
class="py-2 px-1 text-caption text-primary"
no-gutters
>
{{ state.file_name }}
</v-row>
<v-row class="ga-1" no-gutters>
<v-col cols="12">
<v-list-item rounded class="pa-1 text-caption">
{{ t("rom.updated") }}:
{{ formatTimestamp(state.updated_at, locale) }}
<span class="ml-1 text-grey text-caption"
>({{
formatRelativeDate(state.updated_at)
}})</span
>
</v-list-item>
</v-col>
<v-col v-if="state.emulator" cols="12" class="mt-1">
<v-chip size="x-small" color="orange" label>
{{ state.emulator }}
</v-chip>
</v-col>
</v-row>
</v-card-text>
</v-card>
/>
</v-col>
</template>
<v-col v-else class="pa-1 mt-1">
@@ -553,43 +418,15 @@ onBeforeUnmount(async () => {
:key="save.id"
cols="6"
sm="4"
class="pa-1"
class="pa-1 align-self-end"
>
<v-card
:style="{
zIndex: selectedSave?.id === save.id ? 11 : undefined,
}"
class="bg-toplayer transform-scale"
:class="{
'border-selected': selectedSave?.id === save.id,
}"
<AssetCard
:asset="save"
type="save"
:selected="selectedSave?.id === save.id"
:show-hover-actions="false"
@click="selectSave(save)"
>
<v-card-text class="pa-2">
<v-row
class="py-2 px-1 text-caption text-primary"
no-gutters
>
{{ save.file_name }}
</v-row>
<v-row class="ga-1" no-gutters>
<v-col cols="12">
<v-list-item rounded class="pa-1 text-caption">
{{ t("rom.updated") }}:
{{ formatTimestamp(save.updated_at, locale) }}
<span class="ml-1 text-grey text-caption"
>({{ formatRelativeDate(save.updated_at) }})</span
>
</v-list-item>
</v-col>
<v-col v-if="save.emulator" cols="12" class="mt-1">
<v-chip size="x-small" color="orange" label>
{{ save.emulator }}
</v-chip>
</v-col>
</v-row>
</v-card-text>
</v-card>
/>
</v-col>
</template>
<v-col v-else class="pa-1 mt-1">