build: initialize RetroHub with Laravel, Inertia, Vue, and Horizon

This commit is contained in:
2026-08-10 13:45:18 +02:00
commit 717db65139
218 changed files with 28356 additions and 0 deletions
@@ -0,0 +1,55 @@
<script setup>
import GuestLayout from '@/Shared/Layouts/GuestLayout.vue';
import InputError from '@/Shared/Components/InputError.vue';
import InputLabel from '@/Shared/Components/InputLabel.vue';
import PrimaryButton from '@/Shared/Components/PrimaryButton.vue';
import TextInput from '@/Shared/Components/TextInput.vue';
import { Head, useForm } from '@inertiajs/vue3';
const form = useForm({
password: '',
});
const submit = () => {
form.post(route('password.confirm'), {
onFinish: () => form.reset(),
});
};
</script>
<template>
<GuestLayout>
<Head title="Confirm Password" />
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
This is a secure area of the application. Please confirm your
password before continuing.
</div>
<form @submit.prevent="submit">
<div>
<InputLabel for="password" value="Password" />
<TextInput
id="password"
type="password"
class="mt-1 block w-full"
v-model="form.password"
required
autocomplete="current-password"
autofocus
/>
<InputError class="mt-2" :message="form.errors.password" />
</div>
<div class="mt-4 flex justify-end">
<PrimaryButton
class="ms-4"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
Confirm
</PrimaryButton>
</div>
</form>
</GuestLayout>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup>
import AppLayout from '@/Shared/Layouts/AppLayout.vue';
import DeleteUserForm from './Partials/DeleteUserForm.vue';
import UpdatePasswordForm from './Partials/UpdatePasswordForm.vue';
import UpdateProfileInformationForm from './Partials/UpdateProfileInformationForm.vue';
import { Head } from '@inertiajs/vue3';
defineProps({
mustVerifyEmail: {
type: Boolean,
},
status: {
type: String,
},
});
</script>
<template>
<Head title="Profile" />
<AppLayout>
<template #header>
<h2
class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200"
>
Profile
</h2>
</template>
<div class="py-12">
<div class="mx-auto max-w-7xl space-y-6 sm:px-6 lg:px-8">
<div
class="bg-white p-4 shadow sm:rounded-lg sm:p-8 dark:bg-gray-800"
>
<UpdateProfileInformationForm
:must-verify-email="mustVerifyEmail"
:status="status"
class="max-w-xl"
/>
</div>
<div
class="bg-white p-4 shadow sm:rounded-lg sm:p-8 dark:bg-gray-800"
>
<UpdatePasswordForm class="max-w-xl" />
</div>
<div
class="bg-white p-4 shadow sm:rounded-lg sm:p-8 dark:bg-gray-800"
>
<DeleteUserForm class="max-w-xl" />
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,68 @@
<script setup>
import GuestLayout from '@/Shared/Layouts/GuestLayout.vue';
import InputError from '@/Shared/Components/InputError.vue';
import InputLabel from '@/Shared/Components/InputLabel.vue';
import PrimaryButton from '@/Shared/Components/PrimaryButton.vue';
import TextInput from '@/Shared/Components/TextInput.vue';
import { Head, useForm } from '@inertiajs/vue3';
defineProps({
status: {
type: String,
},
});
const form = useForm({
email: '',
});
const submit = () => {
form.post(route('password.email'));
};
</script>
<template>
<GuestLayout>
<Head title="Forgot Password" />
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Forgot your password? No problem. Just let us know your email
address and we will email you a password reset link that will allow
you to choose a new one.
</div>
<div
v-if="status"
class="mb-4 text-sm font-medium text-green-600 dark:text-green-400"
>
{{ status }}
</div>
<form @submit.prevent="submit">
<div>
<InputLabel for="email" value="Email" />
<TextInput
id="email"
type="email"
class="mt-1 block w-full"
v-model="form.email"
required
autofocus
autocomplete="username"
/>
<InputError class="mt-2" :message="form.errors.email" />
</div>
<div class="mt-4 flex items-center justify-end">
<PrimaryButton
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
Email Password Reset Link
</PrimaryButton>
</div>
</form>
</GuestLayout>
</template>
+100
View File
@@ -0,0 +1,100 @@
<script setup>
import Checkbox from '@/Shared/Components/Checkbox.vue';
import GuestLayout from '@/Shared/Layouts/GuestLayout.vue';
import InputError from '@/Shared/Components/InputError.vue';
import InputLabel from '@/Shared/Components/InputLabel.vue';
import PrimaryButton from '@/Shared/Components/PrimaryButton.vue';
import TextInput from '@/Shared/Components/TextInput.vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
defineProps({
canResetPassword: {
type: Boolean,
},
status: {
type: String,
},
});
const form = useForm({
email: '',
password: '',
remember: false,
});
const submit = () => {
form.post(route('login'), {
onFinish: () => form.reset('password'),
});
};
</script>
<template>
<GuestLayout>
<Head title="Log in" />
<div v-if="status" class="mb-4 text-sm font-medium text-green-600">
{{ status }}
</div>
<form @submit.prevent="submit">
<div>
<InputLabel for="email" value="Email" />
<TextInput
id="email"
type="email"
class="mt-1 block w-full"
v-model="form.email"
required
autofocus
autocomplete="username"
/>
<InputError class="mt-2" :message="form.errors.email" />
</div>
<div class="mt-4">
<InputLabel for="password" value="Password" />
<TextInput
id="password"
type="password"
class="mt-1 block w-full"
v-model="form.password"
required
autocomplete="current-password"
/>
<InputError class="mt-2" :message="form.errors.password" />
</div>
<div class="mt-4 block">
<label class="flex items-center">
<Checkbox name="remember" v-model:checked="form.remember" />
<span class="ms-2 text-sm text-gray-600 dark:text-gray-400"
>Remember me</span
>
</label>
</div>
<div class="mt-4 flex items-center justify-end">
<Link
v-if="canResetPassword"
:href="route('password.request')"
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:text-gray-400 dark:hover:text-gray-100 dark:focus:ring-offset-gray-800"
>
Forgot your password?
</Link>
<PrimaryButton
class="ms-4"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
Log in
</PrimaryButton>
</div>
</form>
</GuestLayout>
</template>
@@ -0,0 +1,108 @@
<script setup>
import DangerButton from '@/Shared/Components/DangerButton.vue';
import InputError from '@/Shared/Components/InputError.vue';
import InputLabel from '@/Shared/Components/InputLabel.vue';
import Modal from '@/Shared/Components/Modal.vue';
import SecondaryButton from '@/Shared/Components/SecondaryButton.vue';
import TextInput from '@/Shared/Components/TextInput.vue';
import { useForm } from '@inertiajs/vue3';
import { nextTick, ref } from 'vue';
const confirmingUserDeletion = ref(false);
const passwordInput = ref(null);
const form = useForm({
password: '',
});
const confirmUserDeletion = () => {
confirmingUserDeletion.value = true;
nextTick(() => passwordInput.value.focus());
};
const deleteUser = () => {
form.delete(route('profile.destroy'), {
preserveScroll: true,
onSuccess: () => closeModal(),
onError: () => passwordInput.value.focus(),
onFinish: () => form.reset(),
});
};
const closeModal = () => {
confirmingUserDeletion.value = false;
form.clearErrors();
form.reset();
};
</script>
<template>
<section class="space-y-6">
<header>
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
Delete Account
</h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Once your account is deleted, all of its resources and data will
be permanently deleted. Before deleting your account, please
download any data or information that you wish to retain.
</p>
</header>
<DangerButton @click="confirmUserDeletion">Delete Account</DangerButton>
<Modal :show="confirmingUserDeletion" @close="closeModal">
<div class="p-6">
<h2
class="text-lg font-medium text-gray-900 dark:text-gray-100"
>
Are you sure you want to delete your account?
</h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Once your account is deleted, all of its resources and data
will be permanently deleted. Please enter your password to
confirm you would like to permanently delete your account.
</p>
<div class="mt-6">
<InputLabel
for="password"
value="Password"
class="sr-only"
/>
<TextInput
id="password"
ref="passwordInput"
v-model="form.password"
type="password"
class="mt-1 block w-3/4"
placeholder="Password"
@keyup.enter="deleteUser"
/>
<InputError :message="form.errors.password" class="mt-2" />
</div>
<div class="mt-6 flex justify-end">
<SecondaryButton @click="closeModal">
Cancel
</SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
@click="deleteUser"
>
Delete Account
</DangerButton>
</div>
</div>
</Modal>
</section>
</template>
@@ -0,0 +1,122 @@
<script setup>
import InputError from '@/Shared/Components/InputError.vue';
import InputLabel from '@/Shared/Components/InputLabel.vue';
import PrimaryButton from '@/Shared/Components/PrimaryButton.vue';
import TextInput from '@/Shared/Components/TextInput.vue';
import { useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
const passwordInput = ref(null);
const currentPasswordInput = ref(null);
const form = useForm({
current_password: '',
password: '',
password_confirmation: '',
});
const updatePassword = () => {
form.put(route('password.update'), {
preserveScroll: true,
onSuccess: () => form.reset(),
onError: () => {
if (form.errors.password) {
form.reset('password', 'password_confirmation');
passwordInput.value.focus();
}
if (form.errors.current_password) {
form.reset('current_password');
currentPasswordInput.value.focus();
}
},
});
};
</script>
<template>
<section>
<header>
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
Update Password
</h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Ensure your account is using a long, random password to stay
secure.
</p>
</header>
<form @submit.prevent="updatePassword" class="mt-6 space-y-6">
<div>
<InputLabel for="current_password" value="Current Password" />
<TextInput
id="current_password"
ref="currentPasswordInput"
v-model="form.current_password"
type="password"
class="mt-1 block w-full"
autocomplete="current-password"
/>
<InputError
:message="form.errors.current_password"
class="mt-2"
/>
</div>
<div>
<InputLabel for="password" value="New Password" />
<TextInput
id="password"
ref="passwordInput"
v-model="form.password"
type="password"
class="mt-1 block w-full"
autocomplete="new-password"
/>
<InputError :message="form.errors.password" class="mt-2" />
</div>
<div>
<InputLabel
for="password_confirmation"
value="Confirm Password"
/>
<TextInput
id="password_confirmation"
v-model="form.password_confirmation"
type="password"
class="mt-1 block w-full"
autocomplete="new-password"
/>
<InputError
:message="form.errors.password_confirmation"
class="mt-2"
/>
</div>
<div class="flex items-center gap-4">
<PrimaryButton :disabled="form.processing">Save</PrimaryButton>
<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p
v-if="form.recentlySuccessful"
class="text-sm text-gray-600 dark:text-gray-400"
>
Saved.
</p>
</Transition>
</div>
</form>
</section>
</template>
@@ -0,0 +1,112 @@
<script setup>
import InputError from '@/Shared/Components/InputError.vue';
import InputLabel from '@/Shared/Components/InputLabel.vue';
import PrimaryButton from '@/Shared/Components/PrimaryButton.vue';
import TextInput from '@/Shared/Components/TextInput.vue';
import { Link, useForm, usePage } from '@inertiajs/vue3';
defineProps({
mustVerifyEmail: {
type: Boolean,
},
status: {
type: String,
},
});
const user = usePage().props.auth.user;
const form = useForm({
name: user.name,
email: user.email,
});
</script>
<template>
<section>
<header>
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
Profile Information
</h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Update your account's profile information and email address.
</p>
</header>
<form
@submit.prevent="form.patch(route('profile.update'))"
class="mt-6 space-y-6"
>
<div>
<InputLabel for="name" value="Name" />
<TextInput
id="name"
type="text"
class="mt-1 block w-full"
v-model="form.name"
required
autofocus
autocomplete="name"
/>
<InputError class="mt-2" :message="form.errors.name" />
</div>
<div>
<InputLabel for="email" value="Email" />
<TextInput
id="email"
type="email"
class="mt-1 block w-full"
v-model="form.email"
required
autocomplete="username"
/>
<InputError class="mt-2" :message="form.errors.email" />
</div>
<div v-if="mustVerifyEmail && user.email_verified_at === null">
<p class="mt-2 text-sm text-gray-800 dark:text-gray-200">
Your email address is unverified.
<Link
:href="route('verification.send')"
method="post"
as="button"
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:text-gray-400 dark:hover:text-gray-100 dark:focus:ring-offset-gray-800"
>
Click here to re-send the verification email.
</Link>
</p>
<div
v-show="status === 'verification-link-sent'"
class="mt-2 text-sm font-medium text-green-600 dark:text-green-400"
>
A new verification link has been sent to your email address.
</div>
</div>
<div class="flex items-center gap-4">
<PrimaryButton :disabled="form.processing">Save</PrimaryButton>
<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p
v-if="form.recentlySuccessful"
class="text-sm text-gray-600 dark:text-gray-400"
>
Saved.
</p>
</Transition>
</div>
</form>
</section>
</template>
@@ -0,0 +1,113 @@
<script setup>
import GuestLayout from '@/Shared/Layouts/GuestLayout.vue';
import InputError from '@/Shared/Components/InputError.vue';
import InputLabel from '@/Shared/Components/InputLabel.vue';
import PrimaryButton from '@/Shared/Components/PrimaryButton.vue';
import TextInput from '@/Shared/Components/TextInput.vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
const form = useForm({
name: '',
email: '',
password: '',
password_confirmation: '',
});
const submit = () => {
form.post(route('register'), {
onFinish: () => form.reset('password', 'password_confirmation'),
});
};
</script>
<template>
<GuestLayout>
<Head title="Register" />
<form @submit.prevent="submit">
<div>
<InputLabel for="name" value="Name" />
<TextInput
id="name"
type="text"
class="mt-1 block w-full"
v-model="form.name"
required
autofocus
autocomplete="name"
/>
<InputError class="mt-2" :message="form.errors.name" />
</div>
<div class="mt-4">
<InputLabel for="email" value="Email" />
<TextInput
id="email"
type="email"
class="mt-1 block w-full"
v-model="form.email"
required
autocomplete="username"
/>
<InputError class="mt-2" :message="form.errors.email" />
</div>
<div class="mt-4">
<InputLabel for="password" value="Password" />
<TextInput
id="password"
type="password"
class="mt-1 block w-full"
v-model="form.password"
required
autocomplete="new-password"
/>
<InputError class="mt-2" :message="form.errors.password" />
</div>
<div class="mt-4">
<InputLabel
for="password_confirmation"
value="Confirm Password"
/>
<TextInput
id="password_confirmation"
type="password"
class="mt-1 block w-full"
v-model="form.password_confirmation"
required
autocomplete="new-password"
/>
<InputError
class="mt-2"
:message="form.errors.password_confirmation"
/>
</div>
<div class="mt-4 flex items-center justify-end">
<Link
:href="route('login')"
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:text-gray-400 dark:hover:text-gray-100 dark:focus:ring-offset-gray-800"
>
Already registered?
</Link>
<PrimaryButton
class="ms-4"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
Register
</PrimaryButton>
</div>
</form>
</GuestLayout>
</template>
@@ -0,0 +1,101 @@
<script setup>
import GuestLayout from '@/Shared/Layouts/GuestLayout.vue';
import InputError from '@/Shared/Components/InputError.vue';
import InputLabel from '@/Shared/Components/InputLabel.vue';
import PrimaryButton from '@/Shared/Components/PrimaryButton.vue';
import TextInput from '@/Shared/Components/TextInput.vue';
import { Head, useForm } from '@inertiajs/vue3';
const props = defineProps({
email: {
type: String,
required: true,
},
token: {
type: String,
required: true,
},
});
const form = useForm({
token: props.token,
email: props.email,
password: '',
password_confirmation: '',
});
const submit = () => {
form.post(route('password.store'), {
onFinish: () => form.reset('password', 'password_confirmation'),
});
};
</script>
<template>
<GuestLayout>
<Head title="Reset Password" />
<form @submit.prevent="submit">
<div>
<InputLabel for="email" value="Email" />
<TextInput
id="email"
type="email"
class="mt-1 block w-full"
v-model="form.email"
required
autofocus
autocomplete="username"
/>
<InputError class="mt-2" :message="form.errors.email" />
</div>
<div class="mt-4">
<InputLabel for="password" value="Password" />
<TextInput
id="password"
type="password"
class="mt-1 block w-full"
v-model="form.password"
required
autocomplete="new-password"
/>
<InputError class="mt-2" :message="form.errors.password" />
</div>
<div class="mt-4">
<InputLabel
for="password_confirmation"
value="Confirm Password"
/>
<TextInput
id="password_confirmation"
type="password"
class="mt-1 block w-full"
v-model="form.password_confirmation"
required
autocomplete="new-password"
/>
<InputError
class="mt-2"
:message="form.errors.password_confirmation"
/>
</div>
<div class="mt-4 flex items-center justify-end">
<PrimaryButton
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
Reset Password
</PrimaryButton>
</div>
</form>
</GuestLayout>
</template>
@@ -0,0 +1,61 @@
<script setup>
import { computed } from 'vue';
import GuestLayout from '@/Shared/Layouts/GuestLayout.vue';
import PrimaryButton from '@/Shared/Components/PrimaryButton.vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
const props = defineProps({
status: {
type: String,
},
});
const form = useForm({});
const submit = () => {
form.post(route('verification.send'));
};
const verificationLinkSent = computed(
() => props.status === 'verification-link-sent',
);
</script>
<template>
<GuestLayout>
<Head title="Email Verification" />
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Thanks for signing up! Before getting started, could you verify your
email address by clicking on the link we just emailed to you? If you
didn't receive the email, we will gladly send you another.
</div>
<div
class="mb-4 text-sm font-medium text-green-600 dark:text-green-400"
v-if="verificationLinkSent"
>
A new verification link has been sent to the email address you
provided during registration.
</div>
<form @submit.prevent="submit">
<div class="mt-4 flex items-center justify-between">
<PrimaryButton
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
Resend Verification Email
</PrimaryButton>
<Link
:href="route('logout')"
method="post"
as="button"
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:text-gray-400 dark:hover:text-gray-100 dark:focus:ring-offset-gray-800"
>Log Out</Link
>
</div>
</form>
</GuestLayout>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import AppLayout from '@/Shared/Layouts/AppLayout.vue';
import { Head, Link } from '@inertiajs/vue3';
import type { ConsoleData } from '@/types';
interface Props {
consoles: ConsoleData[];
user: {
name: string;
email: string;
};
}
defineProps<Props>();
</script>
<template>
<AppLayout title="Consoles" :user="user">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Consoles</h2>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
<Link
v-for="console in consoles"
:key="console.id"
:href="route('consoles.show', console.slug)"
class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6 hover:shadow-2xl transition-shadow"
>
<div class="text-center">
<div class="w-20 h-20 mx-auto mb-4 bg-purple-100 rounded-full flex items-center justify-center">
<span class="text-2xl">🎮</span>
</div>
<h3 class="font-semibold text-lg">{{ console.name }}</h3>
<p class="text-gray-500 text-sm mt-1">{{ console.gamesCount }} games</p>
</div>
</Link>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import AppLayout from '@/Shared/Layouts/AppLayout.vue';
import { Head, Link } from '@inertiajs/vue3';
import type { ConsoleData, GameData } from '@/types';
interface PaginatedGames {
data: GameData[];
links: Array<{ url: string | null; label: string; active: boolean }>;
}
interface Props {
selectedConsole: ConsoleData;
games: PaginatedGames;
user: {
name: string;
email: string;
};
}
defineProps<Props>();
</script>
<template>
<AppLayout :title="selectedConsole.name" :user="user">
<template #header>
<div class="flex items-center gap-2">
<Link :href="route('consoles.index')" class="text-purple-600 hover:underline">
Consoles
</Link>
<span class="text-gray-400">/</span>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ selectedConsole.name }}</h2>
</div>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div v-if="games.data.length === 0" class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-12 text-center">
<p class="text-gray-500 text-lg">No games found for this console.</p>
<Link
:href="route('upload.index')"
class="mt-4 inline-block bg-purple-600 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded"
>
Upload ROMs
</Link>
</div>
<div v-else class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<Link
v-for="game in games.data"
:key="game.id"
:href="route('games.show', game.slug)"
class="bg-white overflow-hidden shadow-xl sm:rounded-lg overflow-hidden hover:shadow-2xl transition-shadow"
>
<div class="aspect-[3/4] bg-gray-200 flex items-center justify-center overflow-hidden">
<img
v-if="game.coverPath"
:src="'/storage/' + game.coverPath"
:alt="game.title"
class="w-full h-full object-cover"
/>
<span v-else class="text-gray-400 text-sm">No cover</span>
</div>
<div class="p-2">
<div class="font-medium text-sm truncate">{{ game.title }}</div>
<div class="text-xs text-gray-500">{{ game.romFiles?.length ?? 0 }} ROM(s)</div>
</div>
</Link>
</div>
<!-- Pagination -->
<div v-if="games.links.length > 3" class="mt-8 flex justify-center gap-2">
<Link
v-for="link in games.links"
:key="link.label"
:href="link.url || '#'"
class="px-3 py-1 rounded text-sm"
:class="{
'bg-purple-600 text-white': link.active,
'bg-white text-gray-700 hover:bg-gray-50': !link.active && link.url,
'text-gray-300 cursor-not-allowed': !link.url,
}"
v-html="link.label"
/>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,122 @@
<script setup lang="ts">
import AppLayout from '@/Shared/Layouts/AppLayout.vue';
import { Head, Link } from '@inertiajs/vue3';
import type { ConsoleData, GameData, DashboardStatsData } from '@/types';
interface Props {
stats: DashboardStatsData;
recentGames: GameData[];
consoles: ConsoleData[];
user: {
name: string;
email: string;
};
}
defineProps<Props>();
</script>
<template>
<AppLayout title="Dashboard" :user="user">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Dashboard</h2>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<!-- Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<div class="text-2xl font-bold text-purple-600">{{ stats.totalGames }}</div>
<div class="text-gray-500">Games</div>
</div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<div class="text-2xl font-bold text-purple-600">{{ stats.totalConsoles }}</div>
<div class="text-gray-500">Consoles</div>
</div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<div class="text-2xl font-bold text-purple-600">{{ stats.totalRoms }}</div>
<div class="text-gray-500">ROM Files</div>
</div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<div class="text-2xl font-bold text-purple-600">{{ stats.userSaves }}</div>
<div class="text-gray-500">Save Files</div>
</div>
</div>
<!-- Quick Actions -->
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6 mb-8">
<h3 class="text-lg font-semibold mb-4">Quick Actions</h3>
<div class="flex gap-4">
<Link
:href="route('upload.index')"
class="bg-purple-600 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded"
>
Upload ROMs
</Link>
<Link
:href="route('consoles.index')"
class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded"
>
Browse Library
</Link>
<Link
:href="route('scraper.index')"
class="bg-amber-600 hover:bg-amber-700 text-white font-bold py-2 px-4 rounded"
>
Cover Scraper
</Link>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Recent Games -->
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-4">Recent Games</h3>
<div v-if="recentGames.length === 0" class="text-gray-500">
No games uploaded yet.
</div>
<div v-else class="space-y-3">
<Link
v-for="game in recentGames"
:key="game.id"
:href="route('games.show', game.slug)"
class="flex items-center gap-3 p-2 rounded hover:bg-gray-50"
>
<div class="w-10 h-14 bg-gray-200 rounded flex items-center justify-center overflow-hidden">
<img
v-if="game.coverPath"
:src="'/storage/' + game.coverPath"
:alt="game.title"
class="w-full h-full object-cover"
/>
<span v-else class="text-xs text-gray-400">No cover</span>
</div>
<div>
<div class="font-medium">{{ game.title }}</div>
<div class="text-sm text-gray-500">{{ game.console?.name }}</div>
</div>
</Link>
</div>
</div>
<!-- Consoles -->
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-4">Consoles</h3>
<div class="grid grid-cols-2 gap-3">
<Link
v-for="console in consoles"
:key="console.id"
:href="route('consoles.show', console.slug)"
class="p-3 border rounded-lg hover:bg-gray-50"
>
<div class="font-medium">{{ console.name }}</div>
<div class="text-sm text-gray-500">{{ console.gamesCount }} games</div>
</Link>
</div>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
+148
View File
@@ -0,0 +1,148 @@
<script setup lang="ts">
import AppLayout from '@/Shared/Layouts/AppLayout.vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
import type { GameData, ConsoleData } from '@/types';
interface Props {
game: GameData;
consoles: ConsoleData[];
user: {
name: string;
email: string;
};
}
const props = defineProps<Props>();
const form = useForm({
title: props.game.title,
description: props.game.description || '',
release_year: props.game.releaseYear || '',
developer: props.game.developer || '',
publisher: props.game.publisher || '',
genre: props.game.genre || '',
rating: props.game.rating || '',
});
const submit = () => {
form.put(route('games.update', props.game.slug));
};
</script>
<template>
<AppLayout :title="'Edit ' + game.title" :user="user">
<template #header>
<div class="flex items-center gap-2">
<Link :href="route('games.show', game.slug)" class="text-purple-600 hover:underline">
{{ game.title }}
</Link>
<span class="text-gray-400">/</span>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Edit</h2>
</div>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<form @submit.prevent="submit" class="space-y-6">
<div>
<label for="title" class="block text-sm font-medium text-gray-700">Title</label>
<input
id="title"
v-model="form.title"
type="text"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500"
/>
<div v-if="form.errors.title" class="text-red-500 text-sm mt-1">{{ form.errors.title }}</div>
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700">Description</label>
<textarea
id="description"
v-model="form.description"
rows="4"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="release_year" class="block text-sm font-medium text-gray-700">Release Year</label>
<input
id="release_year"
v-model="form.release_year"
type="number"
min="1970"
max="2030"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div>
<label for="rating" class="block text-sm font-medium text-gray-700">Rating (0-10)</label>
<input
id="rating"
v-model="form.rating"
type="number"
min="0"
max="10"
step="0.1"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="developer" class="block text-sm font-medium text-gray-700">Developer</label>
<input
id="developer"
v-model="form.developer"
type="text"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div>
<label for="publisher" class="block text-sm font-medium text-gray-700">Publisher</label>
<input
id="publisher"
v-model="form.publisher"
type="text"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
<div>
<label for="genre" class="block text-sm font-medium text-gray-700">Genre</label>
<input
id="genre"
v-model="form.genre"
type="text"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div class="flex justify-end gap-2">
<Link
:href="route('games.show', game.slug)"
class="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
Cancel
</Link>
<button
type="submit"
:disabled="form.processing"
class="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50"
>
Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
</AppLayout>
</template>
+164
View File
@@ -0,0 +1,164 @@
<script setup lang="ts">
import AppLayout from '@/Shared/Layouts/AppLayout.vue';
import { Head, Link, router } from '@inertiajs/vue3';
import type { GameData } from '@/types';
import { ref } from 'vue';
interface Props {
game: GameData;
user: {
name: string;
email: string;
};
}
const props = defineProps<Props>();
const showDeleteModal = ref(false);
const deleteGame = () => {
router.delete(route('games.destroy', props.game.slug));
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
</script>
<template>
<AppLayout :title="game.title" :user="user">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Link :href="route('consoles.index')" class="text-purple-600 hover:underline">
Consoles
</Link>
<span class="text-gray-400">/</span>
<Link :href="route('consoles.show', game.console?.slug)" class="text-purple-600 hover:underline">
{{ game.console?.name }}
</Link>
<span class="text-gray-400">/</span>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ game.title }}</h2>
</div>
<div class="flex gap-2">
<Link
:href="route('games.edit', game.slug)"
class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded"
>
Edit
</Link>
<button
@click="showDeleteModal = true"
class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
>
Delete
</button>
</div>
</div>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<div class="flex gap-6">
<!-- Cover -->
<div class="w-48 h-64 bg-gray-200 rounded-lg overflow-hidden flex-shrink-0">
<img
v-if="game.coverPath"
:src="'/storage/' + game.coverPath"
:alt="game.title"
class="w-full h-full object-cover"
/>
<div v-else class="w-full h-full flex items-center justify-center text-gray-400">
No cover
</div>
</div>
<!-- Details -->
<div class="flex-1">
<h1 class="text-2xl font-bold mb-4">{{ game.title }}</h1>
<dl class="grid grid-cols-2 gap-4">
<div v-if="game.releaseYear">
<dt class="text-gray-500 text-sm">Release Year</dt>
<dd class="font-medium">{{ game.releaseYear }}</dd>
</div>
<div v-if="game.developer">
<dt class="text-gray-500 text-sm">Developer</dt>
<dd class="font-medium">{{ game.developer }}</dd>
</div>
<div v-if="game.publisher">
<dt class="text-gray-500 text-sm">Publisher</dt>
<dd class="font-medium">{{ game.publisher }}</dd>
</div>
<div v-if="game.genre">
<dt class="text-gray-500 text-sm">Genre</dt>
<dd class="font-medium">{{ game.genre }}</dd>
</div>
<div v-if="game.rating">
<dt class="text-gray-500 text-sm">Rating</dt>
<dd class="font-medium">{{ game.rating }}/10</dd>
</div>
</dl>
<div v-if="game.description" class="mt-4">
<dt class="text-gray-500 text-sm mb-1">Description</dt>
<dd class="text-gray-700">{{ game.description }}</dd>
</div>
</div>
</div>
<!-- ROM Files -->
<div v-if="game.romFiles?.length" class="mt-6 border-t pt-6">
<h3 class="text-lg font-semibold mb-4">ROM Files</h3>
<div class="space-y-2">
<div
v-for="rom in game.romFiles"
:key="rom.id"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div>
<div class="font-medium">{{ rom.fileName }}</div>
<div class="text-sm text-gray-500">{{ formatFileSize(rom.fileSize) }}</div>
</div>
<Link
:href="route('games.download', { game: game.slug, romFile: rom.id })"
class="bg-purple-600 hover:bg-purple-700 text-white text-sm py-1 px-3 rounded"
>
Download
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Modal -->
<teleport to="body">
<div v-if="showDeleteModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-bold mb-4">Delete Game</h3>
<p class="text-gray-600 mb-6">
Are you sure you want to delete "{{ game.title }}"? This will also delete all associated ROM files.
</p>
<div class="flex justify-end gap-2">
<button
@click="showDeleteModal = false"
class="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
Cancel
</button>
<button
@click="deleteGame"
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
</teleport>
</AppLayout>
</template>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
interface Props {
canLogin?: boolean;
canRegister?: boolean;
}
defineProps<Props>();
</script>
<template>
<div class="min-h-screen bg-gradient-to-br from-purple-900 via-indigo-900 to-purple-800">
<Head title="RetroHub" />
<div class="flex flex-col items-center justify-center min-h-screen px-4">
<div class="text-center">
<h1 class="text-6xl font-bold text-white mb-4">RetroHub</h1>
<p class="text-xl text-purple-200 mb-8">Your retro gaming library</p>
<div v-if="canLogin" class="flex gap-4 justify-center">
<Link
v-if="$page.props.auth?.user"
:href="route('dashboard')"
class="bg-purple-600 hover:bg-purple-700 text-white font-bold py-3 px-6 rounded-lg transition"
>
Dashboard
</Link>
<template v-else>
<Link
:href="route('login')"
class="bg-white hover:bg-gray-100 text-purple-900 font-bold py-3 px-6 rounded-lg transition"
>
Log in
</Link>
<Link
v-if="canRegister"
:href="route('register')"
class="bg-purple-600 hover:bg-purple-700 text-white font-bold py-3 px-6 rounded-lg transition"
>
Register
</Link>
</template>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import AppLayout from '@/Shared/Layouts/AppLayout.vue';
import DeleteUserForm from '@/Features/Auth/Pages/Partials/DeleteUserForm.vue';
import UpdatePasswordForm from '@/Features/Auth/Pages/Partials/UpdatePasswordForm.vue';
import UpdateProfileInformationForm from '@/Features/Auth/Pages/Partials/UpdateProfileInformationForm.vue';
import { Head } from '@inertiajs/vue3';
defineProps({
mustVerifyEmail: {
type: Boolean,
},
status: {
type: String,
},
});
</script>
<template>
<Head title="Profile" />
<AppLayout>
<template #header>
<h2 class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
Profile
</h2>
</template>
<div class="py-12">
<div class="mx-auto max-w-7xl space-y-6 sm:px-6 lg:px-8">
<div class="bg-white p-4 shadow sm:rounded-lg sm:p-8 dark:bg-gray-800">
<!-- @ts-expect-error user comes from page props via usePage -->
<UpdateProfileInformationForm
:must-verify-email="mustVerifyEmail"
:status="status"
class="max-w-xl"
/>
</div>
<div class="bg-white p-4 shadow sm:rounded-lg sm:p-8 dark:bg-gray-800">
<UpdatePasswordForm class="max-w-xl" />
</div>
<div class="bg-white p-4 shadow sm:rounded-lg sm:p-8 dark:bg-gray-800">
<DeleteUserForm class="max-w-xl" />
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,139 @@
<script setup lang="ts">
import AppLayout from '@/Shared/Layouts/AppLayout.vue';
import { Head, Link, router } from '@inertiajs/vue3';
import type { ConsoleData, ScraperResultData } from '@/types';
import { ref } from 'vue';
interface Props {
consoles: ConsoleData[];
user: {
name: string;
email: string;
};
}
defineProps<Props>();
const searchQuery = ref('');
const selectedConsole = ref('');
const results = ref<ScraperResultData[]>([]);
const isSearching = ref(false);
const selectedGameSlug = ref<string | null>(null);
const search = async () => {
if (searchQuery.value.length < 2) return;
isSearching.value = true;
try {
const response = await fetch(
route('scraper.search') + '?query=' + encodeURIComponent(searchQuery.value) +
(selectedConsole.value ? '&console=' + selectedConsole.value : '')
);
const data = await response.json();
results.value = data.results || [];
} catch (error) {
console.error('Search failed:', error);
}
isSearching.value = false;
};
const selectGame = (slug: string) => {
selectedGameSlug.value = slug;
};
const applyCover = async (gameSlug: string, coverUrl: string) => {
router.post(route('scraper.apply', gameSlug), {
cover_url: coverUrl,
}, {
preserveScroll: true,
});
};
</script>
<template>
<AppLayout title="Cover Scraper" :user="user">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Cover Scraper</h2>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<!-- Search Form -->
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<div class="flex gap-4">
<div class="flex-1">
<input
v-model="searchQuery"
type="text"
placeholder="Search for a game..."
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500"
@keyup.enter="search"
/>
</div>
<select
v-model="selectedConsole"
class="border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500"
>
<option value="">All Consoles</option>
<option v-for="console in consoles" :key="console.id" :value="console.slug">
{{ console.name }}
</option>
</select>
<button
@click="search"
:disabled="isSearching"
class="bg-purple-600 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded disabled:opacity-50"
>
{{ isSearching ? 'Searching...' : 'Search' }}
</button>
</div>
</div>
<!-- Results -->
<div v-if="results.length > 0" class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-4">Search Results</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="result in results"
:key="result.id"
class="border rounded-lg p-4 hover:shadow-md transition"
>
<div class="flex gap-4">
<div class="w-20 h-28 bg-gray-200 rounded overflow-hidden flex-shrink-0">
<img
v-if="result.coverUrl"
:src="result.coverUrl"
:alt="result.title"
class="w-full h-full object-cover"
/>
<div v-else class="w-full h-full flex items-center justify-center text-gray-400 text-xs">
No cover
</div>
</div>
<div class="flex-1">
<div class="font-medium">{{ result.title }}</div>
<div class="text-sm text-gray-500">{{ result.year }}</div>
<div class="text-xs text-gray-400 mt-1">Source: {{ result.source }}</div>
<div class="mt-2 flex gap-2">
<button
v-if="result.coverUrl"
@click="selectGame(result.title)"
class="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded hover:bg-purple-200"
>
Apply Cover
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else-if="searchQuery && !isSearching" class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-12 text-center">
<p class="text-gray-500">No results found for "{{ searchQuery }}".</p>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,124 @@
<script setup lang="ts">
import AppLayout from '@/Shared/Layouts/AppLayout.vue';
import { Head, Link, useForm, router } from '@inertiajs/vue3';
import type { WebDavAccountData } from '@/types';
interface Props {
webDavAccounts: WebDavAccountData[];
appUrl: string;
user: {
name: string;
email: string;
};
}
defineProps<Props>();
const form = useForm({
username: '',
password: '',
});
const submit = () => {
form.post(route('settings.webdav.store'), {
preserveScroll: true,
onSuccess: () => {
form.reset();
},
});
};
const deleteAccount = (id: number) => {
if (confirm('Are you sure you want to delete this WebDAV account?')) {
router.delete(route('settings.webdav.destroy', id));
}
};
</script>
<template>
<AppLayout title="WebDAV Settings" :user="user">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">WebDAV Settings</h2>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<!-- Instructions -->
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-4">EmuDeck Setup Instructions</h3>
<div class="prose max-w-none">
<ol class="list-decimal list-inside space-y-2">
<li>Install rclone on your Steam Deck or Desktop</li>
<li>Run <code class="bg-gray-100 px-2 py-1 rounded">rclone config</code> and create a new WebDAV remote</li>
<li>Set the URL to: <code class="bg-gray-100 px-2 py-1 rounded">{{ appUrl }}/webdav/default</code></li>
<li>Set the vendor to "other"</li>
<li>Enter your WebDAV username and password below</li>
<li>Configure EmuDeck CloudSync to use this remote</li>
</ol>
</div>
</div>
<!-- Create Account -->
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-4">Create WebDAV Account</h3>
<form @submit.prevent="submit" class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700">Username</label>
<input
id="username"
v-model="form.username"
type="text"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500"
required
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<input
id="password"
v-model="form.password"
type="password"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500"
required
minlength="8"
/>
</div>
<button
type="submit"
:disabled="form.processing"
class="bg-purple-600 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded disabled:opacity-50"
>
Create Account
</button>
</form>
</div>
<!-- Existing Accounts -->
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-4">Existing Accounts</h3>
<div v-if="webDavAccounts.length === 0" class="text-gray-500">
No WebDAV accounts created yet.
</div>
<div v-else class="space-y-3">
<div
v-for="account in webDavAccounts"
:key="account.id"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div>
<div class="font-medium">{{ account.username }}</div>
<div class="text-sm text-gray-500">{{ account.displayName }}</div>
</div>
<button
@click="deleteAccount(account.id)"
class="text-red-500 hover:text-red-700 text-sm"
>
Delete
</button>
</div>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,142 @@
<script setup lang="ts">
import AppLayout from '@/Shared/Layouts/AppLayout.vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
import type { ConsoleData } from '@/types';
import { ref } from 'vue';
interface Props {
consoles: ConsoleData[];
user: {
name: string;
email: string;
};
}
defineProps<Props>();
const form = useForm({
console_id: '',
rom_files: [] as File[],
});
const fileInput = ref<HTMLInputElement | null>(null);
const isDragging = ref(false);
const handleFileSelect = (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.files) {
form.rom_files = Array.from(target.files);
}
};
const handleDrop = (e: DragEvent) => {
isDragging.value = false;
if (e.dataTransfer?.files) {
form.rom_files = Array.from(e.dataTransfer.files);
}
};
const submit = () => {
form.post(route('upload.store'), {
preserveScroll: true,
onSuccess: () => {
form.reset();
},
});
};
</script>
<template>
<AppLayout title="Upload ROMs" :user="user">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Upload ROMs</h2>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<form @submit.prevent="submit" class="space-y-6">
<!-- Console Selection -->
<div>
<label for="console" class="block text-sm font-medium text-gray-700">Select Console</label>
<select
id="console"
v-model="form.console_id"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500"
required
>
<option value="">Choose a console...</option>
<option v-for="console in consoles" :key="console.id" :value="console.id">
{{ console.name }}
</option>
</select>
</div>
<!-- File Drop Zone -->
<div
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
:class="[
'border-2 border-dashed rounded-lg p-12 text-center transition-colors',
isDragging ? 'border-purple-500 bg-purple-50' : 'border-gray-300 hover:border-gray-400'
]"
>
<div class="space-y-4">
<div class="text-4xl">📁</div>
<div class="text-gray-600">
<p class="text-lg">Drag & drop ROM files here</p>
<p class="text-sm text-gray-400">or click to browse</p>
</div>
<button
type="button"
@click="fileInput?.click()"
class="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-2 px-4 rounded"
>
Select Files
</button>
<input
ref="fileInput"
type="file"
multiple
accept=".nes,.sfc,.smc,.n64,.v64,.z64,.gba,.nds,.md,.bin,.gen,.iso,.gcm,.cue,.gdi,.cdi,.cso"
@change="handleFileSelect"
class="hidden"
/>
</div>
</div>
<!-- Selected Files -->
<div v-if="form.rom_files.length > 0" class="space-y-2">
<h4 class="font-medium">Selected Files ({{ form.rom_files.length }})</h4>
<div
v-for="(file, index) in form.rom_files"
:key="index"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<span class="text-sm">{{ file.name }}</span>
<button
type="button"
@click="form.rom_files.splice(index, 1)"
class="text-red-500 hover:text-red-700 text-sm"
>
Remove
</button>
</div>
</div>
<div class="flex justify-end">
<button
type="submit"
:disabled="form.processing || form.rom_files.length === 0"
class="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50"
>
{{ form.processing ? 'Uploading...' : 'Upload ROMs' }}
</button>
</div>
</form>
</div>
</div>
</div>
</AppLayout>
</template>