build: initialize RetroHub with Laravel, Inertia, Vue, and Horizon
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user