Finish up more admin features, clean up code and layouts
Some checks are pending
Build / build (push) Waiting to run

This commit is contained in:
Trenton Zimmer
2025-07-06 00:47:35 -04:00
parent 3a5df7a7d0
commit ffe3d7133c
16 changed files with 612 additions and 74 deletions

View File

@@ -1,4 +1,4 @@
VITE_APP_VERSION="3.0.15"
VITE_APP_VERSION="3.0.16"
VITE_API_URL="http://10.5.7.5:8000/"
VITE_API_KEY="your_api_key_should_be_here"
VITE_ASSET_PATH="/assets"

View File

@@ -1,4 +1,4 @@
VITE_APP_VERSION="3.0.15"
VITE_APP_VERSION="3.0.16"
VITE_API_URL="https://restfulsleep.phaseii.network"
VITE_API_KEY="your_api_key_should_be_here"
VITE_ASSET_PATH="https://cdn.phaseii.network/file/PhaseII/web-assets"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -14,5 +14,6 @@
"3.0.12": ["- (Feature) Add new game event settings to SILENT SCOPE: BONE EATER and beatmaniaIIDX EPOLIS."],
"3.0.13": ["- (Feature) Add new game event settings to beatmaniaIIDX EPOLIS."],
"3.0.14": ["- (Minor) Fix footer buttons on tables, add generic export table button for every table."],
"3.0.15": ["- (Minor) Add beta support for Pinky Crush, clean up gameDB a little.", "- (Optimization) Convert dashboard to a more modular backend.", "- (Bugfix) Fix warnings on auth pages."]
"3.0.15": ["- (Minor) Add beta support for Pinky Crush, clean up gameDB a little.", "- (Optimization) Convert dashboard to a more modular backend.", "- (Bugfix) Fix warnings on auth pages."],
"3.0.16": ["- (Major) Add multiple new admin features for network management.", "- (Optimization) Optimize backend for arcade operations.", "- (Bugfix) Fix possible backend issues with better type enforcing.", "- (Bugfix) Fix event data and game data for IIDX Pinky Crush.", "- (Minor) Add assets for Nostalgia."]
}

View File

@@ -423,14 +423,9 @@ export const IIDXOptions = {
type: "Array",
options: [
{ id: 0, label: "No Event" },
{ id: 1, label: "MY POLIS DESIGNER (MAP 1)" },
{ id: 2, label: "MY POLIS DESIGNER (MAP 2)" },
{ id: 3, label: "MY POLIS DESIGNER (MAP 3)" },
{ id: 4, label: "MY POLIS DESIGNER (MAP FINAL)" },
{ id: 5, label: "EPOLIS RESTORATION (SYSTEM A)" },
{ id: 6, label: "EPOLIS RESTORATION (SYSTEM B)" },
{ id: 7, label: "EPOLIS RESTORATION (SYSTEM C)" },
{ id: 8, label: "EPOLIS RESTORATION (SYSTEM D + X)" },
{ id: 1, label: "Pinky Jump UP! (MAP 1)" },
{ id: 2, label: "Pinky Jump UP! (MAP 2)" },
{ id: 3, label: "Pinky Jump UP! (MAP 3)" },
],
},
{

View File

@@ -1407,7 +1407,7 @@ export const gameData = [
},
{
id: VersionConstants.IIDX_PINKY_CRUSH,
label: "PINKY CRUSH",
label: "Pinky Crush",
maxRivals: 5,
},
],

View File

@@ -42,6 +42,20 @@ export async function checkPCBID(PCBID) {
}
}
export async function APIAdminCreateMachine(arcadeId, newMachine) {
try {
const data = await mainStore.callApi(
`/admin/arcade/${arcadeId}/machine`,
"POST",
newMachine
);
return data;
} catch (error) {
console.log("Error adding machine:", error);
throw error;
}
}
export async function APIOnboardArcade(newArcade) {
try {
const data = await mainStore.callApi(
@@ -56,6 +70,20 @@ export async function APIOnboardArcade(newArcade) {
}
}
export async function APIAdminUpdateArcade(arcadeId, newArcade) {
try {
const data = await mainStore.callApi(
`/admin/arcade/${arcadeId}`,
"POST",
newArcade
);
return data;
} catch (error) {
console.log("Error updating arcade:", error);
throw error;
}
}
export async function APIAdminMaintenancePeriods() {
try {
const data = await mainStore.callApi(`/admin/maint`);
@@ -95,3 +123,33 @@ export async function APIAdminCreateClient(newClient) {
throw error;
}
}
export async function APIAdminUsers() {
try {
const data = await mainStore.callApi(`/admin/users`);
return data.data;
} catch (error) {
console.log("Error loading users:", error);
throw error;
}
}
export async function APIAdminNews() {
try {
const data = await mainStore.callApi(`/admin/news`);
return data.data;
} catch (error) {
console.log("Error loading news:", error);
throw error;
}
}
export async function APIAdminCreateNews(newNews) {
try {
const data = await mainStore.callApi(`/admin/news`, "POST", newNews);
return data;
} catch (error) {
console.log("Error posting news:", error);
throw error;
}
}

View File

@@ -94,6 +94,10 @@ function filterArcades() {
if (filterForm.filter) {
return arcadeData.value.filter(
(arcade) =>
arcade.id
.toString()
.toLowerCase()
.includes(filterForm.filter.toLowerCase()) ||
arcade.name.toLowerCase().includes(filterForm.filter.toLowerCase()) ||
arcade.description
.toLowerCase()
@@ -112,30 +116,6 @@ function filterArcades() {
<p class="text-sm text-gray-400">Enter an arcade to manage it</p>
</CardBox>
<SectionTitleLine
:icon="mdiStoreOutline"
title="All Arcades"
color="text-blue-400"
main
/>
<CardBox has-table class="mb-6">
<div
class="bg-white dark:bg-slate-900/95 rounded-2xl lg:flex lg:justify-between"
>
<div class="w-full">
<GeneralTable
:headers="headers"
:items="arcadeData"
@row-clicked="openArcade"
/>
<h1 class="mx-4 mt-1 mb-2 text-md lg:text-lg">
Click row to open arcade
</h1>
</div>
</div>
</CardBox>
<SectionTitleLine
:icon="mdiStoreEditOutline"
title="Search Arcades"
@@ -145,7 +125,7 @@ function filterArcades() {
<CardBox class="mb-6">
<FormField
label="Search"
help="Search by name, description, or owner."
help="Search by ID, name, description, or owner."
class="w-full md:w-1/3"
>
<FormControl
@@ -167,9 +147,16 @@ function filterArcades() {
{{ arcade.description }}
</h2>
<h2 class="text-md">Managed by {{ arcade.owners }}</h2>
<div
class="bg-slate-900 p-2 rounded-md mt-2 w-12 max-w-14 text-center"
>
<h2 class="text-lg font-mono text-pink-700">
{{ arcade.id }}
</h2>
</div>
</div>
<div class="flex align-middle mt-2 md:mt-0">
<div class="flex align-middle mt-2 md:mt-0 max-h-12">
<BaseButton
label="Open Arcade"
color="info"
@@ -181,6 +168,29 @@ function filterArcades() {
</div>
</CardBox>
<SectionTitleLine
:icon="mdiStoreOutline"
title="All Arcades"
color="text-blue-400"
main
/>
<CardBox has-table class="mb-6">
<div
class="bg-white dark:bg-slate-900/95 rounded-2xl lg:flex lg:justify-between"
>
<div class="w-full">
<GeneralTable
:headers="headers"
:items="arcadeData"
@row-clicked="openArcade"
/>
<h1 class="mx-4 mt-1 mb-2 text-md lg:text-lg">
Click row to open arcade
</h1>
</div>
</div>
</CardBox>
<SectionTitleLine
:icon="mdiStorePlusOutline"
title="Create Arcade"

View File

@@ -1,30 +1,147 @@
<script setup>
import { mdiFlagCheckered, mdiSecurity } from "@mdi/js";
import { ref, reactive, onMounted } from "vue";
import { useRouter } from "vue-router";
import { mdiNewspaper, mdiNewspaperPlus } from "@mdi/js";
import SectionMain from "@/components/SectionMain.vue";
import CardBox from "@/components/CardBox.vue";
import CardBoxWidget from "@/components/CardBoxWidget.vue";
import GeneralTable from "@/components/GeneralTable.vue";
import LayoutAuthenticated from "@/layouts/LayoutAuthenticated.vue";
import SectionTitleLine from "@/components/SectionTitleLine.vue";
import BaseButton from "@/components/BaseButton.vue";
import FormField from "@/components/FormField.vue";
import FormControl from "@/components/FormControl.vue";
import { APIAdminNews, APIAdminCreateNews } from "@/stores/api/admin";
const $router = useRouter();
const newsData = ref([]);
const headers = [
{
text: "Title",
value: "title",
width: 150,
sortable: true,
},
{
text: "Body",
value: "body",
width: 300,
},
{
text: "Image URL",
value: "data.img",
width: 300,
},
];
onMounted(async () => {
loadData();
});
async function loadData() {
try {
const data = await APIAdminNews();
var formattedItems = [];
for (var item of data) {
if (item.timestamp) {
const date = new Date(item.timestamp * 1000);
item.timestamp = date.toLocaleString();
}
formattedItems.push(item);
}
newsData.value = formattedItems;
} catch (error) {
console.error("Failed to fetch news data:", error);
}
}
const openNews = (item) => {
const arcadeId = item.id;
$router.push(`/news/${arcadeId}`);
};
const initNews = {
title: null,
body: null,
data: { img: null },
};
const newNews = reactive({
...initNews,
});
async function createNews() {
try {
const data = await APIAdminCreateNews(newNews);
newsData.value = data;
} catch (error) {
console.error("Failed to post news:", error);
}
Object.assign(newNews, initNews);
loadData();
}
</script>
<template>
<LayoutAuthenticated>
<SectionMain>
<CardBox class="mb-6 p-1">
<h1 class="text-3xl">News Management</h1>
<p class="text-sm text-gray-400">Enter an post to read it</p>
</CardBox>
<SectionTitleLine
:icon="mdiSecurity"
title="Network Administration"
color="text-red-600"
:icon="mdiNewspaperPlus"
title="Create News"
color="text-emerald-600"
main
/>
<CardBox is-form class="row-span-1 mb-6" @submit.prevent="createNews()">
<FormField label="Title" help="News headline.">
<FormControl v-model="newNews.title" required />
</FormField>
<FormField label="Body" help="News body.">
<FormControl v-model="newNews.body" required type="textarea" />
</FormField>
<FormField label="Image URL" help="News image URL.">
<FormControl v-model="newNews.data.img" required />
</FormField>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 mb-6">
<CardBoxWidget :number="1" label="User Account(s)" />
<CardBoxWidget :number="2" label="Registered Arcades" />
<CardBoxWidget :number="3" label="Published Scores" />
</div>
<div>
<BaseButton
type="submit"
color="success"
label="Create"
:small="false"
/>
</div>
</CardBox>
<SectionTitleLine :icon="mdiFlagCheckered" title="Recent Errors" />
<CardBox> </CardBox>
<SectionTitleLine
:icon="mdiNewspaper"
title="All News"
color="text-blue-400"
main
/>
<CardBox has-table class="mb-6">
<div
class="bg-white dark:bg-slate-900/95 rounded-2xl lg:flex lg:justify-between"
>
<div class="w-full">
<GeneralTable
:headers="headers"
:items="newsData"
@row-clicked="openNews"
/>
<h1 class="mx-4 mt-1 mb-2 text-md lg:text-lg">
Click row to open arcade
</h1>
</div>
</div>
</CardBox>
</SectionMain>
</LayoutAuthenticated>
</template>

View File

@@ -1,30 +1,183 @@
<script setup>
import { mdiFlagCheckered, mdiSecurity } from "@mdi/js";
import { ref, reactive, onMounted } from "vue";
import { useRouter } from "vue-router";
import { mdiAccountBadgeOutline } from "@mdi/js";
import SectionMain from "@/components/SectionMain.vue";
import CardBox from "@/components/CardBox.vue";
import CardBoxWidget from "@/components/CardBoxWidget.vue";
import GeneralTable from "@/components/GeneralTable.vue";
import FormField from "@/components/FormField.vue";
import FormControl from "@/components/FormControl.vue";
import LayoutAuthenticated from "@/layouts/LayoutAuthenticated.vue";
import SectionTitleLine from "@/components/SectionTitleLine.vue";
import BaseButton from "@/components/BaseButton.vue";
import { APIAdminUsers } from "@/stores/api/admin";
const $router = useRouter();
const userData = ref([]);
const headers = [
{
text: "User ID",
value: "id",
width: 50,
sortable: true,
},
{
text: "Username",
value: "username",
width: 150,
sortable: true,
},
{
text: "Admin",
value: "admin",
width: 120,
sortable: true,
},
{
text: "Banned",
value: "banned",
width: 120,
sortable: true,
},
{
text: "Discord Linked",
value: "data.discord.linked",
width: 120,
sortable: true,
},
{
text: "Tachi Linked",
value: "data.tachi.linked",
width: 120,
sortable: true,
},
];
onMounted(async () => {
loadData();
});
async function loadData() {
try {
const data = await APIAdminUsers();
var formattedItems = [];
for (var item of data) {
if (!item.username) {
item.username = "Unclaimed Account";
}
formattedItems.push(item);
}
userData.value = formattedItems;
} catch (error) {
console.error("Failed to fetch user data:", error);
}
}
const openUser = (item) => {
const userId = item.id;
$router.push(`/user/${userId}`);
};
const filterForm = reactive({
filter: null,
});
function filterUsers() {
if (filterForm.filter) {
return userData.value
.filter(
(user) =>
user.id
.toString()
.toLowerCase()
.includes(filterForm.filter.toLowerCase()) ||
user.username.toLowerCase().includes(filterForm.filter.toLowerCase())
)
.slice(0, 25); // Cap results to 25
}
return [];
}
</script>
<template>
<LayoutAuthenticated>
<SectionMain>
<CardBox class="mb-6 p-1">
<h1 class="text-3xl">User Management</h1>
<p class="text-sm text-gray-400">Click a row to open User</p>
</CardBox>
<CardBox class="mb-6">
<FormField
label="Search"
help="Search by ID or username."
class="w-full md:w-1/3"
>
<FormControl
v-model="filterForm.filter"
:model-value="filterForm.filter"
/>
</FormField>
<div class="grid gap-4">
<div
v-for="user of filterUsers()"
:key="user.id"
class="bg-slate-800 p-4 rounded-xl"
>
<div class="md:flex w-full place-content-between">
<div>
<h1 class="text-lg md:text-xl">{{ user.username }}</h1>
<h2 class="text-md md:text-lg">
User {{ user.banned ? "is" : "isn't" }} banned.
</h2>
<h2 class="text-md">
Discord
{{ user.data?.discord?.linked ? "is" : "isn't" }} linked.
</h2>
<div
class="bg-slate-900 p-2 rounded-md mt-2 w-14 max-w-16 text-center"
>
<h2 class="text-lg font-mono text-pink-700">
{{ user.id }}
</h2>
</div>
</div>
<div class="flex align-middle mt-2 md:mt-0 max-h-16">
<BaseButton
label="Open User"
color="info"
@click="openUser(user)"
/>
</div>
</div>
</div>
</div>
</CardBox>
<SectionTitleLine
:icon="mdiSecurity"
title="Network Administration"
color="text-red-600"
:icon="mdiAccountBadgeOutline"
title="All Users"
color="text-blue-400"
main
/>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 mb-6">
<CardBoxWidget :number="1" label="User Account(s)" />
<CardBoxWidget :number="2" label="Registered Arcades" />
<CardBoxWidget :number="3" label="Published Scores" />
</div>
<SectionTitleLine :icon="mdiFlagCheckered" title="Recent Errors" />
<CardBox> </CardBox>
<CardBox has-table class="mb-6">
<div
class="bg-white dark:bg-slate-900/95 rounded-2xl lg:flex lg:justify-between"
>
<div class="w-full">
<GeneralTable
:headers="headers"
:items="userData"
@row-clicked="openUser(user)"
/>
</div>
</div>
</CardBox>
</SectionMain>
</LayoutAuthenticated>
</template>

View File

@@ -1,7 +1,15 @@
<script setup>
import { useRoute } from "vue-router";
import { ref, onMounted } from "vue";
import { mdiStore, mdiStoreCog, mdiCogOutline } from "@mdi/js";
import {
mdiStore,
mdiStoreCog,
mdiCogOutline,
mdiShieldEditOutline,
mdiCheckOutline,
mdiCloseOutline,
mdiInformationOutline,
} from "@mdi/js";
import SectionMain from "@/components/SectionMain.vue";
import LayoutAuthenticated from "@/layouts/LayoutAuthenticated.vue";
import SectionTitleLine from "@/components/SectionTitleLine.vue";
@@ -9,7 +17,9 @@ import ArcadeCard from "@/components/ArcadeCard.vue";
import CardBox from "@/components/CardBox.vue";
import FormField from "@/components/FormField.vue";
import FormCheckRadio from "@/components/FormCheckRadio.vue";
import FormControl from "@/components/FormControl.vue";
import PillTag from "@/components/PillTag.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import BaseButton from "@/components/BaseButton.vue";
import CardBoxWidget from "@/components/CardBoxWidget.vue";
import { getNestedValue, setNestedValue } from "@/constants/values";
@@ -18,11 +28,16 @@ import {
APIUpdateArcade,
APIGetArcadeVPN,
} from "@/stores/api/arcade";
import { checkArcadeName, APIAdminUpdateArcade } from "@/stores/api/admin";
import { useMainStore } from "@/stores/main";
const mainStore = useMainStore();
const optionForm = ref(null);
const bareForm = ref(null);
const arcadeData = ref(null);
const loading = ref(true);
const arcadeCheck = ref(null);
const newArcade = ref(null);
const $route = useRoute();
const arcadeId = parseInt($route.params.id);
@@ -61,6 +76,7 @@ async function loadArcade() {
bareForm.value = {};
const data = await APIGetArcade(arcadeId);
arcadeData.value = data;
newArcade.value = JSON.parse(JSON.stringify(data));
for (const setting of arcadeOptions) {
const value = getNestedValue(arcadeData.value.data, setting.id);
@@ -119,6 +135,27 @@ async function exportVPN() {
console.log("Failed to fetch arcade data:", error);
}
}
async function checkName() {
if (newArcade.value.name) {
const data = await checkArcadeName(newArcade.value.name);
if (data.unused != undefined) {
arcadeCheck.value = data.unused;
}
}
}
async function adminUpdateArcade() {
const response = await APIAdminUpdateArcade(arcadeId, {
name: newArcade.value.name,
description: newArcade.value.description,
beta: newArcade.value.data.is_beta,
});
if (response.status != "error") {
await loadArcade();
}
}
</script>
<template>
@@ -127,12 +164,76 @@ async function exportVPN() {
<template v-if="!loading">
<ArcadeCard class="mb-6" :arcade="arcadeData" />
<SectionTitleLine
:icon="mdiStoreCog"
title="Arcade Administration"
main
/>
<template v-if="mainStore.userAdmin">
<SectionTitleLine
:icon="mdiShieldEditOutline"
title="Arcade Administration"
main
/>
<CardBox is-form class="mb-6" @submit.prevent="adminUpdateArcade">
<PillTag
color="info"
label="General Information"
:icon="mdiInformationOutline"
class="mb-2"
/>
<FormField label="Arcade Name">
<FormControl
v-model="newArcade.name"
:input-value="newArcade.name"
name="arcadeName"
required
/>
</FormField>
<div class="mb-4 flex gap-2 items-stretch">
<BaseButton
color="info"
label="Check Name"
@click="checkName()"
/>
<BaseIcon
v-if="arcadeCheck == true"
:path="mdiCheckOutline"
color="text-green-400"
size="25"
/>
<BaseIcon
v-else-if="arcadeCheck == false"
:path="mdiCloseOutline"
color="text-red-400"
size="25"
/>
</div>
<FormField label="Description">
<FormControl
v-model="newArcade.description"
name="description"
required
/>
</FormField>
<FormField label="Beta Enabled">
<FormCheckRadio
v-model="newArcade.data.is_beta"
name="beta"
:model-value="newArcade.data.is_beta"
:input-value="newArcade.data.is_beta"
type="switch"
/>
</FormField>
<div
v-if="JSON.stringify(arcadeData) !== JSON.stringify(newArcade)"
class="space-x-2 mt-6"
>
<BaseButton color="success" label="Save" type="submit" />
<BaseButton color="danger" label="Revert" @click="loadArcade()" />
</div>
</CardBox>
</template>
<SectionTitleLine :icon="mdiStoreCog" title="Arcade Management" main />
<CardBox is-form class="mb-6" @submit.prevent="updateArcade">
<PillTag
color="info"

View File

@@ -1,15 +1,23 @@
<script setup>
import { ref, onMounted } from "vue";
import { ref, reactive, onMounted } from "vue";
import { useRoute } from "vue-router";
import { mdiGamepad } from "@mdi/js";
import { mdiGamepad, mdiDatabasePlus } from "@mdi/js";
import { useMainStore } from "@/stores/main";
import SectionMain from "@/components/SectionMain.vue";
import CardBox from "@/components/CardBox.vue";
import BaseButton from "@/components/BaseButton.vue";
import FormField from "@/components/FormField.vue";
import FormControl from "@/components/FormControl.vue";
import FormCheckRadio from "@/components/FormCheckRadio.vue";
import LayoutAuthenticated from "@/layouts/LayoutAuthenticated.vue";
import SectionTitleLine from "@/components/SectionTitleLine.vue";
import ArcadeCard from "@/components/ArcadeCard.vue";
import GeneralTable from "@/components/GeneralTable.vue";
import { generatePCBID } from "@/constants/pcbid.js";
import { APIGetArcade } from "@/stores/api/arcade";
import { checkPCBID, APIAdminCreateMachine } from "@/stores/api/admin";
const mainStore = useMainStore();
const arcadeData = ref({});
const loading = ref(true);
@@ -18,7 +26,6 @@ const arcadeId = parseInt($route.params.id);
async function loadArcade() {
try {
arcadeData.value = null;
const data = await APIGetArcade(arcadeId);
arcadeData.value = data;
loading.value = false;
@@ -66,6 +73,42 @@ const copyToClipboard = (text) => {
alert("Failed to copy to clipboard!");
});
};
const initMachine = {
name: "",
cabinet: false,
ota: false,
generatePCBID: true,
PCBID: "",
};
const newMachine = reactive({
...initMachine,
});
async function addMachine() {
if (newMachine.generatePCBID) {
newMachine.PCBID = generatePCBID();
}
while (!(await checkPCBID(newMachine.PCBID))) {
if (newMachine.generatePCBID) {
newMachine.PCBID = generatePCBID();
} else {
window.alert("PCBID already in use!");
return;
}
}
APIAdminCreateMachine(arcadeData.value.id, {
name: newMachine.name,
cabinet: newMachine.cabinet,
ota: newMachine.ota,
PCBID: newMachine.PCBID.toUpperCase(),
});
Object.assign(newMachine, initMachine);
loadArcade();
}
</script>
<template>
@@ -73,8 +116,68 @@ const copyToClipboard = (text) => {
<SectionMain>
<template v-if="!loading">
<ArcadeCard class="mb-6" :arcade="arcadeData" :use-small="true" />
<SectionTitleLine :icon="mdiGamepad" title="Machines" main />
<template v-if="mainStore.userAdmin">
<SectionTitleLine :icon="mdiDatabasePlus" title="Add Machine" main />
<CardBox is-form class="mb-6">
<form class="h-full" @submit.prevent="addMachine()">
<FormField
label="Generate PCBID"
help="Generate a PCBID rather than supply one."
>
<FormCheckRadio
v-model="newMachine.generatePCBID"
:input-value="newMachine.generatePCBID"
name="generatePCBID"
type="switch"
/>
</FormField>
<FormField v-if="!newMachine.generatePCBID" label="PCBID">
<FormControl
v-model="newMachine.PCBID"
name="pcbid"
required
minlength="20"
maxlength="20"
/>
</FormField>
<FormField label="Name">
<FormControl v-model="newMachine.name" name="name" required />
</FormField>
<div
class="grid grid-cols-1 w-full gap-2 md:gap-6 md:flex md:place-content-stretch pb-4 md:pb-0"
>
<FormField
label="Arcade Cabinet"
help="If this is a real cabinet, enable this."
>
<FormCheckRadio
v-model="newMachine.cabinet"
:input-value="newMachine.cabinet"
name="cabinet"
type="switch"
/>
</FormField>
<FormField
label="Enable Updates"
help="Enables or disables OTA updates."
>
<FormCheckRadio
v-model="newMachine.ota"
:input-value="newMachine.ota"
name="ota"
type="switch"
/>
</FormField>
</div>
<BaseButton color="success" type="submit" label="Add Machine" />
</form>
</CardBox>
</template>
<SectionTitleLine :icon="mdiGamepad" title="Machines" main />
<CardBox has-table>
<div
class="bg-white dark:bg-slate-900/95 rounded-2xl lg:flex lg:justify-between"