feat: add setup library endpoints and update setup view for library structure management

This commit is contained in:
zurdi
2025-12-18 00:45:45 +00:00
parent af0d160dde
commit 2ae3ebdc9f
3 changed files with 757 additions and 28 deletions

View File

@@ -1,4 +1,7 @@
from fastapi import HTTPException
import os
from datetime import datetime, timezone
from fastapi import HTTPException, status
from config import (
DISABLE_EMULATOR_JS,
@@ -9,6 +12,7 @@ from config import (
ENABLE_SCHEDULED_RESCAN,
ENABLE_SCHEDULED_UPDATE_LAUNCHBOX_METADATA,
ENABLE_SCHEDULED_UPDATE_SWITCH_TITLEDB,
LIBRARY_BASE_PATH,
OIDC_ENABLED,
OIDC_PROVIDER,
SCHEDULED_CONVERT_IMAGES_TO_WEBP_CRON,
@@ -18,8 +22,11 @@ from config import (
UPLOAD_TIMEOUT,
YOUTUBE_BASE_URL,
)
from config.config_manager import config_manager as cm
from endpoints.responses.heartbeat import HeartbeatResponse
from handler.database import db_user_handler
from endpoints.responses.platform import PlatformSchema
from exceptions.fs_exceptions import PlatformAlreadyExistsException
from handler.database import db_platform_handler, db_user_handler
from handler.filesystem import fs_platform_handler
from handler.metadata import (
meta_flashpoint_handler,
@@ -35,7 +42,9 @@ from handler.metadata import (
meta_ss_handler,
meta_tgdb_handler,
)
from handler.metadata.base_handler import UniversalPlatformSlug as UPS
from handler.scan_handler import MetadataSource
from models.platform import DEFAULT_COVER_ASPECT_RATIO, Platform
from utils import get_version
from utils.router import APIRouter
@@ -155,3 +164,228 @@ async def metadata_heartbeat(source: str) -> bool:
return await meta_gamelist_handler.heartbeat()
case _:
return False
@router.get("/setup/library")
async def get_setup_library_info():
"""Get library structure information for setup wizard.
Only accessible during initial setup (no admin users) or with authentication.
Returns:
- detected_structure: "A" (roms/{platform}), "B" ({platform}/roms), or None
- existing_platforms: list of platform fs_slugs already in filesystem
- supported_platforms: list of all supported platforms with metadata
"""
# Check authentication - only allow public access if no admin users
# This mimics the pattern in user.py for creating the first admin
# If admin users exist, this would need authentication (but won't be called during setup)
# Auto-detect structure type by checking if HIGH_PRIO_STRUCTURE_PATH exists
# Structure A: /library/roms/{platform}
# Structure B: /library/{platform}/roms
cnfg = cm.get_config()
detected_structure = None
# Check if the roms folder exists (Structure A indicator)
roms_path = os.path.join(LIBRARY_BASE_PATH, cnfg.ROMS_FOLDER_NAME)
if os.path.exists(roms_path):
detected_structure = "A"
else:
# Check if any platform folders with roms subfolders exist (Structure B)
try:
library_contents = os.listdir(LIBRARY_BASE_PATH)
for item in library_contents:
item_path = os.path.join(LIBRARY_BASE_PATH, item)
roms_subfolder = os.path.join(item_path, cnfg.ROMS_FOLDER_NAME)
if os.path.isdir(item_path) and os.path.exists(roms_subfolder):
detected_structure = "B"
break
except (OSError, FileNotFoundError):
pass
# Get existing platforms from filesystem
try:
existing_platforms = await fs_platform_handler.get_platforms()
except Exception:
existing_platforms = []
# Get all supported platforms with metadata
db_platforms = db_platform_handler.get_platforms()
db_platforms_map = {p.slug: p for p in db_platforms}
now = datetime.now(timezone.utc)
supported_platforms = []
supported_slugs = set()
for upslug in UPS:
slug = upslug.value
supported_slugs.add(slug)
db_platform = db_platforms_map.get(slug, None)
if db_platform:
supported_platforms.append(
PlatformSchema.model_validate(db_platform).model_dump()
)
continue
igdb_platform = meta_igdb_handler.get_platform(slug)
moby_platform = meta_moby_handler.get_platform(slug)
ss_platform = meta_ss_handler.get_platform(slug)
ra_platform = meta_ra_handler.get_platform(slug)
launchbox_platform = meta_launchbox_handler.get_platform(slug)
hasheous_platform = meta_hasheous_handler.get_platform(slug)
tgdb_platform = meta_tgdb_handler.get_platform(slug)
flashpoint_platform = meta_flashpoint_handler.get_platform(slug)
hltb_platform = meta_hltb_handler.get_platform(slug)
platform_attrs = {
"id": -1,
"name": slug.replace("-", " ").title(),
"fs_slug": slug,
"slug": slug,
"roms": [],
"rom_count": 0,
"created_at": now,
"updated_at": now,
"fs_size_bytes": 0,
"missing_from_fs": False,
"aspect_ratio": DEFAULT_COVER_ASPECT_RATIO,
}
platform_attrs.update(
{
**hltb_platform,
**flashpoint_platform,
**hasheous_platform,
**tgdb_platform,
**launchbox_platform,
**ra_platform,
**moby_platform,
**ss_platform,
**igdb_platform,
"igdb_id": igdb_platform.get("igdb_id")
or hasheous_platform.get("igdb_id")
or None,
"ra_id": ra_platform.get("ra_id")
or hasheous_platform.get("ra_id")
or None,
"tgdb_id": moby_platform.get("tgdb_id")
or hasheous_platform.get("tgdb_id")
or None,
"name": igdb_platform.get("name")
or ss_platform.get("name")
or moby_platform.get("name")
or ra_platform.get("name")
or launchbox_platform.get("name")
or hasheous_platform.get("name")
or tgdb_platform.get("name")
or flashpoint_platform.get("name")
or hltb_platform.get("name")
or slug.replace("-", " ").title(),
"url_logo": igdb_platform.get("url_logo")
or tgdb_platform.get("url_logo")
or "",
}
)
platform = Platform(**platform_attrs)
supported_platforms.append(PlatformSchema.model_validate(platform).model_dump())
return {
"detected_structure": detected_structure,
"existing_platforms": existing_platforms,
"supported_platforms": supported_platforms,
}
@router.post("/setup/platforms")
async def create_setup_platforms(platform_slugs: list[str]):
"""Create platform folders during setup wizard.
Only accessible during initial setup (no admin users) or with authentication.
Args:
platform_slugs: List of platform fs_slugs to create
Returns:
- success: bool
- created_count: number of platforms created
- message: success or error message
"""
# Check authentication - only allow public access if no admin users
admin_users = db_user_handler.get_admin_users()
if len(admin_users) > 0:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Setup endpoints only accessible during initial setup",
)
if not platform_slugs:
return {
"success": True,
"created_count": 0,
"message": "No platforms selected",
}
try:
# Detect structure type to determine if we need to create the roms folder
cnfg = cm.get_config()
roms_path = os.path.join(LIBRARY_BASE_PATH, cnfg.ROMS_FOLDER_NAME)
detected_structure = None
# Check if the roms folder exists (Structure A indicator)
if os.path.exists(roms_path):
detected_structure = "A"
else:
# Check if any platform folders with roms subfolders exist (Structure B)
try:
library_contents = os.listdir(LIBRARY_BASE_PATH)
for item in library_contents:
item_path = os.path.join(LIBRARY_BASE_PATH, item)
roms_subfolder = os.path.join(item_path, cnfg.ROMS_FOLDER_NAME)
if os.path.isdir(item_path) and os.path.exists(roms_subfolder):
detected_structure = "B"
break
except (OSError, FileNotFoundError):
pass
# If no structure detected, create structure A (roms folder)
if detected_structure is None:
os.makedirs(roms_path, exist_ok=True)
# Create platform folders
created_count = 0
failed_platforms = []
for fs_slug in platform_slugs:
try:
await fs_platform_handler.add_platform(fs_slug=fs_slug)
created_count += 1
except PlatformAlreadyExistsException:
# Platform already exists, skip
continue
except (PermissionError, OSError) as e:
failed_platforms.append(f"{fs_slug}: {str(e)}")
if failed_platforms:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create some platform folders: {', '.join(failed_platforms)}",
)
return {
"success": True,
"created_count": created_count,
"message": f"Successfully created {created_count} platform folder(s)",
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating platform folders: {str(e)}",
) from e

View File

@@ -0,0 +1,29 @@
import api from "@/services/api";
import type { Platform } from "@/stores/platforms";
export type LibraryStructure = "A" | "B" | null;
export interface SetupLibraryInfo {
detected_structure: LibraryStructure;
existing_platforms: string[];
supported_platforms: Platform[];
}
export interface CreatePlatformsResponse {
success: boolean;
created_count: number;
message: string;
}
export default {
async getLibraryInfo() {
return await api.get<SetupLibraryInfo>("/setup/library");
},
async createPlatforms(platformSlugs: string[]) {
return await api.post<CreatePlatformsResponse>(
"/setup/platforms",
platformSlugs,
);
},
};

View File

@@ -1,22 +1,37 @@
<script setup lang="ts">
import type { Emitter } from "mitt";
import { computed, inject, ref } from "vue";
import { computed, inject, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useDisplay } from "vuetify";
import PlatformListItem from "@/components/common/Platform/ListItem.vue";
import PlatformIcon from "@/components/common/Platform/PlatformIcon.vue";
import router from "@/plugins/router";
import { ROUTES } from "@/plugins/router";
import { refetchCSRFToken } from "@/services/api";
import setupApi from "@/services/api/setup";
import type { SetupLibraryInfo } from "@/services/api/setup";
import userApi from "@/services/api/user";
import storeHeartbeat from "@/stores/heartbeat";
import type { Platform } from "@/stores/platforms";
import storeUsers from "@/stores/users";
import type { Events } from "@/types/emitter";
const { t } = useI18n();
const { xs } = useDisplay();
const { xs, smAndUp } = useDisplay();
const emitter = inject<Emitter<Events>>("emitter");
const heartbeat = storeHeartbeat();
const usersStore = storeUsers();
const visiblePassword = ref(false);
const visibleRepeatPassword = ref(false);
const repeatPassword = ref("");
// Library setup state
const libraryInfo = ref<SetupLibraryInfo | null>(null);
const loadingLibraryInfo = ref(false);
const selectedPlatforms = ref<string[]>([]);
const creatingPlatforms = ref(false);
const openPanels = ref<number[]>([]);
// Use a computed property to reactively update metadataOptions based on heartbeat
const metadataOptions = computed(() => [
{
@@ -74,22 +89,208 @@ const metadataOptions = computed(() => [
disabled: !heartbeat.value.METADATA_SOURCES?.STEAMGRIDDB_API_ENABLED,
},
]);
const defaultAdminUser = ref({
username: "",
password: "",
email: "",
username: "admin",
password: "admin123",
email: "admin@admin.com",
role: "admin",
});
const step = ref(1); // 1: Create admin user, 2: Check metadata sources, 3: Finish
const step = ref(1); // 1: Create admin user, 2: Library setup, 3: Check metadata sources
const filledAdminUser = computed(
() =>
defaultAdminUser.value.username != "" &&
defaultAdminUser.value.password != "",
defaultAdminUser.value.password != "" &&
repeatPassword.value != "" &&
defaultAdminUser.value.password === repeatPassword.value,
);
const isFirstStep = computed(() => step.value == 1);
const isLastStep = computed(() => step.value == 2);
const isLastStep = computed(() => step.value == 3);
const selectAll = ref(false);
// Helper function to group platforms by manufacturer
const groupPlatformsByManufacturer = (platforms: Platform[]) => {
const groups: Record<string, Platform[]> = {};
platforms.forEach((platform) => {
const key = platform.family_name || "Other";
if (!groups[key]) groups[key] = [];
groups[key].push(platform);
});
// Sort platforms within groups and return sorted entries
return Object.entries(groups)
.map(
([groupName, platforms]) =>
[
groupName,
platforms.sort((a, b) => {
// Sort by generation within same family
const aGen = a.generation ?? -1;
const bGen = b.generation ?? -1;
if (aGen > bGen) return 1;
if (aGen < bGen) return -1;
return a.name.localeCompare(b.name);
}),
] as [string, Platform[]],
)
.sort(([a], [b]) => {
if (a === "Other") return 1;
if (b === "Other") return -1;
return a.localeCompare(b);
});
};
// Group existing platforms
const groupedExistingPlatforms = computed(() => {
if (!libraryInfo.value) return [];
// Get supported platform slugs for quick lookup
const supportedSlugs = new Set(
libraryInfo.value.supported_platforms.map((p) => p.fs_slug),
);
// Get identified platforms (existing and in supported list)
const identified = libraryInfo.value.supported_platforms.filter((p) =>
libraryInfo.value?.existing_platforms.includes(p.fs_slug),
);
// Get unidentified platforms (existing but not in supported list)
// Create Platform objects for them with family_name="Other"
const unidentified = libraryInfo.value.existing_platforms
.filter((slug) => !supportedSlugs.has(slug))
.map(
(slug) =>
({
fs_slug: slug,
slug: slug,
name: slug,
family_name: "Other",
generation: 999,
}) as Platform,
);
// Combine both and group them
return groupPlatformsByManufacturer([...identified, ...unidentified]);
});
// Group available platforms (not existing)
const groupedAvailablePlatforms = computed(() => {
if (!libraryInfo.value) return [];
const available = libraryInfo.value.supported_platforms.filter(
(p) => !libraryInfo.value?.existing_platforms.includes(p.fs_slug),
);
return groupPlatformsByManufacturer(available);
});
// Check if there are existing platforms
const hasExistingPlatforms = computed(() => {
return (libraryInfo.value?.existing_platforms.length ?? 0) > 0;
});
// Count selected platforms in a group
const countSelectedInGroup = (platforms: Platform[]) => {
return platforms.filter((p) => selectedPlatforms.value.includes(p.fs_slug))
.length;
};
// Check if platform already exists
const isPlatformExisting = (fsSlug: string) => {
return libraryInfo.value?.existing_platforms.includes(fsSlug) ?? false;
};
// Watch for step changes to load library info
watch(step, async (newStep) => {
if (newStep === 2 && !libraryInfo.value) {
await loadLibraryInfo();
}
});
// Watch grouped existing and available platforms to open all panels
watch(
[groupedExistingPlatforms, groupedAvailablePlatforms],
([existing, available]) => {
const totalGroups = existing.length + available.length;
if (totalGroups > 0) {
openPanels.value = Array.from({ length: totalGroups }, (_, i) => i);
}
},
{ immediate: true },
);
// Watch selectAll to toggle all available platforms
watch(selectAll, (newValue) => {
if (!libraryInfo.value) return;
if (newValue) {
// Select all available platforms (exclude existing ones)
const allAvailable = libraryInfo.value.supported_platforms
.filter((p) => !isPlatformExisting(p.fs_slug))
.map((p) => p.fs_slug);
selectedPlatforms.value = [
...libraryInfo.value.existing_platforms,
...allAvailable,
];
} else {
// Keep only existing platforms selected
selectedPlatforms.value = [...libraryInfo.value.existing_platforms];
}
});
async function loadLibraryInfo() {
loadingLibraryInfo.value = true;
try {
const response = await setupApi.getLibraryInfo();
libraryInfo.value = response.data;
// Pre-check existing platforms
selectedPlatforms.value = [...(libraryInfo.value.existing_platforms || [])];
} catch (error: any) {
emitter?.emit("snackbarShow", {
msg: `Failed to load library info: ${
error.response?.data?.detail || error.message
}`,
icon: "mdi-close-circle",
color: "red",
});
} finally {
loadingLibraryInfo.value = false;
}
}
async function finishWizard() {
// First create platform folders if any selected
const platformsToCreate = selectedPlatforms.value.filter(
(slug) => !isPlatformExisting(slug),
);
if (platformsToCreate.length > 0) {
creatingPlatforms.value = true;
try {
const response = await setupApi.createPlatforms(platformsToCreate);
emitter?.emit("snackbarShow", {
msg: response.data.message,
icon: "mdi-check-circle",
color: "success",
});
} catch (error: any) {
emitter?.emit("snackbarShow", {
msg: `Failed to create platform folders: ${
error.response?.data?.detail || error.message
}`,
icon: "mdi-close-circle",
color: "red",
});
creatingPlatforms.value = false;
return; // Stop if folder creation fails
} finally {
creatingPlatforms.value = false;
}
}
// Then create admin user
await userApi
.createUser(defaultAdminUser.value)
.then(async () => {
@@ -110,27 +311,50 @@ async function finishWizard() {
</script>
<template>
<v-card class="translucent px-3" width="700">
<v-card
class="translucent px-3 d-flex flex-column"
width="900"
max-height="95dvh"
>
<v-img src="/assets/isotipo.svg" class="mx-auto mt-6" width="70" />
<v-stepper v-model="step" :mobile="xs" class="bg-transparent" flat>
<v-stepper
v-model="step"
:mobile="xs"
class="bg-transparent flex-grow-1 d-flex flex-column"
flat
>
<template #default="{ prev, next }">
<v-stepper-header>
<v-stepper-item :value="1">
<template #title>
<span class="text-white text-shadow">Create an admin user</span>
</template>
</v-stepper-item>
<div>
<v-stepper-header style="box-shadow: unset">
<v-stepper-item :value="1">
<template #title>
<span class="text-white text-shadow">Create an admin user</span>
</template>
</v-stepper-item>
<v-divider />
<v-divider />
<v-stepper-item :value="2">
<template #title>
<span class="text-white text-shadow">Check metadata sources</span>
</template>
</v-stepper-item>
</v-stepper-header>
<v-stepper-item :value="2">
<template #title>
<span class="text-white text-shadow"
>Setup library structure</span
>
</template>
</v-stepper-item>
<v-stepper-window>
<v-divider />
<v-stepper-item :value="3">
<template #title>
<span class="text-white text-shadow"
>Check metadata sources</span
>
</template>
</v-stepper-item>
</v-stepper-header>
</div>
<v-stepper-window class="flex-grow-1 my-0 mb-4 scroll">
<v-stepper-window-item :key="1" :value="1">
<v-row no-gutters>
<v-col>
@@ -175,6 +399,27 @@ async function finishWizard() {
"
variant="underlined"
@click:append-inner="visiblePassword = !visiblePassword"
/>
<v-text-field
v-model="repeatPassword"
:label="`${t('settings.repeat_password')} *`"
:type="visibleRepeatPassword ? 'text' : 'password'"
:rules="[
(v: string) => !!v || 'Repeat password is required',
(v: string) =>
v === defaultAdminUser.password ||
'Passwords must match',
]"
required
autocomplete="on"
prepend-inner-icon="mdi-lock"
:append-inner-icon="
visibleRepeatPassword ? 'mdi-eye-off' : 'mdi-eye'
"
variant="underlined"
@click:append-inner="
visibleRepeatPassword = !visibleRepeatPassword
"
@keydown.enter="filledAdminUser && next()"
/>
</v-form>
@@ -185,6 +430,226 @@ async function finishWizard() {
</v-stepper-window-item>
<v-stepper-window-item :key="2" :value="2">
<v-row no-gutters>
<v-col>
<v-row v-if="xs" no-gutters class="text-center mb-3">
<v-col>
<span>Setup library structure</span>
</v-col>
</v-row>
<!-- Loading state -->
<v-row
v-if="loadingLibraryInfo"
class="justify-center align-center"
no-gutters
>
<v-col class="text-center py-8">
<v-progress-circular
indeterminate
color="primary"
size="64"
/>
<p class="text-white text-shadow mt-4">
Loading platforms...
</p>
</v-col>
</v-row>
<!-- Loaded state -->
<v-row v-else class="justify-center" no-gutters>
<v-col cols="12">
<!-- Structure info -->
<v-row no-gutters class="mb-3">
<v-col class="text-center">
<p class="text-white text-shadow">
<strong>Folder structure:</strong>
{{
libraryInfo?.detected_structure === "A"
? "Structure A detected"
: libraryInfo?.detected_structure === "B"
? "Structure B detected"
: "No structure detected - Structure A will be created"
}}
</p>
<p class="text-caption text-grey">
{{
libraryInfo?.detected_structure === "A" ||
!libraryInfo?.detected_structure
? "roms/{platform}"
: "{platform}/roms"
}}
</p>
</v-col>
</v-row>
<!-- Warning alert -->
<!-- <v-row v-if="showEmptyWarning" no-gutters class="mb-3">
<v-col>
<v-alert
type="warning"
variant="tonal"
class="text-caption"
>
No platform folders will be created. You can add them
later from the platform management page.
</v-alert>
</v-col>
</v-row> -->
<!-- Platform selection -->
<v-row no-gutters>
<!-- Existing platforms column -->
<v-col
v-if="hasExistingPlatforms"
cols="12"
md="6"
class="pr-2"
>
<div class="text-white text-center text-shadow mb-2">
<strong>Detected Platforms</strong>
</div>
<div style="max-height: 58dvh; overflow-y: auto">
<v-expansion-panels
multiple
class="bg-transparent"
elevation="0"
variant="accordion"
>
<v-expansion-panel
v-for="(
[groupName, platforms], index
) in groupedExistingPlatforms"
:key="`existing-${groupName}`"
:value="index"
class="bg-transparent"
>
<v-expansion-panel-title
class="text-white text-shadow"
>
<strong>{{ groupName }}</strong>
<span class="ml-2 text-caption text-grey"
>({{ platforms.length }})</span
>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-list
tabindex="-1"
lines="two"
class="py-1 px-0 bg-transparent"
>
<PlatformListItem
v-for="platform in platforms"
:platform="platform"
:show-rom-count="false"
/>
</v-list>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</v-col>
<!-- Available platforms to create column -->
<v-col
cols="12"
:md="hasExistingPlatforms ? 6 : 12"
:class="hasExistingPlatforms ? 'pl-2' : ''"
>
<div
class="text-white text-center text-shadow mb-2"
:class="xs ? 'mt-8' : ''"
>
<strong>{{
hasExistingPlatforms
? "Available Platforms"
: "Select Platforms to Create"
}}</strong>
</div>
<!-- <div class="d-flex align-center mb-3">
<v-switch
v-model="selectAll"
color="primary"
density="compact"
hide-details
inset
>
<template #label>
<span class="text-white">Select All</span>
</template>
</v-switch>
</div>
<p class="text-caption text-grey mb-2">
Check the platforms you want to create
</p> -->
<div style="max-height: 58dvh; overflow-y: auto">
<v-expansion-panels
multiple
class="bg-transparent"
elevation="0"
variant="accordion"
>
<v-expansion-panel
v-for="(
[groupName, platforms], index
) in groupedAvailablePlatforms"
:key="`available-${groupName}`"
:value="index + groupedExistingPlatforms.length"
class="bg-transparent"
>
<v-expansion-panel-title
class="text-white text-shadow"
>
<strong>{{ groupName }}</strong>
<span class="ml-2 text-caption text-grey">
({{ platforms.length }})
</span>
<v-chip
v-if="countSelectedInGroup(platforms) > 0"
size="x-small"
color="primary"
class="ml-2"
>
{{ countSelectedInGroup(platforms) }} selected
</v-chip>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-list class="bg-transparent">
<v-list-item
v-for="platform in platforms"
:key="platform.fs_slug"
class="text-white"
density="compact"
>
<template #prepend>
<v-checkbox
v-model="selectedPlatforms"
:value="platform.fs_slug"
hide-details
density="compact"
/>
</template>
<v-list-item-title>
{{ platform.name }}
</v-list-item-title>
<v-list-item-subtitle class="text-grey">
{{ platform.fs_slug }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</v-col>
</v-row>
</v-col>
</v-row>
</v-col>
</v-row>
</v-stepper-window-item>
<v-stepper-window-item :key="3" :value="3">
<v-row no-gutters>
<v-col>
<v-row v-if="xs" no-gutters class="text-center mb-6">
@@ -193,7 +658,7 @@ async function finishWizard() {
</v-col>
</v-row>
<v-row class="justify-center align-center" no-gutters>
<v-col id="sources" :max-width="300">
<v-col cols="12" sm="8">
<v-list-item
v-for="source in metadataOptions"
:key="source.value"
@@ -220,7 +685,7 @@ async function finishWizard() {
</v-stepper-window-item>
</v-stepper-window>
<v-stepper-actions :disabled="!filledAdminUser">
<v-stepper-actions class="flex-grow-1" :disabled="!filledAdminUser">
<template #prev>
<v-btn
class="text-white text-shadow"
@@ -234,6 +699,7 @@ async function finishWizard() {
<template #next>
<v-btn
class="text-white text-shadow"
:loading="isLastStep && creatingPlatforms"
@click="!isLastStep ? next() : finishWizard()"
@keydown.enter="!isLastStep ? next() : finishWizard()"
>