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>
@@ -0,0 +1,7 @@
<template>
<svg viewBox="0 0 316 316" xmlns="http://www.w3.org/2000/svg">
<path
d="M305.8 81.125C305.77 80.995 305.69 80.885 305.65 80.755C305.56 80.525 305.49 80.285 305.37 80.075C305.29 79.935 305.17 79.815 305.07 79.685C304.94 79.515 304.83 79.325 304.68 79.175C304.55 79.045 304.39 78.955 304.25 78.845C304.09 78.715 303.95 78.575 303.77 78.475L251.32 48.275C249.97 47.495 248.31 47.495 246.96 48.275L194.51 78.475C194.33 78.575 194.19 78.725 194.03 78.845C193.89 78.955 193.73 79.045 193.6 79.175C193.45 79.325 193.34 79.515 193.21 79.685C193.11 79.815 192.99 79.935 192.91 80.075C192.79 80.285 192.71 80.525 192.63 80.755C192.58 80.875 192.51 80.995 192.48 81.125C192.38 81.495 192.33 81.875 192.33 82.265V139.625L148.62 164.795V52.575C148.62 52.185 148.57 51.805 148.47 51.435C148.44 51.305 148.36 51.195 148.32 51.065C148.23 50.835 148.16 50.595 148.04 50.385C147.96 50.245 147.84 50.125 147.74 49.995C147.61 49.825 147.5 49.635 147.35 49.485C147.22 49.355 147.06 49.265 146.92 49.155C146.76 49.025 146.62 48.885 146.44 48.785L93.99 18.585C92.64 17.805 90.98 17.805 89.63 18.585L37.18 48.785C37 48.885 36.86 49.035 36.7 49.155C36.56 49.265 36.4 49.355 36.27 49.485C36.12 49.635 36.01 49.825 35.88 49.995C35.78 50.125 35.66 50.245 35.58 50.385C35.46 50.595 35.38 50.835 35.3 51.065C35.25 51.185 35.18 51.305 35.15 51.435C35.05 51.805 35 52.185 35 52.575V232.235C35 233.795 35.84 235.245 37.19 236.025L142.1 296.425C142.33 296.555 142.58 296.635 142.82 296.725C142.93 296.765 143.04 296.835 143.16 296.865C143.53 296.965 143.9 297.015 144.28 297.015C144.66 297.015 145.03 296.965 145.4 296.865C145.5 296.835 145.59 296.775 145.69 296.745C145.95 296.655 146.21 296.565 146.45 296.435L251.36 236.035C252.72 235.255 253.55 233.815 253.55 232.245V174.885L303.81 145.945C305.17 145.165 306 143.725 306 142.155V82.265C305.95 81.875 305.89 81.495 305.8 81.125ZM144.2 227.205L100.57 202.515L146.39 176.135L196.66 147.195L240.33 172.335L208.29 190.625L144.2 227.205ZM244.75 114.995V164.795L226.39 154.225L201.03 139.625V89.825L219.39 100.395L244.75 114.995ZM249.12 57.105L292.81 82.265L249.12 107.425L205.43 82.265L249.12 57.105ZM114.49 184.425L96.13 194.995V85.305L121.49 70.705L139.85 60.135V169.815L114.49 184.425ZM91.76 27.425L135.45 52.585L91.76 77.745L48.07 52.585L91.76 27.425ZM43.67 60.135L62.03 70.705L87.39 85.305V202.545V202.555V202.565C87.39 202.735 87.44 202.895 87.46 203.055C87.49 203.265 87.49 203.485 87.55 203.695V203.705C87.6 203.875 87.69 204.035 87.76 204.195C87.84 204.375 87.89 204.575 87.99 204.745C87.99 204.745 87.99 204.755 88 204.755C88.09 204.905 88.22 205.035 88.33 205.175C88.45 205.335 88.55 205.495 88.69 205.635L88.7 205.645C88.82 205.765 88.98 205.855 89.12 205.965C89.28 206.085 89.42 206.225 89.59 206.325C89.6 206.325 89.6 206.325 89.61 206.335C89.62 206.335 89.62 206.345 89.63 206.345L139.87 234.775V285.065L43.67 229.705V60.135ZM244.75 229.705L148.58 285.075V234.775L219.8 194.115L244.75 179.875V229.705ZM297.2 139.625L253.49 164.795V114.995L278.85 100.395L297.21 89.825V139.625H297.2Z"
/>
</svg>
</template>
@@ -0,0 +1,34 @@
<script setup>
import { computed } from 'vue';
const emit = defineEmits(['update:checked']);
const props = defineProps({
checked: {
type: [Array, Boolean],
required: true,
},
value: {
default: null,
},
});
const proxyChecked = computed({
get() {
return props.checked;
},
set(val) {
emit('update:checked', val);
},
});
</script>
<template>
<input
type="checkbox"
:value="value"
v-model="proxyChecked"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:focus:ring-indigo-600 dark:focus:ring-offset-gray-800"
/>
</template>
@@ -0,0 +1,7 @@
<template>
<button
class="inline-flex items-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 active:bg-red-700 dark:focus:ring-offset-gray-800"
>
<slot />
</button>
</template>
@@ -0,0 +1,84 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
const props = defineProps({
align: {
type: String,
default: 'right',
},
width: {
type: String,
default: '48',
},
contentClasses: {
type: String,
default: 'py-1 bg-white dark:bg-gray-700',
},
});
const closeOnEscape = (e) => {
if (open.value && e.key === 'Escape') {
open.value = false;
}
};
onMounted(() => document.addEventListener('keydown', closeOnEscape));
onUnmounted(() => document.removeEventListener('keydown', closeOnEscape));
const widthClass = computed(() => {
return {
48: 'w-48',
}[props.width.toString()];
});
const alignmentClasses = computed(() => {
if (props.align === 'left') {
return 'ltr:origin-top-left rtl:origin-top-right start-0';
} else if (props.align === 'right') {
return 'ltr:origin-top-right rtl:origin-top-left end-0';
} else {
return 'origin-top';
}
});
const open = ref(false);
</script>
<template>
<div class="relative">
<div @click="open = !open">
<slot name="trigger" />
</div>
<!-- Full Screen Dropdown Overlay -->
<div
v-show="open"
class="fixed inset-0 z-40"
@click="open = false"
></div>
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-show="open"
class="absolute z-50 mt-2 rounded-md shadow-lg"
:class="[widthClass, alignmentClasses]"
style="display: none"
@click="open = false"
>
<div
class="rounded-md ring-1 ring-black ring-opacity-5"
:class="contentClasses"
>
<slot name="content" />
</div>
</div>
</Transition>
</div>
</template>
@@ -0,0 +1,19 @@
<script setup>
import { Link } from '@inertiajs/vue3';
defineProps({
href: {
type: String,
required: true,
},
});
</script>
<template>
<Link
:href="href"
class="block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 transition duration-150 ease-in-out hover:bg-gray-100 focus:bg-gray-100 focus:outline-none dark:text-gray-300 dark:hover:bg-gray-800 dark:focus:bg-gray-800"
>
<slot />
</Link>
</template>
@@ -0,0 +1,15 @@
<script setup>
defineProps({
message: {
type: String,
},
});
</script>
<template>
<div v-show="message">
<p class="text-sm text-red-600 dark:text-red-400">
{{ message }}
</p>
</div>
</template>
@@ -0,0 +1,14 @@
<script setup>
defineProps({
value: {
type: String,
},
});
</script>
<template>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
<span v-if="value">{{ value }}</span>
<span v-else><slot /></span>
</label>
</template>
+123
View File
@@ -0,0 +1,123 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
maxWidth: {
type: String,
default: '2xl',
},
closeable: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['close']);
const dialog = ref();
const showSlot = ref(props.show);
watch(
() => props.show,
() => {
if (props.show) {
document.body.style.overflow = 'hidden';
showSlot.value = true;
dialog.value?.showModal();
} else {
document.body.style.overflow = '';
setTimeout(() => {
dialog.value?.close();
showSlot.value = false;
}, 200);
}
},
);
const close = () => {
if (props.closeable) {
emit('close');
}
};
const closeOnEscape = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
if (props.show) {
close();
}
}
};
onMounted(() => document.addEventListener('keydown', closeOnEscape));
onUnmounted(() => {
document.removeEventListener('keydown', closeOnEscape);
document.body.style.overflow = '';
});
const maxWidthClass = computed(() => {
return {
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
}[props.maxWidth];
});
</script>
<template>
<dialog
class="z-50 m-0 min-h-full min-w-full overflow-y-auto bg-transparent backdrop:bg-transparent"
ref="dialog"
>
<div
class="fixed inset-0 z-50 overflow-y-auto px-4 py-6 sm:px-0"
scroll-region
>
<Transition
enter-active-class="ease-out duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="ease-in duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-show="show"
class="fixed inset-0 transform transition-all"
@click="close"
>
<div
class="absolute inset-0 bg-gray-500 opacity-75 dark:bg-gray-900"
/>
</div>
</Transition>
<Transition
enter-active-class="ease-out duration-300"
enter-from-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to-class="opacity-100 translate-y-0 sm:scale-100"
leave-active-class="ease-in duration-200"
leave-from-class="opacity-100 translate-y-0 sm:scale-100"
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div
v-show="show"
class="mb-6 transform overflow-hidden rounded-lg bg-white shadow-xl transition-all sm:mx-auto sm:w-full dark:bg-gray-800"
:class="maxWidthClass"
>
<slot v-if="showSlot" />
</div>
</Transition>
</div>
</dialog>
</template>
@@ -0,0 +1,26 @@
<script setup>
import { computed } from 'vue';
import { Link } from '@inertiajs/vue3';
const props = defineProps({
href: {
type: String,
required: true,
},
active: {
type: Boolean,
},
});
const classes = computed(() =>
props.active
? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 dark:border-indigo-600 text-sm font-medium leading-5 text-gray-900 dark:text-gray-100 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out'
: 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-700 focus:outline-none focus:text-gray-700 dark:focus:text-gray-300 focus:border-gray-300 dark:focus:border-gray-700 transition duration-150 ease-in-out',
);
</script>
<template>
<Link :href="href" :class="classes">
<slot />
</Link>
</template>
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import NavLink from './NavLink.vue';
interface Props {
user: {
name: string;
email: string;
};
}
defineProps<Props>();
</script>
<template>
<nav class="bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="shrink-0 flex items-center">
<Link :href="route('dashboard')">
<h1 class="text-xl font-bold text-indigo-600">RetroHub</h1>
</Link>
</div>
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<NavLink :href="route('dashboard')" :active="route().current('dashboard')">
Dashboard
</NavLink>
<NavLink :href="route('consoles.index')" :active="route().current('consoles.*')">
Consoles
</NavLink>
<NavLink :href="route('upload.index')" :active="route().current('upload.*')">
Upload
</NavLink>
<NavLink :href="route('scraper.index')" :active="route().current('scraper.*')">
Scraper
</NavLink>
</div>
</div>
<div class="hidden sm:flex sm:items-center sm:ms-6">
<div class="ms-3 relative">
<div class="flex items-center gap-3">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ user.name }}</span>
<Link
:href="route('logout')"
method="post"
as="button"
class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
Log out
</Link>
</div>
</div>
</div>
<div class="-me-2 flex items-center sm:hidden">
<button
@click="showingNavigationDropdown = !showingNavigationDropdown"
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-800 focus:text-gray-500 dark:focus:text-gray-400 transition duration-150 ease-in-out"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path v-if="!showingNavigationDropdown" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<div :class="{ block: showingNavigationDropdown, hidden: !showingNavigationDropdown }" class="sm:hidden">
<div class="pt-2 pb-3 space-y-1">
<ResponsiveNavLink :href="route('dashboard')" :active="route().current('dashboard')">
Dashboard
</ResponsiveNavLink>
<ResponsiveNavLink :href="route('consoles.index')" :active="route().current('consoles.*')">
Consoles
</ResponsiveNavLink>
<ResponsiveNavLink :href="route('upload.index')" :active="route().current('upload.*')">
Upload
</ResponsiveNavLink>
<ResponsiveNavLink :href="route('scraper.index')" :active="route().current('scraper.*')">
Scraper
</ResponsiveNavLink>
</div>
<div class="border-t border-gray-200 dark:border-gray-600 pt-4 pb-1">
<div class="px-4">
<div class="font-medium text-base text-gray-800 dark:text-gray-200">{{ user.name }}</div>
<div class="font-medium text-sm text-gray-500">{{ user.email }}</div>
</div>
<div class="mt-3 space-y-1">
<ResponsiveNavLink :href="route('profile.edit')">
Profile
</ResponsiveNavLink>
<ResponsiveNavLink :href="route('logout')" method="post" as="button">
Log Out
</ResponsiveNavLink>
</div>
</div>
</div>
</nav>
</template>
<script lang="ts">
import ResponsiveNavLink from './ResponsiveNavLink.vue';
export default {
components: {
ResponsiveNavLink,
},
data() {
return {
showingNavigationDropdown: false,
};
},
};
</script>
@@ -0,0 +1,15 @@
<script setup lang="ts">
interface Props {
title?: string;
}
defineProps<Props>();
</script>
<template>
<header v-if="title" class="bg-white dark:bg-gray-800 shadow">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<slot />
</div>
</header>
</template>
@@ -0,0 +1,7 @@
<template>
<button
class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300"
>
<slot />
</button>
</template>
@@ -0,0 +1,26 @@
<script setup>
import { computed } from 'vue';
import { Link } from '@inertiajs/vue3';
const props = defineProps({
href: {
type: String,
required: true,
},
active: {
type: Boolean,
},
});
const classes = computed(() =>
props.active
? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 dark:border-indigo-600 text-start text-base font-medium text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/50 focus:outline-none focus:text-indigo-800 dark:focus:text-indigo-200 focus:bg-indigo-100 dark:focus:bg-indigo-900 focus:border-indigo-700 dark:focus:border-indigo-300 transition duration-150 ease-in-out'
: 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-600 focus:outline-none focus:text-gray-800 dark:focus:text-gray-200 focus:bg-gray-50 dark:focus:bg-gray-700 focus:border-gray-300 dark:focus:border-gray-600 transition duration-150 ease-in-out',
);
</script>
<template>
<Link :href="href" :class="classes">
<slot />
</Link>
</template>
@@ -0,0 +1,17 @@
<script setup>
defineProps({
type: {
type: String,
default: 'button',
},
});
</script>
<template>
<button
:type="type"
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-xs font-semibold uppercase tracking-widest text-gray-700 shadow-sm transition duration-150 ease-in-out hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25 dark:border-gray-500 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:focus:ring-offset-gray-800"
>
<slot />
</button>
</template>
@@ -0,0 +1,26 @@
<script setup>
import { onMounted, ref } from 'vue';
const model = defineModel({
type: String,
required: true,
});
const input = ref(null);
onMounted(() => {
if (input.value.hasAttribute('autofocus')) {
input.value.focus();
}
});
defineExpose({ focus: () => input.value.focus() });
</script>
<template>
<input
class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600"
v-model="model"
ref="input"
/>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import Navigation from '@/Shared/Components/Navigation.vue';
interface Props {
title?: string;
user: {
name: string;
email: string;
};
}
defineProps<Props>();
</script>
<template>
<div class="min-h-screen bg-gray-100 dark:bg-gray-900">
<Navigation :user="user" />
<header v-if="$slots.header" class="bg-white dark:bg-gray-800 shadow">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<slot name="header" />
</div>
</header>
<main>
<slot />
</main>
</div>
</template>
@@ -0,0 +1,22 @@
<script setup>
import ApplicationLogo from '@/Shared/Components/ApplicationLogo.vue';
import { Link } from '@inertiajs/vue3';
</script>
<template>
<div
class="flex min-h-screen flex-col items-center bg-gray-100 pt-6 sm:justify-center sm:pt-0 dark:bg-gray-900"
>
<div>
<Link href="/">
<ApplicationLogo class="h-20 w-20 fill-current text-gray-500" />
</Link>
</div>
<div
class="mt-6 w-full overflow-hidden bg-white px-6 py-4 shadow-md sm:max-w-md sm:rounded-lg dark:bg-gray-800"
>
<slot />
</div>
</div>
</template>
+43
View File
@@ -0,0 +1,43 @@
import '../css/app.css';
import './bootstrap';
import { createInertiaApp } from '@inertiajs/vue3';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createApp, DefineComponent, h } from 'vue';
import { ZiggyVue } from '../../vendor/tightenco/ziggy';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
const pages = import.meta.glob<DefineComponent>('./Features/**/*.vue');
createInertiaApp({
title: (title) => `${title} - ${appName}`,
resolve: (name) => {
const feature = name.includes('/') ? name.split('/').slice(0, -1).join('/') : '';
const pageName = name.includes('/') ? name.split('/').pop() : name;
const candidates = feature
? [
`./Features/${feature}/Pages/${pageName}.vue`,
`./Features/${feature}/${pageName}.vue`,
`./Features/${name}.vue`,
]
: [
`./Features/Pages/${pageName}.vue`,
`./Features/${pageName}.vue`,
];
const found = candidates.find((path) => pages[path]);
if (!found) {
throw new Error(`Page not found: ${name}`);
}
return resolvePageComponent(found, pages);
},
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.use(ZiggyVue)
.mount(el);
},
progress: {
color: '#4B5563',
},
});
+4
View File
@@ -0,0 +1,4 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
+91
View File
@@ -0,0 +1,91 @@
declare namespace App {
namespace Features {
namespace Consoles {
namespace Data {
export type ConsoleData = {
id: number;
name: string;
slug: string;
iconPath: string | null;
romExtensions: Array<any>;
igdbPlatformId: number | null;
tgdbPlatformId: number | null;
gamesCount: number;
};
}
}
namespace Games {
namespace Data {
export type DashboardStatsData = {
totalGames: number;
totalConsoles: number;
totalRoms: number;
userSaves: number;
};
export type GameData = {
id: number;
consoleId: number;
title: string;
slug: string;
igdbId: number | null;
tgdbId: number | null;
description: string | null;
releaseYear: number | null;
developer: string | null;
publisher: string | null;
genre: string | null;
rating: number | null;
coverPath: string | null;
thumbnailPath: string | null;
console: unknown | null;
romFiles: Array<any> | null;
};
export type RomFileData = {
id: number;
gameId: number;
filePath: string;
fileName: string;
fileSize: number;
fileHash: string;
region: string | null;
language: string | null;
};
}
}
namespace Scraper {
namespace Data {
export type ScraperResultData = {
id: number;
title: string;
coverUrl: string | null;
year: string | null;
source: string;
};
}
}
namespace Settings {
namespace Data {
export type SaveFileData = {
id: number;
userId: number;
gameId: number;
filePath: string;
fileName: string;
fileSize: number;
lastSyncedAt: string | null;
};
}
}
namespace WebDav {
namespace Data {
export type WebDavAccountData = {
id: number;
userId: number;
username: string;
displayName: string;
enabled: boolean;
};
}
}
}
}
@@ -0,0 +1,3 @@
{
"generated.d.ts": "3733eca6dbf6445da45a6d26cda73566"
}
+23
View File
@@ -0,0 +1,23 @@
import { PageProps as InertiaPageProps } from '@inertiajs/core';
import { AxiosInstance } from 'axios';
import { route as ziggyRoute } from 'ziggy-js';
import { PageProps as AppPageProps } from './';
declare global {
interface Window {
axios: AxiosInstance;
}
/* eslint-disable no-var */
var route: typeof ziggyRoute;
}
declare module 'vue' {
interface ComponentCustomProperties {
route: typeof ziggyRoute;
}
}
declare module '@inertiajs/core' {
interface PageProps extends InertiaPageProps, AppPageProps {}
}
+78
View File
@@ -0,0 +1,78 @@
export interface User {
id: number;
name: string;
email: string;
email_verified_at?: string;
}
export interface ConsoleData {
id: number;
name: string;
slug: string;
iconPath: string | null;
romExtensions: string[];
igdbPlatformId: number | null;
tgdbPlatformId: number | null;
gamesCount: number;
}
export interface GameData {
id: number;
consoleId: number;
title: string;
slug: string;
igdbId: number | null;
tgdbId: number | null;
description: string | null;
releaseYear: number | null;
developer: string | null;
publisher: string | null;
genre: string | null;
rating: number | null;
coverPath: string | null;
thumbnailPath: string | null;
console?: ConsoleData;
romFiles?: RomFileData[];
}
export interface RomFileData {
id: number;
gameId: number;
filePath: string;
fileName: string;
fileSize: number;
fileHash: string;
region: string | null;
language: string | null;
}
export interface DashboardStatsData {
totalGames: number;
totalConsoles: number;
totalRoms: number;
userSaves: number;
}
export interface ScraperResultData {
id: number;
title: string;
coverUrl: string | null;
year: string | null;
source: string;
}
export interface WebDavAccountData {
id: number;
userId: number;
username: string;
displayName: string;
enabled: boolean;
}
export type PageProps<
T extends Record<string, unknown> = Record<string, unknown>,
> = T & {
auth: {
user: User;
};
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />